From 54fac471a98a4f80c5b3c5c02a92ada203ab196c Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:11:26 +0300 Subject: [PATCH] feat(standards): ISO-25 and ANSI dimension, text and leader style presets as pinned data Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/standards/__init__.py | 39 +++ engineering/standards/dimstyles.py | 285 +++++++++++++++++++++ engineering/standards/mleaderstyles.py | 66 +++++ engineering/standards/textstyles.py | 100 ++++++++ tests/test_standards_presets.py | 326 +++++++++++++++++++++++++ 5 files changed, 816 insertions(+) create mode 100644 engineering/standards/__init__.py create mode 100644 engineering/standards/dimstyles.py create mode 100644 engineering/standards/mleaderstyles.py create mode 100644 engineering/standards/textstyles.py create mode 100644 tests/test_standards_presets.py diff --git a/engineering/standards/__init__.py b/engineering/standards/__init__.py new file mode 100644 index 0000000..cac391e --- /dev/null +++ b/engineering/standards/__init__.py @@ -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", +] diff --git a/engineering/standards/dimstyles.py b/engineering/standards/dimstyles.py new file mode 100644 index 0000000..8e81e42 --- /dev/null +++ b/engineering/standards/dimstyles.py @@ -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"], + } diff --git a/engineering/standards/mleaderstyles.py b/engineering/standards/mleaderstyles.py new file mode 100644 index 0000000..53ac0d1 --- /dev/null +++ b/engineering/standards/mleaderstyles.py @@ -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 diff --git a/engineering/standards/textstyles.py b/engineering/standards/textstyles.py new file mode 100644 index 0000000..daf6e53 --- /dev/null +++ b/engineering/standards/textstyles.py @@ -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), + } diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py new file mode 100644 index 0000000..3eb0321 --- /dev/null +++ b/tests/test_standards_presets.py @@ -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)