feat(standards): ISO-25 and ANSI dimension, text and leader style presets as pinned data
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
49fc6ac6a5
commit
54fac471a9
@@ -0,0 +1,39 @@
|
|||||||
|
"""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 (
|
||||||
|
DIM_VARIABLE_RANGES,
|
||||||
|
DIM_VARIABLE_WHITELIST,
|
||||||
|
PRESET_VARIABLES,
|
||||||
|
PRESETS,
|
||||||
|
check_dim_value,
|
||||||
|
describe_preset,
|
||||||
|
resolve_dimstyle,
|
||||||
|
validate_overrides,
|
||||||
|
)
|
||||||
|
from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleaderstyle
|
||||||
|
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DIM_VARIABLE_RANGES",
|
||||||
|
"DIM_VARIABLE_WHITELIST",
|
||||||
|
"MLEADER_PRESETS",
|
||||||
|
"PRESETS",
|
||||||
|
"PRESET_VARIABLES",
|
||||||
|
"TEXT_PRESETS",
|
||||||
|
"check_dim_value",
|
||||||
|
"describe_preset",
|
||||||
|
"resolve_dimstyle",
|
||||||
|
"resolve_font",
|
||||||
|
"resolve_mleaderstyle",
|
||||||
|
"validate_overrides",
|
||||||
|
"validate_textstyle",
|
||||||
|
]
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""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 and
|
||||||
|
ezdxf raises on a string in the header, so the translation belongs to the
|
||||||
|
backends, not to the data.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ANSI",
|
||||||
|
"DIM_VARIABLE_RANGES",
|
||||||
|
"DIM_VARIABLE_WHITELIST",
|
||||||
|
"ISO_25",
|
||||||
|
"LINEWEIGHT_CODES",
|
||||||
|
"PRESETS",
|
||||||
|
"PRESET_SOURCES",
|
||||||
|
"PRESET_VARIABLES",
|
||||||
|
"check_dim_value",
|
||||||
|
"describe_preset",
|
||||||
|
"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())
|
||||||
|
|
||||||
|
#: 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.
|
||||||
|
#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow).
|
||||||
|
#: * ``("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": ("str",),
|
||||||
|
"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},
|
||||||
|
"DIMTP": ("float", 0.0, 1e6),
|
||||||
|
"DIMTM": ("float", 0.0, 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": ("str",),
|
||||||
|
"DIMBLK2": ("str",),
|
||||||
|
"DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none
|
||||||
|
}
|
||||||
|
|
||||||
|
DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES)
|
||||||
|
|
||||||
|
#: DXF forbids these in a symbol-table name; the same rule ``security.py``
|
||||||
|
#: applies to block and layer names, restated here so this module stays free
|
||||||
|
#: of fastmcp.
|
||||||
|
_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')
|
||||||
|
|
||||||
|
|
||||||
|
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 not isinstance(value, str):
|
||||||
|
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||||
|
if kind == "char":
|
||||||
|
if len(value) != 1:
|
||||||
|
raise ValueError(f"{key}: {value!r} must be exactly one character")
|
||||||
|
return value
|
||||||
|
if kind == "name":
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError(f"{key}: a style name cannot be empty")
|
||||||
|
bad = sorted(set(text) & _ILLEGAL_NAME_CHARS)
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}")
|
||||||
|
return text
|
||||||
|
return value # "str"
|
||||||
|
|
||||||
|
|
||||||
|
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,66 @@
|
|||||||
|
"""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. Only ezdxf can author an MLEADERSTYLE object;
|
||||||
|
ActiveX exposes no collection for it (see ``backends/contracts/styles.py``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_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")
|
||||||
|
|
||||||
|
_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')
|
||||||
|
|
||||||
|
|
||||||
|
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":
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ValueError("text_style: a text style name cannot be empty")
|
||||||
|
bad = sorted(set(value.strip()) & _ILLEGAL_NAME_CHARS)
|
||||||
|
if bad:
|
||||||
|
raise ValueError(
|
||||||
|
f"text_style: {value!r} contains characters DXF forbids in a name: {bad}"
|
||||||
|
)
|
||||||
|
values[name] = value.strip()
|
||||||
|
elif name == "landing_gap":
|
||||||
|
values[name] = _positive(name, value, allow_zero=True)
|
||||||
|
else:
|
||||||
|
values[name] = _positive(name, value)
|
||||||
|
return values
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
from pathlib import PurePath
|
||||||
|
|
||||||
|
__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
|
||||||
|
|
||||||
|
_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')
|
||||||
|
|
||||||
|
|
||||||
|
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°."""
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise ValueError("name: a text style name cannot be empty")
|
||||||
|
clean = name.strip()
|
||||||
|
bad = sorted(set(clean) & _ILLEGAL_NAME_CHARS)
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"name: {clean!r} contains characters DXF forbids in a name: {bad}")
|
||||||
|
font_file, known = resolve_font(font)
|
||||||
|
for key, value in (
|
||||||
|
("height", height),
|
||||||
|
("width_factor", width_factor),
|
||||||
|
("oblique_deg", oblique_deg),
|
||||||
|
):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||||
|
if float(height) < 0.0:
|
||||||
|
raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'")
|
||||||
|
if float(width_factor) <= 0.0:
|
||||||
|
raise ValueError(f"width_factor: {width_factor!r} must be greater than 0")
|
||||||
|
if abs(float(oblique_deg)) > 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": float(height),
|
||||||
|
"width_factor": float(width_factor),
|
||||||
|
"oblique_deg": float(oblique_deg),
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
# tests/test_standards_presets.py
|
||||||
|
"""Provenance for the authored style presets, pinned by name.
|
||||||
|
|
||||||
|
Every ISO-25 and ANSI value is asserted against the table in spec §4.1 — a
|
||||||
|
reader with ISO 129-1 / ISO 3098-1 / ASME Y14.2 open can check each line.
|
||||||
|
The refusals are pinned too: a value the standards data lets through is a
|
||||||
|
value a backend writes, so the data is where "nothing written on refusal"
|
||||||
|
starts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engineering.standards.dimstyles import (
|
||||||
|
DIM_VARIABLE_RANGES,
|
||||||
|
DIM_VARIABLE_WHITELIST,
|
||||||
|
LINEWEIGHT_CODES,
|
||||||
|
PRESET_VARIABLES,
|
||||||
|
PRESETS,
|
||||||
|
check_dim_value,
|
||||||
|
describe_preset,
|
||||||
|
resolve_dimstyle,
|
||||||
|
validate_overrides,
|
||||||
|
)
|
||||||
|
from engineering.standards.mleaderstyles import (
|
||||||
|
MLEADER_KEYS,
|
||||||
|
MLEADER_PRESETS,
|
||||||
|
resolve_mleaderstyle,
|
||||||
|
)
|
||||||
|
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
|
||||||
|
|
||||||
|
# ── ISO-25: ISO 129-1:2018 at the 2.5 mm row of ISO 3098-1 ──────────────────
|
||||||
|
|
||||||
|
ISO_25_EXPECTED = {
|
||||||
|
"DIMTXT": 2.5,
|
||||||
|
"DIMASZ": 2.5,
|
||||||
|
"DIMEXE": 1.25,
|
||||||
|
"DIMEXO": 0.625,
|
||||||
|
"DIMGAP": 0.625,
|
||||||
|
"DIMTAD": 1,
|
||||||
|
"DIMTIH": 0,
|
||||||
|
"DIMTOH": 0,
|
||||||
|
"DIMDEC": 2,
|
||||||
|
"DIMDSEP": ",",
|
||||||
|
"DIMLUNIT": 2,
|
||||||
|
"DIMZIN": 8,
|
||||||
|
"DIMBLK": "",
|
||||||
|
"DIMTXSTY": "ISOCP",
|
||||||
|
"DIMLWD": -2,
|
||||||
|
"DIMLWE": -2,
|
||||||
|
"DIMSCALE": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── ANSI: ASME Y14.2-2014, metric values ────────────────────────────────────
|
||||||
|
|
||||||
|
ANSI_EXPECTED = {
|
||||||
|
"DIMTXT": 3.0,
|
||||||
|
"DIMASZ": 3.0,
|
||||||
|
"DIMEXE": 1.5,
|
||||||
|
"DIMEXO": 1.5,
|
||||||
|
"DIMGAP": 1.0,
|
||||||
|
"DIMTAD": 0,
|
||||||
|
"DIMTIH": 1,
|
||||||
|
"DIMTOH": 1,
|
||||||
|
"DIMDEC": 2,
|
||||||
|
"DIMDSEP": ".",
|
||||||
|
"DIMLUNIT": 2,
|
||||||
|
"DIMZIN": 8,
|
||||||
|
"DIMBLK": "",
|
||||||
|
"DIMTXSTY": "ROMANS",
|
||||||
|
"DIMLWD": -2,
|
||||||
|
"DIMLWE": -2,
|
||||||
|
"DIMSCALE": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("variable", "expected"), sorted(ISO_25_EXPECTED.items()))
|
||||||
|
def test_iso25_value_by_name(variable, expected):
|
||||||
|
assert PRESETS["iso-25"][variable] == expected
|
||||||
|
assert type(PRESETS["iso-25"][variable]) is type(expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("variable", "expected"), sorted(ANSI_EXPECTED.items()))
|
||||||
|
def test_ansi_value_by_name(variable, expected):
|
||||||
|
assert PRESETS["ansi"][variable] == expected
|
||||||
|
assert type(PRESETS["ansi"][variable]) is type(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_presets_set_exactly_the_seventeen_spec_variables():
|
||||||
|
assert len(PRESET_VARIABLES) == 17
|
||||||
|
for name, preset in PRESETS.items():
|
||||||
|
assert tuple(preset) == PRESET_VARIABLES, name
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_whitelist_is_the_seventeen_plus_the_sixteen_spec_extras():
|
||||||
|
extras = set(
|
||||||
|
"DIMTOL DIMTP DIMTM DIMTOLJ DIMTFAC DIMLFAC DIMRND DIMATFIT "
|
||||||
|
"DIMTMOVE DIMCLRD DIMCLRE DIMCLRT DIMSAH DIMBLK1 DIMBLK2 DIMCEN".split()
|
||||||
|
)
|
||||||
|
assert DIM_VARIABLE_WHITELIST == set(PRESET_VARIABLES) | extras
|
||||||
|
assert set(DIM_VARIABLE_RANGES) == DIM_VARIABLE_WHITELIST
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_lineweights_are_all_legal_codes():
|
||||||
|
for mm in (0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0):
|
||||||
|
assert round(mm * 100) in LINEWEIGHT_CODES
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_preset_names_the_standard():
|
||||||
|
iso = describe_preset("ISO-25")
|
||||||
|
assert iso["name"] == "iso-25"
|
||||||
|
assert "ISO 129-1" in iso["source"] and "ISO 3098-1" in iso["source"]
|
||||||
|
assert iso["values"] == ISO_25_EXPECTED and iso["textstyle"] == "ISOCP"
|
||||||
|
ansi = describe_preset("ansi")
|
||||||
|
assert "ASME Y14.2" in ansi["source"] and ansi["textstyle"] == "ROMANS"
|
||||||
|
with pytest.raises(ValueError, match="preset"):
|
||||||
|
describe_preset("din")
|
||||||
|
|
||||||
|
|
||||||
|
# ── resolve_dimstyle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_preset_is_the_iso25_base():
|
||||||
|
assert resolve_dimstyle(None, None) == ISO_25_EXPECTED
|
||||||
|
assert resolve_dimstyle("ISO-25", {}) == ISO_25_EXPECTED
|
||||||
|
assert resolve_dimstyle("ansi", None) == ANSI_EXPECTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_overrides_apply_on_top_and_are_upper_cased_and_typed():
|
||||||
|
values = resolve_dimstyle("iso-25", {"dimtxt": 3, "DIMDEC": 3.0, "dimdsep": "."})
|
||||||
|
assert values["DIMTXT"] == 3.0 and type(values["DIMTXT"]) is float
|
||||||
|
assert values["DIMDEC"] == 3 and type(values["DIMDEC"]) is int
|
||||||
|
assert values["DIMDSEP"] == "."
|
||||||
|
assert values["DIMASZ"] == 2.5, "untouched values stay the preset's"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_leaves_the_preset_table_untouched():
|
||||||
|
resolve_dimstyle("iso-25", {"DIMTXT": 9.0})
|
||||||
|
assert PRESETS["iso-25"]["DIMTXT"] == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("overrides", "fragment"),
|
||||||
|
[
|
||||||
|
({"DIMFOO": 1}, "DIMFOO"),
|
||||||
|
({"DIMDEC": 9}, "DIMDEC"),
|
||||||
|
({"DIMDEC": -1}, "DIMDEC"),
|
||||||
|
({"DIMDEC": 2.5}, "whole number"),
|
||||||
|
({"DIMTXT": 0}, "DIMTXT"),
|
||||||
|
({"DIMASZ": -1}, "DIMASZ"),
|
||||||
|
({"DIMTAD": 5}, "DIMTAD"),
|
||||||
|
({"DIMTIH": 2}, "DIMTIH"),
|
||||||
|
({"DIMDSEP": ",,"}, "DIMDSEP"),
|
||||||
|
({"DIMDSEP": ""}, "DIMDSEP"),
|
||||||
|
({"DIMLWD": 27}, "DIMLWD"),
|
||||||
|
({"DIMLUNIT": 0}, "DIMLUNIT"),
|
||||||
|
({"DIMZIN": 16}, "DIMZIN"),
|
||||||
|
({"DIMTXSTY": ""}, "DIMTXSTY"),
|
||||||
|
({"DIMTXSTY": "a/b"}, "DIMTXSTY"),
|
||||||
|
({"DIMLFAC": 0}, "DIMLFAC"),
|
||||||
|
({"DIMTFAC": 0}, "DIMTFAC"),
|
||||||
|
({"DIMCLRT": 257}, "DIMCLRT"),
|
||||||
|
({"DIMRND": -0.5}, "DIMRND"),
|
||||||
|
({"DIMSCALE": float("nan")}, "DIMSCALE"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_out_of_range_or_unknown_overrides_are_refused_by_name(overrides, fragment):
|
||||||
|
with pytest.raises(ValueError, match=fragment):
|
||||||
|
resolve_dimstyle("iso-25", overrides)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("overrides", "fragment"),
|
||||||
|
[
|
||||||
|
({"DIMTXT": "2.5"}, "DIMTXT"),
|
||||||
|
({"DIMTAD": True}, "DIMTAD"),
|
||||||
|
({"DIMDSEP": 44}, "DIMDSEP"),
|
||||||
|
({"DIMBLK": 0}, "DIMBLK"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_wrong_types_are_a_type_error_naming_the_key(overrides, fragment):
|
||||||
|
with pytest.raises(TypeError, match=fragment):
|
||||||
|
resolve_dimstyle("iso-25", overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_preset_is_refused():
|
||||||
|
with pytest.raises(ValueError, match="preset"):
|
||||||
|
resolve_dimstyle("din", None)
|
||||||
|
with pytest.raises(TypeError, match="overrides"):
|
||||||
|
resolve_dimstyle("iso-25", [("DIMTXT", 2.5)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_overrides_alone_is_what_modify_uses():
|
||||||
|
assert validate_overrides(None) == {}
|
||||||
|
assert validate_overrides({"dimgap": -1}) == {"DIMGAP": -1.0}, "negative gap boxes the text"
|
||||||
|
assert check_dim_value("dimlwe", 25) == 25
|
||||||
|
assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE"
|
||||||
|
|
||||||
|
|
||||||
|
# ── text styles ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_presets_are_the_four_spec_fonts():
|
||||||
|
assert TEXT_PRESETS == {
|
||||||
|
"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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("font", "expected"),
|
||||||
|
[
|
||||||
|
("ISOCP", "isocp.shx"),
|
||||||
|
("isocp", "isocp.shx"),
|
||||||
|
("isocp.shx", "isocp.shx"),
|
||||||
|
("ISOCP.SHX", "isocp.shx"),
|
||||||
|
("ISOCPEUR", "isocpeur.ttf"),
|
||||||
|
("arial", "arial.ttf"),
|
||||||
|
("Arial.ttf", "arial.ttf"),
|
||||||
|
("romans.shx", "romans.shx"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_preset_font_resolves_known(font, expected):
|
||||||
|
assert resolve_font(font) == (expected, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_font_passes_through_unknown_never_refused():
|
||||||
|
assert resolve_font("simplex.shx") == ("simplex.shx", False)
|
||||||
|
assert resolve_font(" c:/fonts/custom.ttf ") == ("c:/fonts/custom.ttf", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_or_control_font_names_are_refused():
|
||||||
|
with pytest.raises(ValueError, match="font"):
|
||||||
|
resolve_font("")
|
||||||
|
with pytest.raises(ValueError, match="font"):
|
||||||
|
resolve_font("bad\nfont.shx")
|
||||||
|
with pytest.raises(TypeError, match="font"):
|
||||||
|
resolve_font(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_textstyle_types_the_request():
|
||||||
|
spec = validate_textstyle(" ISOCP ", "isocp", 0, 1, 0)
|
||||||
|
assert spec == {
|
||||||
|
"name": "ISOCP",
|
||||||
|
"font_file": "isocp.shx",
|
||||||
|
"known": True,
|
||||||
|
"height": 0.0,
|
||||||
|
"width_factor": 1.0,
|
||||||
|
"oblique_deg": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "fragment"),
|
||||||
|
[
|
||||||
|
({"name": ""}, "name"),
|
||||||
|
({"name": "A:B"}, "name"),
|
||||||
|
({"height": -1}, "height"),
|
||||||
|
({"width_factor": 0}, "width_factor"),
|
||||||
|
({"oblique_deg": 86}, "oblique_deg"),
|
||||||
|
({"oblique_deg": -90}, "oblique_deg"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_textstyle_refuses_by_field(kwargs, fragment):
|
||||||
|
base = {
|
||||||
|
"name": "T",
|
||||||
|
"font": "arial.ttf",
|
||||||
|
"height": 0.0,
|
||||||
|
"width_factor": 1.0,
|
||||||
|
"oblique_deg": 0.0,
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
with pytest.raises(ValueError, match=fragment):
|
||||||
|
validate_textstyle(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_textstyle_refuses_wrong_types():
|
||||||
|
with pytest.raises(TypeError, match="width_factor"):
|
||||||
|
validate_textstyle("T", "arial.ttf", 0.0, "1", 0.0)
|
||||||
|
with pytest.raises(TypeError, match="height"):
|
||||||
|
validate_textstyle("T", "arial.ttf", True, 1.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── multileader styles ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_mleader_presets_match_the_plan():
|
||||||
|
assert MLEADER_PRESETS == {
|
||||||
|
"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},
|
||||||
|
}
|
||||||
|
assert MLEADER_KEYS == ("arrow_size", "landing_gap", "text_style", "text_height")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_mleaderstyle_applies_overrides():
|
||||||
|
values = resolve_mleaderstyle("ISO", {"arrow_size": 3, "text_style": " ARIAL "})
|
||||||
|
assert values == {
|
||||||
|
"arrow_size": 3.0,
|
||||||
|
"landing_gap": 1.0,
|
||||||
|
"text_style": "ARIAL",
|
||||||
|
"text_height": 2.5,
|
||||||
|
}
|
||||||
|
assert resolve_mleaderstyle("ansi") == MLEADER_PRESETS["ansi"]
|
||||||
|
assert resolve_mleaderstyle("iso", {"landing_gap": 0})["landing_gap"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("preset", "overrides", "exc", "fragment"),
|
||||||
|
[
|
||||||
|
("din", None, ValueError, "preset"),
|
||||||
|
(None, None, ValueError, "preset"),
|
||||||
|
("iso", {"colour": 1}, ValueError, "colour"),
|
||||||
|
("iso", {"arrow_size": 0}, ValueError, "arrow_size"),
|
||||||
|
("iso", {"text_height": -2}, ValueError, "text_height"),
|
||||||
|
("iso", {"text_style": ""}, ValueError, "text_style"),
|
||||||
|
("iso", {"text_style": "a|b"}, ValueError, "text_style"),
|
||||||
|
("iso", {"arrow_size": "2.5"}, TypeError, "arrow_size"),
|
||||||
|
("iso", ["arrow_size"], TypeError, "overrides"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment):
|
||||||
|
with pytest.raises(exc, match=fragment):
|
||||||
|
resolve_mleaderstyle(preset, overrides)
|
||||||
Reference in New Issue
Block a user