From 2e2cd2773db1123a7665f600c8539e7c7ee25b3d Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:52:01 +0300 Subject: [PATCH] fix(task-9): signed DIMTP/DIMTM, read-side arrowhead spelling, and the Task 10 handshake Two blocking review findings on the standards data layer: * DIMTP and DIMTM were pinned to ("float", 0.0, 1e6), refusing every signed deviation with a false "must be at least 0". AutoCAD documents both as signed and the repo already relies on it: build_dim_override writes dimtm=-0.022 for ISO 286 p6 and dimtp=-0.020 for f7, and dimension_linear(fit=...) emits exactly those. A dimension style could therefore never hold a double-positive or double-negative fit that the same server draws correctly per dimension, and an agent obeying the message would ship a sign-flipped tolerance. Both are now ("float", -1e6, 1e6); a provenance test resolves the p6/f7/H7/h6 overrides build_dim_override emits through resolve_dimstyle and pins the signs. * 827719f made check_dim_value return DIMBLK/DIMBLK1/DIMBLK2 in the canonical _OBLIQUE spelling and added ezdxf_arrowhead without telling Task 10, whose plan helpers store the value verbatim and read the raw string back - executed as written the headless drawing raised at render and at doc.write, a user block passed the data layer and failed at save, and ezdxf's OBLIQUE-in-session / _OBLIQUE-after-reload split made an idempotent re-set report a change (spec section 8.3). This commit adds the read-side twin reported_arrowhead (never raises; a foreign name passes through), pins it with an in-session-equals-reloaded round trip, and edits the plan: Task 9 Produces and Task 10 Consumes now name ARROWHEAD_BLOCKS, canonical_arrowhead, ezdxf_arrowhead and reported_arrowhead; Task 10's _write_dimstyle_values maps through ezdxf_arrowhead, _dimstyle_value and _com_dimvar_for_report map through reported_arrowhead, a new _require_arrowhead_blocks refuses a user block absent from doc.blocks before dimstyle_create/modify write anything (and rewrites the name to the block table's own spelling, measured to be what a reload reports), and two headless tests witness the rendered INSERTs, the save/reopen spelling and the refusal. The plan's own copy of the range table is corrected to match the module (arrow kind, signed tolerances). The edited helper block was executed against ezdxf 1.4.4 before being written into the plan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- ...2026-09-16-v1.6-settings-implementation.md | 129 ++++++++++++++++-- engineering/standards/__init__.py | 2 + engineering/standards/dimstyles.py | 31 ++++- tests/test_standards_presets.py | 87 ++++++++++++ 4 files changed, 236 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md b/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md index cb2b59e..9de3233 100644 --- a/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md +++ b/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md @@ -3351,7 +3351,7 @@ Part of `docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`. Its - Test: `tests/test_standards_presets.py` **Interfaces:** -- Produces: `PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}`, `PRESET_VARIABLES` (the 17, spec order), `DIM_VARIABLE_WHITELIST: frozenset` (17 + 16 = 33), `DIM_VARIABLE_RANGES: dict[str, tuple | set]`, `resolve_dimstyle(preset, overrides) -> dict`, `validate_overrides(overrides) -> dict`, `check_dim_value(name, value)`, `describe_preset(name) -> dict`; `TEXT_PRESETS`, `resolve_font(font) -> (font_file, known)`, `validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict`; `MLEADER_PRESETS`, `MLEADER_KEYS`, `resolve_mleaderstyle(preset, overrides) -> dict`. +- Produces: `PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}`, `PRESET_VARIABLES` (the 17, spec order), `DIM_VARIABLE_WHITELIST: frozenset` (17 + 16 = 33), `DIM_VARIABLE_RANGES: dict[str, tuple | set]`, `resolve_dimstyle(preset, overrides) -> dict`, `validate_overrides(overrides) -> dict`, `check_dim_value(name, value)`, `describe_preset(name) -> dict`; the arrowhead vocabulary (added by the Task 9 review fixes and consumed by Task 10's helpers below): `ARROWHEAD_BLOCKS: frozenset` (AutoCAD's nineteen named built-ins, `_OBLIQUE` spelling, pinned equal to ezdxf's `ARROWS.__acad__`), `canonical_arrowhead(key, value) -> str` (what `check_dim_value` returns for `DIMBLK`/`DIMBLK1`/`DIMBLK2`: `""` for closed filled and its aliases `.`/`CLOSEDFILLED`/`_CLOSEDFILLED`, a built-in in any case with or without its underscore → its block name, anything else a user block name under the symbol-name rule — whether that block *exists* is the backend's check against `doc.blocks`, before any write), `ezdxf_arrowhead(canonical) -> str` (the one translation the headless **writer** needs: `_OBLIQUE` → `OBLIQUE`, user blocks and `""` unchanged — ezdxf raises `DXFValueError: Block "_OBLIQUE" does not exist` at `doc.write` and `DXFUndefinedBlockError` at render if the canonical spelling is stored verbatim), `reported_arrowhead(raw) -> str` (the **read** side, never raises: ezdxf reports `OBLIQUE` in-session and `_OBLIQUE` after a reload, COM reports `_OBLIQUE`; both come back as the canonical name, a foreign name passes through); `TEXT_PRESETS`, `resolve_font(font) -> (font_file, known)`, `validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict`; `MLEADER_PRESETS`, `MLEADER_KEYS`, `resolve_mleaderstyle(preset, overrides) -> dict`. - Every refusal is `ValueError` (wrong type: `TypeError`) naming the key, and nothing in this package writes anything — the backends call these before they touch a table. - Groups C and P add `sysvars.py`, `papers.py`, `pdfinfo.py`, `templates.py`, `plot.py` to the same package and import them by path; this `__init__.py` re-exports only group S's names, so those groups never need to edit it. @@ -3821,7 +3821,9 @@ LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_T #: * ``("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). +#: * ``("arrow",)`` — an arrowhead: ``""`` (closed filled), one of ``ARROWHEAD_BLOCKS`` +#: given with or without its underscore in any case, or a user block name under the +#: symbol-name rule; ``check_dim_value`` returns ``canonical_arrowhead(key, value)``. #: * ``("name",)`` — a non-empty DXF symbol-table name. #: * a ``set`` — one of its members. DIM_VARIABLE_RANGES: dict[str, tuple | set] = { @@ -3838,15 +3840,20 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "DIMDSEP": ("char",), "DIMLUNIT": ("int", 1, 6), "DIMZIN": ("int", 0, 15), # a bitmask - "DIMBLK": ("str",), + "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}, - "DIMTP": ("float", 0.0, 1e6), - "DIMTM": ("float", 0.0, 1e6), + # 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), @@ -3857,8 +3864,8 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "DIMCLRE": ("int", 0, 256), "DIMCLRT": ("int", 0, 256), "DIMSAH": {0, 1}, - "DIMBLK1": ("str",), - "DIMBLK2": ("str",), + "DIMBLK1": ("arrow",), + "DIMBLK2": ("arrow",), "DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none } @@ -4224,7 +4231,7 @@ git commit -m "feat(standards): ISO-25 and ANSI dimension, text and leader style - Test: `tests/test_styles.py` **Interfaces:** -- Consumes: `resolve_dimstyle`, `validate_overrides`, `PRESET_VARIABLES`, `DIM_VARIABLE_WHITELIST`, `TEXT_PRESETS`, `validate_textstyle`, `MLEADER_KEYS` (Task 9); `sanitize_symbol_name` (`security.py`) — note it raises `ToolError`, not `ValueError`, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the `ValueError`/`TypeError` rule); `_async`/`_require_doc`/`_mark_dirty` (ezdxf), `_run`/`_acad_doc`/`_acad_app`/`_regen`/`deg2rad`/`rad2deg` (COM). +- Consumes: `resolve_dimstyle`, `validate_overrides`, `PRESET_VARIABLES`, `DIM_VARIABLE_WHITELIST`, `ARROWHEAD_BLOCKS`, `ezdxf_arrowhead`, `reported_arrowhead`, `TEXT_PRESETS`, `validate_textstyle`, `MLEADER_KEYS` (Task 9) — `validate_overrides` already returns `DIMBLK`/`DIMBLK1`/`DIMBLK2` in the canonical `_OBLIQUE` spelling, which ActiveX takes verbatim but ezdxf's writer refuses, so the headless helpers translate on write (`ezdxf_arrowhead`), canonicalise on read (`reported_arrowhead`, both engines) and refuse a user block absent from `doc.blocks` before any write; `sanitize_symbol_name` (`security.py`) — note it raises `ToolError`, not `ValueError`, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the `ValueError`/`TypeError` rule); `_async`/`_require_doc`/`_mark_dirty` (ezdxf), `_run`/`_acad_doc`/`_acad_app`/`_regen`/`deg2rad`/`rad2deg` (COM). - Produces: the nine `StylesContract` methods on both backends, exactly the plan's signatures. Extra keys beyond the plan's row shapes, on both engines: `dimstyle_list` rows carry `values_available: bool`; `dimstyle_create` carries `written: [vars]`; `dimstyle_set_current` / `textstyle_set_current` carry `changed: bool`; `mleaderstyle_create` carries `textstyle_created`. Names are matched case-insensitively (AutoCAD's rule) and reported in the table's own spelling. - The COM rule, stated once in the code and enforced by the fake: `AcadDimStyle` has `Name` and `CopyFrom` only. Values are written by setting the `DIM*` document variables and `CopyFrom(document)`; making a style current (`ActiveDimStyle`) restores its saved settings and drops unsaved overrides; reading a non-current style's values is impossible, so `dimstyle_list` reports `values` for the current style only. `mleaderstyle_create` stays the `@capability("mleaderstyle")` default on COM (never defined there); `mleaderstyle_list` reads names from the `ACAD_MLEADERSTYLE` dictionary with `values_available: false`. @@ -4368,6 +4375,46 @@ async def test_refusals_happen_before_anything_is_written(backend): assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2 +async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, tmp_path): + """`validate_overrides` hands the canonical `_OBLIQUE`; stored verbatim ezdxf + raises at render and at `doc.write`. The witness is the rendered INSERT and + a save/reopen that reports the same name before and after.""" + values = resolve_dimstyle("iso-25", {"DIMBLK": "oblique", "DIMSAH": 1, "DIMBLK1": "_DOT", + "DIMBLK2": "."}) + result = await backend.dimstyle_create("ISO-25", values, set_current=True) + assert result["values"]["DIMBLK"] == "_OBLIQUE" + style = backend._doc.dimstyles.get("ISO-25") + assert style.dxf.dimblk == "OBLIQUE" and style.dxf.dimblk1 == "DOT" and style.dxf.dimblk2 == "" + dim = await backend.dimension_linear(0, 0, 20, 0, 10, -10) + block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry) + assert {e.dxf.name for e in block if e.dxftype() == "INSERT"} == {"_DOT", "_CLOSEDFILLED"}, ( + "DIMSAH on: the two separate arrowheads win over DIMBLK" + ) + again = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "OBLIQUE", "DIMBLK1": "_dot"}) + assert again["changed"] == {}, "re-setting a value to itself is not a change (spec §8.3)" + path = str(tmp_path / "arrows.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + rows = {r["name"]: r for r in await backend.dimstyle_list()} + assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling" + reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"}) + assert reopened["changed"] == {} + + +async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend): + with pytest.raises(ValueError, match="DIMBLK: block 'MYARROW' does not exist"): + await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMBLK": "MYARROW"})) + assert not backend._doc.dimstyles.has_entry("X") + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + with pytest.raises(ValueError, match="DIMBLK1: block 'MYARROW' does not exist"): + await backend.dimstyle_modify("ISO-25", {"DIMSAH": 1, "DIMBLK1": "MYARROW"}) + assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimsah"), "nothing written" + backend._doc.blocks.new("MYARROW").add_line((0, 0), (1, 1)) + result = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "myarrow"}) + assert result["changed"] == {"DIMBLK": ["", "MYARROW"]}, "the block table's own spelling" + assert backend._doc.dimstyles.get("ISO-25").dxf.dimblk == "MYARROW" + + async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(backend, tmp_path): await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) result = await backend.dimstyle_set_current("iso-25") @@ -5019,8 +5066,14 @@ def _dimstyle_schema_default(var: str): return None if attrib is None else attrib.default +_ARROWHEAD_VARS = ("DIMBLK", "DIMBLK1", "DIMBLK2") + + def _dimstyle_value(style, var: str): - """A style's value for ``var`` in the tool vocabulary (DIMDSEP as the character).""" + """A style's value for ``var`` in the tool vocabulary (DIMDSEP as the + character, arrowheads in the canonical ``_OBLIQUE`` spelling).""" + from engineering.standards.dimstyles import reported_arrowhead + attr = var.lower() raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None if raw is None: @@ -5030,6 +5083,12 @@ def _dimstyle_value(style, var: str): return "," if code == 0 else chr(code) if var == "DIMTXSTY": return str(raw) if raw else "Standard" + if var in _ARROWHEAD_VARS: + # Measured: ezdxf holds the writer's ``OBLIQUE`` in-session and the + # block name ``_OBLIQUE`` after a reload. Raw, one style would be + # spelled two ways and re-setting a value to itself would report a + # change (spec §8.3); canonicalised, both reads agree. + return reported_arrowhead(raw) return raw @@ -5039,19 +5098,58 @@ def _read_dimstyle_values(style, variables=None) -> dict[str, Any]: return {var: _dimstyle_value(style, var) for var in (variables or PRESET_VARIABLES)} +def _require_arrowhead_blocks(doc, values: dict[str, Any]) -> None: + """Refuse a user arrowhead block the drawing does not define — before any write. + + A built-in (``ARROWHEAD_BLOCKS``) and closed filled (``""``) need no block: + ezdxf creates the built-in's block on first use. A user block name passes + the data layer (it cannot see the drawing) and, stored, ezdxf raises + ``DXFUndefinedBlockError`` at the next dimension render and ``DXFValueError: + Block "MYARROW" does not exist`` at ``doc.write`` — after the create tool + already said ``ok``. ``doc.blocks`` membership is case-insensitive, and a + reload reports the block table's own spelling (measured: ``myarrow`` stored, + ``MYARROW`` after reopen), so the name is rewritten in ``values`` to that + spelling here — the same "reported in the table's own spelling" rule the + style names follow, and what keeps a re-set after reopen a non-change. + """ + from engineering.standards.dimstyles import ARROWHEAD_BLOCKS + + for var in _ARROWHEAD_VARS: + name = values.get(var) + if not name or name in ARROWHEAD_BLOCKS: + continue + if name not in doc.blocks: + raise ValueError( + f"{var}: block {name!r} does not exist in this drawing; define it first " + "with block_define, or name one of AutoCAD's built-in arrowheads" + ) + values[var] = doc.blocks.get(name).name + + def _write_dimstyle_values(style, values: dict[str, Any]) -> None: """Store typed values (from ``validate_overrides``) on a DIMSTYLE entry. DIMRND 0 is AutoCAD's "no rounding"; ezdxf applies ``xround(x, 0.0)`` to a *stored* 0.0 and rounds every dimension to a whole unit, so it is discarded rather than stored (the `_normalise_dimstyle_rounding` lesson). + + Arrowheads arrive in the canonical ``_OBLIQUE`` spelling (what ActiveX + takes verbatim); ezdxf's writer wants ``OBLIQUE`` and raises at + ``doc.write`` on the underscore form, so they go through + ``ezdxf_arrowhead``. The caller has run ``_require_arrowhead_blocks``. """ + from engineering.standards.dimstyles import ezdxf_arrowhead + for var, value in values.items(): attr = var.lower() if var == "DIMRND" and float(value) == 0.0: style.dxf.discard("dimrnd") continue - style.dxf.set(attr, ord(value) if var == "DIMDSEP" else value) + if var == "DIMDSEP": + value = ord(value) + elif var in _ARROWHEAD_VARS: + value = ezdxf_arrowhead(value) + style.dxf.set(attr, value) def _sync_header_to_dimstyle(doc, style) -> None: @@ -5179,6 +5277,7 @@ Insert immediately before ` # ── corner ops` in `EzdxfBackend` (directly "use dimstyle_modify to change it" ) previous = _current_dimstyle_name(doc) + _require_arrowhead_blocks(doc, typed) txsty, textstyle_created = _ensure_textstyle( doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY" ) @@ -5225,6 +5324,7 @@ Insert immediately before ` # ── corner ops` in `EzdxfBackend` (directly f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; " "create it first with textstyle_create" ) + _require_arrowhead_blocks(doc, typed) before = _read_dimstyle_values(style, tuple(typed)) _write_dimstyle_values(style, typed) after = _read_dimstyle_values(style, tuple(typed)) @@ -5458,7 +5558,14 @@ def _com_dimvar_for_report(var: str, raw: Any) -> Any: return "," if raw == 0 else chr(raw) text = str(raw) return text[:1] if text else "," - if var in ("DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"): + if var in ("DIMBLK", "DIMBLK1", "DIMBLK2"): + # AutoCAD reports the block name (``_OBLIQUE``, ``""`` for closed + # filled) — already canonical; the same read-side rule as ezdxf keeps + # the two engines' rows spelled identically. + from engineering.standards.dimstyles import reported_arrowhead + + return reported_arrowhead(raw) + if var == "DIMTXSTY": return str(raw) return raw diff --git a/engineering/standards/__init__.py b/engineering/standards/__init__.py index 90b4f1a..abb8477 100644 --- a/engineering/standards/__init__.py +++ b/engineering/standards/__init__.py @@ -19,6 +19,7 @@ from engineering.standards.dimstyles import ( check_dim_value, describe_preset, ezdxf_arrowhead, + reported_arrowhead, resolve_dimstyle, validate_overrides, ) @@ -37,6 +38,7 @@ __all__ = [ "check_dim_value", "describe_preset", "ezdxf_arrowhead", + "reported_arrowhead", "resolve_dimstyle", "resolve_font", "resolve_mleaderstyle", diff --git a/engineering/standards/dimstyles.py b/engineering/standards/dimstyles.py index e6b5f1a..f7ce151 100644 --- a/engineering/standards/dimstyles.py +++ b/engineering/standards/dimstyles.py @@ -41,6 +41,7 @@ __all__ = [ "check_dim_value", "describe_preset", "ezdxf_arrowhead", + "reported_arrowhead", "resolve_dimstyle", "validate_overrides", ] @@ -179,8 +180,13 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "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), + # 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), @@ -229,6 +235,27 @@ def canonical_arrowhead(key: str, value: Any) -> str: 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. diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py index cacd97f..61def1a 100644 --- a/tests/test_standards_presets.py +++ b/tests/test_standards_presets.py @@ -22,6 +22,7 @@ from engineering.standards.dimstyles import ( check_dim_value, describe_preset, ezdxf_arrowhead, + reported_arrowhead, resolve_dimstyle, validate_overrides, ) @@ -206,6 +207,51 @@ def test_validate_overrides_alone_is_what_modify_uses(): assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE" +# ── tolerances: DIMTP / DIMTM are signed ──────────────────────────────────── + + +@pytest.mark.parametrize( + ("fit", "expected"), + [ + ("p6", {"DIMTP": 0.035, "DIMTM": -0.022}), # double positive: -DIMTM is +0.022 + ("f7", {"DIMTP": -0.02, "DIMTM": 0.041}), # double negative: DIMTP below zero + ("H7", {"DIMTP": 0.021, "DIMTM": 0.0}), + ("h6", {"DIMTP": 0.0, "DIMTM": 0.013}), + ], +) +def test_a_style_holds_the_same_signed_deviations_a_dimension_gets(fit, expected): + """``dimension_linear(fit=...)`` writes ``build_dim_override``'s DIMTP/DIMTM + onto one dimension; a dimension style must accept the identical numbers, + signs included, or a p6 / f7 fit can be drawn but never styled — and an + agent obeying an "at least 0" refusal would ship a sign-flipped tolerance.""" + from engineering.fits import fit_lookup + from engineering.tolerances import build_dim_override + + deviation = fit_lookup(fit, 20.0) + override, _ = build_dim_override(deviation.upper_mm, -deviation.lower_mm, "deviation", None) + styled = {k: v for k, v in override.items() if k.upper() in DIM_VARIABLE_WHITELIST} + assert {"dimtol", "dimtp", "dimtm"} <= set(styled) + typed = resolve_dimstyle("iso-25", styled) + assert typed["DIMTOL"] == 1 + assert typed["DIMTP"] == pytest.approx(expected["DIMTP"]) + assert typed["DIMTM"] == pytest.approx(expected["DIMTM"]) + assert typed["DIMTM"] == pytest.approx(override["dimtm"]), "sign preserved, not abs()" + assert typed["DIMTP"] == pytest.approx(override["dimtp"]) + + +@pytest.mark.parametrize("var", ["DIMTP", "DIMTM"]) +def test_tolerance_limits_are_signed_finite_floats(var): + assert DIM_VARIABLE_RANGES[var] == ("float", -1e6, 1e6) + assert check_dim_value(var, -0.022) == -0.022 + assert check_dim_value(var, 0) == 0.0 + with pytest.raises(ValueError, match=var): + check_dim_value(var, float("-inf")) + with pytest.raises(ValueError, match=var): + check_dim_value(var, -1e7) + with pytest.raises(TypeError, match=var): + check_dim_value(var, "-0.022") + + # ── arrowheads: DIMBLK / DIMBLK1 / DIMBLK2 ────────────────────────────────── @@ -298,6 +344,47 @@ def test_the_canonical_spelling_is_not_what_the_headless_writer_takes(): doc.write(io.StringIO()) +@pytest.mark.parametrize( + ("stored", "reported"), + [ + ("OBLIQUE", "_OBLIQUE"), # what ezdxf holds in-session after the writer set it + ("_OBLIQUE", "_OBLIQUE"), # what ezdxf holds after a reload; what COM reports + ("", ""), + (None, ""), + ("_CLOSEDFILLED", ""), + ("MYARROW", "MYARROW"), + ("MY ARROW,1", "MY ARROW,1"), # a foreign drawing's name: passed through, never raised + (" ", " "), + ], +) +def test_reported_arrowhead_spells_a_stored_name_one_way_and_never_raises(stored, reported): + assert reported_arrowhead(stored) == reported + + +@pytest.mark.parametrize("canonical", ["_OBLIQUE", "_DOT", "", "MYARROW"]) +def test_a_style_reports_the_same_arrowhead_before_and_after_a_reload(canonical): + """The spec §8.3 witness at the data layer: a raw read spells ``OBLIQUE`` + in-session and ``_OBLIQUE`` after reopen, so ``dimstyle_modify`` re-setting + the same value would report a change. Through ``reported_arrowhead`` the two + reads agree with each other and with what ``check_dim_value`` accepts.""" + import io + + import ezdxf + + doc = ezdxf.new("R2018") + if canonical == "MYARROW": + doc.blocks.new("MYARROW").add_line((0, 0), (1, 1)) + style = doc.dimstyles.new("X") + style.dxf.dimblk = ezdxf_arrowhead(canonical) + in_session = reported_arrowhead(style.dxf.dimblk) + buffer = io.StringIO() + doc.write(buffer) + buffer.seek(0) + reloaded = reported_arrowhead(ezdxf.read(buffer).dimstyles.get("X").dxf.dimblk) + assert in_session == reloaded == canonical + assert check_dim_value("DIMBLK", in_session) == canonical + + # ── the symbol-name rule ────────────────────────────────────────────────────