merge: track-e group S
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Authored drafting standards data.
|
||||
|
||||
Dimension, text and leader style presets live here (track E, group S); the
|
||||
system-variable catalogue, paper sizes, plot styles and templates are sibling
|
||||
modules added by their own groups and imported by path. Everything in this
|
||||
package is data plus validation — no I/O, no ezdxf, no COM — so the backends,
|
||||
the tools and the provenance tests read one copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.standards.dimstyles import (
|
||||
ARROWHEAD_BLOCKS,
|
||||
DIM_VARIABLE_RANGES,
|
||||
DIM_VARIABLE_WHITELIST,
|
||||
PRESET_VARIABLES,
|
||||
PRESETS,
|
||||
canonical_arrowhead,
|
||||
check_dim_value,
|
||||
describe_preset,
|
||||
ezdxf_arrowhead,
|
||||
reported_arrowhead,
|
||||
resolve_dimstyle,
|
||||
validate_overrides,
|
||||
)
|
||||
from engineering.standards.mleaderstyles import (
|
||||
MLEADER_PRESETS,
|
||||
resolve_mleaderstyle,
|
||||
validate_mleaderstyle,
|
||||
)
|
||||
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
|
||||
|
||||
__all__ = [
|
||||
"ARROWHEAD_BLOCKS",
|
||||
"DIM_VARIABLE_RANGES",
|
||||
"DIM_VARIABLE_WHITELIST",
|
||||
"MLEADER_PRESETS",
|
||||
"PRESETS",
|
||||
"PRESET_VARIABLES",
|
||||
"TEXT_PRESETS",
|
||||
"canonical_arrowhead",
|
||||
"check_dim_value",
|
||||
"describe_preset",
|
||||
"ezdxf_arrowhead",
|
||||
"reported_arrowhead",
|
||||
"resolve_dimstyle",
|
||||
"resolve_font",
|
||||
"resolve_mleaderstyle",
|
||||
"validate_mleaderstyle",
|
||||
"validate_overrides",
|
||||
"validate_textstyle",
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""One call to put a drawing on a drafting standard: styles, units, layers.
|
||||
|
||||
Composes only backend methods that already exist (``textstyle_*``,
|
||||
``dimstyle_*``, ``system_get/set_variable``, ``drawing_apply_iso_layers``),
|
||||
so it runs on both engines and needs nothing from the settings facade. Units
|
||||
are the five system variables both standards share; the decimal separator is
|
||||
the preset's and arrives through the dimension style. ANSI layer naming is
|
||||
company-specific, so both standards use the ``mech`` layer set — only styles
|
||||
and the separator differ (spec §4.2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from engineering.standards.dimstyles import resolve_dimstyle
|
||||
from engineering.standards.textstyles import TEXT_PRESETS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import AutoCADBackend
|
||||
|
||||
__all__ = ["STANDARDS", "STANDARD_SYSVARS", "apply_standard", "resolve_standard"]
|
||||
|
||||
STANDARDS: dict[str, dict[str, str]] = {
|
||||
"iso": {"preset": "iso-25", "dimstyle": "ISO-25", "textstyle": "ISOCP", "layer_set": "mech"},
|
||||
"ansi": {"preset": "ansi", "dimstyle": "ANSI", "textstyle": "ROMANS", "layer_set": "mech"},
|
||||
}
|
||||
|
||||
#: Millimetres, decimal linear and angular units, 1:1 linetype and dimension
|
||||
#: scale. Written by name so the report can say what moved.
|
||||
STANDARD_SYSVARS: tuple[tuple[str, Any], ...] = (
|
||||
("INSUNITS", 4),
|
||||
("LUNITS", 2),
|
||||
("AUNITS", 0),
|
||||
("LTSCALE", 1.0),
|
||||
("DIMSCALE", 1.0),
|
||||
)
|
||||
|
||||
|
||||
def resolve_standard(standard: str) -> tuple[str, dict[str, str]]:
|
||||
"""``(key, row)`` for ``iso`` / ``ansi`` (any case), else ``ValueError``."""
|
||||
key = str(standard).strip().lower() if standard is not None else ""
|
||||
if key not in STANDARDS:
|
||||
raise ValueError(f"standard: unknown value {standard!r}; use one of {sorted(STANDARDS)}")
|
||||
return key, STANDARDS[key]
|
||||
|
||||
|
||||
def _same(old: Any, new: Any) -> bool:
|
||||
if old is None:
|
||||
return False
|
||||
try:
|
||||
return abs(float(old) - float(new)) < 1e-9
|
||||
except (TypeError, ValueError):
|
||||
return str(old) == str(new)
|
||||
|
||||
|
||||
async def apply_standard(
|
||||
backend: AutoCADBackend, standard: str, layers: bool = True, units: bool = True
|
||||
) -> dict:
|
||||
"""Styles created (or reused) and made current, units written, layers bootstrapped.
|
||||
|
||||
Every item reports whether it was created or already present; ``settings``
|
||||
reports only the variables that moved. An existing style of the standard's
|
||||
name is used as it is — ``dimstyle_modify`` changes one on purpose.
|
||||
"""
|
||||
key, row = resolve_standard(standard)
|
||||
if not isinstance(layers, bool) or not isinstance(units, bool):
|
||||
raise TypeError("layers and units must be booleans")
|
||||
|
||||
text_names = {entry["name"].lower() for entry in await backend.textstyle_list()}
|
||||
textstyle_created = row["textstyle"].lower() not in text_names
|
||||
if textstyle_created:
|
||||
font_file, width, oblique = TEXT_PRESETS[row["textstyle"]]
|
||||
await backend.textstyle_create(row["textstyle"], font_file, 0.0, width, oblique, False)
|
||||
await backend.textstyle_set_current(row["textstyle"])
|
||||
|
||||
dim_names = {entry["name"].lower() for entry in await backend.dimstyle_list()}
|
||||
dimstyle_created = row["dimstyle"].lower() not in dim_names
|
||||
if dimstyle_created:
|
||||
await backend.dimstyle_create(
|
||||
row["dimstyle"], resolve_dimstyle(row["preset"], None), set_current=True
|
||||
)
|
||||
else:
|
||||
await backend.dimstyle_set_current(row["dimstyle"])
|
||||
|
||||
changed: dict[str, list] = {}
|
||||
if units:
|
||||
for var, value in STANDARD_SYSVARS:
|
||||
old = await backend.system_get_variable(var)
|
||||
if _same(old, value):
|
||||
continue
|
||||
await backend.system_set_variable(var, value)
|
||||
changed[var] = [old, value]
|
||||
|
||||
layer_result = None
|
||||
if layers:
|
||||
applied = await backend.drawing_apply_iso_layers(row["layer_set"])
|
||||
layer_result = {"layer_set": row["layer_set"], "layers": applied["layers"]}
|
||||
|
||||
return {
|
||||
"standard": key,
|
||||
"dimstyle": {"name": row["dimstyle"], "created": dimstyle_created, "current": True},
|
||||
"textstyle": {"name": row["textstyle"], "created": textstyle_created, "current": True},
|
||||
"settings": {"changed": changed, "applied": units},
|
||||
"layers": layer_result,
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"""ISO-25 and ANSI dimension style presets as data.
|
||||
|
||||
Every value in ``PRESETS`` is pinned by name in
|
||||
``tests/test_standards_presets.py`` against the table in §4.1 of
|
||||
``docs/superpowers/specs/2026-09-16-v1.6-settings-design.md``:
|
||||
|
||||
* ``iso-25`` — ISO 129-1:2018 dimensioning at the 2.5 mm lettering row of
|
||||
ISO 3098-1: 2.5 mm text and arrowheads, text above and aligned with the
|
||||
dimension line, decimal comma (ISO 80000-1 allows either marker; AutoCAD's
|
||||
own ISO-25 uses the comma).
|
||||
* ``ansi`` — ASME Y14.2-2014 line conventions and lettering (3 mm / .12 in
|
||||
lettering, .125 in arrowheads) in metric values: 3 mm text and arrowheads,
|
||||
text centred in a gap and horizontal, decimal point.
|
||||
|
||||
``DIMDSEP`` is a one-character *string* here. DXF stores the character code
|
||||
(group 278, a 16-bit value) and ezdxf raises on a string in the header, so the
|
||||
translation belongs to the backends, not to the data — but the data is where
|
||||
the refusal starts: a control character (``\\n`` splits the dimension text over
|
||||
two lines, NUL reads back as a comma) or a code point above ``0x7FFF``
|
||||
(``ezdxf`` writes it, AutoCAD refuses it as "not a single character") is
|
||||
refused here, before ``ord()`` runs in a backend.
|
||||
|
||||
Pure data plus validation: no I/O, no ezdxf, no COM, so the backends, the
|
||||
tools and the provenance tests all read one copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = [
|
||||
"ANSI",
|
||||
"ARROWHEAD_BLOCKS",
|
||||
"DIM_VARIABLE_RANGES",
|
||||
"DIM_VARIABLE_WHITELIST",
|
||||
"ISO_25",
|
||||
"LINEWEIGHT_CODES",
|
||||
"PRESETS",
|
||||
"PRESET_SOURCES",
|
||||
"PRESET_VARIABLES",
|
||||
"canonical_arrowhead",
|
||||
"check_dim_value",
|
||||
"describe_preset",
|
||||
"ezdxf_arrowhead",
|
||||
"reported_arrowhead",
|
||||
"resolve_dimstyle",
|
||||
"validate_overrides",
|
||||
]
|
||||
|
||||
#: The seventeen variables every preset sets, in the order the spec table
|
||||
#: lists them. ``dimstyle_list`` reports exactly these.
|
||||
PRESET_VARIABLES: tuple[str, ...] = (
|
||||
"DIMTXT",
|
||||
"DIMASZ",
|
||||
"DIMEXE",
|
||||
"DIMEXO",
|
||||
"DIMGAP",
|
||||
"DIMTAD",
|
||||
"DIMTIH",
|
||||
"DIMTOH",
|
||||
"DIMDEC",
|
||||
"DIMDSEP",
|
||||
"DIMLUNIT",
|
||||
"DIMZIN",
|
||||
"DIMBLK",
|
||||
"DIMTXSTY",
|
||||
"DIMLWD",
|
||||
"DIMLWE",
|
||||
"DIMSCALE",
|
||||
)
|
||||
|
||||
ISO_25: dict[str, Any] = {
|
||||
"DIMTXT": 2.5, # text height, mm — ISO 3098-1 lettering row 2.5
|
||||
"DIMASZ": 2.5, # arrowhead length, matched to the text
|
||||
"DIMEXE": 1.25, # extension line beyond the dimension line
|
||||
"DIMEXO": 0.625, # extension line offset from the feature
|
||||
"DIMGAP": 0.625, # gap between dimension line and text
|
||||
"DIMTAD": 1, # text above the dimension line
|
||||
"DIMTIH": 0, # inside text aligned with the dimension line
|
||||
"DIMTOH": 0, # outside text aligned with the dimension line
|
||||
"DIMDEC": 2, # linear decimals
|
||||
"DIMDSEP": ",", # decimal comma
|
||||
"DIMLUNIT": 2, # decimal linear units
|
||||
"DIMZIN": 8, # trailing zeros suppressed
|
||||
"DIMBLK": "", # closed filled arrowhead
|
||||
"DIMTXSTY": "ISOCP", # ISO 3098 type B lettering (created if missing)
|
||||
"DIMLWD": -2, # dimension line lineweight ByBlock
|
||||
"DIMLWE": -2, # extension line lineweight ByBlock
|
||||
"DIMSCALE": 1.0,
|
||||
}
|
||||
|
||||
ANSI: dict[str, Any] = {
|
||||
"DIMTXT": 3.0, # 3 mm / .12 in lettering, ASME Y14.2-2014
|
||||
"DIMASZ": 3.0, # .125 in arrowheads, metric
|
||||
"DIMEXE": 1.5,
|
||||
"DIMEXO": 1.5,
|
||||
"DIMGAP": 1.0,
|
||||
"DIMTAD": 0, # text centred in a gap in the dimension line
|
||||
"DIMTIH": 1, # inside text horizontal
|
||||
"DIMTOH": 1, # outside text horizontal
|
||||
"DIMDEC": 2,
|
||||
"DIMDSEP": ".", # decimal point
|
||||
"DIMLUNIT": 2,
|
||||
"DIMZIN": 8,
|
||||
"DIMBLK": "",
|
||||
"DIMTXSTY": "ROMANS", # AutoCAD's simplex roman, the ASME Gothic look
|
||||
"DIMLWD": -2,
|
||||
"DIMLWE": -2,
|
||||
"DIMSCALE": 1.0,
|
||||
}
|
||||
|
||||
PRESETS: dict[str, dict[str, Any]] = {"iso-25": ISO_25, "ansi": ANSI}
|
||||
|
||||
PRESET_SOURCES: dict[str, str] = {
|
||||
"iso-25": (
|
||||
"ISO 129-1:2018 (dimensioning) with the 2.5 mm lettering row of "
|
||||
"ISO 3098-1 (text and arrow sizes); decimal comma per ISO 80000-1"
|
||||
),
|
||||
"ansi": (
|
||||
"ASME Y14.2-2014 line conventions and lettering: 3 mm / .12 in lettering, "
|
||||
".125 in arrowheads, metric values"
|
||||
),
|
||||
}
|
||||
|
||||
#: The lineweights AutoCAD accepts, in hundredths of a millimetre, plus the
|
||||
#: ByLayer / ByBlock / Default sentinels. Every ISO 128 weight is in here.
|
||||
_LINEWEIGHT_TEXT = (
|
||||
"-3 -2 -1 0 5 9 13 15 18 20 25 30 35 40 50 53 60 70 80 90 100 106 120 140 158 200 211"
|
||||
)
|
||||
LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_TEXT.split())
|
||||
|
||||
#: AutoCAD's nineteen named built-in arrowheads, spelled the way the DIMBLK
|
||||
#: documentation, the ``$DIMBLK`` header, ActiveX and a loaded ezdxf DIMSTYLE
|
||||
#: all spell them: the block name, underscore first. The twentieth, closed
|
||||
#: filled, has no block name and is ``""`` (the spec §4.1 default).
|
||||
#: ``tests/test_standards_presets.py`` pins this set equal to ezdxf's
|
||||
#: ``ARROWS.__acad__`` so the two vocabularies cannot drift apart unseen.
|
||||
ARROWHEAD_BLOCKS: frozenset[str] = frozenset(
|
||||
"_ARCHTICK _BOXBLANK _BOXFILLED _CLOSED _CLOSEDBLANK _DATUMBLANK _DATUMFILLED "
|
||||
"_DOT _DOTBLANK _DOTSMALL _INTEGRAL _NONE _OBLIQUE _OPEN _OPEN30 _OPEN90 "
|
||||
"_ORIGIN _ORIGIN2 _SMALL".split()
|
||||
)
|
||||
|
||||
#: The spellings of the closed filled default a caller may send: AutoCAD's own
|
||||
#: "enter a single period to return to closed filled", and the block name
|
||||
#: ezdxf gives the arrow it draws for it.
|
||||
_CLOSED_FILLED_ALIASES = frozenset({".", "CLOSEDFILLED", "_CLOSEDFILLED"})
|
||||
|
||||
#: What ``dimstyle_create`` / ``dimstyle_modify`` will write, and the range each
|
||||
#: value must sit in. The shape of each entry:
|
||||
#:
|
||||
#: * ``("float", low, high)`` — a number (bool refused) within the inclusive
|
||||
#: bounds; a ``low`` of ``1e-9`` is the repo's spelling of "strictly positive"
|
||||
#: (``backends/contracts/settings.py::_SETTING_RANGES``).
|
||||
#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted).
|
||||
#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused.
|
||||
#: * ``("char",)`` — exactly one character.
|
||||
#: * ``("arrow",)`` — an arrowhead: ``""`` (closed filled), one of
|
||||
#: :data:`ARROWHEAD_BLOCKS` given with or without its underscore in any case,
|
||||
#: or a user block name under the symbol-name rule; see :func:`canonical_arrowhead`.
|
||||
#: * ``("name",)`` — a non-empty DXF symbol-table name.
|
||||
#: * a ``set`` — one of its members.
|
||||
DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
# the seventeen the presets set
|
||||
"DIMTXT": ("float", 1e-9, 1e6),
|
||||
"DIMASZ": ("float", 1e-9, 1e6),
|
||||
"DIMEXE": ("float", 0.0, 1e6),
|
||||
"DIMEXO": ("float", 0.0, 1e6),
|
||||
"DIMGAP": ("float", -1e6, 1e6), # negative draws the basic-dimension box
|
||||
"DIMTAD": ("int", 0, 4),
|
||||
"DIMTIH": {0, 1},
|
||||
"DIMTOH": {0, 1},
|
||||
"DIMDEC": ("int", 0, 8),
|
||||
"DIMDSEP": ("char",),
|
||||
"DIMLUNIT": ("int", 1, 6),
|
||||
"DIMZIN": ("int", 0, 15), # a bitmask
|
||||
"DIMBLK": ("arrow",),
|
||||
"DIMTXSTY": ("name",),
|
||||
"DIMLWD": LINEWEIGHT_CODES,
|
||||
"DIMLWE": LINEWEIGHT_CODES,
|
||||
"DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport"
|
||||
# tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist
|
||||
"DIMTOL": {0, 1},
|
||||
# Both signed, as AutoCAD documents them: the displayed lower deviation is
|
||||
# -DIMTM, so a double-positive fit (ISO 286 p6, +0.035/+0.022) stores
|
||||
# DIMTM -0.022 and a double-negative one (f7, -0.020/-0.041) stores DIMTP
|
||||
# -0.020 — exactly what engineering/tolerances.py::build_dim_override emits
|
||||
# per dimension; a style must be able to hold the same numbers.
|
||||
"DIMTP": ("float", -1e6, 1e6),
|
||||
"DIMTM": ("float", -1e6, 1e6),
|
||||
"DIMTOLJ": ("int", 0, 2),
|
||||
"DIMTFAC": ("float", 1e-9, 1e6),
|
||||
"DIMLFAC": ("nonzero_float", -1e6, 1e6),
|
||||
"DIMRND": ("float", 0.0, 1e6), # 0 = no rounding; the backends discard it, never store it
|
||||
"DIMATFIT": ("int", 0, 3),
|
||||
"DIMTMOVE": ("int", 0, 2),
|
||||
"DIMCLRD": ("int", 0, 256),
|
||||
"DIMCLRE": ("int", 0, 256),
|
||||
"DIMCLRT": ("int", 0, 256),
|
||||
"DIMSAH": {0, 1},
|
||||
"DIMBLK1": ("arrow",),
|
||||
"DIMBLK2": ("arrow",),
|
||||
"DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none
|
||||
}
|
||||
|
||||
DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES)
|
||||
|
||||
|
||||
def canonical_arrowhead(key: str, value: Any) -> str:
|
||||
"""``value`` as the arrowhead name DIMBLK stores, or ``TypeError`` /
|
||||
``ValueError`` naming ``key``.
|
||||
|
||||
``""``, ``"."``, ``CLOSEDFILLED`` and ``_CLOSEDFILLED`` are the closed
|
||||
filled default and come back as ``""``. A built-in is accepted with or
|
||||
without its underscore in any case (``oblique``, ``_Dot``) and comes back
|
||||
as its block name (``_OBLIQUE``, ``_DOT``) — AutoCAD's documented spelling,
|
||||
what ActiveX takes verbatim and what ezdxf reports after a load. Anything
|
||||
else is a user block name and must obey the symbol-name rule; whether the
|
||||
block exists is the backend's question, asked against the open drawing.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
if value == "":
|
||||
return ""
|
||||
text = value.strip()
|
||||
if text.upper() in _CLOSED_FILLED_ALIASES:
|
||||
return ""
|
||||
if not text:
|
||||
raise ValueError(
|
||||
f'{key}: {value!r} is blank; use "" for the closed filled arrowhead or a block name'
|
||||
)
|
||||
upper = text.upper()
|
||||
candidate = upper if upper.startswith("_") else "_" + upper
|
||||
if candidate in ARROWHEAD_BLOCKS:
|
||||
return candidate
|
||||
return check_name(key, text, what="block name")
|
||||
|
||||
|
||||
def reported_arrowhead(raw: Any) -> str:
|
||||
"""A *stored* arrowhead name in the canonical spelling, never raising.
|
||||
|
||||
The read-side twin of :func:`canonical_arrowhead`: ezdxf reports the name
|
||||
the writer stored (``OBLIQUE``) in-session and the block name
|
||||
(``_OBLIQUE``) after a reload, so a raw read would spell one style two
|
||||
ways and make re-setting a value to itself look like a change (spec §8.3).
|
||||
A built-in in any spelling comes back as its block name and the closed
|
||||
filled aliases as ``""``; ``None`` is ``""``; anything else — a user block,
|
||||
or a foreign drawing's name the symbol-name rule would refuse — passes
|
||||
through unchanged, because a read must not raise.
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
text = str(raw)
|
||||
try:
|
||||
return canonical_arrowhead("DIMBLK", text)
|
||||
except (TypeError, ValueError):
|
||||
return text
|
||||
|
||||
|
||||
def ezdxf_arrowhead(canonical: str) -> str:
|
||||
"""The name ezdxf's DIMSTYLE takes for a :func:`canonical_arrowhead` value.
|
||||
|
||||
ezdxf's ``ARROWS`` vocabulary drops the underscore (``_DOT`` → ``DOT``,
|
||||
``""`` stays ``""``) and its export raises ``DXFValueError`` on ``_DOT``
|
||||
because no block of that name exists until it creates one from ``DOT``. A
|
||||
user block is passed through unchanged. The COM engine needs no mapping:
|
||||
AutoCAD takes the canonical spelling.
|
||||
"""
|
||||
if canonical in ARROWHEAD_BLOCKS:
|
||||
return canonical[1:]
|
||||
return canonical
|
||||
|
||||
|
||||
DIMDSEP_MAX_CODE = 0x7FFF
|
||||
"""The largest character code ``DIMDSEP`` accepts.
|
||||
|
||||
DXF group 278 is a 16-bit integer and the DWG field a signed short; AutoCAD
|
||||
refuses anything above it as "not a single character", while ezdxf would
|
||||
write it verbatim (measured: ``'\\U0001F600'`` reached the file as 128512).
|
||||
"""
|
||||
|
||||
|
||||
def _number(key: str, value: Any) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError(f"{key}: {value!r} is not a finite number")
|
||||
return number
|
||||
|
||||
|
||||
def check_dim_value(name: str, value: Any) -> Any:
|
||||
"""``value`` coerced to what ``name`` stores, or ``TypeError``/``ValueError`` naming it.
|
||||
|
||||
Nothing here writes; the backends call this before touching a table.
|
||||
"""
|
||||
key = str(name).strip().upper()
|
||||
spec = DIM_VARIABLE_RANGES.get(key)
|
||||
if spec is None:
|
||||
raise ValueError(
|
||||
f"{name}: not a dimension variable this tool writes; allowed: "
|
||||
f"{sorted(DIM_VARIABLE_WHITELIST)}"
|
||||
)
|
||||
if isinstance(spec, (set, frozenset)):
|
||||
number = _number(key, value)
|
||||
if number != int(number) or int(number) not in spec:
|
||||
raise ValueError(f"{key}: {value!r} is not one of {sorted(spec)}")
|
||||
return int(number)
|
||||
kind = spec[0]
|
||||
if kind in ("float", "nonzero_float"):
|
||||
number = _number(key, value)
|
||||
low, high = spec[1], spec[2]
|
||||
if not low <= number <= high:
|
||||
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
|
||||
raise ValueError(
|
||||
f"{key}: {number:g} is out of range - must be {low_text} and at most {high:g}."
|
||||
)
|
||||
if kind == "nonzero_float" and number == 0.0:
|
||||
raise ValueError(f"{key}: 0 is refused (AutoCAD treats it as no factor at all)")
|
||||
return number
|
||||
if kind == "int":
|
||||
number = _number(key, value)
|
||||
if number != int(number):
|
||||
raise ValueError(f"{key}: {value!r} must be a whole number")
|
||||
low, high = spec[1], spec[2]
|
||||
if not low <= number <= high:
|
||||
raise ValueError(f"{key}: {int(number)} is out of range {low}..{high}")
|
||||
return int(number)
|
||||
if kind == "arrow":
|
||||
return canonical_arrowhead(key, value)
|
||||
if kind == "name":
|
||||
return check_name(key, value, what="style name")
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
if len(value) != 1: # "char"
|
||||
raise ValueError(f"{key}: {value!r} must be exactly one character")
|
||||
code = ord(value)
|
||||
if code < 32 or code == 127:
|
||||
raise ValueError(
|
||||
f"{key}: {value!r} is a control character, which a DXF cannot hold as a separator"
|
||||
)
|
||||
if code > DIMDSEP_MAX_CODE:
|
||||
raise ValueError(
|
||||
f"{key}: {value!r} (U+{code:04X}) is outside the 16-bit character code DXF group "
|
||||
f"278 stores (at most U+{DIMDSEP_MAX_CODE:04X})"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def validate_overrides(overrides: dict | None) -> dict[str, Any]:
|
||||
"""``overrides`` as a typed, upper-cased dict — every key checked, nothing written."""
|
||||
if overrides is None:
|
||||
return {}
|
||||
if not isinstance(overrides, dict):
|
||||
raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}")
|
||||
typed: dict[str, Any] = {}
|
||||
for raw_key, value in overrides.items():
|
||||
key = str(raw_key).strip().upper()
|
||||
typed[key] = check_dim_value(key, value)
|
||||
return typed
|
||||
|
||||
|
||||
def resolve_dimstyle(preset: str | None, overrides: dict | None) -> dict[str, Any]:
|
||||
"""The preset's seventeen values with ``overrides`` applied on top.
|
||||
|
||||
``preset`` ``None`` means the ISO-25 base. An unknown preset, an override
|
||||
key outside :data:`DIM_VARIABLE_WHITELIST` or a value outside its range
|
||||
raises ``ValueError`` (``TypeError`` for the wrong type) naming the key —
|
||||
before anything is written, because nothing here writes.
|
||||
"""
|
||||
key = "iso-25" if preset is None else str(preset).strip().lower()
|
||||
if key not in PRESETS:
|
||||
raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(PRESETS)}")
|
||||
values = dict(PRESETS[key])
|
||||
values.update(validate_overrides(overrides))
|
||||
return values
|
||||
|
||||
|
||||
def describe_preset(name: str) -> dict:
|
||||
"""``{name, source, values, textstyle}`` for a preset, or ``ValueError``."""
|
||||
key = str(name).strip().lower()
|
||||
if key not in PRESETS:
|
||||
raise ValueError(f"preset: unknown value {name!r}; use one of {sorted(PRESETS)}")
|
||||
return {
|
||||
"name": key,
|
||||
"source": PRESET_SOURCES[key],
|
||||
"values": dict(PRESETS[key]),
|
||||
"textstyle": PRESETS[key]["DIMTXSTY"],
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Multileader style presets: arrow, landing gap, text style and height.
|
||||
|
||||
Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows
|
||||
and lettering; ANSI: 3 mm), so a leader note and a dimension on the same
|
||||
sheet read at one size. Both engines author the style: ezdxf on
|
||||
``doc.mleader_styles``, ActiveX through the ``ACAD_MLEADERSTYLE`` dictionary's
|
||||
``AddObject(name, "AcDbMLeaderStyle")`` and the ``IAcadMLeaderStyle``
|
||||
properties (see ``backends/contracts/styles.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle", "validate_mleaderstyle"]
|
||||
|
||||
MLEADER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5},
|
||||
"ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0},
|
||||
}
|
||||
|
||||
MLEADER_KEYS: tuple[str, ...] = ("arrow_size", "landing_gap", "text_style", "text_height")
|
||||
|
||||
|
||||
def _positive(key: str, value: Any, *, allow_zero: bool = False) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError(f"{key}: {value!r} is not a finite number")
|
||||
if number < 0.0 or (number == 0.0 and not allow_zero):
|
||||
raise ValueError(f"{key}: {value!r} must be greater than 0")
|
||||
return number
|
||||
|
||||
|
||||
def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str, Any]:
|
||||
"""The preset's four values with ``overrides`` applied; refusals name the key."""
|
||||
key = str(preset).strip().lower() if preset is not None else ""
|
||||
if key not in MLEADER_PRESETS:
|
||||
raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(MLEADER_PRESETS)}")
|
||||
values = dict(MLEADER_PRESETS[key])
|
||||
if overrides is None:
|
||||
return values
|
||||
if not isinstance(overrides, dict):
|
||||
raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}")
|
||||
for raw_key, value in overrides.items():
|
||||
name = str(raw_key).strip().lower()
|
||||
if name not in MLEADER_KEYS:
|
||||
raise ValueError(
|
||||
f"overrides: {raw_key!r} is not a leader style key; allowed: {list(MLEADER_KEYS)}"
|
||||
)
|
||||
if name == "text_style":
|
||||
values[name] = check_name(name, value, what="text style name")
|
||||
elif name == "landing_gap":
|
||||
values[name] = _positive(name, value, allow_zero=True)
|
||||
else:
|
||||
values[name] = _positive(name, value)
|
||||
return values
|
||||
|
||||
|
||||
def validate_mleaderstyle(values: Any) -> dict[str, Any]:
|
||||
"""The four ``MLEADER_KEYS`` of ``values``, typed, or a refusal naming the key.
|
||||
|
||||
What both engines' ``mleaderstyle_create`` run before touching a table:
|
||||
a missing key is ``ValueError`` (``resolve a preset first``), and each
|
||||
value is checked exactly as :func:`resolve_mleaderstyle` checks an
|
||||
override — so a hand-built dict is held to the preset's rules too.
|
||||
Unknown keys are ignored: the row shape is the contract, not the input.
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
raise TypeError(f"values: expected a mapping, got {type(values).__name__}")
|
||||
missing = [key for key in MLEADER_KEYS if key not in values]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"mleaderstyle_create: values is missing {missing}; resolve a preset first"
|
||||
)
|
||||
return {
|
||||
"arrow_size": _positive("arrow_size", values["arrow_size"]),
|
||||
"landing_gap": _positive("landing_gap", values["landing_gap"], allow_zero=True),
|
||||
"text_style": check_name("text_style", values["text_style"], what="text style name"),
|
||||
"text_height": _positive("text_height", values["text_height"]),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The DXF symbol-table name rule, in one place, for the standards data.
|
||||
|
||||
``security.illegal_symbol_name_chars`` is the same rule at the tool layer; it
|
||||
sits behind a fastmcp import and this package stays free of fastmcp, so the
|
||||
rule is restated here and ``tests/test_standards_presets.py`` pins the two
|
||||
character sets equal. A style, block or font name that passes here is one
|
||||
ezdxf will save and AutoCAD will load; ezdxf's own check fires at
|
||||
``doc.write``, long after a create tool has already said ``ok``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["ILLEGAL_NAME_CHARS", "check_name", "illegal_name_chars"]
|
||||
|
||||
#: Characters DXF forbids in a symbol-table name, plus the whitespace that
|
||||
#: ends a SendCommand segment — byte-for-byte ``security._FORBIDDEN_SYMBOL_CHARS``.
|
||||
ILLEGAL_NAME_CHARS: frozenset[str] = frozenset('<>/\\":;?*|,=`') | frozenset("\n\r\t")
|
||||
|
||||
|
||||
def illegal_name_chars(name: str) -> list[str]:
|
||||
"""The characters in ``name`` a DXF symbol-table name cannot hold, sorted."""
|
||||
return sorted({c for c in name if c in ILLEGAL_NAME_CHARS or ord(c) < 32})
|
||||
|
||||
|
||||
def check_name(key: str, value: Any, *, what: str = "name") -> str:
|
||||
"""``value`` as a stripped, non-empty, legal symbol-table name, or a
|
||||
``TypeError`` / ``ValueError`` naming ``key`` (``what`` words the refusal)."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError(f"{key}: a {what} cannot be empty")
|
||||
bad = illegal_name_chars(text)
|
||||
if bad:
|
||||
raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}")
|
||||
return text
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Text style presets: the four fonts the standards data refers to by name.
|
||||
|
||||
``TEXT_PRESETS`` maps a style name to ``(font file, width factor, oblique
|
||||
degrees)``. ISOCP is AutoCAD's ISO 3098 type B lettering as an SHX shape
|
||||
file; ISOCPEUR is the TrueType twin with accented characters; ARIAL is the
|
||||
general-purpose TrueType face; ROMANS is AutoCAD's simplex roman, the face
|
||||
ASME Y14.2 Gothic lettering is usually drawn with. A DXF stores only the font
|
||||
*name*, so an unknown file is never refused here: ``resolve_font`` says
|
||||
whether it is a bundled preset, the backend adds whether its font path has
|
||||
it, and the caller sees ``font_resolved``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import PurePath
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = [
|
||||
"MAX_OBLIQUE_DEG",
|
||||
"TEXT_PRESETS",
|
||||
"resolve_font",
|
||||
"validate_textstyle",
|
||||
]
|
||||
|
||||
TEXT_PRESETS: dict[str, tuple[str, float, float]] = {
|
||||
"ISOCP": ("isocp.shx", 1.0, 0.0),
|
||||
"ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0),
|
||||
"ARIAL": ("arial.ttf", 1.0, 0.0),
|
||||
"ROMANS": ("romans.shx", 1.0, 0.0),
|
||||
}
|
||||
|
||||
#: AutoCAD's own limit for the STYLE command's obliquing angle.
|
||||
MAX_OBLIQUE_DEG = 85.0
|
||||
|
||||
|
||||
def _number(key: str, value: Any) -> float:
|
||||
"""A finite number, or ``TypeError`` / ``ValueError`` naming ``key``.
|
||||
|
||||
``nan`` and ``inf`` pass every ``<`` / ``>`` check, and a backend writing
|
||||
them verbatim leaves ``50\\nnan`` in the STYLE record — the silent wrong
|
||||
number the contract forbids.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError(f"{key}: {value!r} is not a finite number")
|
||||
return number
|
||||
|
||||
|
||||
def resolve_font(font: str) -> tuple[str, bool]:
|
||||
"""``(font_file, known)``: a preset name or file resolves to the bundled
|
||||
file with ``known=True``; anything else is passed through with ``False``.
|
||||
|
||||
Accepts the style name (``ISOCP``), the file (``isocp.shx``) or the stem
|
||||
(``isocp``), case-insensitively. Refuses an empty string or control
|
||||
characters — the only inputs a DXF cannot hold.
|
||||
"""
|
||||
if not isinstance(font, str):
|
||||
raise TypeError(f"font: expected a string, got {type(font).__name__}")
|
||||
text = font.strip()
|
||||
if not text:
|
||||
raise ValueError("font: a font file name cannot be empty")
|
||||
if any(ord(ch) < 32 for ch in text):
|
||||
raise ValueError("font: control characters are refused")
|
||||
upper = text.upper()
|
||||
if upper in TEXT_PRESETS:
|
||||
return TEXT_PRESETS[upper][0], True
|
||||
for font_file, _width, _oblique in TEXT_PRESETS.values():
|
||||
if upper in (font_file.upper(), PurePath(font_file).stem.upper()):
|
||||
return font_file, True
|
||||
return text, False
|
||||
|
||||
|
||||
def validate_textstyle(
|
||||
name: str,
|
||||
font: str,
|
||||
height: float = 0.0,
|
||||
width_factor: float = 1.0,
|
||||
oblique_deg: float = 0.0,
|
||||
) -> dict:
|
||||
"""The typed request both engines write, or a ``ValueError``/``TypeError``
|
||||
naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal;
|
||||
the width factor must be positive; the oblique angle is AutoCAD's ±85°;
|
||||
every number must be finite. The name obeys the DXF symbol-name rule
|
||||
(``engineering.standards.names``) — this is the only gate on the
|
||||
``textstyle_create`` path, and a name that passes must be one the drawing
|
||||
can still be saved with."""
|
||||
clean = check_name("name", name, what="text style name")
|
||||
font_file, known = resolve_font(font)
|
||||
height_value = _number("height", height)
|
||||
width_value = _number("width_factor", width_factor)
|
||||
oblique_value = _number("oblique_deg", oblique_deg)
|
||||
if height_value < 0.0:
|
||||
raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'")
|
||||
if width_value <= 0.0:
|
||||
raise ValueError(f"width_factor: {width_factor!r} must be greater than 0")
|
||||
if abs(oblique_value) > MAX_OBLIQUE_DEG:
|
||||
raise ValueError(
|
||||
f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees"
|
||||
)
|
||||
return {
|
||||
"name": clean,
|
||||
"font_file": font_file,
|
||||
"known": known,
|
||||
"height": height_value,
|
||||
"width_factor": width_value,
|
||||
"oblique_deg": oblique_value,
|
||||
}
|
||||
Reference in New Issue
Block a user