From 5497dc01d9f0f533562d0942d216cc0b855608d5 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 16:50:33 +0300 Subject: [PATCH] =?UTF-8?q?docs(plan):=20tracks=20B+G=20=E2=80=94=2016=20T?= =?UTF-8?q?DD=20tasks=20in=20five=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assembles the skeleton and the five drafted parts into one plan, Task 1 through Task 16, each a TDD task with its failing test, its implementation and its gate. Groups: - (main) Task 1 — primitives, the ACADMCP_MECH codec, the standards registry and the ISO 128-50 material map; the shared foundation the four branches all consume. - M (Tasks 2-7) — the part model, the feature catalogue with its DIN/ISO tables, the view engine (orthographic, then sections and details), dimensioning, and SECTION 21's six tools. - S (Tasks 8-9) — the ISO 4014/4017/4032/7089/4762 and ISO 15 tables, and SECTION 22's three tools. - A (Tasks 10-11) — ISO 21920-1 surface texture, ISO 2553 welds, centre marks, the ISO 128-40 cutting-plane line and the material hatch: SECTION 23's five tools. - G (Tasks 12-14) — ISO 5457 frames and ISO 7200 title blocks A4-A0, revisions, the ISO 7573 parts list and ISO 6433 balloons, xrefs, images, data extract and DWG: SECTION 24's eleven tools and the four contract members. - F (Tasks 15-16) — the six mech_* critique focuses, the mech pack, lean, aliases and golden queries; then the v5 benchmark, the correctness checks, the live COM smoke and the docs. Interface pass over the assembled file: View.dims is tuple[DimIntent, ...] as the skeleton pins it; every XDATA writer calls Task 1's to_values rather than re-deriving the chunk split from encode; stdparts' five standard features are STD_FEATURE_KINDS so they do not collide with features.FEATURE_KINDS; the revision block carries its own ACADMCP_SHEET_REV application instead of a fourth ACADMCP_MECH kind that the pinned KINDS would refuse; and Task 16 states 39 correctness checks, which is what it registers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- .../2026-09-22-v1.6-mech-implementation.md | 22698 ++++++++++++++++ 1 file changed, 22698 insertions(+) diff --git a/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md index 7094fe1..8a883df 100644 --- a/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md +++ b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md @@ -305,3 +305,22701 @@ set (inventory `--check`, `tests/test_tool_registry.py`, `tests/test_release_consistency.py` — its collected-tests drift is Task 15's). --- + +### Task 1: Shared foundation — primitives, the XDATA codec, the standards registry and the ISO 128-50 material map + +**Files:** +- Create: `engineering/mech/__init__.py` +- Create: `engineering/mech/primitives.py` +- Create: `engineering/mech/xdata.py` +- Create: `engineering/mech/standards/__init__.py` +- Create: `engineering/mech/standards/materials.py` +- Test: `tests/test_mech_primitives.py` +- Test: `tests/test_mech_xdata.py` +- Test: `tests/test_mech_standards.py` +- Test: `tests/test_mech_materials.py` + +**Interfaces:** +- Consumes: `backends/xdata_specs.py::MAX_BYTES` and `app_size(app, tags) -> int` (already in the tree — the one place the 255-character and 16 KB XDATA limits are decided); `ezdxf.tools.pattern.load()` (test-side only, to prove every pattern name exists). +- Produces, exactly as the skeleton's Interfaces block pins them: + - `engineering/mech/primitives.py`: `Role`, `Pt`, `ROLE_LAYER: dict[Role, str]`, `Line(p1, p2, role="visible")`, `Arc(center, radius, start_deg, end_deg, role="visible")`, `Circle(center, radius, role="visible")`, `Poly(points, closed=False, role="visible")`, `HatchArea(loops, material="steel")`, `Text(at, text, height=3.5, rotation=0.0, role="text")`, `DimIntent(kind, p1, p2, text_at=None, text_override=None, fit=None, tol=None, feature="")`, `Prim`, `bbox(prims) -> tuple[float, float, float, float]`, `translate(prims, dx, dy) -> tuple[Prim, ...]`, `rotate(prims, deg, about=(0.0, 0.0)) -> tuple[Prim, ...]`, `scale(prims, factor, about=(0.0, 0.0)) -> tuple[Prim, ...]`. Additive helpers later tasks may use: `ROLES: tuple[str, ...]`, `layer_for(prim) -> str`, `points_of(prim) -> tuple[Pt, ...]`. + - `engineering/mech/xdata.py`: `APP_ID = "ACADMCP_MECH"`, `PAYLOAD_VERSION = 1`, `KINDS = ("part", "std_part", "balloon")`, `encode(payload: dict) -> list[tuple[int, str]]`, `decode(rows) -> dict`, `to_values(payload: dict) -> list[str]`. + - `engineering/mech/standards/__init__.py`: `Coverage(low, high, unit="mm")`, `register(standard, rows, coverage, source) -> None`, `lookup(standard, size, **kw) -> dict`, `standards() -> tuple[str, ...]`, `coverage_of(standard) -> Coverage`, `source_of(standard) -> str`. + - `engineering/mech/standards/materials.py`: `hatch_for(material: str) -> dict` returning exactly `{"pattern", "angle", "scale"}`, `MATERIALS: tuple[str, ...]`, `MATERIAL_TABLE: dict[str, dict]` (the full rows, `source` included), `ALIASES: dict[str, str]`. + +- [ ] **Step 1: Write the failing test for the primitive descriptions** + +Create `tests/test_mech_primitives.py`: + +```python +"""The typed primitive descriptions every pure mechanical module emits. + +Geometry is asserted on the emitted tuples to 1e-9 — never on a rendered +drawing. That is what lets the view engine be tested without a backend. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import ( + ROLE_LAYER, + ROLES, + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Text, + bbox, + layer_for, + points_of, + rotate, + scale, + translate, +) + +EPS = 1e-9 + + +def test_role_layer_maps_every_role_to_an_engineering_layer(): + assert ROLE_LAYER == { + "visible": "GEOMETRY", + "hidden": "HIDDEN", + "center": "CENTER", + "phantom": "PHANTOM", + "hatch": "HATCH", + "dim": "DIM", + "text": "TEXT", + } + assert ROLES == tuple(ROLE_LAYER) + + +def test_defaults_match_the_pinned_interface(): + assert Line((0.0, 0.0), (1.0, 0.0)).role == "visible" + assert Arc((0.0, 0.0), 5.0, 0.0, 90.0).role == "visible" + assert Circle((0.0, 0.0), 5.0).role == "visible" + poly = Poly(((0.0, 0.0), (1.0, 0.0))) + assert poly.closed is False and poly.role == "visible" + assert HatchArea((((0.0, 0.0), (1.0, 0.0), (1.0, 1.0)),)).material == "steel" + label = Text((0.0, 0.0), "M20") + assert (label.height, label.rotation, label.role) == (3.5, 0.0, "text") + intent = DimIntent("linear", (0.0, 0.0), (10.0, 0.0)) + assert intent.text_at is None + assert intent.text_override is None + assert intent.fit is None + assert intent.tol is None + assert intent.feature == "" + + +def test_layer_for_uses_the_role_map_and_a_hatch_has_no_role(): + assert layer_for(Line((0.0, 0.0), (1.0, 0.0), "hidden")) == "HIDDEN" + assert layer_for(Text((0.0, 0.0), "A")) == "TEXT" + assert layer_for(HatchArea((((0.0, 0.0), (1.0, 0.0), (1.0, 1.0)),))) == "HATCH" + + +def test_arc_bbox_includes_the_quadrant_it_sweeps_through(): + """A 0-180 arc of r=5 reaches y=+5 at 90 deg; an endpoint-only bbox misses it.""" + x0, y0, x1, y1 = bbox([Arc((0.0, 0.0), 5.0, 0.0, 180.0)]) + assert abs(x0 + 5.0) < EPS + assert abs(x1 - 5.0) < EPS + assert abs(y0) < EPS + assert abs(y1 - 5.0) < EPS + + +def test_circle_bbox_is_the_full_square(): + assert bbox([Circle((10.0, 0.0), 2.0)]) == (8.0, -2.0, 12.0, 2.0) + + +def test_bbox_of_nothing_is_refused_rather_than_reported_as_zero(): + with pytest.raises(ValueError, match="no primitives"): + bbox([]) + + +def test_translate_moves_every_primitive_kind(): + prims = ( + Line((0.0, 0.0), (10.0, 0.0)), + Arc((0.0, 0.0), 5.0, 0.0, 90.0), + Circle((0.0, 0.0), 5.0), + Poly(((0.0, 0.0), (1.0, 1.0)), closed=True, role="hidden"), + HatchArea((((0.0, 0.0), (1.0, 0.0), (1.0, 1.0)),), material="cast_iron"), + Text((0.0, 0.0), "A-A"), + ) + moved = translate(prims, 3.0, -2.0) + assert moved[0] == Line((3.0, -2.0), (13.0, -2.0)) + assert moved[1] == Arc((3.0, -2.0), 5.0, 0.0, 90.0) + assert moved[2] == Circle((3.0, -2.0), 5.0) + assert moved[3] == Poly(((3.0, -2.0), (4.0, -1.0)), closed=True, role="hidden") + assert moved[4] == HatchArea( + (((3.0, -2.0), (4.0, -2.0), (4.0, -1.0)),), material="cast_iron" + ) + assert moved[5] == Text((3.0, -2.0), "A-A") + + +def test_rotate_turns_arc_angles_with_the_geometry(): + turned = rotate([Arc((10.0, 0.0), 5.0, 0.0, 90.0)], 90.0)[0] + assert abs(turned.center[0]) < EPS + assert abs(turned.center[1] - 10.0) < EPS + assert abs(turned.start_deg - 90.0) < EPS + assert abs(turned.end_deg - 180.0) < EPS + + +def test_rotate_adds_to_a_text_rotation(): + label = rotate([Text((1.0, 0.0), "N", rotation=10.0)], 90.0)[0] + assert abs(label.at[0]) < EPS + assert abs(label.at[1] - 1.0) < EPS + assert abs(label.rotation - 100.0) < EPS + + +def test_scale_scales_radius_height_and_position_about_a_point(): + prims = scale( + [Circle((10.0, 0.0), 2.0), Text((10.0, 0.0), "x", height=3.5)], + 2.0, + about=(0.0, 0.0), + ) + assert prims[0] == Circle((20.0, 0.0), 4.0) + assert prims[1].height == 7.0 + + +def test_scale_refuses_a_non_positive_factor(): + with pytest.raises(ValueError, match="scale factor"): + scale([Circle((0.0, 0.0), 1.0)], 0.0) + + +def test_points_of_reports_the_text_anchor_only(): + assert points_of(Text((4.0, 5.0), "long label")) == ((4.0, 5.0),) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_primitives.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.mech'`. + +- [ ] **Step 3: Write `engineering/mech/primitives.py` and the package `__init__`** + +`engineering/mech/primitives.py`: + +```python +"""Typed primitive descriptions emitted by the pure mechanical modules. + +`part.py`, `features.py`, `views.py`, `dimension.py` and everything under +`standards/` never touch a backend: they return these tuples, and +`engineering/mech/draw.py` is the only module that turns them into entities. +That is what makes the view engine testable without a drawing — a test asserts +the emitted primitives, not a screenshot. + +Coordinates here are part-local; `draw.py` maps them to WCS through the part's +placement. `role` is the ISO 128 line role, and `ROLE_LAYER` is the one place a +role becomes a layer name, so no module hardcodes a layer. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from typing import Literal, NamedTuple + +Role = Literal["visible", "hidden", "center", "phantom", "hatch", "dim", "text"] +Pt = tuple[float, float] + +#: ISO 128 line role -> the engineering layer `drawing_new` already bootstraps. +ROLE_LAYER: dict[str, str] = { + "visible": "GEOMETRY", + "hidden": "HIDDEN", + "center": "CENTER", + "phantom": "PHANTOM", + "hatch": "HATCH", + "dim": "DIM", + "text": "TEXT", +} +ROLES: tuple[str, ...] = tuple(ROLE_LAYER) + + +class Line(NamedTuple): + p1: Pt + p2: Pt + role: Role = "visible" + + +class Arc(NamedTuple): + """Counter-clockwise from ``start_deg`` to ``end_deg``, degrees from +X.""" + + center: Pt + radius: float + start_deg: float + end_deg: float + role: Role = "visible" + + +class Circle(NamedTuple): + center: Pt + radius: float + role: Role = "visible" + + +class Poly(NamedTuple): + points: tuple[Pt, ...] + closed: bool = False + role: Role = "visible" + + +class HatchArea(NamedTuple): + """``loops[0]`` is the outer boundary; ``loops[1:]`` are islands. + + It carries no ``role``: a hatch is always on ``ROLE_LAYER["hatch"]`` and its + pattern comes from ``standards.materials.hatch_for(material)``. + """ + + loops: tuple[tuple[Pt, ...], ...] + material: str = "steel" + + +class Text(NamedTuple): + at: Pt + text: str + height: float = 3.5 + rotation: float = 0.0 + role: Role = "text" + + +class DimIntent(NamedTuple): + """What a feature wants dimensioned, before any placement decision.""" + + kind: Literal["linear", "aligned", "diameter", "radius", "angular"] + p1: Pt + p2: Pt + text_at: Pt | None = None + text_override: str | None = None + fit: str | None = None + tol: dict | None = None + feature: str = "" + + +Prim = Line | Arc | Circle | Poly | HatchArea | Text + + +def layer_for(prim: Prim) -> str: + """The layer a primitive belongs on. A HATCH has no role of its own.""" + if isinstance(prim, HatchArea): + return ROLE_LAYER["hatch"] + role = getattr(prim, "role", "visible") + try: + return ROLE_LAYER[role] + except KeyError: + raise ValueError(f"unknown line role {role!r}; roles are {', '.join(ROLES)}") from None + + +def _on_circle(center: Pt, radius: float, deg: float) -> Pt: + rad = math.radians(deg) + return (center[0] + radius * math.cos(rad), center[1] + radius * math.sin(rad)) + + +def _arc_points(arc: Arc) -> tuple[Pt, ...]: + """Endpoints plus every axis extreme the sweep actually passes through. + + Without this a 0-180 arc reports a bbox that stops at its endpoints, and + the view layout then puts the next view on top of it. + """ + start = arc.start_deg % 360.0 + sweep = (arc.end_deg - arc.start_deg) % 360.0 + if sweep == 0.0: + sweep = 360.0 + points = [ + _on_circle(arc.center, arc.radius, arc.start_deg), + _on_circle(arc.center, arc.radius, arc.end_deg), + ] + for quadrant in (0.0, 90.0, 180.0, 270.0): + if ((quadrant - start) % 360.0) <= sweep: + points.append(_on_circle(arc.center, arc.radius, quadrant)) + return tuple(points) + + +def points_of(prim: Prim) -> tuple[Pt, ...]: + """Every point that bounds a primitive. + + A ``Text`` reports only its anchor: the rendered width depends on the style + and the engine, and a guessed box is a silent wrong number. + """ + if isinstance(prim, Line): + return (prim.p1, prim.p2) + if isinstance(prim, Arc): + return _arc_points(prim) + if isinstance(prim, Circle): + cx, cy = prim.center + r = prim.radius + return ((cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)) + if isinstance(prim, Poly): + return tuple(prim.points) + if isinstance(prim, HatchArea): + return tuple(pt for loop in prim.loops for pt in loop) + if isinstance(prim, Text): + return (prim.at,) + raise TypeError(f"not a primitive: {prim!r}") + + +def bbox(prims: Iterable[Prim]) -> tuple[float, float, float, float]: + """``(minx, miny, maxx, maxy)`` over every primitive. Empty is refused.""" + xs: list[float] = [] + ys: list[float] = [] + for prim in prims: + for x, y in points_of(prim): + xs.append(float(x)) + ys.append(float(y)) + if not xs: + raise ValueError("bbox: no primitives to bound") + return (min(xs), min(ys), max(xs), max(ys)) + + +def _map_points(prim: Prim, fn) -> Prim: + if isinstance(prim, Line): + return prim._replace(p1=fn(prim.p1), p2=fn(prim.p2)) + if isinstance(prim, (Arc, Circle)): + return prim._replace(center=fn(prim.center)) + if isinstance(prim, Poly): + return prim._replace(points=tuple(fn(p) for p in prim.points)) + if isinstance(prim, HatchArea): + return prim._replace(loops=tuple(tuple(fn(p) for p in loop) for loop in prim.loops)) + if isinstance(prim, Text): + return prim._replace(at=fn(prim.at)) + raise TypeError(f"not a primitive: {prim!r}") + + +def translate(prims: Iterable[Prim], dx: float, dy: float) -> tuple[Prim, ...]: + def move(p: Pt) -> Pt: + return (p[0] + dx, p[1] + dy) + + return tuple(_map_points(prim, move) for prim in prims) + + +def rotate(prims: Iterable[Prim], deg: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...]: + cos = math.cos(math.radians(deg)) + sin = math.sin(math.radians(deg)) + ox, oy = about + + def turn(p: Pt) -> Pt: + dx = p[0] - ox + dy = p[1] - oy + return (ox + dx * cos - dy * sin, oy + dx * sin + dy * cos) + + out: list[Prim] = [] + for prim in prims: + moved = _map_points(prim, turn) + if isinstance(moved, Arc): + moved = moved._replace( + start_deg=moved.start_deg + deg, end_deg=moved.end_deg + deg + ) + elif isinstance(moved, Text): + moved = moved._replace(rotation=moved.rotation + deg) + out.append(moved) + return tuple(out) + + +def scale(prims: Iterable[Prim], factor: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...]: + if not math.isfinite(factor) or factor <= 0.0: + raise ValueError(f"scale factor must be finite and positive, got {factor!r}") + ox, oy = about + + def grow(p: Pt) -> Pt: + return (ox + (p[0] - ox) * factor, oy + (p[1] - oy) * factor) + + out: list[Prim] = [] + for prim in prims: + moved = _map_points(prim, grow) + if isinstance(moved, (Arc, Circle)): + moved = moved._replace(radius=moved.radius * factor) + elif isinstance(moved, Text): + moved = moved._replace(height=moved.height * factor) + out.append(moved) + return tuple(out) +``` + +`engineering/mech/__init__.py`: + +```python +"""The mechanical part model, view engine and standards tables. + +Merge rule for the four track-B/G branches: the re-exports below are additive +and alphabetical — keep every side's names. +""" + +from __future__ import annotations + +from .primitives import ( + ROLE_LAYER, + ROLES, + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Prim, + Pt, + Role, + Text, + bbox, + layer_for, + points_of, + rotate, + scale, + translate, +) + +__all__ = [ + "ROLES", + "ROLE_LAYER", + "Arc", + "Circle", + "DimIntent", + "HatchArea", + "Line", + "Poly", + "Prim", + "Pt", + "Role", + "Text", + "bbox", + "layer_for", + "points_of", + "rotate", + "scale", + "translate", +] +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_primitives.py -q` +Expected: 12 passed. + +- [ ] **Step 5: Write the failing test for the XDATA codec** + +Create `tests/test_mech_xdata.py`: + +```python +"""The ACADMCP_MECH chunking codec: Track A's shape, one app, 255-char chunks.""" + +from __future__ import annotations + +import pytest + +from backends.xdata_specs import encode_values +from engineering.mech.xdata import APP_ID, KINDS, PAYLOAD_VERSION, decode, encode, to_values + +PART = { + "v": 1, + "kind": "part", + "id": "1A2B", + "view": "front", + "part": {"name": "shaft", "segments": [{"length": 60.0, "d_outer": 40.0}]}, +} + + +def test_app_id_version_and_kinds_are_the_pinned_ones(): + assert APP_ID == "ACADMCP_MECH" + assert PAYLOAD_VERSION == 1 + assert KINDS == ("part", "std_part", "balloon") + + +def test_encode_opens_with_the_app_id_and_chunks_at_255(): + rows = encode({"v": 1, "kind": "balloon", "item": 3, "targets": ["1A2B"] * 60}) + assert rows[0] == (1001, APP_ID) + assert all(code == 1000 for code, _value in rows[1:]) + assert len(rows) > 2, "the payload must actually have been split" + assert all(len(value) <= 255 for _code, value in rows[1:]) + + +def test_roundtrip_is_exact(): + assert decode(encode(PART)) == PART + + +def test_decode_reads_the_bare_value_list_a_backend_returns(): + """`entity_get_xdata` hands back decoded values, not (code, value) rows.""" + assert decode(to_values(PART)) == PART + + +def test_the_version_is_injected_when_absent_and_a_wrong_one_is_refused(): + payload = { + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + assert decode(encode(payload))["v"] == PAYLOAD_VERSION + with pytest.raises(ValueError, match="version"): + encode({**payload, "v": 99}) + + +def test_a_foreign_application_is_refused(): + with pytest.raises(ValueError, match="ACADMCP_PID"): + decode([(1001, "ACADMCP_PID"), (1000, '{"v":1,"kind":"part"}')]) + + +def test_a_corrupt_payload_is_refused_not_silently_dropped(): + with pytest.raises(ValueError, match="not JSON"): + decode([(1001, APP_ID), (1000, '{"v":1,"kind":')]) + + +def test_an_unknown_kind_is_refused_on_both_sides(): + with pytest.raises(ValueError, match="kind"): + encode({"v": 1, "kind": "sheet_frame"}) + with pytest.raises(ValueError, match="kind"): + decode([(1001, APP_ID), (1000, '{"v":1,"kind":"sheet_frame"}')]) + + +def test_the_16_kb_entity_ceiling_is_enforced_before_writing(): + huge = {"v": 1, "kind": "part", "id": "1", "view": "front", "part": {"n": "x" * 20000}} + with pytest.raises(ValueError, match="16 KB"): + encode(huge) + + +def test_every_chunk_is_a_value_the_backend_will_accept(): + """`entity_set_xdata` runs the values through `encode_values`; ASCII-only + JSON means no line separator can reach a 1000 tag and split the DXF.""" + rows = encode(PART) + tags = encode_values(to_values(PART)) + assert all(code == 1000 for code, _value in tags) + assert len(tags) == len(rows) - 1 +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_xdata.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.mech.xdata'`. + +- [ ] **Step 7: Write `engineering/mech/xdata.py`** + +```python +"""``ACADMCP_MECH`` XDATA: the part model written onto the drawing. + +The Track A codec (`engineering/pid/xdata.py`) with its limits made explicit. +XDATA strings cap at 255 characters and an entity carries at most 16 KB across +every application, so a payload is JSON-serialised ASCII-only and split into +1000 chunks under one 1001 application marker. `json.dumps(ensure_ascii=True)` +escapes every newline, so no chunk can carry a line separator into the DXF. + +A payload that is not ours is *refused*, never decoded into something +plausible: a foreign application marker, a chunk stream that is not JSON, a +version this build does not read, or a kind outside `KINDS`. Three shapes are +written, and both the writer and every reader test them literally: + + {"v": 1, "kind": "part", "id": "", "view": "front", "part": {...}} + {"v": 1, "kind": "std_part", "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", "size": "M12x60", "qty": 1, "material": "8.8"} + {"v": 1, "kind": "balloon", "item": 3, "targets": ["1A2B", ...]} +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence + +from backends.xdata_specs import MAX_BYTES, app_size + +APP_ID = "ACADMCP_MECH" +PAYLOAD_VERSION = 1 +CHUNK = 255 +KINDS: tuple[str, ...] = ("part", "std_part", "balloon") + + +def _validate(payload: dict) -> dict: + if not isinstance(payload, dict): + raise TypeError(f"{APP_ID}: a payload must be a dict, got {type(payload).__name__}") + version = payload.get("v", PAYLOAD_VERSION) + if version != PAYLOAD_VERSION: + raise ValueError( + f"{APP_ID}: payload version {version!r}; this build writes and reads " + f"version {PAYLOAD_VERSION}" + ) + kind = payload.get("kind") + if kind not in KINDS: + raise ValueError( + f"{APP_ID}: unknown payload kind {kind!r}; kinds are {', '.join(KINDS)}" + ) + return {**payload, "v": PAYLOAD_VERSION} + + +def encode(payload: dict) -> list[tuple[int, str]]: + """``[(1001, APP_ID), (1000, chunk), ...]`` — refused before anything is written.""" + checked = _validate(payload) + text = json.dumps(checked, separators=(",", ":"), sort_keys=True, ensure_ascii=True) + rows: list[tuple[int, str]] = [(1001, APP_ID)] + rows.extend((1000, text[i : i + CHUNK]) for i in range(0, len(text), CHUNK)) + size = app_size(APP_ID, [(code, value) for code, value in rows if code != 1001]) + if size > MAX_BYTES: + raise ValueError( + f"{APP_ID}: payload encodes to {size} bytes; the limit is 16 KB per entity " + "across every application" + ) + return rows + + +def to_values(payload: dict) -> list[str]: + """Just the chunk strings — what ``entity_set_xdata(handle, APP_ID, values)`` takes.""" + return [value for code, value in encode(payload) if code == 1000] + + +def decode(rows: Sequence) -> dict: + """``rows`` may be ``(code, value)`` pairs or the bare value list a backend + returns from ``entity_get_xdata``. Raises ValueError on anything foreign.""" + if not rows: + raise ValueError(f"{APP_ID}: empty XDATA") + parts: list[str] = [] + for row in rows: + if isinstance(row, str): + parts.append(row) + continue + try: + code, value = row + except (TypeError, ValueError): + raise ValueError(f"{APP_ID}: {row!r} is not an XDATA (code, value) row") from None + if code == 1001: + if str(value) != APP_ID: + raise ValueError( + f"{APP_ID}: this XDATA belongs to application {value!r}, not to us" + ) + continue + if code != 1000: + raise ValueError(f"{APP_ID}: group code {code} is not a payload chunk") + parts.append(str(value)) + try: + payload = json.loads("".join(parts)) + except json.JSONDecodeError as exc: + raise ValueError(f"{APP_ID}: the chunk stream is not JSON ({exc.msg})") from exc + if not isinstance(payload, dict): + raise ValueError( + f"{APP_ID}: the payload decoded to {type(payload).__name__}, not a dict" + ) + return _validate(payload) +``` + +- [ ] **Step 8: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_xdata.py -q` +Expected: 10 passed. + +- [ ] **Step 9: Write the failing test for the standards registry** + +Create `tests/test_mech_standards.py`: + +```python +"""The standards registry: one row or a named refusal, never an interpolation.""" + +from __future__ import annotations + +import pytest + +import engineering.mech.standards as standards_mod +from engineering.mech.standards import ( + Coverage, + coverage_of, + lookup, + register, + source_of, + standards, +) + +SOURCE = "TEST 1 (2026), table 1 — fixture rows, not a real standard" + + +@pytest.fixture(autouse=True) +def _fixture_tables(): + """Swap the process-wide registry for three fixture tables, then restore it.""" + saved = dict(standards_mod._REGISTRY) + standards_mod._REGISTRY.clear() + register( + "TEST 1", + {10: {"d": 10.0, "w": 3.0}, 20: {"d": 20.0, "w": 6.0}}, + Coverage(10.0, 20.0), + SOURCE, + ) + register( + "TEST 2", + {18: ({"form": "E", "r": 0.6}, {"form": "F", "r": 0.8})}, + Coverage(18.0, 18.0), + SOURCE, + ) + register("TEST 3", {"M12": {"pitch": 1.75}}, Coverage(12.0, 12.0), SOURCE) + yield + standards_mod._REGISTRY.clear() + standards_mod._REGISTRY.update(saved) + + +def test_registered_standards_are_listed_and_carry_their_source(): + assert standards() == ("TEST 1", "TEST 2", "TEST 3") + assert source_of("TEST 1") == SOURCE + assert coverage_of("TEST 1") == Coverage(10.0, 20.0, "mm") + + +def test_a_listed_size_returns_a_copy_of_its_row(): + row = lookup("TEST 1", 10) + assert row == {"d": 10.0, "w": 3.0} + row["w"] = 99.0 + assert lookup("TEST 1", 10)["w"] == 3.0 + + +def test_a_size_outside_the_coverage_is_refused_by_name(): + with pytest.raises(ValueError, match="TEST 1 covers 10-20 mm; 95 mm is outside the table"): + lookup("TEST 1", 95) + + +def test_a_size_inside_the_coverage_but_not_in_the_table_is_never_interpolated(): + with pytest.raises(ValueError, match="no row for 15"): + lookup("TEST 1", 15) + + +def test_a_variant_is_selected_by_keyword(): + assert lookup("TEST 2", 18, form="E")["r"] == 0.6 + assert lookup("TEST 2", 18, form="F")["r"] == 0.8 + + +def test_an_unknown_variant_is_refused_and_an_ambiguous_one_too(): + with pytest.raises(ValueError, match="no form='G' row"): + lookup("TEST 2", 18, form="G") + with pytest.raises(ValueError, match="ambiguous"): + lookup("TEST 2", 18) + + +def test_a_designation_key_is_case_insensitive(): + assert lookup("TEST 3", "m12")["pitch"] == 1.75 + + +def test_an_unregistered_standard_names_the_ones_that_exist(): + with pytest.raises(ValueError, match="DIN 9999.*TEST 1"): + lookup("DIN 9999", 10) + + +def test_a_source_that_does_not_name_its_table_is_refused(): + with pytest.raises(ValueError, match="SOURCE"): + register("TEST 4", {1: {"a": 1}}, Coverage(1.0, 1.0), "made up") + + +def test_registering_the_same_standard_twice_is_refused(): + with pytest.raises(ValueError, match="already registered"): + register("TEST 1", {10: {"d": 10.0}}, Coverage(10.0, 10.0), SOURCE) +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_standards.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.mech.standards'`. + +- [ ] **Step 11: Write `engineering/mech/standards/__init__.py`** + +```python +"""Registry for the authored standards tables under ``engineering/mech/standards``. + +Every table registers itself at import with the standard's name, its rows, the +coverage it was transcribed for and a SOURCE line naming standard, edition and +table. ``lookup`` returns a row or refuses by name: a size outside the coverage +is refused against the coverage, and a size *inside* it that is not a listed row +is refused against the rows. Nothing is ever interpolated — the rule +`engineering/fits.py` (ISO 286) already follows, applied to every table here. A +wrong row ships a wrong workshop drawing; a narrow table is merely narrow. + +``rows`` maps a size key (a number in millimetres, or a designation like +``"M12"``) to a row dict, or to a tuple of row dicts when the standard has +variants at the same size (DIN 509 forms E and F, DIN 332 forms A/B/R). ``**kw`` +selects among those variants and is refused when it selects none or more than +one — an ambiguous lookup never quietly picks the first row. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import NamedTuple + + +class Coverage(NamedTuple): + low: float + high: float + unit: str = "mm" + + +@dataclass(frozen=True) +class _Table: + standard: str + rows: dict + coverage: Coverage + source: str + + +_REGISTRY: dict[str, _Table] = {} + + +def _key(value): + if isinstance(value, bool): + raise TypeError("standards: a size key cannot be a bool") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + return value.strip().upper() + raise TypeError(f"standards: a size key must be a number or a designation, got {value!r}") + + +def _variants(value) -> tuple[dict, ...]: + if isinstance(value, dict): + return (dict(value),) + if isinstance(value, (list, tuple)) and value and all(isinstance(v, dict) for v in value): + return tuple(dict(v) for v in value) + raise TypeError("standards: a row must be a dict, or a non-empty tuple of dicts for variants") + + +def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None: + """Add a table. Refuses a duplicate name and a SOURCE that names no table.""" + if not isinstance(standard, str) or not standard.strip(): + raise ValueError("standards.register: standard must be a non-empty name") + if standard in _REGISTRY: + raise ValueError(f"standards.register: {standard!r} is already registered") + if not rows: + raise ValueError(f"standards.register: {standard!r} registered no rows") + if not isinstance(source, str) or "table" not in source.lower(): + raise ValueError( + f"standards.register: {standard!r} needs a SOURCE naming the standard, its " + "edition and the table its rows were transcribed from" + ) + _REGISTRY[standard] = _Table( + standard, {_key(k): _variants(v) for k, v in rows.items()}, coverage, source + ) + + +def standards() -> tuple[str, ...]: + return tuple(sorted(_REGISTRY)) + + +def _table(standard: str) -> _Table: + table = _REGISTRY.get(standard) + if table is None: + known = ", ".join(standards()) or "(none registered)" + raise ValueError(f"standards: {standard!r} is not registered; known: {known}") + return table + + +def coverage_of(standard: str) -> Coverage: + return _table(standard).coverage + + +def source_of(standard: str) -> str: + return _table(standard).source + + +def _num(value: float) -> str: + return str(int(value)) if float(value).is_integer() else str(value) + + +def _listing(table: _Table) -> str: + keys = sorted(table.rows, key=lambda k: (isinstance(k, str), k)) + shown = [k if isinstance(k, str) else _num(k) for k in keys[:12]] + return ", ".join(shown) + (" ..." if len(keys) > 12 else "") + + +def _kw_text(kw: dict) -> str: + return ", ".join(f"{k}={v!r}" for k, v in sorted(kw.items())) + + +def lookup(standard: str, size, **kw) -> dict: + """One row, or a ValueError naming the standard and what it does cover.""" + table = _table(standard) + key = _key(size) + rows = table.rows.get(key) + if rows is None: + if isinstance(key, float) and not (table.coverage.low <= key <= table.coverage.high): + unit = table.coverage.unit + raise ValueError( + f"{standard} covers {_num(table.coverage.low)}-{_num(table.coverage.high)} " + f"{unit}; {_num(key)} {unit} is outside the table" + ) + raise ValueError(f"{standard} has no row for {size!r}; the table lists {_listing(table)}") + matches = [row for row in rows if all(row.get(k) == v for k, v in kw.items())] + if not matches: + offered = sorted({str(row.get(k)) for row in rows for k in kw}) + raise ValueError( + f"{standard} has no {_kw_text(kw)} row for {size!r}; it lists {', '.join(offered)}" + ) + if len(matches) > 1: + discriminators = sorted({k for row in rows for k in row} - set(kw)) + raise ValueError( + f"{standard} row for {size!r} is ambiguous: {len(matches)} variants match; " + f"narrow it with one of {', '.join(discriminators)}" + ) + return dict(matches[0]) +``` + +- [ ] **Step 12: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_standards.py -q` +Expected: 10 passed. + +- [ ] **Step 13: Write the failing test for the material map** + +Create `tests/test_mech_materials.py`: + +```python +"""The section-hatch map: every pattern exists in both engines, every row cites. + +The ezdxf failure this guards is silent: ``Hatch.set_pattern_fill`` falls back +to ANSI31 for a name it does not know (``ezdxf/entities/polygon.py``), so a +typo'd pattern draws steel hatching on a cast-iron part and says nothing. +""" + +from __future__ import annotations + +import pytest +from ezdxf.tools import pattern as ezpattern + +from engineering.mech.standards.materials import ( + ALIASES, + MATERIAL_TABLE, + MATERIALS, + hatch_for, +) + +ANSI31_SPACING = 2.2450640303 # acadiso.pat ANSI31, the ISO 128-50 general hatching + + +def _spacing(name: str, defs: dict) -> float: + """The finest perpendicular line spacing in a pattern definition.""" + return min(abs(line[2][1]) for line in defs[name] if abs(line[2][1]) > 1e-9) + + +def test_the_map_covers_the_nine_named_materials(): + assert MATERIALS == ( + "steel", + "cast_iron", + "aluminium", + "copper_alloy", + "plastic", + "rubber", + "concrete", + "wood", + "insulation", + ) + + +def test_hatch_for_returns_exactly_pattern_angle_scale(): + assert set(hatch_for("steel")) == {"pattern", "angle", "scale"} + + +@pytest.mark.parametrize( + ("material", "expected"), + [ + ("steel", "ANSI31"), + ("cast_iron", "ANSI32"), + ("aluminium", "ANSI38"), + ("copper_alloy", "ANSI33"), + ("plastic", "ANSI34"), + ("rubber", "ANSI34"), + ("insulation", "ANSI37"), + ("concrete", "AR-CONC"), + ("wood", "JIS_WOOD"), + ], +) +def test_each_material_keeps_its_pattern(material, expected): + assert hatch_for(material)["pattern"] == expected + + +def test_aliases_resolve_and_are_case_and_separator_insensitive(): + assert hatch_for("Cast Iron")["pattern"] == "ANSI32" + assert hatch_for("cast-iron")["pattern"] == "ANSI32" + assert hatch_for("aluminum")["pattern"] == "ANSI38" + assert hatch_for("BRASS")["pattern"] == "ANSI33" + assert hatch_for("bronze")["pattern"] == "ANSI33" + assert hatch_for("copper")["pattern"] == "ANSI33" + assert set(ALIASES.values()) <= set(MATERIALS) + + +def test_an_unknown_material_is_refused_with_the_list(): + with pytest.raises(ValueError, match="unobtainium.*cast_iron"): + hatch_for("unobtainium") + + +def test_every_pattern_exists_in_both_measurement_sets(): + """Guards ezdxf's silent ANSI31 fallback and AutoCAD's unknown-pattern error.""" + iso = ezpattern.load(measurement=1) + imperial = ezpattern.load(measurement=0) + for material in MATERIALS: + name = hatch_for(material)["pattern"] + assert name in iso, f"{material}: {name} is not an ezdxf ISO pattern" + assert name in imperial, f"{material}: {name} is not an ezdxf imperial pattern" + + +def test_the_map_adds_no_rotation_because_the_patterns_already_carry_45_degrees(): + defs = ezpattern.load(measurement=1) + for material in MATERIALS: + row = hatch_for(material) + assert row["angle"] == 0.0 + if row["pattern"].startswith("ANSI3"): + assert defs[row["pattern"]][0][0] == 45.0 + + +def test_the_ansi_rows_ship_at_the_pattern_definition_scale(): + for material in ( + "steel", + "cast_iron", + "aluminium", + "copper_alloy", + "plastic", + "rubber", + "insulation", + ): + assert hatch_for(material)["scale"] == 1.0 + + +def test_the_two_non_ansi_patterns_are_scaled_to_the_iso_line_spacing(): + """Concrete and wood are architectural patterns: AR-CONC is defined at + building scale and JIS_WOOD at 0.5 mm. Their scale is derived, not chosen — + the finest line in the pattern is brought to ANSI31's spacing.""" + defs = ezpattern.load(measurement=1) + assert abs(_spacing("ANSI31", defs) - ANSI31_SPACING) < 1e-6 + for material in ("concrete", "wood"): + row = hatch_for(material) + derived = round(ANSI31_SPACING / _spacing(row["pattern"], defs), 3) + assert row["scale"] == derived + + +def test_every_row_states_where_its_symbol_comes_from(): + for material in MATERIALS: + source = MATERIAL_TABLE[material]["source"] + assert any(token in source for token in ("ISO 128-50", "ASME Y14.2M", "acadiso.pat")) +``` + +- [ ] **Step 14: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_materials.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.mech.standards.materials'`. + +- [ ] **Step 15: Write `engineering/mech/standards/materials.py`** + +```python +"""Material -> section hatch (pattern, angle, scale). + +SOURCE, stated exactly, because it is not one document: + +* ISO 128-50:2020, clause 5 (representation of areas on cuts and sections) + standardises the *general* hatching only — narrow continuous lines, drawn + preferably at 45 degrees to the principal outline — and leaves distinguishing + materials to other conventions. ``ANSI31`` in AutoCAD 2026's ``acadiso.pat`` + is exactly that line set (45 deg, 2.245 mm apart), and it is what + ``engineering/gear.py`` already draws for a steel section, so `steel` keeps + it. +* The distinguishing symbols are ASME Y14.2M's section-lining symbols as + shipped in ``acadiso.pat``, whose own descriptions name the material — read + off the installed file on this machine (AutoCAD 2026, + ``R25.1/enu/Support/acadiso.pat``): ``*ANSI32,ANSI Steel``, + ``*ANSI33,ANSI Bronze, Brass, Copper``, ``*ANSI34,ANSI Plastic, Rubber``, + ``*ANSI37,ANSI Lead, Zinc, Magnesium, Sound/Heat/Elec Insulation``, + ``*ANSI38,ANSI Aluminum``. +* ``cast_iron`` is the one deliberate departure and says so in its own row: + ASME's cast-iron symbol is ANSI31, which this map reserves for the ISO + general hatching, so cast iron takes ANSI32 to stay *distinguishable* on the + sheet. That is a repository convention, not a standard symbol — the material + is identified by the parts-list entry, never by the pattern alone. +* ``concrete`` and ``wood`` have no mechanical section symbol in either ISO + 128-50 or ASME Y14.2M. They use AutoCAD's own ``AR-CONC`` and ``JIS_WOOD`` + (``*JIS_WOOD, WOOD JIS A 0150``), declared as such in their source line. + +``angle`` is the rotation *added* to the pattern, and it is 0.0 everywhere: the +ANSI3x definitions already carry 45 degrees, so an angle of 45 would draw steel +hatching vertically. ``scale`` is 1.0 for the ANSI family (one scaled set) and, +for the two architectural patterns, is derived so the finest line in the +pattern matches ANSI31's 2.245 mm spacing — AR-CONC is drawn at building scale +and JIS_WOOD at 0.5 mm, so 1.0 would be unreadable on a 40 mm part. Both +derived numbers are recomputed from ezdxf's own pattern definitions by +``tests/test_mech_materials.py`` rather than trusted here. +""" + +from __future__ import annotations + +MATERIAL_TABLE: dict[str, dict] = { + "steel": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 1.0, + "source": ( + "ISO 128-50:2020 general hatching (45 deg narrow continuous lines) " + "= acadiso.pat ANSI31" + ), + }, + "cast_iron": { + "pattern": "ANSI32", + "angle": 0.0, + "scale": 1.0, + "source": ( + "acadiso.pat ANSI32 (ASME Y14.2M steel symbol); repository convention so cast " + "iron stays distinguishable from the ISO general hatching reserved for steel" + ), + }, + "aluminium": { + "pattern": "ANSI38", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI38,ANSI Aluminum", + }, + "copper_alloy": { + "pattern": "ANSI33", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI33,ANSI Bronze, Brass, Copper", + }, + "plastic": { + "pattern": "ANSI34", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI34,ANSI Plastic, Rubber", + }, + "rubber": { + "pattern": "ANSI34", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI34,ANSI Plastic, Rubber", + }, + "concrete": { + "pattern": "AR-CONC", + "angle": 0.0, + "scale": 0.392, + "source": ( + "no mechanical section symbol in ISO 128-50 or ASME Y14.2M; acadiso.pat " + "*AR-CONC, random dot and stone pattern, scaled to the ANSI31 line spacing" + ), + }, + "wood": { + "pattern": "JIS_WOOD", + "angle": 0.0, + "scale": 4.49, + "source": ( + "no mechanical section symbol in ISO 128-50 or ASME Y14.2M; acadiso.pat " + "*JIS_WOOD, WOOD JIS A 0150, scaled to the ANSI31 line spacing" + ), + }, + "insulation": { + "pattern": "ANSI37", + "angle": 0.0, + "scale": 1.0, + "source": ( + "ASME Y14.2M section lining = acadiso.pat *ANSI37,ANSI Lead, Zinc, Magnesium, " + "Sound/Heat/Elec Insulation" + ), + }, +} + +MATERIALS: tuple[str, ...] = tuple(MATERIAL_TABLE) + +#: Spellings a caller will reasonably type, mapped to a canonical name. +ALIASES: dict[str, str] = { + "aluminum": "aluminium", + "alu": "aluminium", + "brass": "copper_alloy", + "bronze": "copper_alloy", + "copper": "copper_alloy", + "cast_steel": "steel", + "grey_cast_iron": "cast_iron", + "gg": "cast_iron", + "ductile_iron": "cast_iron", + "polymer": "plastic", + "elastomer": "rubber", +} + + +def _canonical(material: str) -> str: + if not isinstance(material, str): + raise TypeError(f"material must be a name, got {type(material).__name__}") + name = material.strip().lower().replace("-", "_").replace(" ", "_") + name = ALIASES.get(name, name) + if name not in MATERIAL_TABLE: + raise ValueError( + f"unknown material {material!r}; the ISO 128-50 section-hatch map covers " + f"{', '.join(MATERIALS)}" + ) + return name + + +def hatch_for(material: str) -> dict: + """``{"pattern", "angle", "scale"}`` for a material. Unknown names are refused.""" + row = MATERIAL_TABLE[_canonical(material)] + return {"pattern": row["pattern"], "angle": row["angle"], "scale": row["scale"]} +``` + +- [ ] **Step 16: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_materials.py -q` +Expected: 18 passed (nine parametrised cases plus nine others). + +- [ ] **Step 17: Run the new files together with the tests that could be broken by the new imports** + +Run: `uv run --frozen pytest tests/test_mech_primitives.py tests/test_mech_xdata.py tests/test_mech_standards.py tests/test_mech_materials.py tests/test_xdata.py tests/test_engineering_gear.py -q` +Expected: all pass. `tests/test_xdata.py` is in the list because `engineering/mech/xdata.py` imports `backends/xdata_specs.py`; a circular import would surface here and nowhere else. + +- [ ] **Step 18: Gates** + +Run, in order: + +``` +uv run --frozen ruff format engineering/mech tests/test_mech_primitives.py tests/test_mech_xdata.py tests/test_mech_standards.py tests/test_mech_materials.py +uv run --frozen ruff check engineering/mech tests/test_mech_primitives.py tests/test_mech_xdata.py tests/test_mech_standards.py tests/test_mech_materials.py +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest tests/test_capability_contract.py tests/test_tool_registry.py tests/test_discovery_aliases.py -q +``` + +Expected: ruff clean; the suite green; the three gate files green. No tool is registered in this task, so `docs/tool-inventory.json` does not change and the README tool count does not move. The deselected test is the known collected-tests drift named in the Global Constraints — Task 15 retakes that figure; record the deselection in `deviations`. + +- [ ] **Step 19: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(mech): shared foundation - primitives, XDATA codec, standards registry, ISO 128-50 materials + +engineering/mech/ opens with the four modules every track-B/G branch imports, +so the four worktrees can be cut from this commit and none of them has to +invent a name the others will conflict with. + +- primitives.py: the typed descriptions the pure modules emit (Line, Arc, + Circle, Poly, HatchArea, Text, DimIntent) with bbox / translate / rotate / + scale, and ROLE_LAYER - the one place an ISO 128 line role becomes a layer + name, so no module hardcodes a layer. The arc bbox sweeps its quadrants: a + 0-180 arc reports y=+r, not its endpoints, which is what keeps the view + layout from stacking two views on top of each other. +- xdata.py: the ACADMCP_MECH codec in Track A's shape - a 1001 application + marker, 1000 chunks of at most 255 ASCII characters, the 16 KB per-entity + ceiling enforced before anything is written, and a foreign application, a + corrupt chunk stream, a wrong version or an unknown kind refused with + ValueError instead of decoded into something plausible. +- standards/__init__.py: the table registry. A size outside the coverage is + refused against the coverage; a size inside it that is not a listed row is + refused against the rows. Nothing is interpolated, an ambiguous variant is + never resolved by taking the first row, and a registration whose SOURCE does + not name the table it was transcribed from is refused. +- standards/materials.py: the section-hatch map for nine materials. ISO + 128-50 standardises only the general hatching (narrow continuous lines at 45 + degrees) - that is acadiso.pat ANSI31 and what engineering/gear.py already + draws for steel. The distinguishing symbols are ASME Y14.2M's section lining + as shipped in AutoCAD 2026's acadiso.pat, read off the installed file; cast + iron's departure from ASME and the absence of any mechanical symbol for + concrete and wood are stated in the rows themselves. Angles are 0.0 because + the patterns already carry 45 degrees, and the two architectural patterns' + scales are derived from the ANSI31 line spacing and recomputed by the test + rather than chosen. + +No tool is registered here. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 2: The part model — RevolvedPart, PrismaticPart, build_part and its refusals + +**Files:** +- Create: `engineering/mech/part.py` +- Create: `tests/test_mech_part.py` +- Modify: `engineering/mech/__init__.py` (re-export the part model) +- Test: `tests/test_mech_part.py` + +**Interfaces:** +- Consumes (Task 1, already on `main`): + `engineering.mech.primitives.Pt = tuple[float, float]`; + `engineering.mech.standards.materials.hatch_for(material) -> dict` and + `MATERIALS: tuple[str, ...]`. +- Consumes (already in the repository, unchanged): + `engineering.measure.is_self_intersecting(vertices: Sequence[tuple[float, float]]) -> bool | None` + (`True` = crossed, `False` = clean, `None` = more than 2000 vertices, not checked). +- Produces (Tasks 3–7 consume exactly these): + ```python + class Segment(NamedTuple): + length: float; d_outer: float; d_inner: float = 0.0; taper_to: float | None = None + @dataclass(frozen=True) + class RevolvedPart: + name: str; segments: tuple[Segment, ...]; features: tuple[Any, ...] = () + material: str = "steel" + @dataclass(frozen=True) + class PrismaticPart: + name: str; outline: tuple[Pt, ...]; thickness: float; features: tuple[Any, ...] = () + material: str = "steel" + Part = RevolvedPart | PrismaticPart + PART_KINDS: tuple[str, ...] # ("revolved", "prismatic") + def build_part(spec: dict) -> Part # ValueError(": ") + def part_to_dict(part: Part) -> dict # round-trips through build_part + def part_length(part: Part) -> float + def part_max_diameter(part: RevolvedPart) -> float + def validate_part(part: Part) -> None # feature validation + overlap gate + def segment_bounds(part: RevolvedPart) -> tuple[tuple[float, float], ...] + def outer_radius_at(part: RevolvedPart, x: float) -> float + def inner_radius_at(part: RevolvedPart, x: float) -> float + def outline_bbox(part: PrismaticPart) -> tuple[float, float, float, float] + def point_in_outline(part: PrismaticPart, x: float, y: float) -> bool + ``` + `features` is annotated `tuple[Any, ...]` rather than the skeleton's + `tuple[Feature, ...]`: `Feature` is a Protocol in `engineering/mech/features.py` + (Task 3), which imports this module, so naming it here would be a circular + import. The surface is duck-typed and `validate_part` checks it at runtime, so + every caller written against the pinned annotation is unaffected. + The duck-typed feature surface `build_part` relies on (Task 3 implements it): + `feature.id: str`, `feature.validate(part) -> None`, `feature.to_dict() -> dict`, + optional `feature.removal_box(part) -> tuple[float, float, float, float] | None`, + optional `feature.expand() -> tuple[Feature, ...]`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_mech_part.py`: + +```python +"""The part model: construction, round-trip and every refusal the spec names. + +Pure arithmetic - no backend, no DXF. The feature surface is exercised through +a local stub so this module's gate (placement on the part, two features +removing the same material) is pinned before `engineering/mech/features.py` +exists. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + Segment, + build_part, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + part_max_diameter, + part_to_dict, + point_in_outline, + segment_bounds, + validate_part, +) + +EPS = 1e-9 + +SHAFT = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [ + {"length": 20.0, "d_outer": 25.0}, + {"length": 40.0, "d_outer": 30.0, "d_inner": 10.0}, + {"length": 15.0, "d_outer": 20.0, "taper_to": 16.0}, + ], +} + +PLATE = { + "kind": "prismatic", + "name": "plate", + "material": "aluminium", + "thickness": 8.0, + "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]], +} + + +@dataclass(frozen=True, kw_only=True) +class StubFeature: + """The duck-typed surface `build_part` uses, and nothing else.""" + + id: str + segment: int = 0 + box: tuple[float, float, float, float] | None = None + + def validate(self, part) -> None: + if not 0 <= self.segment < len(part.segments): + raise ValueError( + f"segment {self.segment} is outside the {len(part.segments)}-segment part " + f"(segments 0-{len(part.segments) - 1})." + ) + + def removal_box(self, part): + return self.box + + def to_dict(self) -> dict: + return {"kind": "stub", "id": self.id, "segment": self.segment} + + +def test_build_part_makes_a_revolved_part_with_typed_segments(): + part = build_part(SHAFT) + assert isinstance(part, RevolvedPart) + assert part.name == "shaft" + assert part.material == "steel" + assert part.segments[1] == Segment(40.0, 30.0, 10.0, None) + assert part.segments[2].taper_to == 16.0 + assert part.features == () + + +def test_build_part_makes_a_prismatic_part_and_drops_a_repeated_closing_vertex(): + closed = dict(PLATE, outline=PLATE["outline"] + [[0.0, 0.0]]) + part = build_part(closed) + assert isinstance(part, PrismaticPart) + assert len(part.outline) == 4 + assert part.thickness == 8.0 + assert outline_bbox(part) == (0.0, 0.0, 80.0, 50.0) + + +@pytest.mark.parametrize("spec", [SHAFT, PLATE]) +def test_part_to_dict_round_trips_through_build_part(spec): + part = build_part(spec) + assert build_part(part_to_dict(part)) == part + + +def test_geometry_helpers_report_the_real_numbers(): + part = build_part(SHAFT) + assert part_length(part) == 75.0 + assert part_max_diameter(part) == 30.0 + assert segment_bounds(part) == ((0.0, 20.0), (20.0, 60.0), (60.0, 75.0)) + assert outer_radius_at(part, 10.0) == 12.5 + assert inner_radius_at(part, 30.0) == 5.0 + assert inner_radius_at(part, 10.0) == 0.0 + # the taper runs 20 -> 16 mm over the last 15 mm, so its mid-point is 18 mm + assert outer_radius_at(part, 67.5) == pytest.approx(9.0, abs=EPS) + plate = build_part(PLATE) + assert part_length(plate) == 80.0 + assert point_in_outline(plate, 40.0, 25.0) is True + assert point_in_outline(plate, 90.0, 25.0) is False + + +@pytest.mark.parametrize("bad", [float("inf"), float("nan"), 0.0, -5.0]) +def test_a_non_finite_or_non_positive_number_is_refused_by_path(bad): + spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]]) + spec["segments"][2]["d_outer"] = bad + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[2].d_outer" in str(excinfo.value) + + +def test_a_bore_that_is_not_smaller_than_the_outside_is_refused(): + spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]]) + spec["segments"][1]["d_inner"] = 30.0 + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[1].d_inner" in str(excinfo.value) + assert "two segments" in str(excinfo.value) + + +def test_an_outline_with_fewer_than_three_distinct_vertices_is_refused(): + spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 0.0], [10.0, 0.0]]) + with pytest.raises(ValueError, match="at least 3 distinct vertices"): + build_part(spec) + + +def test_a_self_intersecting_outline_is_refused(): + spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 10.0]]) + with pytest.raises(ValueError, match="crosses itself"): + build_part(spec) + + +def test_an_unknown_material_is_refused(): + with pytest.raises(ValueError, match="material"): + build_part(dict(SHAFT, material="unobtainium")) + + +def test_a_feature_off_the_part_is_refused_by_its_own_name(): + part = RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=(StubFeature(id="keyway_1", segment=5),), + ) + with pytest.raises(ValueError) as excinfo: + validate_part(part) + message = str(excinfo.value) + assert "keyway_1" in message + assert "segment 5" in message + assert "segments 0-2" in message + + +def test_two_features_removing_the_same_material_are_refused_with_both_names(): + part = RevolvedPart( + name="shaft", + segments=(Segment(60.0, 30.0),), + features=( + StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)), + StubFeature(id="groove_b", box=(12.0, 13.0, 16.0, 15.0)), + ), + ) + with pytest.raises(ValueError) as excinfo: + validate_part(part) + message = str(excinfo.value) + assert "groove_a" in message and "groove_b" in message + assert "same material" in message + + +def test_features_that_only_touch_are_not_an_overlap(): + part = RevolvedPart( + name="shaft", + segments=(Segment(60.0, 30.0),), + features=( + StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)), + StubFeature(id="groove_b", box=(14.0, 12.0, 18.0, 15.0)), + ), + ) + validate_part(part) + + +def test_an_unknown_kind_is_refused(): + with pytest.raises(ValueError, match="kind"): + build_part(dict(SHAFT, kind="lofted")) + + +def test_an_unknown_segment_key_is_refused_by_name(): + spec = dict(SHAFT, segments=[{"length": 10.0, "d_outer": 20.0, "diameter": 20.0}]) + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[0]" in str(excinfo.value) + assert "diameter" in str(excinfo.value) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_part.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.part'` — Task 1 created +the `engineering/mech/` package but no `part.py`. + +- [ ] **Step 3: Write `engineering/mech/part.py`** + +```python +"""The mechanical part model: a profile plus a list of typed features. + +Pure: no backend, no async, no DXF. A part is either **revolved** (a stack of +segments along +X, the axis at y = 0, the left end at x = 0) or **prismatic** +(a closed outline in part-local XY plus a thickness along +Z). Every coordinate +here is part-local; `engineering/mech/draw.py` is the only module that maps +part-local to WCS. + +Everything is validated before anything is drawn (the repository's +validate-first rule), and every refusal names the offending path the way +`pid_from_spec` does: ``segments[2].d_outer: ...``. + +The feature surface is duck-typed on purpose, so this module does not import +`features.py` at module scope and the two are testable independently: + + feature.id -> str + feature.validate(part) -> None, ValueError naming what is wrong + feature.to_dict() -> dict accepted by feature_from_dict + feature.removal_box(part) -> (x0, y0, x1, y1) | None (optional) + feature.expand() -> tuple[Feature, ...] (optional) +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace +from typing import Any, NamedTuple + +from engineering.measure import is_self_intersecting +from engineering.mech.primitives import Pt +from engineering.mech.standards.materials import hatch_for + +_EPS = 1e-9 + + +class Segment(NamedTuple): + """One turned step of a revolved part, left to right along +X.""" + + length: float + d_outer: float + d_inner: float = 0.0 + taper_to: float | None = None + + +@dataclass(frozen=True) +class RevolvedPart: + name: str + segments: tuple[Segment, ...] + features: tuple[Any, ...] = () + material: str = "steel" + + +@dataclass(frozen=True) +class PrismaticPart: + name: str + outline: tuple[Pt, ...] + thickness: float + features: tuple[Any, ...] = () + material: str = "steel" + + +Part = RevolvedPart | PrismaticPart + +PART_KINDS: tuple[str, ...] = ("revolved", "prismatic") + +_SEGMENT_KEYS = frozenset({"length", "d_outer", "d_inner", "taper_to"}) + + +# -- number guards ----------------------------------------------------------- + + +def _finite(value: Any, path: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{path}: {value!r} is not a finite number.") + return number + + +def _positive(value: Any, path: str) -> float: + number = _finite(value, path) + if number <= _EPS: + raise ValueError(f"{path}: must be greater than zero, got {number}.") + return number + + +def _non_negative(value: Any, path: str) -> float: + number = _finite(value, path) + if number < -_EPS: + raise ValueError(f"{path}: must not be negative, got {number}.") + return max(number, 0.0) + + +# -- geometry helpers (Tasks 3-7 read the part through these) ---------------- + + +def segment_bounds(part: RevolvedPart) -> tuple[tuple[float, float], ...]: + """``((x0, x1), ...)`` per segment, left to right from x = 0.""" + bounds: list[tuple[float, float]] = [] + x = 0.0 + for segment in part.segments: + bounds.append((x, x + segment.length)) + x += segment.length + return tuple(bounds) + + +def part_length(part: Part) -> float: + if isinstance(part, RevolvedPart): + return float(sum(segment.length for segment in part.segments)) + x0, _, x1, _ = outline_bbox(part) + return x1 - x0 + + +def part_max_diameter(part: RevolvedPart) -> float: + diameters = [segment.d_outer for segment in part.segments] + diameters += [s.taper_to for s in part.segments if s.taper_to is not None] + return float(max(diameters)) + + +def _segment_at(part: RevolvedPart, x: float) -> tuple[int, float, float, Segment]: + for index, ((x0, x1), segment) in enumerate(zip(segment_bounds(part), part.segments)): + if x0 - _EPS <= x <= x1 + _EPS: + return index, x0, x1, segment + raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).") + + +def outer_radius_at(part: RevolvedPart, x: float) -> float: + """Outer radius at an axial station, honouring a segment's taper.""" + _, x0, x1, segment = _segment_at(part, float(x)) + if segment.taper_to is None: + return segment.d_outer / 2.0 + span = x1 - x0 + t = 0.0 if span <= _EPS else (float(x) - x0) / span + return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0 + + +def inner_radius_at(part: RevolvedPart, x: float) -> float: + """Bore radius at an axial station; 0.0 where the part is solid.""" + _, _, _, segment = _segment_at(part, float(x)) + return segment.d_inner / 2.0 + + +def outline_bbox(part: PrismaticPart) -> tuple[float, float, float, float]: + xs = [p[0] for p in part.outline] + ys = [p[1] for p in part.outline] + return (min(xs), min(ys), max(xs), max(ys)) + + +def point_in_outline(part: PrismaticPart, x: float, y: float) -> bool: + """Even-odd ray cast against the closed outline; a point on an edge counts as in.""" + points = part.outline + count = len(points) + inside = False + for i in range(count): + ax, ay = points[i] + bx, by = points[(i + 1) % count] + cross = (bx - ax) * (y - ay) - (by - ay) * (x - ax) + on_span = min(ax, bx) - _EPS <= x <= max(ax, bx) + _EPS + on_span = on_span and min(ay, by) - _EPS <= y <= max(ay, by) + _EPS + if abs(cross) < 1e-9 and on_span: + return True + if (ay > y) != (by > y): + t = (y - ay) / (by - ay) + if x < ax + t * (bx - ax): + inside = not inside + return inside + + +# -- construction ------------------------------------------------------------ + + +def _segment_from(index: int, raw: Any) -> Segment: + if not isinstance(raw, dict): + raise ValueError(f"segments[{index}]: expected a dict, got {type(raw).__name__}.") + unknown = set(raw) - _SEGMENT_KEYS + if unknown: + raise ValueError( + f"segments[{index}]: unknown key(s) {sorted(unknown)}; " + f"a segment takes {sorted(_SEGMENT_KEYS)}." + ) + length = _positive(raw.get("length"), f"segments[{index}].length") + d_outer = _positive(raw.get("d_outer"), f"segments[{index}].d_outer") + d_inner = _non_negative(raw.get("d_inner") or 0.0, f"segments[{index}].d_inner") + if d_inner >= d_outer - _EPS: + raise ValueError( + f"segments[{index}].d_inner: {d_inner} mm is not smaller than d_outer " + f"{d_outer} mm. A bore that changes diameter is two segments." + ) + taper_to = raw.get("taper_to") + if taper_to is not None: + taper_to = _positive(taper_to, f"segments[{index}].taper_to") + if taper_to <= d_inner + _EPS: + raise ValueError( + f"segments[{index}].taper_to: {taper_to} mm is not larger than the bore " + f"{d_inner} mm." + ) + return Segment(length, d_outer, d_inner, taper_to) + + +def _outline_from(raw: Any) -> tuple[Pt, ...]: + if not isinstance(raw, (list, tuple)) or not raw: + raise ValueError("outline: expected a non-empty list of [x, y] points.") + points: list[Pt] = [] + for index, item in enumerate(raw): + if not isinstance(item, (list, tuple)) or len(item) != 2: + raise ValueError(f"outline[{index}]: expected [x, y], got {item!r}.") + points.append( + ( + _finite(item[0], f"outline[{index}].x"), + _finite(item[1], f"outline[{index}].y"), + ) + ) + if len(points) > 1 and math.dist(points[0], points[-1]) < _EPS: + points = points[:-1] + distinct: list[Pt] = [] + for point in points: + if not distinct or math.dist(distinct[-1], point) > _EPS: + distinct.append(point) + if len(distinct) < 3: + raise ValueError( + f"outline: a closed outline needs at least 3 distinct vertices, got {len(distinct)}." + ) + if is_self_intersecting(distinct) is True: + raise ValueError( + "outline: the closed outline crosses itself. A crossed loop has no single " + "enclosed area, so neither the view engine nor the section hatch can be right." + ) + return tuple(distinct) + + +def _features_from(raw: Any) -> tuple[Any, ...]: + from engineering.mech.features import feature_from_dict # local: avoids an import cycle + + if raw is None or raw == (): + return () + if not isinstance(raw, (list, tuple)): + raise ValueError("features: expected a list of feature dicts.") + built: list[Any] = [] + seen: set[str] = set() + for index, item in enumerate(raw): + if not isinstance(item, dict): + raise ValueError(f"features[{index}]: expected a dict with a 'kind' key.") + try: + feature = feature_from_dict(item) + expand = getattr(feature, "expand", None) + expanded = tuple(expand()) if callable(expand) else (feature,) + except ValueError as exc: + raise ValueError(f"features[{index}]: {exc}") from exc + for one in expanded: + if one.id in seen: + raise ValueError(f"features[{index}]: duplicate feature id {one.id!r}.") + seen.add(one.id) + built.append(one) + return tuple(built) + + +def validate_part(part: Part) -> None: + """Every feature validates against the part, and no two remove the same material.""" + for index, feature in enumerate(part.features): + try: + feature.validate(part) + except ValueError as exc: + raise ValueError(f"features[{index}] ({feature.id}): {exc}") from exc + + boxes: list[tuple[str, tuple[float, float, float, float]]] = [] + for feature in part.features: + getter = getattr(feature, "removal_box", None) + box = getter(part) if callable(getter) else None + if box is not None: + x0, y0, x1, y1 = (float(v) for v in box) + boxes.append((feature.id, (x0, y0, x1, y1))) + + for a in range(len(boxes)): + id_a, (ax0, ay0, ax1, ay1) = boxes[a] + for b in range(a + 1, len(boxes)): + id_b, (bx0, by0, bx1, by1) = boxes[b] + ox0, ox1 = max(ax0, bx0), min(ax1, bx1) + oy0, oy1 = max(ay0, by0), min(ay1, by1) + if ox1 - ox0 > _EPS and oy1 - oy0 > _EPS: + raise ValueError( + f"features {id_a!r} and {id_b!r} remove the same material " + f"(overlap x {ox0:.3f}..{ox1:.3f}, y {oy0:.3f}..{oy1:.3f}). " + "Move one of them, or merge them into a single feature." + ) + + +def build_part(spec: dict) -> Part: + """Validate a part spec and return the typed model. ValueError names the path.""" + if not isinstance(spec, dict): + raise ValueError(f"spec: expected a dict describing the part, got {type(spec).__name__}.") + kind = str(spec.get("kind", "")).strip().lower() + if kind not in PART_KINDS: + raise ValueError(f"kind: expected one of {PART_KINDS}, got {spec.get('kind')!r}.") + name = spec.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError("name: a non-empty part name is required.") + material = str(spec.get("material", "steel")) + try: + hatch_for(material) + except ValueError as exc: + raise ValueError(f"material: {exc}") from exc + + if kind == "revolved": + raw_segments = spec.get("segments") + if not isinstance(raw_segments, (list, tuple)) or not raw_segments: + raise ValueError("segments: a revolved part needs at least one segment.") + part: Part = RevolvedPart( + name=name.strip(), + segments=tuple(_segment_from(i, s) for i, s in enumerate(raw_segments)), + material=material, + ) + else: + part = PrismaticPart( + name=name.strip(), + outline=_outline_from(spec.get("outline")), + thickness=_positive(spec.get("thickness"), "thickness"), + material=material, + ) + + part = replace(part, features=_features_from(spec.get("features"))) + validate_part(part) + return part + + +def part_to_dict(part: Part) -> dict: + """The dict `build_part` accepts back, unchanged.""" + common = { + "name": part.name, + "material": part.material, + "features": [feature.to_dict() for feature in part.features], + } + if isinstance(part, RevolvedPart): + return { + "kind": "revolved", + **common, + "segments": [ + { + "length": s.length, + "d_outer": s.d_outer, + "d_inner": s.d_inner, + "taper_to": s.taper_to, + } + for s in part.segments + ], + } + return { + "kind": "prismatic", + **common, + "outline": [[x, y] for x, y in part.outline], + "thickness": part.thickness, + } +``` + +- [ ] **Step 4: Run it to verify the refusals fire and the round-trip holds** + +Run: +`uv run --frozen pytest tests/test_mech_part.py -q --deselect tests/test_mech_part.py::test_a_duplicate_feature_id_is_refused` +Expected: PASS — 17 passed. + +The deselected test is the only one that reaches `feature_from_dict`, which +Task 3 writes; until then it fails with +`ModuleNotFoundError: No module named 'engineering.mech.features'`. Task 3's +Step 8 runs `tests/test_mech_part.py` in full and is the step that turns it +green. Record the deselection in this task's `deviations`. + +- [ ] **Step 5: Re-export from the package surface** + +In `engineering/mech/__init__.py` (Task 1 created it — keep every name it +already has), add the import block and extend `__all__`, both alphabetical: + +```python +from engineering.mech.part import ( + PART_KINDS, + PrismaticPart, + RevolvedPart, + Segment, + build_part, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + part_max_diameter, + part_to_dict, + point_in_outline, + segment_bounds, + validate_part, +) +``` + +- [ ] **Step 6: Gates** + +``` +uv run --frozen ruff check engineering/mech/part.py engineering/mech/__init__.py tests/test_mech_part.py +uv run --frozen ruff format engineering/mech/part.py tests/test_mech_part.py +uv run --frozen pytest tests/test_mech_part.py -q --deselect tests/test_mech_part.py::test_a_duplicate_feature_id_is_refused +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree --deselect tests/test_mech_part.py::test_a_duplicate_feature_id_is_refused +``` + +No tool is registered in this task, so the inventory, README count, registry +snapshot and alias gates do not move. The release-consistency deselection is +the known collected-tests drift named in the Global Constraints — record both +deselections in `deviations`. + +- [ ] **Step 7: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-2): the mechanical part model - RevolvedPart, PrismaticPart, build_part + +engineering/mech/part.py is the model the whole track draws from: a revolved +stack of segments along +X, or a closed prismatic outline plus a thickness, each +carrying a list of typed features. Pure arithmetic - no backend, no async, no +DXF - which is what lets the view engine be tested without a drawing. + +Every refusal fires before anything is drawn and names the offending path the +way pid_from_spec does: a non-finite or non-positive number as +segments[2].d_outer, a bore that is not smaller than its outside (a bore that +changes diameter is two segments), an outline with fewer than three distinct +vertices, an outline that crosses itself (engineering.measure.is_self_intersecting +- the shoelace cancels crossed lobes rather than failing, so a bowtie would +otherwise measure 0.0), an unknown material, an unknown segment key, a duplicate +feature id. + +Two gates are the reason this is a model rather than a dict: a feature whose +placement is off the part is refused by its own name and by the range it missed, +and two features that would remove the same material are refused with both names +and the overlapping box. Neither is clamped. + +part_to_dict round-trips through build_part, which is what lets mech_part_draw +write the model onto the drawing as ACADMCP_MECH XDATA and mech_view_add read it +back months later without the caller re-describing the part. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 3: Features and their standard tables — threads, undercuts, retaining grooves, centre holes, O-ring grooves + +**Files:** +- Create: `engineering/mech/standards/threads.py` +- Create: `engineering/mech/standards/undercuts.py` +- Create: `engineering/mech/standards/rings.py` +- Create: `engineering/mech/standards/centres.py` +- Create: `engineering/mech/standards/orings.py` +- Create: `engineering/mech/features.py` +- Create: `tests/test_mech_threads.py` +- Create: `tests/test_mech_standards_coverage.py` +- Create: `tests/test_mech_features.py` +- Modify: `engineering/mech/__init__.py` (re-export the feature surface) +- Test: `tests/test_mech_threads.py`, `tests/test_mech_standards_coverage.py`, + `tests/test_mech_features.py` + +**Interfaces:** +- Consumes (Task 1, on `main`): + `engineering.mech.primitives.{Line, Arc, Circle, Poly, HatchArea, Text, DimIntent, Prim, Pt, ROLE_LAYER}`; + `engineering.mech.standards.{Coverage, register, lookup, standards}`. +- Consumes (Task 2, committed): + `engineering.mech.part.{RevolvedPart, PrismaticPart, Segment, segment_bounds, + outer_radius_at, inner_radius_at, part_length, outline_bbox, point_in_outline}`. +- Consumes (already in the repository, unchanged): + `engineering.keyway.keyway_dimensions(bore_diameter: float) -> dict` + with keys `width`, `height`, `depth_shaft`, `depth_hub` (DIN 6885-1, d 6–130 mm); + `engineering.gear.generate_full_gear_outline(module, teeth, pressure_angle=20.0, + helix_angle=0.0, hand="RH", center=(0.0, 0.0), n_points_per_flank=40) -> list[tuple[float, float]]`. +- Produces: + ```python + # engineering/mech/standards/threads.py + COARSE: dict[float, float] # major diameter mm -> coarse pitch mm + FINE: dict[float, tuple[float, ...]] # major diameter mm -> fine pitches mm + DIAMETERS: tuple[float, ...] + MINOR_RATIO: float # 0.8, ISO 6410-1 representation + SOURCE_PITCH: str + def parse_designation(designation: str) -> dict # {"designation","d","pitch","series","hand"} + def pitch_for(d: float, pitch: float | None = None) -> float + def basic_minor_diameter(d: float, pitch: float) -> float + def basic_pitch_diameter(d: float, pitch: float) -> float + def thread_axial_prims(*, x0, length, d, internal=False, minor_ratio=MINOR_RATIO, + limit_line=True) -> tuple[Prim, ...] + def thread_end_prims(*, center, d, internal=False, gap_center_deg=45.0, + minor_ratio=MINOR_RATIO) -> tuple[Prim, ...] + + # engineering/mech/standards/undercuts.py + def undercut_profile(form: str, d: float) -> tuple[tuple[float, float], ...] + # engineering/mech/standards/rings.py + def ring_groove_dims(standard: str, d: float) -> dict # {"m","d2"} + # engineering/mech/standards/centres.py + def centre_hole_dims(form: str, size: float) -> dict # {"d1","d2","d3","t"} + # engineering/mech/standards/orings.py + def oring_groove_dims(cord: float, kind: str = "radial") -> dict # {"b","h"} + + # engineering/mech/features.py + @runtime_checkable + class Feature(Protocol): + id: str + def validate(self, part) -> None + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...] + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...] + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...] + def feature_from_dict(d: dict) -> Feature + FEATURE_KINDS: tuple[str, ...] + def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...] + # feature classes: Chamfer, Fillet, Keyway, Thread, Undercut, RetainingGroove, + # CentreHole, ORingGroove, CrossHole, HoleCircle, AxialBore, GearTeeth, + # Hole, Slot, Pocket, CornerFillet, CornerChamfer, BendLine, HolePattern + # every class additionally answers, with these exact record shapes: + # removal_box(part) -> (x0, y0, x1, y1) | None in the FRONT view plane + # radial_edit(part) -> tuple[{"kind": "inner"|"outer", "x0": f, "x1": f, "radius": f}, ...] + # corner_edit(part) -> {"corner": Pt, "before": Pt, "after": Pt, "arc": Arc | None} | None + # outline_edit(part) -> {"vertex": int, "before": Pt, "after": Pt, "arc": Arc | None} | None + # omission(part, view) -> str | None + # no_section_hatch: bool (ISO 128-3 rib rule) + # axisymmetric: bool True when the front prims are the +Y half only + ``` + +- [ ] **Step 1: Write the failing test for the ISO 261 / ISO 6410 thread table** + +Create `tests/test_mech_threads.py`: + +```python +"""ISO 261 pitches, ISO 724 basic diameters and the ISO 6410 representation. + +Provenance: every asserted pitch is a value the module transcribed from +ISO 261:2022; every asserted basic diameter is a published ISO 724 value that +the ISO 68-1 formula has to reproduce exactly. The coverage refusals are +asserted at both ends, because a size outside the transcribed table must be +refused by name and never interpolated. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.primitives import Arc, Line +from engineering.mech.standards import lookup +from engineering.mech.standards.threads import ( + COARSE, + DIAMETERS, + FINE, + MINOR_RATIO, + basic_minor_diameter, + basic_pitch_diameter, + parse_designation, + pitch_for, + thread_axial_prims, + thread_end_prims, +) + + +@pytest.mark.parametrize( + ("d", "pitch"), + [ + (1.6, 0.35), + (2.0, 0.40), + (3.0, 0.50), + (5.0, 0.80), + (6.0, 1.00), + (8.0, 1.25), + (10.0, 1.50), + (12.0, 1.75), + (14.0, 2.00), + (16.0, 2.00), + (20.0, 2.50), + (24.0, 3.00), + (30.0, 3.50), + (33.0, 3.50), + (36.0, 4.00), + (42.0, 4.50), + (48.0, 5.00), + (56.0, 5.50), + (64.0, 6.00), + ], +) +def test_iso261_coarse_pitches_are_the_transcribed_values(d, pitch): + assert COARSE[d] == pitch + + +def test_the_transcribed_diameters_are_first_choice_plus_six_second_choice(): + assert DIAMETERS[0] == 1.6 + assert DIAMETERS[-1] == 64.0 + assert {14.0, 18.0, 22.0, 27.0, 33.0, 39.0} <= set(DIAMETERS) + # third-choice diameters are deliberately absent + assert 7.0 not in DIAMETERS + assert 9.0 not in DIAMETERS + assert 3.5 not in DIAMETERS + + +@pytest.mark.parametrize( + ("d", "pitches"), + [ + (8.0, (1.0, 0.75)), + (10.0, (1.25, 1.0, 0.75)), + (12.0, (1.5, 1.25, 1.0)), + (16.0, (1.5, 1.0)), + (20.0, (2.0, 1.5, 1.0)), + (24.0, (2.0, 1.5, 1.0)), + (36.0, (3.0, 2.0, 1.5)), + (64.0, (4.0, 3.0, 2.0, 1.5)), + ], +) +def test_iso261_fine_pitch_series(d, pitches): + assert FINE[d] == pitches + + +def test_a_bare_designation_resolves_to_the_coarse_pitch(): + assert parse_designation("M20") == { + "designation": "M20", + "d": 20.0, + "pitch": 2.5, + "series": "coarse", + "hand": "right", + } + + +def test_a_fine_designation_resolves_and_keeps_its_hand(): + parsed = parse_designation("M20x1.5-LH") + assert parsed["d"] == 20.0 + assert parsed["pitch"] == 1.5 + assert parsed["series"] == "fine" + assert parsed["hand"] == "left" + + +def test_a_diameter_outside_the_transcribed_table_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + parse_designation("M7") + message = str(excinfo.value) + assert "ISO 261" in message + assert "M7" in message + with pytest.raises(ValueError, match="ISO 261"): + parse_designation("M70") + + +def test_a_fine_pitch_that_is_not_in_the_series_is_refused_and_lists_the_series(): + with pytest.raises(ValueError) as excinfo: + parse_designation("M20x1.25") + message = str(excinfo.value) + assert "M20" in message + assert "2.0" in message and "1.5" in message and "1.0" in message + + +def test_a_malformed_designation_is_refused(): + with pytest.raises(ValueError, match="designation"): + parse_designation("20mm") + + +def test_pitch_for_matches_parse_designation(): + assert pitch_for(20.0) == 2.5 + assert pitch_for(20.0, 1.5) == 1.5 + with pytest.raises(ValueError): + pitch_for(20.0, 1.25) + + +@pytest.mark.parametrize( + ("d", "pitch", "d1", "d2"), + [ + (20.0, 2.50, 17.294, 18.376), + (10.0, 1.50, 8.376, 9.026), + (8.0, 1.25, 6.647, 7.188), + (12.0, 1.75, 10.106, 10.863), + (6.0, 1.00, 4.917, 5.350), + ], +) +def test_iso68_1_reproduces_the_published_iso724_basic_diameters(d, pitch, d1, d2): + assert basic_minor_diameter(d, pitch) == pytest.approx(d1, abs=5e-4) + assert basic_pitch_diameter(d, pitch) == pytest.approx(d2, abs=5e-4) + + +def test_the_registry_carries_the_pitch_table_and_refuses_outside_its_coverage(): + assert lookup("ISO 261", 20.0)["pitch"] == 2.5 + with pytest.raises(ValueError, match="ISO 261"): + lookup("ISO 261", 70.0) + + +def test_iso6410_axial_representation_of_an_external_thread(): + prims = thread_axial_prims(x0=10.0, length=30.0, d=20.0) + lines = [p for p in prims if isinstance(p, Line)] + assert len(prims) == 3 and len(lines) == 3 + minor = MINOR_RATIO * 20.0 / 2.0 + assert {round(line.p1[1], 9) for line in lines[:2]} == {round(minor, 9), round(-minor, 9)} + assert lines[0].p1 == (10.0, minor) and lines[0].p2 == (40.0, minor) + assert lines[1].p1 == (10.0, -minor) and lines[1].p2 == (40.0, -minor) + # the thread-length limit line runs the full major diameter + assert lines[2].p1 == (40.0, -10.0) and lines[2].p2 == (40.0, 10.0) + assert {line.role for line in lines} == {"visible"} + + +def test_iso6410_axial_representation_of_an_internal_thread_is_hidden(): + prims = thread_axial_prims(x0=0.0, length=20.0, d=12.0, internal=True) + assert {p.role for p in prims} == {"hidden"} + assert prims[0].p1 == (0.0, 6.0) and prims[0].p2 == (20.0, 6.0) + + +def test_iso6410_end_view_is_three_quarters_of_a_circle(): + (arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0) + assert isinstance(arc, Arc) + assert arc.radius == pytest.approx(8.0) + assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0) + assert arc.start_deg == pytest.approx(90.0) + + +def test_the_end_view_gap_can_be_moved_and_stays_a_quarter(): + (arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0, gap_center_deg=135.0) + assert arc.start_deg == pytest.approx(180.0) + assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0) + assert math.isclose(arc.radius, 8.0) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_threads.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.standards.threads'`. + +- [ ] **Step 3: Write `engineering/mech/standards/threads.py`** + +```python +"""ISO 261 metric thread pitches and the ISO 6410 drawing representation. + +SOURCE +------ +**Pitches.** ISO 261:2022, *ISO general purpose metric screw threads - General +plan*, Table 1 (coarse pitch series) and Table 2 (fine pitch series). + +**Transcribed coverage.** The first-choice diameters M1.6 to M64, plus the +second-choice diameters M14, M18, M22, M27, M33 and M39. Third-choice +diameters (M3.5, M7, M9, M11, M15, M17, ...), everything above M64, and the +fine pitch series below M8 are **not** transcribed and are refused by name +rather than interpolated - the table rule of this track. + +**Basic diameters.** ISO 68-1:1998 fundamental triangle, H = (sqrt(3)/2) * P, +with ISO 724's basic minor diameter d1 = d - 1.25*H = d - 1.082532*P and basic +pitch diameter d2 = d - 0.75*H = d - 0.649519*P. That is the standard's own +formula rather than transcribed table data, which is why it can be pinned +against published ISO 724 values instead of trusted: M20x2.5 reproduces +17.294 / 18.376 exactly, M10x1.5 reproduces 8.376 / 9.026, M8x1.25 reproduces +6.647 / 7.188. + +**Representation.** ISO 6410-1:1993, *Technical drawings - Screw threads and +threaded parts*: the minor diameter is drawn as a thin continuous line at +0.8 x the major diameter (`MINOR_RATIO`); in a view along the axis that same +circle is drawn as three quarters of a circle with the gap in one quadrant; +the thread run-out / thread-length limit is a line across the full major +diameter. An internal thread shown without a section is drawn hidden +throughout. +""" + +from __future__ import annotations + +import math +import re + +from engineering.mech.primitives import Arc, Line, Prim +from engineering.mech.standards import Coverage, register + +SOURCE_PITCH = ( + "ISO 261:2022 Table 1 (coarse) and Table 2 (fine); first-choice M1.6-M64 " + "plus second-choice M14, M18, M22, M27, M33, M39" +) +SOURCE_BASIC = "ISO 68-1:1998 fundamental triangle; values pinned against ISO 724" +SOURCE_REPRESENTATION = "ISO 6410-1:1993" + +#: ISO 6410-1: the minor diameter is represented at 0.8 x the major diameter. +MINOR_RATIO = 0.8 + +#: ISO 261 Table 1 - coarse pitch series, major diameter (mm) -> pitch (mm). +COARSE: dict[float, float] = { + 1.6: 0.35, + 2.0: 0.40, + 2.5: 0.45, + 3.0: 0.50, + 4.0: 0.70, + 5.0: 0.80, + 6.0: 1.00, + 8.0: 1.25, + 10.0: 1.50, + 12.0: 1.75, + 14.0: 2.00, + 16.0: 2.00, + 18.0: 2.50, + 20.0: 2.50, + 22.0: 2.50, + 24.0: 3.00, + 27.0: 3.00, + 30.0: 3.50, + 33.0: 3.50, + 36.0: 4.00, + 39.0: 4.00, + 42.0: 4.50, + 45.0: 4.50, + 48.0: 5.00, + 52.0: 5.00, + 56.0: 5.50, + 60.0: 5.50, + 64.0: 6.00, +} + +#: ISO 261 Table 2 - fine pitch series, major diameter (mm) -> pitches (mm), +#: coarsest first. Transcribed from M8 upwards only. +FINE: dict[float, tuple[float, ...]] = { + 8.0: (1.0, 0.75), + 10.0: (1.25, 1.0, 0.75), + 12.0: (1.5, 1.25, 1.0), + 14.0: (1.5, 1.0), + 16.0: (1.5, 1.0), + 18.0: (2.0, 1.5, 1.0), + 20.0: (2.0, 1.5, 1.0), + 22.0: (2.0, 1.5, 1.0), + 24.0: (2.0, 1.5, 1.0), + 27.0: (2.0, 1.5, 1.0), + 30.0: (2.0, 1.5, 1.0), + 33.0: (2.0, 1.5), + 36.0: (3.0, 2.0, 1.5), + 39.0: (3.0, 2.0, 1.5), + 42.0: (3.0, 2.0, 1.5), + 45.0: (3.0, 2.0, 1.5), + 48.0: (3.0, 2.0, 1.5), + 52.0: (3.0, 2.0, 1.5), + 56.0: (4.0, 3.0, 2.0, 1.5), + 60.0: (4.0, 3.0, 2.0, 1.5), + 64.0: (4.0, 3.0, 2.0, 1.5), +} + +DIAMETERS: tuple[float, ...] = tuple(sorted(COARSE)) + +COVERAGE = Coverage(DIAMETERS[0], DIAMETERS[-1], "mm") + +_DESIGNATION_RE = re.compile( + r"^\s*M\s*(\d+(?:[.,]\d+)?)\s*(?:[x×X]\s*(\d+(?:[.,]\d+)?))?\s*(?:-\s*(LH|RH))?\s*$", + re.IGNORECASE, +) + +register( + "ISO 261", + {d: {"pitch": COARSE[d], "fine": FINE.get(d, ())} for d in DIAMETERS}, + COVERAGE, + SOURCE_PITCH, +) + + +def _number(text: str) -> float: + return float(text.replace(",", ".")) + + +def pitch_for(d: float, pitch: float | None = None) -> float: + """The coarse pitch for ``d``, or validate an explicit fine pitch against the series.""" + diameter = float(d) + if diameter not in COARSE: + raise ValueError( + f"ISO 261: M{diameter:g} is not in the transcribed table. " + f"Coverage is M{DIAMETERS[0]:g}-M{DIAMETERS[-1]:g}, first-choice diameters plus " + "M14, M18, M22, M27, M33 and M39; third-choice diameters are not transcribed " + "and are not interpolated." + ) + if pitch is None: + return COARSE[diameter] + value = float(pitch) + if abs(value - COARSE[diameter]) < 1e-9: + return COARSE[diameter] + series = FINE.get(diameter, ()) + for candidate in series: + if abs(value - candidate) < 1e-9: + return candidate + known = ", ".join(f"{p:g}" for p in (COARSE[diameter], *series)) + raise ValueError( + f"ISO 261: M{diameter:g} has no pitch {value:g} mm in the transcribed series. " + f"Transcribed pitches for M{diameter:g}: {known}." + ) + + +def parse_designation(designation: str) -> dict: + """``"M20"`` / ``"M20x1.5"`` / ``"M20x1.5-LH"`` -> d, pitch, series, hand.""" + match = _DESIGNATION_RE.match(str(designation or "")) + if not match: + raise ValueError( + f"designation {designation!r}: expected an ISO metric thread such as " + "'M20', 'M20x1.5' or 'M20x1.5-LH'." + ) + diameter = _number(match.group(1)) + raw_pitch = _number(match.group(2)) if match.group(2) else None + pitch = pitch_for(diameter, raw_pitch) + hand = "left" if (match.group(3) or "RH").upper() == "LH" else "right" + series = "coarse" if abs(pitch - COARSE[diameter]) < 1e-9 else "fine" + text = f"M{diameter:g}" if raw_pitch is None else f"M{diameter:g}x{pitch:g}" + if hand == "left": + text += "-LH" + return { + "designation": text, + "d": diameter, + "pitch": pitch, + "series": series, + "hand": hand, + } + + +def _h(pitch: float) -> float: + """ISO 68-1 fundamental triangle height.""" + return math.sqrt(3.0) / 2.0 * float(pitch) + + +def basic_minor_diameter(d: float, pitch: float) -> float: + """ISO 724 basic minor diameter d1 = d - 1.25 * H.""" + return float(d) - 1.25 * _h(pitch) + + +def basic_pitch_diameter(d: float, pitch: float) -> float: + """ISO 724 basic pitch diameter d2 = d - 0.75 * H.""" + return float(d) - 0.75 * _h(pitch) + + +def thread_axial_prims( + *, + x0: float, + length: float, + d: float, + internal: bool = False, + minor_ratio: float = MINOR_RATIO, + limit_line: bool = True, +) -> tuple[Prim, ...]: + """ISO 6410 representation in a view along the axis (axis on y = 0, +X along it). + + External: the two minor-diameter lines at 0.8 x d plus the thread-length + limit line across the full major diameter. Internal (not sectioned): the + same pair at the *major* diameter, hidden, because the drawn bore already + carries the minor. + """ + x1 = float(x0) + float(length) + role = "hidden" if internal else "visible" + radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0 + prims: list[Prim] = [ + Line((float(x0), radius), (x1, radius), role), + Line((float(x0), -radius), (x1, -radius), role), + ] + if limit_line: + major = float(d) / 2.0 + prims.append(Line((x1, -major), (x1, major), role)) + return tuple(prims) + + +def thread_end_prims( + *, + center: tuple[float, float], + d: float, + internal: bool = False, + gap_center_deg: float = 45.0, + minor_ratio: float = MINOR_RATIO, +) -> tuple[Prim, ...]: + """ISO 6410 three-quarter circle for a view along the thread axis. + + External: three quarters of the *minor* circle. Internal: three quarters of + the *major* circle. The quarter-circle gap is centred on ``gap_center_deg`` + (45 deg = the upper-right quadrant, the usual placement). + """ + radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0 + start = (float(gap_center_deg) + 45.0) % 360.0 + end = (start + 270.0) % 360.0 + return (Arc((float(center[0]), float(center[1])), radius, start, end, "visible"),) +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_threads.py -q` +Expected: PASS — 44 passed (32 of them parametrised table rows). + +If `register` refuses a re-registration when the module is imported twice under +different names, that is Task 1's choice: guard the call with +`if "ISO 261" not in standards():` and record it in `deviations`. + +- [ ] **Step 5: Write the failing test for the four not-transcribed tables** + +Create `tests/test_mech_standards_coverage.py`: + +```python +"""The four tables this build does NOT transcribe, and how they refuse. + +The track's table rule is absolute: a row that cannot be sourced against the +standard is not shipped, because a wrong relief depth or a wrong groove +diameter is a wrong workshop drawing, while a narrow table is merely narrow. +DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 therefore ship with their +structure, their SOURCE and an empty row set, and every lookup is refused by +name and says what to transcribe. + +These tests are the gate that keeps that honest in both directions: the +refusal must name the standard and the transcription route, and the shape gate +must fail the moment somebody adds a row that is missing a column the drawing +code reads. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.standards import standards +from engineering.mech.standards.centres import ( + CENTRE_ROW_KEYS, + ROWS_A, + ROWS_B, + SOURCE as SOURCE_CENTRES, + centre_hole_dims, +) +from engineering.mech.standards.orings import ( + ORING_ROW_KEYS, + ROWS as ROWS_ORING, + SOURCE as SOURCE_ORING, + oring_groove_dims, +) +from engineering.mech.standards.rings import ( + RING_ROW_KEYS, + ROWS_471, + ROWS_472, + SOURCE as SOURCE_RINGS, + ring_groove_dims, +) +from engineering.mech.standards.undercuts import ( + ROWS_E, + ROWS_F, + SOURCE as SOURCE_UNDERCUTS, + UNDERCUT_ROW_KEYS, + undercut_profile, +) + + +def test_every_table_is_registered_so_a_later_transcription_is_a_data_edit(): + registered = standards() + for name in ("DIN 509-E", "DIN 509-F", "DIN 471", "DIN 472", + "DIN 332-A", "DIN 332-B", "ISO 3601-2"): + assert name in registered + + +@pytest.mark.parametrize( + ("source", "standard"), + [ + (SOURCE_UNDERCUTS, "DIN 509"), + (SOURCE_RINGS, "DIN 471"), + (SOURCE_CENTRES, "DIN 332"), + (SOURCE_ORING, "ISO 3601-2"), + ], +) +def test_every_source_names_its_standard_and_edition(source, standard): + assert standard in source + assert any(ch.isdigit() for ch in source.split(":")[-1]) + + +def test_no_row_is_shipped_in_this_build(): + assert ROWS_E == {} and ROWS_F == {} + assert ROWS_471 == {} and ROWS_472 == {} + assert ROWS_A == {} and ROWS_B == {} + assert ROWS_ORING == {} + + +def test_the_undercut_lookup_is_refused_by_name_and_says_what_to_transcribe(): + with pytest.raises(ValueError) as excinfo: + undercut_profile("E", 40.0) + message = str(excinfo.value) + assert "DIN 509" in message + assert "form E" in message + assert "40" in message + assert "undercuts.py" in message + + +def test_an_unknown_undercut_form_is_refused_before_the_coverage_refusal(): + with pytest.raises(ValueError, match="forms E and F"): + undercut_profile("G", 40.0) + + +def test_the_ring_groove_lookup_is_refused_by_name_for_shaft_and_bore(): + for standard in ("DIN 471", "DIN 472"): + with pytest.raises(ValueError) as excinfo: + ring_groove_dims(standard, 30.0) + assert standard in str(excinfo.value) + assert "rings.py" in str(excinfo.value) + with pytest.raises(ValueError, match="DIN 471 or DIN 472"): + ring_groove_dims("DIN 6799", 30.0) + + +def test_the_centre_hole_lookup_is_refused_by_name_and_form_r_is_refused_outright(): + with pytest.raises(ValueError) as excinfo: + centre_hole_dims("A", 2.5) + assert "DIN 332" in str(excinfo.value) + assert "centres.py" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_hole_dims("R", 2.5) + assert "form R" in str(excinfo.value) + + +def test_the_oring_lookup_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + oring_groove_dims(3.53) + assert "ISO 3601-2" in str(excinfo.value) + assert "orings.py" in str(excinfo.value) + + +def test_the_row_key_contracts_are_the_ones_the_drawing_code_reads(): + assert UNDERCUT_ROW_KEYS == ("profile",) + assert RING_ROW_KEYS == ("m", "d2") + assert CENTRE_ROW_KEYS == ("d1", "d2", "d3", "t") + assert ORING_ROW_KEYS == ("b", "h") + + +@pytest.mark.parametrize( + ("rows", "keys"), + [ + (ROWS_E, ("profile",)), + (ROWS_F, ("profile",)), + (ROWS_471, ("m", "d2")), + (ROWS_472, ("m", "d2")), + (ROWS_A, ("d1", "d2", "t")), + (ROWS_B, ("d1", "d2", "d3", "t")), + (ROWS_ORING, ("b", "h")), + ], +) +def test_any_row_that_is_ever_added_must_carry_every_key_the_drawing_code_reads(rows, keys): + """Empty today; this gate fires the moment a row lands without a column.""" + for size, row in rows.items(): + missing = [key for key in keys if key not in row] + assert not missing, f"row {size} is missing {missing}" +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_standards_coverage.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.standards.centres'`. + +- [ ] **Step 7: Write the four not-transcribed table modules** + +Create `engineering/mech/standards/undercuts.py`: + +```python +"""DIN 509 relief grooves (Freistiche), forms E and F. + +SOURCE +------ +DIN 509:1998-06, *Technische Zeichnungen - Freistiche*, Table 1 (forms E and F). + +COVERAGE +-------- +**No rows are transcribed in this build.** The track's table rule is absolute: +a row that cannot be verified against the standard's own table is not shipped, +because a wrong relief depth is a wrong workshop drawing while a narrow table +is merely narrow. Every lookup here is therefore refused by name and names the +route to fix it. + +Extending this module is a **data edit with no code change**: fill ``ROWS_E`` / +``ROWS_F`` with ``{shaft_diameter_mm: {"profile": [[along, depth], ...]}}`` and +widen ``COVERAGE_E`` / ``COVERAGE_F`` to the transcribed range. ``profile`` is +the standard's own published relief profile expressed as (distance along the +axis from the shoulder corner, depth below the smaller cylinder) pairs in +millimetres - the same ``profile`` key group S's ``std_feature_draw`` reads - +so nothing downstream ever reconstructs a profile it was not handed. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = "DIN 509:1998-06, Table 1 (forms E and F) - NOT TRANSCRIBED in this build" + +#: The keys every DIN 509 row must carry for the drawing code to use it. +UNDERCUT_ROW_KEYS: tuple[str, ...] = ("profile",) + +FORMS: tuple[str, ...] = ("E", "F") + +ROWS_E: dict[float, dict] = {} +ROWS_F: dict[float, dict] = {} + +COVERAGE_E = Coverage(0.0, 0.0, "mm") +COVERAGE_F = Coverage(0.0, 0.0, "mm") + +register("DIN 509-E", ROWS_E, COVERAGE_E, SOURCE) +register("DIN 509-F", ROWS_F, COVERAGE_F, SOURCE) + +_ROWS = {"E": ROWS_E, "F": ROWS_F} + + +def undercut_profile(form: str, d: float) -> tuple[tuple[float, float], ...]: + """The published relief profile for a shaft diameter, or a named refusal.""" + key = str(form or "").strip().upper() + if key not in FORMS: + raise ValueError(f"DIN 509 defines forms E and F; got {form!r}.") + rows = _ROWS[key] + if not rows: + raise ValueError( + f"DIN 509 form {key}: no rows are transcribed in this build, so no relief " + f"profile can be looked up for d = {float(d):g} mm. Pass the profile " + "explicitly (Undercut(profile=[[along, depth], ...])), or transcribe " + f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/undercuts.py. " + "Nothing is interpolated and no profile is invented." + ) + size = float(d) + if size in rows: + return tuple(tuple(float(v) for v in pair) for pair in rows[size]["profile"]) + raise ValueError( + f"DIN 509 form {key}: d = {size:g} mm has no transcribed row " + f"(transcribed sizes: {sorted(rows)})." + ) +``` + +Create `engineering/mech/standards/rings.py`: + +```python +"""DIN 471 / DIN 472 retaining-ring groove dimensions. + +SOURCE +------ +DIN 471:2011-04, *Retaining rings for shafts*, Table 1 (groove diameter d2 and +groove width m) and DIN 472:2011-04, *Retaining rings for bores*, Table 1. + +COVERAGE +-------- +**No rows are transcribed in this build**, for the reason given in +``undercuts.py``: the groove diameter is the number a wrong drawing gets wrong, +and it is not written here from memory. Every lookup is refused by name. + +Extending is a data edit: fill ``ROWS_471`` / ``ROWS_472`` with +``{nominal_shaft_or_bore_mm: {"m": groove_width_mm, "d2": groove_diameter_mm}}`` +and widen the coverage. Those two keys are the contract group S's +``std_feature_draw`` already reads. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = ( + "DIN 471:2011-04 Table 1 (shafts) and DIN 472:2011-04 Table 1 (bores) " + "- NOT TRANSCRIBED in this build" +) + +#: The keys every DIN 471/472 row must carry. +RING_ROW_KEYS: tuple[str, ...] = ("m", "d2") + +ROWS_471: dict[float, dict] = {} +ROWS_472: dict[float, dict] = {} + +COVERAGE_471 = Coverage(0.0, 0.0, "mm") +COVERAGE_472 = Coverage(0.0, 0.0, "mm") + +register("DIN 471", ROWS_471, COVERAGE_471, SOURCE) +register("DIN 472", ROWS_472, COVERAGE_472, SOURCE) + +_ROWS = {"DIN 471": ROWS_471, "DIN 472": ROWS_472} + + +def ring_groove_dims(standard: str, d: float) -> dict: + """``{"m": ..., "d2": ...}`` for a nominal size, or a named refusal.""" + key = " ".join(str(standard or "").split()).upper() + if key not in _ROWS: + raise ValueError( + f"retaining-ring groove: expected DIN 471 or DIN 472, got {standard!r}." + ) + rows = _ROWS[key] + if not rows: + raise ValueError( + f"{key}: no rows are transcribed in this build, so no groove can be looked up " + f"for d = {float(d):g} mm. Pass m and d2 explicitly, or transcribe " + f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/rings.py. " + "Nothing is interpolated." + ) + size = float(d) + if size in rows: + return {name: float(rows[size][name]) for name in RING_ROW_KEYS} + raise ValueError(f"{key}: d = {size:g} mm has no transcribed row (sizes: {sorted(rows)}).") +``` + +Create `engineering/mech/standards/centres.py`: + +```python +"""DIN 332-1 centre holes, forms A and B. + +SOURCE +------ +DIN 332-1:1986-04, *Centre holes 60 degrees - Forms R, A and B*, Table 1. + +COVERAGE +-------- +**No rows are transcribed in this build** (same rule as ``undercuts.py``). +Form R is not drawn by this build at all: its profile is an arc form whose +radius is not transcribed here, and it is refused outright rather than +approximated by the straight 60 degree cone of forms A and B. + +Extending is a data edit: fill ``ROWS_A`` / ``ROWS_B`` with +``{pilot_diameter_mm: {"d1": ..., "d2": ..., "d3": ..., "t": ...}}`` (``d3`` +only for form B's protective 120 degree countersink) and widen the coverage. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = "DIN 332-1:1986-04, Table 1 (forms A and B) - NOT TRANSCRIBED in this build" + +#: The keys a DIN 332 row must carry; form A rows may omit "d3". +CENTRE_ROW_KEYS: tuple[str, ...] = ("d1", "d2", "d3", "t") + +FORMS: tuple[str, ...] = ("A", "B") + +ROWS_A: dict[float, dict] = {} +ROWS_B: dict[float, dict] = {} + +COVERAGE_A = Coverage(0.0, 0.0, "mm") +COVERAGE_B = Coverage(0.0, 0.0, "mm") + +register("DIN 332-A", ROWS_A, COVERAGE_A, SOURCE) +register("DIN 332-B", ROWS_B, COVERAGE_B, SOURCE) + +_ROWS = {"A": ROWS_A, "B": ROWS_B} + + +def centre_hole_dims(form: str, size: float) -> dict: + """``{"d1","d2","d3","t"}`` for a pilot diameter, or a named refusal.""" + key = str(form or "").strip().upper() + if key == "R": + raise ValueError( + "DIN 332 form R: this build draws forms A and B only. Form R's arc profile " + "is not transcribed here and is refused rather than approximated by the " + "straight 60 degree cone." + ) + if key not in FORMS: + raise ValueError(f"DIN 332: expected form A or B, got {form!r}.") + rows = _ROWS[key] + if not rows: + raise ValueError( + f"DIN 332 form {key}: no rows are transcribed in this build, so no centre hole " + f"can be looked up for d1 = {float(size):g} mm. Pass d1, d2 (and d3 for form B) " + f"and t explicitly, or transcribe {SOURCE.split(' - ')[0]} into " + "engineering/mech/standards/centres.py. Nothing is interpolated." + ) + pilot = float(size) + if pilot in rows: + row = rows[pilot] + return {name: (float(row[name]) if name in row else None) for name in CENTRE_ROW_KEYS} + raise ValueError( + f"DIN 332 form {key}: d1 = {pilot:g} mm has no transcribed row (sizes: {sorted(rows)})." + ) +``` + +Create `engineering/mech/standards/orings.py`: + +```python +"""ISO 3601-2 O-ring housing (groove) dimensions. + +SOURCE +------ +ISO 3601-2:2016, *Fluid power systems - O-rings - Part 2: Housing dimensions +for general applications*, Tables 1 and 2 (radial static housings). + +COVERAGE +-------- +**No rows are transcribed in this build** (same rule as ``undercuts.py``): the +groove width and depth per cord diameter decide whether the seal works, and +they are not written here from memory. Every lookup is refused by name. + +Extending is a data edit: fill ``ROWS`` with +``{cord_diameter_mm: {"b": groove_width_mm, "h": groove_depth_mm}}`` - the two +keys group S's ``std_feature_draw`` already reads - and widen ``COVERAGE``. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = "ISO 3601-2:2016, Tables 1-2 (radial static housings) - NOT TRANSCRIBED in this build" + +#: The keys every ISO 3601-2 row must carry. +ORING_ROW_KEYS: tuple[str, ...] = ("b", "h") + +KINDS: tuple[str, ...] = ("radial", "axial") + +ROWS: dict[float, dict] = {} + +COVERAGE = Coverage(0.0, 0.0, "mm") + +register("ISO 3601-2", ROWS, COVERAGE, SOURCE) + + +def oring_groove_dims(cord: float, kind: str = "radial") -> dict: + """``{"b": width, "h": depth}`` for a cord diameter, or a named refusal.""" + key = str(kind or "").strip().lower() + if key not in KINDS: + raise ValueError(f"O-ring housing: expected kind 'radial' or 'axial', got {kind!r}.") + if not ROWS: + raise ValueError( + f"ISO 3601-2: no rows are transcribed in this build, so no {key} housing can be " + f"looked up for a {float(cord):g} mm cord. Pass b and h explicitly, or transcribe " + f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/orings.py. " + "Nothing is interpolated." + ) + size = float(cord) + if size in ROWS: + return {name: float(ROWS[size][name]) for name in ORING_ROW_KEYS} + raise ValueError(f"ISO 3601-2: cord {size:g} mm has no transcribed row (sizes: {sorted(ROWS)}).") +``` + +- [ ] **Step 8: Run the coverage tests to verify they pass** + +Run: `uv run --frozen pytest tests/test_mech_standards_coverage.py -q` +Expected: PASS — 19 passed. + +- [ ] **Step 9: Write the failing test for the feature catalogue** + +Create `tests/test_mech_features.py`: + +```python +"""Every feature of spec section 5: validation, emitted primitives, dimensions. + +Geometry is asserted as emitted primitive tuples - counts, roles, coordinates +to 1e-9 - never as a screenshot. The two features with a real table +(`Keyway` -> DIN 6885 via engineering.keyway, `GearTeeth` -> the involute +generator in engineering.gear) are pinned against those modules rather than +against numbers restated here; the four not-transcribed tables are exercised +through their refusals. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.gear import generate_full_gear_outline +from engineering.keyway import keyway_dimensions +from engineering.mech.features import ( + FEATURE_KINDS, + AxialBore, + BendLine, + CentreHole, + Chamfer, + CornerChamfer, + CornerFillet, + CrossHole, + Fillet, + GearTeeth, + Hole, + HoleCircle, + HolePattern, + Keyway, + ORingGroove, + Pocket, + RetainingGroove, + Slot, + Thread, + Undercut, + feature_from_dict, + mirror_about_x, +) +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment, build_part +from engineering.mech.primitives import Arc, Circle, Line, Poly, Text + +CTX: dict = {"side": "right", "scale": 1.0, "projection": "first"} + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0, 10.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*features) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(features), + ) + + +def test_the_catalogue_is_the_nineteen_kinds_spec_section_5_names(): + assert FEATURE_KINDS == ( + "axial_bore", + "bend_line", + "centre_hole", + "chamfer", + "corner_chamfer", + "corner_fillet", + "cross_hole", + "fillet", + "gear_teeth", + "hole", + "hole_circle", + "hole_pattern", + "keyway", + "oring_groove", + "pocket", + "retaining_groove", + "slot", + "thread", + "undercut", + ) + + +def test_feature_from_dict_round_trips_every_kind_it_builds(): + payload = {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0, "angle": 45.0} + feature = feature_from_dict(payload) + assert isinstance(feature, Chamfer) + assert feature.to_dict() == payload + + +def test_feature_from_dict_refuses_an_unknown_kind_and_an_unknown_key(): + with pytest.raises(ValueError, match="unknown feature"): + feature_from_dict({"kind": "spline", "id": "s"}) + with pytest.raises(ValueError, match="unknown key"): + feature_from_dict({"kind": "chamfer", "id": "c", "at": "start", "size": 1.0, "deg": 30}) + + +def test_mirror_about_x_flips_every_primitive_type(): + line, arc, circle, poly, text = mirror_about_x( + [ + Line((0.0, 5.0), (10.0, 5.0)), + Arc((0.0, 4.0), 2.0, 30.0, 90.0), + Circle((1.0, 3.0), 2.0), + Poly(((0.0, 1.0), (2.0, 3.0)), True), + Text((1.0, 2.0), "A"), + ] + ) + assert line.p1 == (0.0, -5.0) and line.p2 == (10.0, -5.0) + assert arc.center == (0.0, -4.0) + assert (arc.start_deg, arc.end_deg) == (270.0, 330.0) + assert circle.center == (1.0, -3.0) + assert poly.points == ((0.0, -1.0), (2.0, -3.0)) + assert text.at == (1.0, -2.0) + + +# -- chamfer and fillet ------------------------------------------------------ + + +def test_a_45_degree_chamfer_at_the_start_replaces_the_corner_with_equal_legs(): + feature = Chamfer(id="c1", at="start", size=2.0) + part = shaft(feature) + edit = feature.corner_edit(part) + assert edit["corner"] == (0.0, 12.5) + assert edit["before"] == (0.0, 10.5) + assert edit["after"] == (2.0, 12.5) + assert edit["arc"] is None + assert feature.removal_box(part) == (0.0, 10.5, 2.0, 12.5) + + +def test_a_30_degree_chamfer_at_the_end_cuts_toward_minus_x(): + feature = Chamfer(id="c2", at="end", size=3.0, angle=30.0) + part = shaft(feature) + edit = feature.corner_edit(part) + radial = 3.0 * math.tan(math.radians(30.0)) + assert edit["before"][0] == pytest.approx(72.0) + assert edit["after"] == (75.0, 10.0) + assert edit["before"][1] == pytest.approx(10.0 - radial) + + +def test_a_chamfer_shows_as_an_extra_circle_in_the_end_view(): + feature = Chamfer(id="c1", at="end", size=2.0) + part = shaft(feature) + (circle,) = feature.prims(part, "side", CTX) + assert isinstance(circle, Circle) + assert circle.radius == pytest.approx(8.0) + + +def test_a_chamfer_bigger_than_the_step_is_refused_by_name(): + feature = Chamfer(id="c1", at="start", size=40.0) + with pytest.raises(ValueError, match="c1|size"): + feature.validate(shaft(feature)) + + +def test_a_fillet_at_a_step_up_emits_a_tangent_arc_in_the_lower_right_quadrant(): + feature = Fillet(id="f1", at="step:0", radius=2.0) + part = shaft(feature) + edit = feature.corner_edit(part) + assert edit["corner"] == (20.0, 12.5) + assert edit["before"] == (18.0, 12.5) + assert edit["after"] == (20.0, 14.5) + arc = edit["arc"] + assert isinstance(arc, Arc) + assert arc.center == (18.0, 14.5) + assert (arc.radius, arc.start_deg, arc.end_deg) == (2.0, 270.0, 360.0) + + +def test_a_fillet_at_a_step_down_puts_the_arc_in_the_lower_left_quadrant(): + feature = Fillet(id="f2", at="step:1", radius=1.5) + part = shaft(feature) + edit = feature.corner_edit(part) + assert edit["corner"] == (60.0, 10.0) + assert edit["before"] == (60.0, 11.5) + assert edit["after"] == (61.5, 10.0) + assert edit["arc"].center == (61.5, 11.5) + assert (edit["arc"].start_deg, edit["arc"].end_deg) == (180.0, 270.0) + + +def test_a_fillet_is_only_defined_at_a_step(): + feature = Fillet(id="f3", at="start", radius=1.0) + with pytest.raises(ValueError, match="step"): + feature.validate(shaft(feature)) + + +# -- keyway (DIN 6885 through engineering.keyway) ---------------------------- + + +def test_a_keyway_takes_its_width_and_depth_from_din_6885(): + feature = Keyway(id="kw", segment=1, length=25.0, offset=5.0) + part = shaft(feature) + feature.validate(part) + table = keyway_dimensions(30.0) + prims = feature.prims(part, "front", CTX) + assert len(prims) == 3 + assert all(isinstance(p, Line) and p.role == "visible" for p in prims) + bottom = 15.0 - table["depth_shaft"] + assert prims[0].p1 == (25.0, 15.0) and prims[0].p2 == (25.0, bottom) + assert prims[1].p1 == (25.0, bottom) and prims[1].p2 == (50.0, bottom) + assert prims[2].p1 == (50.0, bottom) and prims[2].p2 == (50.0, 15.0) + assert feature.removal_box(part) == (25.0, bottom, 50.0, 15.0) + + +def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch(): + feature = Keyway(id="kw", segment=1, length=25.0) + part = shaft(feature) + prims = feature.prims(part, "side", CTX) + arcs = [p for p in prims if isinstance(p, Arc)] + lines = [p for p in prims if isinstance(p, Line)] + assert len(arcs) == 1 and len(lines) == 3 + width = keyway_dimensions(30.0)["width"] + assert lines[1].p1[0] == pytest.approx(-width / 2.0) + assert lines[1].p2[0] == pytest.approx(width / 2.0) + + +def test_a_keyway_longer_than_its_segment_is_refused_by_name(): + feature = Keyway(id="kw", segment=1, length=50.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + assert "40" in str(excinfo.value) + + +def test_a_keyway_on_a_segment_that_does_not_exist_is_refused_by_range(): + feature = Keyway(id="kw", segment=5, length=10.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + assert "segments 0-2" in str(excinfo.value) + + +def test_a_keyway_outside_the_din_6885_bore_range_is_refused(): + part = RevolvedPart(name="pin", segments=(Segment(20.0, 4.0),)) + feature = Keyway(id="kw", segment=0, length=10.0) + with pytest.raises(ValueError, match="DIN 6885"): + feature.validate(part) + + +# -- thread ------------------------------------------------------------------ + + +def test_an_external_thread_draws_the_iso6410_minor_lines_and_the_limit_line(): + feature = Thread(id="th", segment=2, designation="M20", length=12.0) + part = shaft(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + assert len(prims) == 3 + assert prims[0].p1 == (60.0, 8.0) and prims[0].p2 == (72.0, 8.0) + assert prims[2].p1 == (72.0, -10.0) and prims[2].p2 == (72.0, 10.0) + + +def test_a_thread_whose_diameter_does_not_match_its_segment_is_refused_with_both_numbers(): + feature = Thread(id="th", segment=1, designation="M20", length=10.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + message = str(excinfo.value) + assert "M20" in message and "30" in message + + +def test_an_internal_thread_enlarges_the_bore_to_the_major_for_the_section_hatch(): + feature = Thread(id="th", segment=0, designation="M10", length=20.0, internal=True) + part = RevolvedPart( + name="boss", + segments=(Segment(30.0, 40.0, 8.0),), # drilled to the ISO 6410 minor, 0.8 x 10 + features=(feature,), + ) + feature.validate(part) + (edit,) = feature.radial_edit(part) + assert edit == {"kind": "inner", "x0": 0.0, "x1": 20.0, "radius": 5.0} + + +def test_a_thread_designation_outside_iso_261_is_refused_by_the_table(): + feature = Thread(id="th", segment=2, designation="M7", length=5.0) + with pytest.raises(ValueError, match="ISO 261"): + feature.validate(shaft(feature)) + + +# -- the four not-transcribed tables, through their features ----------------- + + +def test_an_undercut_without_an_explicit_profile_is_refused_by_din_509(): + feature = Undercut(id="uc", at="step:0", form="E") + with pytest.raises(ValueError, match="DIN 509"): + feature.validate(shaft(feature)) + + +def test_an_undercut_draws_exactly_the_profile_it_is_handed(): + profile = ((0.0, 0.0), (0.5, 0.4), (2.0, 0.4), (2.5, 0.0)) + feature = Undercut(id="uc", at="step:0", form="E", profile=profile) + part = shaft(feature) + feature.validate(part) + (poly,) = feature.prims(part, "front", CTX) + assert isinstance(poly, Poly) and poly.closed is False + assert poly.points[0] == (20.0, 12.5) + assert poly.points[1] == (19.5, 12.1) + assert poly.points[-1] == (17.5, 12.5) + assert feature.removal_box(part) == (17.5, 12.1, 20.0, 12.5) + + +def test_a_retaining_groove_without_explicit_dimensions_is_refused_by_din_471(): + feature = RetainingGroove(id="rg", x=30.0, d=30.0) + with pytest.raises(ValueError, match="DIN 471"): + feature.validate(shaft(feature)) + + +def test_a_retaining_groove_with_explicit_dimensions_cuts_the_profile(): + feature = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + part = shaft(feature) + feature.validate(part) + (edit,) = feature.radial_edit(part) + assert edit["kind"] == "outer" + assert edit["radius"] == pytest.approx(14.3) + assert (edit["x0"], edit["x1"]) == (pytest.approx(29.2), pytest.approx(30.8)) + lines = feature.prims(part, "front", CTX) + assert len(lines) == 3 + assert lines[1].p1[1] == pytest.approx(14.3) + + +def test_a_centre_hole_without_explicit_dimensions_is_refused_by_din_332(): + feature = CentreHole(id="ch", at="end", form="A", size=2.5) + with pytest.raises(ValueError, match="DIN 332"): + feature.validate(shaft(feature)) + + +def test_a_centre_hole_form_r_is_refused_outright(): + feature = CentreHole(id="ch", at="end", form="R", size=2.5, d1=2.5, d2=5.3, t=5.0) + with pytest.raises(ValueError, match="form R"): + feature.validate(shaft(feature)) + + +def test_a_centre_hole_with_explicit_dimensions_draws_a_hidden_60_degree_cone(): + feature = CentreHole(id="ch", at="end", form="A", size=2.5, d1=2.5, d2=5.3, t=5.0) + part = shaft(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + assert {p.role for p in prims} == {"hidden"} + cone_depth = (5.3 - 2.5) / 2.0 / math.tan(math.radians(30.0)) + assert prims[0].p1 == (75.0, 2.65) + assert prims[0].p2[0] == pytest.approx(75.0 - cone_depth) + assert prims[0].p2[1] == pytest.approx(1.25) + + +def test_an_oring_groove_without_explicit_dimensions_is_refused_by_iso_3601(): + feature = ORingGroove(id="og", x=30.0, cord=3.53) + with pytest.raises(ValueError, match="ISO 3601-2"): + feature.validate(shaft(feature)) + + +def test_an_axial_oring_groove_is_refused_rather_than_drawn_on_the_silhouette(): + feature = ORingGroove(id="og", x=30.0, cord=3.53, kind="axial", b=4.7, h=2.7) + with pytest.raises(ValueError, match="face groove"): + feature.validate(shaft(feature)) + + +# -- holes, bores, bolt circles, teeth --------------------------------------- + + +def test_a_cross_hole_draws_two_hidden_lines_through_the_silhouette(): + feature = CrossHole(id="xh", x=30.0, diameter=6.0) + part = shaft(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + assert len(prims) == 2 + assert {p.role for p in prims} == {"hidden"} + assert prims[0].p1 == (27.0, -15.0) and prims[0].p2 == (27.0, 15.0) + assert prims[1].p1 == (33.0, -15.0) and prims[1].p2 == (33.0, 15.0) + + +def test_an_inclined_cross_hole_is_refused_rather_than_drawn_as_if_radial(): + feature = CrossHole(id="xh", x=30.0, diameter=6.0, angle=45.0) + with pytest.raises(ValueError, match="angle"): + feature.validate(shaft(feature)) + + +def test_an_axial_bore_edits_the_radial_profile_and_is_not_flagged_as_omitted(): + feature = AxialBore(id="ab", at="start", diameter=8.0, depth=12.0) + part = shaft(feature) + feature.validate(part) + (edit,) = feature.radial_edit(part) + assert edit == {"kind": "inner", "x0": 0.0, "x1": 12.0, "radius": 4.0} + assert feature.removal_box(part) is None + + +def test_two_axial_bores_from_the_same_end_are_refused_with_both_names(): + a = AxialBore(id="ab1", at="start", diameter=8.0, depth=12.0) + b = AxialBore(id="ab2", at="start", diameter=6.0, depth=20.0) + part = shaft(a, b) + with pytest.raises(ValueError) as excinfo: + a.validate(part) + assert "ab1" in str(excinfo.value) and "ab2" in str(excinfo.value) + + +def test_a_bolt_circle_draws_its_holes_and_its_pitch_circle_in_the_end_view(): + feature = HoleCircle(id="bc", x=10.0, pcd=36.0, count=6, diameter=5.0) + part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,)) + feature.validate(part) + prims = feature.prims(part, "side", CTX) + holes = [p for p in prims if isinstance(p, Circle) and p.role == "visible"] + pitch = [p for p in prims if isinstance(p, Circle) and p.role == "center"] + assert len(holes) == 6 and len(pitch) == 1 + assert pitch[0].radius == pytest.approx(18.0) + assert holes[0].center == (pytest.approx(18.0), pytest.approx(0.0)) + assert holes[1].center[0] == pytest.approx(18.0 * math.cos(math.radians(60.0))) + + +def test_a_bolt_circle_that_does_not_fit_the_flange_is_refused_by_name(): + feature = HoleCircle(id="bc", x=10.0, pcd=58.0, count=6, diameter=5.0) + part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,)) + with pytest.raises(ValueError, match="bc|pcd"): + feature.validate(part) + + +def test_gear_teeth_reuse_the_repository_involute_generator(): + feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20) + part = RevolvedPart(name="pinion", segments=(Segment(15.0, 44.0),), features=(feature,)) + feature.validate(part) + (poly, pitch) = feature.prims(part, "side", CTX)[:2] + expected = generate_full_gear_outline(2.0, 20, 20.0, 0.0, "RH", (0.0, 0.0)) + assert poly.points == tuple(expected) + assert poly.closed is True + assert pitch.radius == pytest.approx(20.0) + + +def test_gear_teeth_whose_tip_diameter_disagrees_with_the_segment_are_refused(): + feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20) + part = RevolvedPart(name="pinion", segments=(Segment(15.0, 40.0),), features=(feature,)) + with pytest.raises(ValueError) as excinfo: + feature.validate(part) + assert "44" in str(excinfo.value) and "40" in str(excinfo.value) + + +# -- prismatic features ------------------------------------------------------ + + +def test_a_hole_draws_a_circle_in_true_shape_and_hidden_lines_in_the_side_view(): + feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + part = plate(feature) + feature.validate(part) + front = feature.prims(part, "front", CTX) + circle = next(p for p in front if isinstance(p, Circle)) + assert circle.center == (20.0, 25.0) and circle.radius == 5.0 + assert sum(1 for p in front if isinstance(p, Line) and p.role == "center") == 2 + side = feature.prims(part, "side", CTX) + assert {p.role for p in side} == {"hidden"} + assert side[0].p1 == (20.0, 0.0) and side[0].p2 == (20.0, 8.0) + top = feature.prims(part, "top", CTX) + assert top[0].p1 == (15.0, 0.0) + + +def test_a_hole_outside_the_outline_is_refused_by_name(): + feature = Hole(id="h1", x=200.0, y=25.0, diameter=10.0) + with pytest.raises(ValueError, match="outline"): + feature.validate(plate(feature)) + + +def test_a_tapped_hole_draws_the_minor_circle_and_a_three_quarter_major_arc(): + feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0, thread="M10") + part = plate(feature) + feature.validate(part) + front = feature.prims(part, "front", CTX) + circles = [p for p in front if isinstance(p, Circle)] + arcs = [p for p in front if isinstance(p, Arc)] + assert circles[0].radius == pytest.approx(4.0) + assert len(arcs) == 1 and arcs[0].radius == pytest.approx(5.0) + + +def test_a_slot_is_an_obround_of_two_lines_and_two_semicircles(): + feature = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0) + part = plate(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + lines = [p for p in prims if isinstance(p, Line) and p.role == "visible"] + arcs = [p for p in prims if isinstance(p, Arc)] + assert len(lines) == 2 and len(arcs) == 2 + assert lines[0].p1 == (pytest.approx(30.0), pytest.approx(30.0)) + assert arcs[0].center == (pytest.approx(50.0), pytest.approx(25.0)) + assert feature.removal_box(part) == (25.0, 20.0, 55.0, 30.0) + + +def test_a_slot_narrower_than_it_is_long_is_required(): + feature = Slot(id="s1", x=40.0, y=25.0, length=5.0, width=10.0) + with pytest.raises(ValueError, match="length"): + feature.validate(plate(feature)) + + +def test_a_pocket_can_declare_itself_unhatched_in_a_longitudinal_section(): + feature = Pocket( + id="p1", + outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)), + depth=4.0, + no_section_hatch=True, + ) + part = plate(feature) + feature.validate(part) + (poly,) = feature.prims(part, "front", CTX) + assert poly.closed is True and poly.role == "visible" + assert feature.no_section_hatch is True + assert feature.removal_box(part) == (10.0, 10.0, 30.0, 30.0) + + +def test_a_pocket_deeper_than_the_plate_is_refused(): + feature = Pocket( + id="p1", + outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0)), + depth=20.0, + ) + with pytest.raises(ValueError, match="thickness"): + feature.validate(plate(feature)) + + +def test_a_corner_fillet_replaces_a_vertex_with_two_tangent_points_and_an_arc(): + feature = CornerFillet(id="cf", vertex=1, radius=10.0) + part = plate(feature) + feature.validate(part) + edit = feature.outline_edit(part) + assert edit["vertex"] == 1 + assert edit["before"] == (pytest.approx(70.0), pytest.approx(0.0)) + assert edit["after"] == (pytest.approx(80.0), pytest.approx(10.0)) + assert edit["arc"].center == (pytest.approx(70.0), pytest.approx(10.0)) + assert edit["arc"].radius == pytest.approx(10.0) + + +def test_a_corner_chamfer_replaces_a_vertex_with_two_points_and_no_arc(): + feature = CornerChamfer(id="cc", vertex=1, size=6.0) + part = plate(feature) + feature.validate(part) + edit = feature.outline_edit(part) + assert edit["before"] == (pytest.approx(74.0), pytest.approx(0.0)) + assert edit["after"] == (pytest.approx(80.0), pytest.approx(6.0)) + assert edit["arc"] is None + + +def test_a_corner_radius_larger_than_its_edges_is_refused(): + feature = CornerFillet(id="cf", vertex=1, radius=60.0) + with pytest.raises(ValueError, match="edge"): + feature.validate(plate(feature)) + + +def test_a_bend_line_is_representation_only_and_says_so(): + feature = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0) + part = plate(feature) + feature.validate(part) + line, text = feature.prims(part, "front", CTX) + assert isinstance(line, Line) and line.role == "phantom" + assert isinstance(text, Text) + assert "R2" in text.text and "90" in text.text + assert feature.dims(part, "front", CTX) == () + + +def test_a_hole_pattern_expands_before_it_reaches_the_part(): + spec = { + "kind": "prismatic", + "name": "plate", + "thickness": 8.0, + "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]], + "features": [ + { + "kind": "hole_pattern", + "id": "hp", + "pattern": "grid", + "base": {"x": 10.0, "y": 10.0, "diameter": 6.0}, + "count": 3, + "spacing": 20.0, + "count_y": 2, + "spacing_y": 25.0, + } + ], + } + part = build_part(spec) + assert len(part.features) == 6 + assert all(isinstance(f, Hole) for f in part.features) + assert [f.id for f in part.features][:3] == ["hp_0_0", "hp_1_0", "hp_2_0"] + assert part.features[2].x == pytest.approx(50.0) + assert part.features[3].y == pytest.approx(35.0) + + +def test_a_polar_hole_pattern_places_its_holes_on_the_pitch_circle(): + pattern = HolePattern( + id="hp", + pattern="polar", + base={"x": 40.0, "y": 25.0, "diameter": 5.0}, + count=4, + pcd=30.0, + ) + holes = pattern.expand() + assert len(holes) == 4 + assert holes[0].x == pytest.approx(55.0) and holes[0].y == pytest.approx(25.0) + assert holes[1].x == pytest.approx(40.0) and holes[1].y == pytest.approx(40.0) + + +def test_a_linear_pattern_without_a_spacing_is_refused(): + pattern = HolePattern( + id="hp", + pattern="linear", + base={"x": 10.0, "y": 10.0, "diameter": 6.0}, + count=3, + ) + with pytest.raises(ValueError, match="spacing"): + pattern.expand() + + +def test_every_feature_declares_its_dimension_intents_against_its_own_id(): + features = ( + Chamfer(id="c1", at="start", size=2.0), + Keyway(id="kw", segment=1, length=25.0), + Thread(id="th", segment=2, designation="M20", length=12.0), + CrossHole(id="xh", x=30.0, diameter=6.0), + ) + part = shaft(*features) + for feature in features: + for intent in feature.dims(part, "front", CTX): + assert intent.feature == feature.id +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_features.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.features'`. + +- [ ] **Step 11: Write `engineering/mech/features.py`** + +```python +"""Every feature of the part model: validation, projected geometry, dimensions. + +Pure: no backend, no async, no DXF. A feature answers four questions about the +part it sits on - *is my placement legal*, *what do I contribute to this view*, +*what do I contribute to a cut face*, *what dimension do I want* - and five +structural ones the view engine asks: + +``removal_box(part)`` + the axis-aligned box of material this feature removes, **in the front + view's plane** (revolved: x along the axis, y radial; prismatic: the + outline's own XY). ``None`` opts out - used by features whose removal is + not a box (an annulus, an axisymmetric bore). +``radial_edit(part)`` + ``{"kind": "inner"|"outer", "x0", "x1", "radius"}`` records that change the + revolved radial profile, which is what makes both the silhouette and the + section cut face come out of one calculation. +``corner_edit(part)`` + ``{"corner", "before", "after", "arc"}`` - replace one silhouette corner + with a chamfer line or a tangent fillet arc. +``outline_edit(part)`` + the prismatic equivalent, keyed by outline vertex index. +``omission(part, view)`` + why this feature has no geometry in that view, for the honesty rule: a + feature that contributes nothing and edits nothing is reported in the + view's ``omitted`` list rather than silently dropped. + +Two features reuse the repository rather than restating a table: +``Keyway`` takes its width and depth from ``engineering.keyway`` (DIN 6885-1) +and ``GearTeeth`` takes its outline from ``engineering.gear``'s involute +generator. ``Thread`` takes its pitch from ``standards/threads.py`` (ISO 261) +and its geometry from ISO 6410. ``Undercut``, ``RetainingGroove``, +``CentreHole`` and ``ORingGroove`` take their dimensions from the registered +standards tables, which are not transcribed in this build - so each of them +either receives explicit dimensions or is refused by name. None of them +invents a number. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from dataclasses import dataclass, fields +from typing import Any, ClassVar, Protocol, runtime_checkable + +from engineering.gear import generate_full_gear_outline +from engineering.keyway import keyway_dimensions +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + point_in_outline, + segment_bounds, +) +from engineering.mech.primitives import Arc, Circle, DimIntent, HatchArea, Line, Poly, Prim, Text +from engineering.mech.standards.centres import centre_hole_dims +from engineering.mech.standards.orings import KINDS as ORING_KINDS, oring_groove_dims +from engineering.mech.standards.rings import ring_groove_dims +from engineering.mech.standards.threads import ( + MINOR_RATIO, + parse_designation, + thread_axial_prims, + thread_end_prims, +) +from engineering.mech.standards.undercuts import undercut_profile + +_EPS = 1e-9 + + +@runtime_checkable +class Feature(Protocol): + """The surface the part model and the view engine rely on.""" + + id: str + + def validate(self, part) -> None: ... + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: ... + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: ... + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: ... + + +# -- shared helpers ---------------------------------------------------------- + + +def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]: + """Mirror primitives about the y = 0 axis (the revolved part's axis).""" + out: list[Prim] = [] + for prim in prims: + if isinstance(prim, Line): + out.append(Line((prim.p1[0], -prim.p1[1]), (prim.p2[0], -prim.p2[1]), prim.role)) + elif isinstance(prim, Arc): + out.append( + Arc( + (prim.center[0], -prim.center[1]), + prim.radius, + (-prim.end_deg) % 360.0, + (-prim.start_deg) % 360.0, + prim.role, + ) + ) + elif isinstance(prim, Circle): + out.append(Circle((prim.center[0], -prim.center[1]), prim.radius, prim.role)) + elif isinstance(prim, Poly): + out.append(Poly(tuple((x, -y) for x, y in prim.points), prim.closed, prim.role)) + elif isinstance(prim, HatchArea): + out.append( + HatchArea( + tuple(tuple((x, -y) for x, y in loop) for loop in prim.loops), + prim.material, + ) + ) + elif isinstance(prim, Text): + out.append( + Text((prim.at[0], -prim.at[1]), prim.text, prim.height, prim.rotation, prim.role) + ) + else: # pragma: no cover - the union is closed + raise ValueError(f"mirror_about_x: unknown primitive {type(prim).__name__}.") + return tuple(out) + + +def _number(value: Any, path: str, *, positive: bool = True) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{path}: {value!r} is not a finite number.") + if positive and number <= _EPS: + raise ValueError(f"{path}: must be greater than zero, got {number}.") + return number + + +def _require_revolved(part, kind: str) -> RevolvedPart: + if not isinstance(part, RevolvedPart): + raise ValueError(f"{kind} applies to a revolved part, not a {type(part).__name__}.") + return part + + +def _require_prismatic(part, kind: str) -> PrismaticPart: + if not isinstance(part, PrismaticPart): + raise ValueError(f"{kind} applies to a prismatic part, not a {type(part).__name__}.") + return part + + +def _segment_index(part: RevolvedPart, index: Any, path: str = "segment") -> int: + try: + value = int(index) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a segment index, got {index!r}.") from None + count = len(part.segments) + if not 0 <= value < count: + raise ValueError( + f"{path} {value} is outside the {count}-segment part (segments 0-{count - 1})." + ) + return value + + +def _parse_at(at: str, part: RevolvedPart, *, allow_step: bool = True) -> tuple[str, int]: + text = str(at or "").strip().lower() + if text in ("start", "end"): + return text, 0 + if text.startswith("step:"): + if not allow_step: + raise ValueError(f"at {at!r}: this feature is placed at 'start' or 'end' only.") + try: + index = int(text.split(":", 1)[1]) + except ValueError: + raise ValueError(f"at {at!r}: expected 'step:'.") from None + last = len(part.segments) - 2 + if last < 0 or not 0 <= index <= last: + raise ValueError( + f"at 'step:{index}': the part has {len(part.segments)} segment(s), so its steps " + f"are step:0-step:{max(last, 0)}." + ) + return "step", index + raise ValueError(f"at {at!r}: expected 'start', 'end' or 'step:'.") + + +def _radius_right_end(part: RevolvedPart, index: int) -> float: + segment = part.segments[index] + return (segment.taper_to if segment.taper_to is not None else segment.d_outer) / 2.0 + + +def _radius_left_end(part: RevolvedPart, index: int) -> float: + return part.segments[index].d_outer / 2.0 + + +def _anchor(feature, part: RevolvedPart) -> tuple[float, float, int]: + """(x, radius, direction) of the corner a chamfer/fillet/undercut sits on.""" + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0.0, _radius_left_end(part, 0), +1 + if kind == "end": + last = len(part.segments) - 1 + return part_length(part), _radius_right_end(part, last), -1 + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return (x, left, -1) if left >= right else (x, right, +1) + + +def _shoulder(feature, part: RevolvedPart) -> tuple[float, float, int]: + """(x, radius of the SMALLER cylinder, direction into it) for a relief groove.""" + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0.0, _radius_left_end(part, 0), +1 + if kind == "end": + last = len(part.segments) - 1 + return part_length(part), _radius_right_end(part, last), -1 + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return (x, right, +1) if left >= right else (x, left, -1) + + +def _jsonable(value: Any) -> Any: + if isinstance(value, tuple): + return [_jsonable(item) for item in value] + if isinstance(value, list): + return [_jsonable(item) for item in value] + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + return value + + +def _tuplify(value: Any) -> Any: + if isinstance(value, list): + return tuple(_tuplify(item) for item in value) + return value + + +# -- the base every feature shares ------------------------------------------- + + +_REGISTRY: dict[str, type] = {} + + +def _register(cls): + _REGISTRY[cls.KIND] = cls + return cls + + +@dataclass(frozen=True, kw_only=True) +class _Base: + id: str + + KIND: ClassVar[str] = "" + #: ISO 128-3: a rib or web cut longitudinally is not hatched. Features that + #: can be one override this as a real field. + no_section_hatch = False + #: True when the feature's *front* primitives describe only the +Y half of + #: an axisymmetric cut, so the view engine mirrors them about the axis. A + #: keyway is not axisymmetric (it sits on one face); a ring groove is. + axisymmetric: ClassVar[bool] = False + + def validate(self, part) -> None: + if not isinstance(self.id, str) or not self.id.strip(): + raise ValueError("id: a non-empty feature id is required.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + return () + + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: + return self.prims(part, "front", ctx) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + return () + + def removal_box(self, part) -> tuple[float, float, float, float] | None: + return None + + def radial_edit(self, part) -> tuple[dict, ...]: + return () + + def corner_edit(self, part) -> dict | None: + return None + + def outline_edit(self, part) -> dict | None: + return None + + def omission(self, part, view: str) -> str | None: + return f"{type(self).__name__} has no {view} projection" + + def to_dict(self) -> dict: + payload = {"kind": self.KIND} + for field_ in fields(self): + payload[field_.name] = _jsonable(getattr(self, field_.name)) + return payload + + +def feature_from_dict(d: dict) -> Feature: + """Build a feature from its dict form; the ``kind`` key selects the class.""" + if not isinstance(d, dict): + raise ValueError(f"expected a dict with a 'kind' key, got {type(d).__name__}.") + kind = str(d.get("kind", "")).strip().lower() + if kind not in _REGISTRY: + raise ValueError(f"unknown feature kind {d.get('kind')!r}. Known kinds: {FEATURE_KINDS}.") + cls = _REGISTRY[kind] + names = {field_.name for field_ in fields(cls)} + payload = {key: value for key, value in d.items() if key != "kind"} + unknown = sorted(set(payload) - names) + if unknown: + raise ValueError(f"{kind}: unknown key(s) {unknown}; it takes {sorted(names)}.") + try: + return cls(**{key: _tuplify(value) for key, value in payload.items()}) + except TypeError as exc: + raise ValueError(f"{kind}: {exc}") from exc +``` + +(the file continues with the feature classes, Step 12) + +- [ ] **Step 12: Append the revolved feature classes to `engineering/mech/features.py`** + +```python +# -- revolved features ------------------------------------------------------- + + +@_register +@dataclass(frozen=True, kw_only=True) +class Chamfer(_Base): + """ISO 128-3 corner chamfer at an end or a step. ``size`` is the axial leg.""" + + KIND: ClassVar[str] = "chamfer" + at: str + size: float + angle: float = 45.0 + + def _legs(self) -> tuple[float, float]: + """(axial leg, radial leg). 45 degrees is special-cased to an exact 1.0 + slope: ``math.tan(math.radians(45))`` is 0.9999999999999999, and the + overwhelmingly common chamfer would otherwise carry that error into + every coordinate it produces.""" + angle = float(self.angle) + slope = 1.0 if abs(angle - 45.0) < 1e-12 else math.tan(math.radians(angle)) + return float(self.size), float(self.size) * slope + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Chamfer") + _number(self.size, "size") + angle = _number(self.angle, "angle") + if not 0.0 < angle < 90.0: + raise ValueError(f"angle: a chamfer angle is between 0 and 90 degrees, got {angle}.") + x, radius, _ = _anchor(self, part) + axial, radial = self._legs() + if radial >= radius: + raise ValueError( + f"size {self.size} at {self.at!r} cuts {radial:.3f} mm off a {radius:.3f} mm " + "radius: the chamfer is larger than the material it sits on." + ) + if axial > part_length(part): + raise ValueError(f"size {self.size} is longer than the part ({part_length(part)} mm).") + del x + + def corner_edit(self, part) -> dict: + x, radius, direction = _anchor(self, _require_revolved(part, "Chamfer")) + axial, radial = self._legs() + if direction > 0: + before, after = (x, radius - radial), (x + axial, radius) + else: + before, after = (x - axial, radius), (x, radius - radial) + return {"corner": (x, radius), "before": before, "after": after, "arc": None} + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "side": + return () + part = _require_revolved(part, "Chamfer") + _, radius, _ = _anchor(self, part) + _, radial = self._legs() + return (Circle((0.0, 0.0), radius - radial, "visible"),) + + def removal_box(self, part): + edit = self.corner_edit(part) + xs = (edit["before"][0], edit["after"][0]) + ys = (edit["before"][1], edit["after"][1]) + return (min(xs), min(ys), max(xs), max(ys)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.corner_edit(part) + return ( + DimIntent( + kind="aligned", + p1=edit["before"], + p2=edit["after"], + text_override=f"{float(self.size):g}x{float(self.angle):g}°", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a chamfer has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Fillet(_Base): + """A tangent fillet radius in the inside corner of a step.""" + + KIND: ClassVar[str] = "fillet" + at: str + radius: float + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Fillet") + radius = _number(self.radius, "radius") + kind, index = _parse_at(self.at, part) + if kind != "step": + raise ValueError( + f"at {self.at!r}: a fillet is defined at a step ('step:'); an end " + "corner takes a Chamfer." + ) + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + if abs(left - right) < _EPS: + raise ValueError(f"at {self.at!r}: the two segments have the same diameter, no step.") + if radius >= abs(left - right): + raise ValueError( + f"radius {radius} is not smaller than the step height {abs(left - right):.3f} mm." + ) + shortest = min(part.segments[index].length, part.segments[index + 1].length) + if radius >= shortest: + raise ValueError(f"radius {radius} is not smaller than the shortest segment {shortest}.") + + def corner_edit(self, part) -> dict: + part = _require_revolved(part, "Fillet") + _, index = _parse_at(self.at, part) + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + radius = float(self.radius) + if left < right: # step up going right: the small cylinder is on the left + corner = (x, left) + before = (x - radius, left) + after = (x, left + radius) + center = (x - radius, left + radius) + arc = Arc(center, radius, 270.0, 360.0, "visible") + else: # step down going right: the small cylinder is on the right + corner = (x, right) + before = (x, right + radius) + after = (x + radius, right) + center = (x + radius, right + radius) + arc = Arc(center, radius, 180.0, 270.0, "visible") + return {"corner": corner, "before": before, "after": after, "arc": arc} + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.corner_edit(part) + return ( + DimIntent( + kind="radius", + p1=edit["arc"].center, + p2=edit["before"], + text_override=f"R{float(self.radius):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + if view == "front": + return None + return "a step fillet projects onto the end view as a circle that coincides with the step" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Keyway(_Base): + """DIN 6885-1 keyway on the +Y face of a segment. + + Width and depth come from ``engineering.keyway.keyway_dimensions`` unless + both are given. Because the slot is modelled on the +Y face, the front view + shows its floor and its two end walls as visible lines; the width edges + coincide with the silhouette and are not drawn twice. + """ + + KIND: ClassVar[str] = "keyway" + segment: int + length: float + offset: float = 0.0 + from_end: str = "left" + width: float | None = None + depth: float | None = None + + def _table(self, part) -> dict: + index = _segment_index(part, self.segment) + diameter = part.segments[index].d_outer + if self.width is not None and self.depth is not None: + return {"width": float(self.width), "depth_shaft": float(self.depth)} + try: + table = keyway_dimensions(diameter) + except ValueError as exc: + raise ValueError(f"DIN 6885: {exc}") from exc + return { + "width": float(self.width) if self.width is not None else table["width"], + "depth_shaft": float(self.depth) if self.depth is not None else table["depth_shaft"], + } + + def _span(self, part) -> tuple[float, float, float, float, float]: + index = _segment_index(part, self.segment) + x0, x1 = segment_bounds(part)[index] + table = self._table(part) + offset = float(self.offset) + length = float(self.length) + if str(self.from_end).strip().lower() == "right": + xa = x1 - offset - length + else: + xa = x0 + offset + radius = part.segments[index].d_outer / 2.0 + return xa, xa + length, radius, table["width"], table["depth_shaft"] + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Keyway") + index = _segment_index(part, self.segment) + _number(self.length, "length") + _number(self.offset, "offset", positive=False) + if str(self.from_end).strip().lower() not in ("left", "right"): + raise ValueError(f"from_end: expected 'left' or 'right', got {self.from_end!r}.") + segment_length = part.segments[index].length + if float(self.offset) + float(self.length) > segment_length + _EPS: + raise ValueError( + f"length {self.length} + offset {self.offset} exceeds segment {index}'s " + f"{segment_length} mm." + ) + table = self._table(part) + radius = part.segments[index].d_outer / 2.0 + if table["depth_shaft"] >= radius: + raise ValueError( + f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "Keyway") + xa, xb, radius, width, depth = self._span(part) + if view == "front": + floor = radius - depth + return ( + Line((xa, radius), (xa, floor), "visible"), + Line((xa, floor), (xb, floor), "visible"), + Line((xb, floor), (xb, radius), "visible"), + ) + if view == "side": + half = width / 2.0 + top = math.sqrt(max(radius * radius - half * half, 0.0)) + floor = radius - depth + start = math.degrees(math.atan2(top, half)) % 360.0 + end = math.degrees(math.atan2(top, -half)) % 360.0 + return ( + Arc((0.0, 0.0), radius, start, end, "visible"), + Line((-half, top), (-half, floor), "visible"), + Line((-half, floor), (half, floor), "visible"), + Line((half, floor), (half, top), "visible"), + ) + return () + + def removal_box(self, part): + xa, xb, radius, _, depth = self._span(part) + return (xa, radius - depth, xb, radius) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + xa, xb, radius, width, depth = self._span(part) + if view == "front": + return ( + DimIntent(kind="linear", p1=(xa, radius), p2=(xb, radius), feature=self.id), + ) + if view == "side": + floor = radius - depth + return ( + DimIntent( + kind="linear", + p1=(-width / 2.0, floor), + p2=(width / 2.0, floor), + feature=self.id, + ), + DimIntent( + kind="linear", + p1=(0.0, -radius), + p2=(0.0, floor), + text_override=f"{radius + floor:g}", + feature=self.id, + ), + ) + return () + + def omission(self, part, view: str) -> str | None: + if view in ("front", "side"): + return None + return "a keyway on the +Y face coincides with the outline in the top view" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Thread(_Base): + """An ISO 261 thread drawn with the ISO 6410 representation.""" + + KIND: ClassVar[str] = "thread" + segment: int + designation: str + length: float + offset: float = 0.0 + from_end: str = "left" + internal: bool = False + + def _parsed(self) -> dict: + return parse_designation(self.designation) + + def _span(self, part) -> tuple[float, float, float]: + index = _segment_index(part, self.segment) + x0, x1 = segment_bounds(part)[index] + length = float(self.length) + if str(self.from_end).strip().lower() == "right": + xa = x1 - float(self.offset) - length + else: + xa = x0 + float(self.offset) + return xa, xa + length, self._parsed()["d"] + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Thread") + index = _segment_index(part, self.segment) + parsed = self._parsed() + _number(self.length, "length") + _number(self.offset, "offset", positive=False) + segment = part.segments[index] + if float(self.offset) + float(self.length) > segment.length + _EPS: + raise ValueError( + f"length {self.length} + offset {self.offset} exceeds segment {index}'s " + f"{segment.length} mm." + ) + if self.internal: + # ISO 6410: a tapped hole is modelled with the *drilled* bore, i.e. + # the minor diameter at 0.8 x major, and the major is what the + # section hatch then runs to (radial_edit below). + wanted = MINOR_RATIO * parsed["d"] + if abs(wanted - segment.d_inner) > 0.01: + raise ValueError( + f"{parsed['designation']} internal is drawn with its minor diameter at " + f"{wanted:g} mm (ISO 6410, 0.8 x major) but segment {index}'s bore is " + f"{segment.d_inner:g} mm. Drill the segment to the minor diameter, or " + "name the thread that fits it." + ) + elif abs(parsed["d"] - segment.d_outer) > 0.01: + raise ValueError( + f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but " + f"segment {index}'s outside is {segment.d_outer:g} mm. Cut the segment to the " + "thread diameter, or name the thread that fits it." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "Thread") + xa, xb, d = self._span(part) + if view == "front": + return thread_axial_prims( + x0=xa, length=xb - xa, d=d, internal=bool(self.internal) + ) + if view == "side": + return thread_end_prims(center=(0.0, 0.0), d=d, internal=bool(self.internal)) + return () + + def radial_edit(self, part) -> tuple[dict, ...]: + if not self.internal: + return () + # ISO 6410 section convention: the hatch of an internal thread runs to + # the major diameter, so the cut face's bore is the major, not the minor. + xa, xb, d = self._span(part) + return ({"kind": "inner", "x0": xa, "x1": xb, "radius": d / 2.0},) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + xa, _, d = self._span(part) + return ( + DimIntent( + kind="diameter", + p1=(xa, d / 2.0), + p2=(xa, -d / 2.0), + text_override=self._parsed()["designation"], + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a thread has no {view} projection" + + @property + def minor_ratio(self) -> float: + return MINOR_RATIO + + +@_register +@dataclass(frozen=True, kw_only=True) +class Undercut(_Base): + """A DIN 509 relief groove drawn from the profile the standard publishes. + + ``profile`` is a sequence of ``(along, depth)`` pairs in millimetres, + measured from the shoulder corner into the smaller cylinder. With no + explicit profile the registered DIN 509 table is asked - and refuses by + name, because no row is transcribed in this build. Nothing is reconstructed. + """ + + KIND: ClassVar[str] = "undercut" + axisymmetric: ClassVar[bool] = True + at: str + form: str = "E" + profile: tuple[tuple[float, float], ...] | None = None + + def _profile(self, part) -> tuple[tuple[float, float], ...]: + if self.profile is not None: + return tuple((float(a), float(b)) for a, b in self.profile) + _, radius, _ = _shoulder(self, part) + return undercut_profile(self.form, radius * 2.0) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Undercut") + _parse_at(self.at, part) + profile = self._profile(part) + if len(profile) < 2: + raise ValueError("profile: a relief profile needs at least two (along, depth) pairs.") + previous = -math.inf + for index, (along, depth) in enumerate(profile): + _number(along, f"profile[{index}].along", positive=False) + _number(depth, f"profile[{index}].depth", positive=False) + if along < previous - _EPS: + raise ValueError(f"profile[{index}].along: the profile must run monotonically.") + previous = along + _, radius, _ = _shoulder(self, part) + deepest = max(depth for _, depth in profile) + if deepest >= radius: + raise ValueError( + f"profile: the deepest relief {deepest} mm is not smaller than the " + f"{radius} mm radius it is cut into." + ) + + def _points(self, part) -> tuple[tuple[float, float], ...]: + x, radius, direction = _shoulder(self, part) + return tuple((x + direction * along, radius - depth) for along, depth in self._profile(part)) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + return (Poly(self._points(_require_revolved(part, "Undercut")), False, "visible"),) + + def removal_box(self, part): + points = self._points(part) + xs = [p[0] for p in points] + ys = [p[1] for p in points] + return (min(xs), min(ys), max(xs), max(ys)) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a relief groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class RetainingGroove(_Base): + """A DIN 471 (shaft) or DIN 472 (bore) retaining-ring groove.""" + + KIND: ClassVar[str] = "retaining_groove" + axisymmetric: ClassVar[bool] = True + x: float + d: float + where: str = "shaft" + m: float | None = None + d2: float | None = None + standard: str | None = None + + def _standard(self) -> str: + if self.standard: + return " ".join(str(self.standard).split()).upper() + return "DIN 471" if str(self.where).strip().lower() == "shaft" else "DIN 472" + + def _dims(self) -> dict: + if self.m is not None and self.d2 is not None: + return {"m": float(self.m), "d2": float(self.d2)} + return ring_groove_dims(self._standard(), float(self.d)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "RetainingGroove") + where = str(self.where).strip().lower() + if where not in ("shaft", "bore"): + raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.") + x = _number(self.x, "x", positive=False) + _number(self.d, "d") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + dims = self._dims() + _number(dims["m"], "m") + _number(dims["d2"], "d2") + radius = outer_radius_at(part, x) + bore = inner_radius_at(part, x) + if where == "shaft" and dims["d2"] / 2.0 >= radius: + raise ValueError( + f"d2 {dims['d2']} mm does not cut into the {radius * 2.0:g} mm shaft at x={x}." + ) + if where == "bore" and dims["d2"] / 2.0 <= bore: + raise ValueError( + f"d2 {dims['d2']} mm does not cut into the {bore * 2.0:g} mm bore at x={x}." + ) + + def _span(self, part) -> tuple[float, float, float, float]: + dims = self._dims() + x = float(self.x) + half = dims["m"] / 2.0 + surface = ( + outer_radius_at(part, x) + if str(self.where).strip().lower() == "shaft" + else inner_radius_at(part, x) + ) + return x - half, x + half, dims["d2"] / 2.0, surface + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1, groove_r, _ = self._span(part) + kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner" + return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + x0, x1, groove_r, surface = self._span(_require_revolved(part, "RetainingGroove")) + return ( + Line((x0, surface), (x0, groove_r), "visible"), + Line((x0, groove_r), (x1, groove_r), "visible"), + Line((x1, groove_r), (x1, surface), "visible"), + ) + + def removal_box(self, part): + x0, x1, groove_r, surface = self._span(part) + return (x0, min(groove_r, surface), x1, max(groove_r, surface)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x0, x1, groove_r, _ = self._span(part) + dims = self._dims() + return ( + DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id), + DimIntent( + kind="diameter", + p1=(float(self.x), groove_r), + p2=(float(self.x), -groove_r), + text_override=f"⌀{dims['d2']:g} ({self._standard()})", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a ring groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CentreHole(_Base): + """A DIN 332-1 centre hole, forms A and B. Form R is refused, not approximated.""" + + KIND: ClassVar[str] = "centre_hole" + at: str = "end" + form: str = "A" + size: float = 0.0 + d1: float | None = None + d2: float | None = None + d3: float | None = None + t: float | None = None + + def _dims(self) -> dict: + form = str(self.form).strip().upper() + explicit = self.d1 is not None and self.d2 is not None and self.t is not None + if form == "B": + explicit = explicit and self.d3 is not None + if explicit: + return { + "d1": float(self.d1), + "d2": float(self.d2), + "d3": float(self.d3) if self.d3 is not None else None, + "t": float(self.t), + } + return centre_hole_dims(self.form, float(self.size)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "CentreHole") + _parse_at(self.at, part, allow_step=False) + if str(self.form).strip().upper() == "R": + # the table module owns the wording; ask it so there is one message + centre_hole_dims("R", float(self.size)) + dims = self._dims() + _number(dims["d1"], "d1") + _number(dims["d2"], "d2") + _number(dims["t"], "t") + if dims["d2"] <= dims["d1"]: + raise ValueError(f"d2 {dims['d2']} must be larger than d1 {dims['d1']}.") + kind, _ = _parse_at(self.at, part, allow_step=False) + x = 0.0 if kind == "start" else part_length(part) + if dims["d2"] / 2.0 >= outer_radius_at(part, x): + raise ValueError( + f"d2 {dims['d2']} mm does not fit the {outer_radius_at(part, x) * 2.0:g} mm face." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "CentreHole") + dims = self._dims() + kind, _ = _parse_at(self.at, part, allow_step=False) + face = 0.0 if kind == "start" else part_length(part) + step = 1.0 if kind == "start" else -1.0 + r1, r2 = dims["d1"] / 2.0, dims["d2"] / 2.0 + cone = (r2 - r1) / math.tan(math.radians(30.0)) # 60 deg included angle + if view == "front": + prims: list[Prim] = [ + Line((face, r2), (face + step * cone, r1), "hidden"), + Line((face, -r2), (face + step * cone, -r1), "hidden"), + Line((face + step * cone, r1), (face + step * dims["t"], r1), "hidden"), + Line((face + step * cone, -r1), (face + step * dims["t"], -r1), "hidden"), + Line( + (face + step * dims["t"], -r1), + (face + step * dims["t"], r1), + "hidden", + ), + ] + if str(self.form).strip().upper() == "B" and dims.get("d3"): + r3 = dims["d3"] / 2.0 + guard = (r3 - r2) / math.tan(math.radians(60.0)) # 120 deg protective cone + prims.insert(0, Line((face, r3), (face + step * guard, r2), "hidden")) + prims.insert(1, Line((face, -r3), (face + step * guard, -r2), "hidden")) + return tuple(prims) + if view == "side": + circles: list[Prim] = [ + Circle((0.0, 0.0), r1, "hidden"), + Circle((0.0, 0.0), r2, "hidden"), + ] + if str(self.form).strip().upper() == "B" and dims.get("d3"): + circles.append(Circle((0.0, 0.0), dims["d3"] / 2.0, "hidden")) + return tuple(circles) + return () + + def removal_box(self, part): + dims = self._dims() + kind, _ = _parse_at(self.at, part, allow_step=False) + face = 0.0 if kind == "start" else part_length(part) + step = 1.0 if kind == "start" else -1.0 + far = face + step * dims["t"] + outer = max(dims["d2"], dims.get("d3") or 0.0) / 2.0 + return (min(face, far), -outer, max(face, far), outer) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a centre hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class ORingGroove(_Base): + """An ISO 3601-2 radial O-ring housing. An axial (face) groove is refused.""" + + KIND: ClassVar[str] = "oring_groove" + axisymmetric: ClassVar[bool] = True + x: float + cord: float + where: str = "shaft" + kind: str = "radial" + b: float | None = None + h: float | None = None + + def _dims(self) -> dict: + if self.b is not None and self.h is not None: + return {"b": float(self.b), "h": float(self.h)} + return oring_groove_dims(float(self.cord), str(self.kind)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "ORingGroove") + if str(self.kind).strip().lower() not in ORING_KINDS: + raise ValueError(f"kind: expected one of {ORING_KINDS}, got {self.kind!r}.") + if str(self.kind).strip().lower() == "axial": + raise ValueError( + "kind 'axial': a face groove is not on the revolved silhouette and is refused " + "rather than drawn as if it were radial. Model the face as a prismatic pocket." + ) + if str(self.where).strip().lower() not in ("shaft", "bore"): + raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.") + x = _number(self.x, "x", positive=False) + _number(self.cord, "cord") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + dims = self._dims() + _number(dims["b"], "b") + _number(dims["h"], "h") + radius = outer_radius_at(part, x) + if str(self.where).strip().lower() == "shaft" and dims["h"] >= radius: + raise ValueError(f"h {dims['h']} mm is not smaller than the radius {radius} mm.") + + def _span(self, part) -> tuple[float, float, float, float]: + dims = self._dims() + x = float(self.x) + half = dims["b"] / 2.0 + if str(self.where).strip().lower() == "shaft": + surface = outer_radius_at(part, x) + return x - half, x + half, surface - dims["h"], surface + surface = inner_radius_at(part, x) + return x - half, x + half, surface + dims["h"], surface + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1, groove_r, _ = self._span(part) + kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner" + return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + x0, x1, groove_r, surface = self._span(_require_revolved(part, "ORingGroove")) + return ( + Line((x0, surface), (x0, groove_r), "visible"), + Line((x0, groove_r), (x1, groove_r), "visible"), + Line((x1, groove_r), (x1, surface), "visible"), + ) + + def removal_box(self, part): + x0, x1, groove_r, surface = self._span(part) + return (x0, min(groove_r, surface), x1, max(groove_r, surface)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x0, x1, groove_r, _ = self._span(part) + return (DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id),) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"an O-ring groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CrossHole(_Base): + """A radial hole through (or into) a revolved part. + + Only a radial hole (``angle`` 90 degrees) is projected. An inclined cross + hole is refused rather than drawn as if it were radial. + """ + + KIND: ClassVar[str] = "cross_hole" + x: float + diameter: float + angle: float = 90.0 + depth: float | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "CrossHole") + x = _number(self.x, "x", positive=False) + diameter = _number(self.diameter, "diameter") + angle = _number(self.angle, "angle", positive=False) + if abs(angle - 90.0) > _EPS: + raise ValueError( + f"angle {angle}: only a radial cross hole (angle=90) is projected by this " + "engine; an inclined hole is refused rather than drawn as if it were radial." + ) + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + radius = outer_radius_at(part, x) + if diameter >= 2.0 * radius: + raise ValueError( + f"diameter {diameter} mm is not smaller than the {2.0 * radius:g} mm outside " + f"diameter at x={x}." + ) + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > 2.0 * radius + _EPS: + raise ValueError(f"depth {depth} mm is deeper than the part at x={x}.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "CrossHole") + x = float(self.x) + half = float(self.diameter) / 2.0 + radius = outer_radius_at(part, x) + if view == "front": + bottom = -radius if self.depth is None else radius - float(self.depth) + prims: list[Prim] = [ + Line((x - half, bottom), (x - half, radius), "hidden"), + Line((x + half, bottom), (x + half, radius), "hidden"), + ] + if self.depth is not None: + prims.append(Line((x - half, bottom), (x + half, bottom), "hidden")) + return tuple(prims) + if view == "side": + return ( + Line((-radius, half), (radius, half), "hidden"), + Line((-radius, -half), (radius, -half), "hidden"), + ) + return () + + def removal_box(self, part): + x = float(self.x) + half = float(self.diameter) / 2.0 + radius = outer_radius_at(part, x) + bottom = -radius if self.depth is None else radius - float(self.depth) + return (x - half, bottom, x + half, radius) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x = float(self.x) + half = float(self.diameter) / 2.0 + return ( + DimIntent( + kind="diameter", + p1=(x - half, 0.0), + p2=(x + half, 0.0), + text_override=f"⌀{float(self.diameter):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a cross hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class HoleCircle(_Base): + """A bolt circle on a face: hidden pairs in the axial view, real holes in the end view.""" + + KIND: ClassVar[str] = "hole_circle" + x: float + pcd: float + count: int + diameter: float + start_angle: float = 0.0 + thread: str | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "HoleCircle") + x = _number(self.x, "x", positive=False) + pcd = _number(self.pcd, "pcd") + diameter = _number(self.diameter, "diameter") + _number(self.start_angle, "start_angle", positive=False) + if int(self.count) < 2: + raise ValueError(f"count: a bolt circle needs at least 2 holes, got {self.count}.") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + radius = outer_radius_at(part, x) + bore = inner_radius_at(part, x) + if pcd / 2.0 + diameter / 2.0 > radius + _EPS: + raise ValueError( + f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 + diameter / 2.0:.3f} mm, " + f"past the {radius:.3f} mm outside radius at x={x}." + ) + if pcd / 2.0 - diameter / 2.0 < bore - _EPS: + raise ValueError( + f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 - diameter / 2.0:.3f} mm, " + f"inside the {bore:.3f} mm bore at x={x}." + ) + if self.thread: + parse_designation(self.thread) + + def _centers(self) -> tuple[tuple[float, float], ...]: + count = int(self.count) + radius = float(self.pcd) / 2.0 + start = math.radians(float(self.start_angle)) + return tuple( + (radius * math.cos(start + 2.0 * math.pi * k / count), + radius * math.sin(start + 2.0 * math.pi * k / count)) + for k in range(count) + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "HoleCircle") + pcd_r = float(self.pcd) / 2.0 + half = float(self.diameter) / 2.0 + if view == "front": + index, _, _, _ = _segment_at_index(part, float(self.x)) + x0, x1 = segment_bounds(part)[index] + prims: list[Prim] = [] + for sign in (1.0, -1.0): + for offset in (half, -half): + y = sign * (pcd_r + offset) + prims.append(Line((x0, y), (x1, y), "hidden")) + prims.append(Line((x0, sign * pcd_r), (x1, sign * pcd_r), "center")) + return tuple(prims) + if view == "side": + prims = [Circle((0.0, 0.0), pcd_r, "center")] + for cx, cy in self._centers(): + prims.append(Circle((cx, cy), half, "visible")) + prims.append(Line((cx - half - 2.0, cy), (cx + half + 2.0, cy), "center")) + prims.append(Line((cx, cy - half - 2.0), (cx, cy + half + 2.0), "center")) + return tuple(prims) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "side": + return () + pcd_r = float(self.pcd) / 2.0 + half = float(self.diameter) / 2.0 + first = self._centers()[0] + label = self.thread or f"⌀{float(self.diameter):g}" + return ( + DimIntent( + kind="diameter", + p1=(first[0] - half, first[1]), + p2=(first[0] + half, first[1]), + text_override=f"{int(self.count)}x {label}", + feature=self.id, + ), + DimIntent( + kind="diameter", + p1=(-pcd_r, 0.0), + p2=(pcd_r, 0.0), + text_override=f"⌀{float(self.pcd):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a bolt circle has no {view} projection" + + +def _segment_at_index(part: RevolvedPart, x: float) -> tuple[int, float, float, Any]: + for index, ((x0, x1), segment) in enumerate(zip(segment_bounds(part), part.segments)): + if x0 - _EPS <= x <= x1 + _EPS: + return index, x0, x1, segment + raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).") + + +@_register +@dataclass(frozen=True, kw_only=True) +class AxialBore(_Base): + """A bore drilled in from one end. Expressed through the radial profile.""" + + KIND: ClassVar[str] = "axial_bore" + at: str = "start" + diameter: float + depth: float | None = None + + def _span(self, part) -> tuple[float, float]: + kind, _ = _parse_at(self.at, part, allow_step=False) + length = part_length(part) + depth = length if self.depth is None else float(self.depth) + return (0.0, depth) if kind == "start" else (length - depth, length) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "AxialBore") + _parse_at(self.at, part, allow_step=False) + diameter = _number(self.diameter, "diameter") + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > part_length(part) + _EPS: + raise ValueError(f"depth {depth} mm is longer than the part {part_length(part)} mm.") + x0, x1 = self._span(part) + for x in (x0 + _EPS, (x0 + x1) / 2.0, x1 - _EPS): + if diameter >= 2.0 * outer_radius_at(part, x): + raise ValueError( + f"diameter {diameter} mm is not smaller than the " + f"{2.0 * outer_radius_at(part, x):g} mm outside diameter at x={x:.3f}." + ) + for other in getattr(part, "features", ()): + if other is self or not isinstance(other, AxialBore): + continue + ox0, ox1 = other._span(part) + if min(x1, ox1) - max(x0, ox0) > _EPS: + raise ValueError( + f"bores {self.id!r} and {other.id!r} both remove material over " + f"x {max(x0, ox0):.3f}..{min(x1, ox1):.3f}. A bore that changes diameter " + "is two segments, not two features." + ) + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1 = self._span(part) + return ({"kind": "inner", "x0": x0, "x1": x1, "radius": float(self.diameter) / 2.0},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "AxialBore") + half = float(self.diameter) / 2.0 + kind, _ = _parse_at(self.at, part, allow_step=False) + x0, x1 = self._span(part) + if view == "front" and self.depth is not None: + bottom = x1 if kind == "start" else x0 + return (Line((bottom, -half), (bottom, half), "hidden"),) + if view == "side": + near = str(ctx.get("side", "right")).strip().lower() + open_here = (near == "left" and kind == "start") or (near == "right" and kind == "end") + return (Circle((0.0, 0.0), half, "visible" if open_here else "hidden"),) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "side": + return () + half = float(self.diameter) / 2.0 + return ( + DimIntent( + kind="diameter", + p1=(-half, 0.0), + p2=(half, 0.0), + text_override=f"⌀{float(self.diameter):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None # the bore always reaches the drawing through the radial profile + + +@_register +@dataclass(frozen=True, kw_only=True) +class GearTeeth(_Base): + """Involute spur/helical teeth on a segment, from the repository's own generator.""" + + KIND: ClassVar[str] = "gear_teeth" + segment: int + module: float + teeth: int + pressure_angle: float = 20.0 + helix_angle: float = 0.0 + hand: str = "RH" + + def _radii(self) -> tuple[float, float, float]: + module = float(self.module) + teeth = int(self.teeth) + pitch = module * teeth / 2.0 + return pitch + module, pitch, pitch - 1.25 * module + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "GearTeeth") + index = _segment_index(part, self.segment) + _number(self.module, "module") + angle = _number(self.pressure_angle, "pressure_angle") + if int(self.teeth) < 6: + raise ValueError(f"teeth: the involute generator needs at least 6 teeth, got {self.teeth}.") + if not 10.0 <= angle <= 35.0: + raise ValueError(f"pressure_angle {angle}: expected between 10 and 35 degrees.") + if str(self.hand).strip().upper() not in ("RH", "LH"): + raise ValueError(f"hand: expected 'RH' or 'LH', got {self.hand!r}.") + tip, _, _ = self._radii() + outside = part.segments[index].d_outer + if abs(tip * 2.0 - outside) > 0.01: + raise ValueError( + f"m={float(self.module):g} z={int(self.teeth)} gives a tip diameter of " + f"{tip * 2.0:g} mm, but segment {index} is {outside:g} mm. Cut the segment to " + "the tip diameter, or name the module and tooth count that fit it." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "GearTeeth") + index = _segment_index(part, self.segment) + _, pitch, root = self._radii() + if view == "front": + x0, x1 = segment_bounds(part)[index] + prims: list[Prim] = [ + Line((x0, root), (x1, root), "visible"), + Line((x0, -root), (x1, -root), "visible"), + Line((x0, pitch), (x1, pitch), "center"), + Line((x0, -pitch), (x1, -pitch), "center"), + ] + if abs(float(self.helix_angle)) > _EPS: + # ISO 2203: three thin lines at the helix angle mark the hand. + mid = (x0 + x1) / 2.0 + run = (x1 - x0) / 3.0 + rise = run * math.tan(math.radians(float(self.helix_angle))) + sign = 1.0 if str(self.hand).strip().upper() == "RH" else -1.0 + for offset in (-run, 0.0, run): + prims.append( + Line( + (mid + offset - run / 2.0, -pitch / 2.0), + (mid + offset + run / 2.0, -pitch / 2.0 + sign * rise), + "phantom", + ) + ) + return tuple(prims) + if view == "side": + outline = generate_full_gear_outline( + float(self.module), + int(self.teeth), + float(self.pressure_angle), + float(self.helix_angle), + str(self.hand).strip().upper(), + (0.0, 0.0), + ) + return ( + Poly(tuple(outline), True, "visible"), + Circle((0.0, 0.0), pitch, "center"), + ) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + tip, _, _ = self._radii() + if view != "side": + return () + return ( + DimIntent( + kind="diameter", + p1=(-tip, 0.0), + p2=(tip, 0.0), + text_override=( + f"⌀{tip * 2.0:g} (m={float(self.module):g}, z={int(self.teeth)})" + ), + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"gear teeth have no {view} projection" +``` + +- [ ] **Step 13: Append the prismatic feature classes to `engineering/mech/features.py`** + +```python +# -- prismatic features ------------------------------------------------------ +# +# View frames for a prismatic part: +# "front" : the outline's own XY +# "side" : u = part Y, v = z in [0, thickness] (looking along -X) +# "top" : u = part X, v = z in [0, thickness] (looking along -Y) + + +@_register +@dataclass(frozen=True, kw_only=True) +class Hole(_Base): + """A hole in a prismatic part, optionally counterbored, countersunk or tapped.""" + + KIND: ClassVar[str] = "hole" + x: float + y: float + diameter: float + depth: float | None = None + cbore_d: float | None = None + cbore_depth: float | None = None + csink_d: float | None = None + thread: str | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Hole") + _number(self.x, "x", positive=False) + _number(self.y, "y", positive=False) + diameter = _number(self.diameter, "diameter") + if not point_in_outline(part, float(self.x), float(self.y)): + raise ValueError( + f"({float(self.x):g}, {float(self.y):g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > part.thickness + _EPS: + raise ValueError(f"depth {depth} is deeper than the {part.thickness} mm thickness.") + for name, value in (("cbore_d", self.cbore_d), ("csink_d", self.csink_d)): + if value is not None and _number(value, name) <= diameter: + raise ValueError(f"{name} {value} must be larger than the hole {diameter}.") + if self.cbore_d is not None and self.cbore_depth is None: + raise ValueError("cbore_depth: a counterbore needs its depth.") + if self.thread: + parsed = parse_designation(self.thread) + if abs(parsed["d"] - diameter) > 0.01: + raise ValueError( + f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but " + f"diameter is {diameter:g} mm." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Hole") + x, y = float(self.x), float(self.y) + half = float(self.diameter) / 2.0 + if view == "front": + prims: list[Prim] = [] + if self.thread: + minor = MINOR_RATIO * float(self.diameter) / 2.0 + prims.append(Circle((x, y), minor, "visible")) + prims.append(Arc((x, y), half, 90.0, 0.0, "visible")) + else: + prims.append(Circle((x, y), half, "visible")) + for extra in (self.cbore_d, self.csink_d): + if extra is not None: + prims.append(Circle((x, y), float(extra) / 2.0, "visible")) + extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None] + reach = max([half, *extras]) + 3.0 + prims.append(Line((x - reach, y), (x + reach, y), "center")) + prims.append(Line((x, y - reach), (x, y + reach), "center")) + return tuple(prims) + if view in ("side", "top"): + u = y if view == "side" else x + depth = part.thickness if self.depth is None else float(self.depth) + prims = [ + Line((u - half, 0.0), (u - half, depth), "hidden"), + Line((u + half, 0.0), (u + half, depth), "hidden"), + ] + if self.depth is not None: + prims.append(Line((u - half, depth), (u + half, depth), "hidden")) + if self.cbore_d is not None: + cb = float(self.cbore_d) / 2.0 + cd = float(self.cbore_depth) + prims.append(Line((u - cb, 0.0), (u - cb, cd), "hidden")) + prims.append(Line((u + cb, 0.0), (u + cb, cd), "hidden")) + prims.append(Line((u - cb, cd), (u + cb, cd), "hidden")) + return tuple(prims) + return () + + def removal_box(self, part): + x, y = float(self.x), float(self.y) + extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None] + outer = max([float(self.diameter) / 2.0, *extras]) + return (x - outer, y - outer, x + outer, y + outer) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x, y = float(self.x), float(self.y) + half = float(self.diameter) / 2.0 + label = self.thread or f"⌀{float(self.diameter):g}" + return ( + DimIntent( + kind="diameter", + p1=(x - half, y), + p2=(x + half, y), + text_override=label, + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Slot(_Base): + """An obround slot: two parallel flanks closed by two semicircular ends.""" + + KIND: ClassVar[str] = "slot" + x: float + y: float + length: float + width: float + angle: float = 0.0 + + def _ends(self) -> tuple[tuple[float, float], tuple[float, float], float]: + half_run = (float(self.length) - float(self.width)) / 2.0 + theta = math.radians(float(self.angle)) + ux, uy = math.cos(theta), math.sin(theta) + x, y = float(self.x), float(self.y) + return ( + (x - half_run * ux, y - half_run * uy), + (x + half_run * ux, y + half_run * uy), + float(self.width) / 2.0, + ) + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Slot") + _number(self.x, "x", positive=False) + _number(self.y, "y", positive=False) + length = _number(self.length, "length") + width = _number(self.width, "width") + _number(self.angle, "angle", positive=False) + if length <= width: + raise ValueError( + f"length {length} must be greater than width {width}; a slot as wide as it is " + "long is a hole." + ) + for point in self._ends()[:2]: + if not point_in_outline(part, point[0], point[1]): + raise ValueError( + f"slot end ({point[0]:g}, {point[1]:g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Slot") + (c1, c2, half) = self._ends() + theta = math.radians(float(self.angle)) + nx, ny = -math.sin(theta), math.cos(theta) + if view == "front": + return ( + Line((c1[0] + half * nx, c1[1] + half * ny), + (c2[0] + half * nx, c2[1] + half * ny), "visible"), + Line((c1[0] - half * nx, c1[1] - half * ny), + (c2[0] - half * nx, c2[1] - half * ny), "visible"), + Arc(c2, half, (float(self.angle) - 90.0) % 360.0, + (float(self.angle) + 90.0) % 360.0, "visible"), + Arc(c1, half, (float(self.angle) + 90.0) % 360.0, + (float(self.angle) + 270.0) % 360.0, "visible"), + Line((c1[0], c1[1]), (c2[0], c2[1]), "center"), + ) + if view in ("side", "top"): + box = self.removal_box(part) + u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2]) + return ( + Line((u0, 0.0), (u0, part.thickness), "hidden"), + Line((u1, 0.0), (u1, part.thickness), "hidden"), + ) + return () + + def removal_box(self, part): + (c1, c2, half) = self._ends() + xs = (c1[0] - half, c1[0] + half, c2[0] - half, c2[0] + half) + ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half) + return (min(xs), min(ys), max(xs), max(ys)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + (c1, c2, half) = self._ends() + theta = math.radians(float(self.angle)) + nx, ny = -math.sin(theta), math.cos(theta) + return ( + DimIntent( + kind="aligned", + p1=(c1[0], c1[1]), + p2=(c2[0], c2[1]), + text_override=f"{float(self.length):g}", + feature=self.id, + ), + DimIntent( + kind="aligned", + p1=(c2[0] + half * nx, c2[1] + half * ny), + p2=(c2[0] - half * nx, c2[1] - half * ny), + text_override=f"{float(self.width):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a slot has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Pocket(_Base): + """A milled pocket. ``no_section_hatch`` marks a rib or web (ISO 128-3).""" + + KIND: ClassVar[str] = "pocket" + outline: tuple[tuple[float, float], ...] + depth: float + no_section_hatch: bool = False + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Pocket") + depth = _number(self.depth, "depth") + if depth >= part.thickness - _EPS: + raise ValueError( + f"depth {depth} is not smaller than the {part.thickness} mm thickness; " + "a pocket that goes through is a Hole or a Slot." + ) + points = tuple((float(x), float(y)) for x, y in self.outline) + if len(points) < 3: + raise ValueError(f"outline: a pocket needs at least 3 vertices, got {len(points)}.") + from engineering.measure import is_self_intersecting + + if is_self_intersecting(points) is True: + raise ValueError("outline: the pocket outline crosses itself.") + for index, point in enumerate(points): + if not point_in_outline(part, point[0], point[1]): + raise ValueError( + f"outline[{index}] ({point[0]:g}, {point[1]:g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Pocket") + points = tuple((float(x), float(y)) for x, y in self.outline) + if view == "front": + return (Poly(points, True, "visible"),) + if view in ("side", "top"): + box = self.removal_box(part) + u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2]) + floor = part.thickness - float(self.depth) + return ( + Line((u0, part.thickness), (u0, floor), "hidden"), + Line((u0, floor), (u1, floor), "hidden"), + Line((u1, floor), (u1, part.thickness), "hidden"), + ) + return () + + def removal_box(self, part): + xs = [float(x) for x, _ in self.outline] + ys = [float(y) for _, y in self.outline] + return (min(xs), min(ys), max(xs), max(ys)) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a pocket has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CornerFillet(_Base): + """A rounded outline corner: two tangent points and the arc between them.""" + + KIND: ClassVar[str] = "corner_fillet" + vertex: int + radius: float + + def _frame(self, part): + points = _require_prismatic(part, "CornerFillet").outline + count = len(points) + index = int(self.vertex) + if not 0 <= index < count: + raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).") + v = points[index] + p = points[(index - 1) % count] + n = points[(index + 1) % count] + return v, p, n + + def validate(self, part) -> None: + super().validate(part) + radius = _number(self.radius, "radius") + v, p, n = self._frame(part) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p) + u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n) + cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1])) + half = math.acos(cosine) / 2.0 + if half < 1e-6 or abs(cosine + 1.0) < 1e-9: + raise ValueError(f"vertex {self.vertex} is a straight run, there is no corner to round.") + tangent = radius / math.tan(half) + if tangent > min(edge_p, edge_n) / 2.0 + _EPS: + raise ValueError( + f"radius {radius} needs {tangent:.3f} mm of edge, more than half the shorter " + f"adjacent edge ({min(edge_p, edge_n):.3f} mm)." + ) + + def outline_edit(self, part) -> dict: + v, p, n = self._frame(part) + radius = float(self.radius) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p) + u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n) + cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1])) + half = math.acos(cosine) / 2.0 + tangent = radius / math.tan(half) + before = (v[0] + u1[0] * tangent, v[1] + u1[1] * tangent) + after = (v[0] + u2[0] * tangent, v[1] + u2[1] * tangent) + bisector = (u1[0] + u2[0], u1[1] + u2[1]) + norm = math.hypot(*bisector) + center = ( + v[0] + bisector[0] / norm * (radius / math.sin(half)), + v[1] + bisector[1] / norm * (radius / math.sin(half)), + ) + start = math.degrees(math.atan2(before[1] - center[1], before[0] - center[0])) % 360.0 + end = math.degrees(math.atan2(after[1] - center[1], after[0] - center[0])) % 360.0 + cross = u1[0] * u2[1] - u1[1] * u2[0] + if cross > 0: # the arc runs the other way round + start, end = end, start + return { + "vertex": int(self.vertex), + "before": before, + "after": after, + "arc": Arc(center, radius, start, end, "visible"), + } + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.outline_edit(part) + return ( + DimIntent( + kind="radius", + p1=edit["arc"].center, + p2=edit["before"], + text_override=f"R{float(self.radius):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a corner radius has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CornerChamfer(_Base): + """A bevelled outline corner: two points and the straight run between them.""" + + KIND: ClassVar[str] = "corner_chamfer" + vertex: int + size: float + + def _frame(self, part): + points = _require_prismatic(part, "CornerChamfer").outline + count = len(points) + index = int(self.vertex) + if not 0 <= index < count: + raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).") + return points[index], points[(index - 1) % count], points[(index + 1) % count] + + def validate(self, part) -> None: + super().validate(part) + size = _number(self.size, "size") + v, p, n = self._frame(part) + shortest = min(math.dist(v, p), math.dist(v, n)) + if size > shortest / 2.0 + _EPS: + raise ValueError( + f"size {size} is more than half the shorter adjacent edge ({shortest:.3f} mm)." + ) + + def outline_edit(self, part) -> dict: + v, p, n = self._frame(part) + size = float(self.size) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + before = (v[0] + (p[0] - v[0]) / edge_p * size, v[1] + (p[1] - v[1]) / edge_p * size) + after = (v[0] + (n[0] - v[0]) / edge_n * size, v[1] + (n[1] - v[1]) / edge_n * size) + return {"vertex": int(self.vertex), "before": before, "after": after, "arc": None} + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.outline_edit(part) + return ( + DimIntent( + kind="aligned", + p1=edit["before"], + p2=edit["after"], + text_override=f"{float(self.size):g}x45°", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a corner chamfer has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class BendLine(_Base): + """A sheet-metal bend line, **representation only**. + + Spec section 2 puts flat patterns and bend allowance out of scope: no + developed length is computed here, because a wrong developed length is a + scrapped part. This feature draws the phantom line and its R/angle note and + claims nothing else. + """ + + KIND: ClassVar[str] = "bend_line" + p1: tuple[float, float] + p2: tuple[float, float] + angle: float + inside_radius: float + direction: str = "up" + + def validate(self, part) -> None: + super().validate(part) + _require_prismatic(part, "BendLine") + for name, point in (("p1", self.p1), ("p2", self.p2)): + if not isinstance(point, (tuple, list)) or len(point) != 2: + raise ValueError(f"{name}: expected [x, y], got {point!r}.") + _number(point[0], f"{name}.x", positive=False) + _number(point[1], f"{name}.y", positive=False) + if math.dist(tuple(self.p1), tuple(self.p2)) < _EPS: + raise ValueError("p1 and p2 are the same point; a bend line needs a direction.") + angle = _number(self.angle, "angle", positive=False) + if not 0.0 < abs(angle) <= 180.0: + raise ValueError(f"angle {angle}: a bend angle is between 0 and 180 degrees.") + _number(self.inside_radius, "inside_radius") + if str(self.direction).strip().lower() not in ("up", "down"): + raise ValueError(f"direction: expected 'up' or 'down', got {self.direction!r}.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + p1 = (float(self.p1[0]), float(self.p1[1])) + p2 = (float(self.p2[0]), float(self.p2[1])) + mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0) + rotation = math.degrees(math.atan2(p2[1] - p1[1], p2[0] - p1[0])) + note = ( + f"R{float(self.inside_radius):g} {float(self.angle):g}° " + f"{str(self.direction).strip().upper()}" + ) + return ( + Line(p1, p2, "phantom"), + Text((mid[0], mid[1] + 2.0), note, 3.5, rotation, "text"), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a bend line has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class HolePattern(_Base): + """A linear, grid or polar array of one base hole. Expanded before validation.""" + + KIND: ClassVar[str] = "hole_pattern" + pattern: str + base: dict + count: int = 2 + spacing: float = 0.0 + count_y: int = 1 + spacing_y: float = 0.0 + pcd: float = 0.0 + start_angle: float = 0.0 + angle: float = 0.0 + + PATTERNS: ClassVar[tuple[str, ...]] = ("linear", "grid", "polar") + + def _base_fields(self) -> dict: + if not isinstance(self.base, dict): + raise ValueError(f"base: expected a dict of Hole fields, got {type(self.base).__name__}.") + payload = {key: value for key, value in self.base.items() if key not in ("kind", "id")} + names = {field_.name for field_ in fields(Hole)} + unknown = sorted(set(payload) - names) + if unknown: + raise ValueError(f"base: unknown key(s) {unknown}; a Hole takes {sorted(names)}.") + for required in ("x", "y", "diameter"): + if required not in payload: + raise ValueError(f"base.{required}: a base hole needs x, y and diameter.") + return payload + + def expand(self) -> tuple[Hole, ...]: + kind = str(self.pattern).strip().lower() + if kind not in self.PATTERNS: + raise ValueError(f"pattern: expected one of {self.PATTERNS}, got {self.pattern!r}.") + base = self._base_fields() + x0, y0 = float(base["x"]), float(base["y"]) + count = int(self.count) + if count < 1: + raise ValueError(f"count: expected at least 1, got {count}.") + holes: list[Hole] = [] + if kind == "linear": + if float(self.spacing) <= _EPS: + raise ValueError("spacing: a linear pattern needs a spacing greater than zero.") + theta = math.radians(float(self.angle)) + for k in range(count): + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{k}", + "x": x0 + k * float(self.spacing) * math.cos(theta), + "y": y0 + k * float(self.spacing) * math.sin(theta), + } + ) + ) + elif kind == "grid": + rows = int(self.count_y) + if rows < 1: + raise ValueError(f"count_y: expected at least 1, got {rows}.") + if count > 1 and float(self.spacing) <= _EPS: + raise ValueError("spacing: a grid with more than one column needs a spacing.") + if rows > 1 and float(self.spacing_y) <= _EPS: + raise ValueError("spacing_y: a grid with more than one row needs a spacing_y.") + for j in range(rows): + for i in range(count): + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{i}_{j}", + "x": x0 + i * float(self.spacing), + "y": y0 + j * float(self.spacing_y), + } + ) + ) + else: + if float(self.pcd) <= _EPS: + raise ValueError("pcd: a polar pattern needs a pitch-circle diameter.") + if count < 2: + raise ValueError(f"count: a polar pattern needs at least 2 holes, got {count}.") + radius = float(self.pcd) / 2.0 + start = math.radians(float(self.start_angle)) + for k in range(count): + theta = start + 2.0 * math.pi * k / count + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{k}", + "x": x0 + radius * math.cos(theta), + "y": y0 + radius * math.sin(theta), + } + ) + ) + return tuple(holes) + + def validate(self, part) -> None: # pragma: no cover - expanded before it reaches a part + super().validate(part) + for hole in self.expand(): + hole.validate(part) + + +FEATURE_KINDS: tuple[str, ...] = tuple(sorted(_REGISTRY)) +``` + +- [ ] **Step 14: Run the feature tests, and the Task 2 test in full** + +Run: `uv run --frozen pytest tests/test_mech_features.py tests/test_mech_part.py -q` +Expected: PASS — 52 + 18 = 70 passed. `tests/test_mech_part.py` now runs +without a deselection, because `feature_from_dict` exists. + +If `test_a_bolt_circle_draws_its_holes_and_its_pitch_circle_in_the_end_view` +reports a different ordering of the emitted circles, print the real tuple with +`uv run --frozen python -c "from engineering.mech.features import HoleCircle; from engineering.mech.part import RevolvedPart, Segment; f=HoleCircle(id='bc', x=10.0, pcd=36.0, count=6, diameter=5.0); print(f.prims(RevolvedPart(name='f', segments=(Segment(20.0,60.0),)), 'side', {}))"` +and fix the *test* to the real order, keeping the counts and radii it asserts. +The order is an implementation detail; the geometry is not. + +- [ ] **Step 15: Re-export from the package surface** + +In `engineering/mech/__init__.py`, add (alphabetically, alongside Task 2's block): + +```python +from engineering.mech.features import ( + FEATURE_KINDS, + AxialBore, + BendLine, + CentreHole, + Chamfer, + CornerChamfer, + CornerFillet, + CrossHole, + Feature, + Fillet, + GearTeeth, + Hole, + HoleCircle, + HolePattern, + Keyway, + ORingGroove, + Pocket, + RetainingGroove, + Slot, + Thread, + Undercut, + feature_from_dict, + mirror_about_x, +) +``` + +and extend `__all__` with the same names, keeping it sorted. + +- [ ] **Step 16: Gates** + +``` +uv run --frozen ruff check engineering/mech/features.py engineering/mech/standards/threads.py engineering/mech/standards/undercuts.py engineering/mech/standards/rings.py engineering/mech/standards/centres.py engineering/mech/standards/orings.py engineering/mech/__init__.py tests/test_mech_threads.py tests/test_mech_standards_coverage.py tests/test_mech_features.py +uv run --frozen ruff format engineering/mech/features.py engineering/mech/standards/threads.py engineering/mech/standards/undercuts.py engineering/mech/standards/rings.py engineering/mech/standards/centres.py engineering/mech/standards/orings.py tests/test_mech_threads.py tests/test_mech_standards_coverage.py tests/test_mech_features.py +uv run --frozen pytest tests/test_mech_part.py tests/test_mech_threads.py tests/test_mech_standards_coverage.py tests/test_mech_features.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +No tool is registered in this task, so the inventory, README count, registry +snapshot and alias gates do not move. + +- [ ] **Step 17: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-3): the feature catalogue and its standards tables + +engineering/mech/features.py is the nineteen typed features of spec section 5, +each answering the same four questions - is my placement legal, what do I +contribute to this view, to a cut face, and what dimension do I want - plus the +five structural hooks the view engine needs: removal_box, radial_edit, +corner_edit, outline_edit and omission. Pure arithmetic; a test asserts emitted +primitive tuples, not a screenshot. + +Nothing restates a table the repository already owns: Keyway takes its width +and depth from engineering.keyway (DIN 6885-1) and is refused by name outside +that table's bore range; GearTeeth takes its outline from engineering.gear's +involute generator and is refused when its tip diameter disagrees with the +segment it sits on. + +standards/threads.py transcribes ISO 261 Table 1 and Table 2 - first-choice +M1.6-M64 plus second-choice M14/M18/M22/M27/M33/M39 - and computes ISO 724's +basic minor and pitch diameters from ISO 68-1's own formula, pinned against the +published values (M20x2.5 -> 17.294 / 18.376). Third-choice diameters and fine +pitches below M8 are not transcribed and are refused by name, never +interpolated. The drawing rules are ISO 6410: minor at 0.8 x major, a +thread-length limit line across the major, a three-quarter circle in the end +view, and an internal thread whose section hatch runs to the major diameter. + +standards/{undercuts,rings,centres,orings}.py ship DIN 509, DIN 471/472, +DIN 332-1 and ISO 3601-2 with their SOURCE, their row-key contract and an +**empty** row set. That is deliberate and is the track's table rule taken at its +word: a row that could not be verified against the standard is not shipped, +because a wrong relief depth or groove diameter is a wrong workshop drawing +while a narrow table is merely narrow. Every lookup is refused by name and says +what to transcribe and where; the features accept explicit dimensions so the +geometry is fully exercised, and DIN 332 form R is refused outright rather than +approximated by the straight 60 degree cone of forms A and B. Filling any of +these tables later is a data edit with no code change, and a shape test already +fails any row that lands without a column the drawing code reads. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 4: The view engine I — orthographic views, hidden lines, centre lines, projection layout + +**Files:** +- Create: `engineering/mech/views.py` +- Create: `tests/test_mech_views.py` +- Modify: `engineering/mech/__init__.py` (re-export the view surface) +- Test: `tests/test_mech_views.py` + +**Interfaces:** +- Consumes (Task 1, on `main`): + `engineering.mech.primitives.{Line, Arc, Circle, Poly, HatchArea, Text, DimIntent, + Prim, Pt, ROLE_LAYER, bbox, translate}`. +- Consumes (Task 2, committed): + `engineering.mech.part.{RevolvedPart, PrismaticPart, Part, segment_bounds, + outer_radius_at, inner_radius_at, part_length, part_max_diameter, outline_bbox}`. +- Consumes (Task 3, committed): `engineering.mech.features.mirror_about_x`, and + every feature's `prims / dims / removal_box / radial_edit / corner_edit / + outline_edit / omission / axisymmetric`. +- Produces (Tasks 5, 6, 7 consume exactly these): + ```python + @dataclass(frozen=True) + class View: + kind: str + prims: tuple[Prim, ...] + dims: tuple[DimIntent, ...] + omitted: tuple[dict, ...] + bbox: tuple[float, float, float, float] + label: str | None = None + scale: float = 1.0 + VIEW_KINDS: tuple[str, ...] # ("front", "side", "top") in this task + PROJECTIONS: tuple[str, ...] # ("first", "third") + class Band(NamedTuple): + x0: float; x1: float; r_in: float; r_out0: float; r_out1: float + def radial_profile(part: RevolvedPart) -> tuple[Band, ...] + def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]] + def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]] + def build_view(part, kind, *, side="right", plane=None, style="full", + scale=1.0, projection="first", detail=None) -> View + def layout_origin(parent: View, kind, *, side="right", projection="first", + gap=20.0, child: View | None = None) -> Pt + ``` + `layout_origin` carries one additive keyword-only argument beyond the + skeleton's pinned signature — `child` — because the position of a view placed + to the *left* or *below* depends on that view's own size. Without it the child + is assumed the same size as the parent, so every call written against the + pinned signature keeps working. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_mech_views.py`: + +```python +"""The orthographic view engine: silhouette, hidden lines, centre lines, layout. + +Everything is asserted as emitted primitive tuples - counts, roles, coordinates +to 1e-9. The radial profile is the single calculation both the silhouette and +(from Task 5) the section cut face come out of, so it is pinned first. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.features import ( + AxialBore, + Chamfer, + CornerFillet, + CrossHole, + Fillet, + Hole, + Keyway, + RetainingGroove, +) +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import Arc, Circle, Line +from engineering.mech.views import ( + PROJECTIONS, + VIEW_KINDS, + Band, + View, + build_view, + layout_origin, + radial_profile, +) + +EPS = 1e-9 + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*features) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(features), + ) + + +def roles(view: View, role: str): + return [p for p in view.prims if getattr(p, "role", None) == role] + + +def test_the_engine_declares_three_orthographic_kinds_and_two_projections(): + assert VIEW_KINDS == ("front", "side", "top") + assert PROJECTIONS == ("first", "third") + + +# -- the radial profile ------------------------------------------------------ + + +def test_a_plain_shaft_has_one_band_per_segment(): + bands = radial_profile(shaft()) + assert bands == ( + Band(0.0, 20.0, 0.0, 12.5, 12.5), + Band(20.0, 60.0, 0.0, 15.0, 15.0), + Band(60.0, 75.0, 0.0, 10.0, 10.0), + ) + + +def test_a_taper_is_carried_as_two_end_radii(): + part = RevolvedPart(name="cone", segments=(Segment(20.0, 30.0, 0.0, 10.0),)) + (band,) = radial_profile(part) + assert band.r_out0 == pytest.approx(15.0, abs=1e-6) + assert band.r_out1 == pytest.approx(5.0, abs=1e-6) + + +def test_a_ring_groove_cuts_a_band_out_of_the_outer_profile(): + groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + bands = radial_profile(shaft(groove)) + cut = [b for b in bands if b.r_out0 < 14.9] + assert len(cut) == 1 + assert cut[0].x0 == pytest.approx(29.2) + assert cut[0].x1 == pytest.approx(30.8) + assert cut[0].r_out0 == pytest.approx(14.3) + + +def test_an_axial_bore_raises_the_inner_profile_over_its_depth(): + bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0) + bands = radial_profile(shaft(bore)) + bored = [b for b in bands if b.r_in > 0.0] + assert [(b.x0, b.x1) for b in bored] == [(0.0, 20.0), (20.0, 30.0)] + assert {b.r_in for b in bored} == {4.0} + + +# -- the revolved front view ------------------------------------------------- + + +def test_a_plain_shaft_front_view_is_a_mirrored_silhouette_on_an_axis(): + view = build_view(shaft(), "front") + assert view.kind == "front" + axis = roles(view, "center") + assert len(axis) == 1 + assert axis[0].p1[1] == 0.0 and axis[0].p2[1] == 0.0 + assert axis[0].p1[0] < 0.0 and axis[0].p2[0] > 75.0 + visible = roles(view, "visible") + # the +Y half-outline is 7 lines (left face, 3 cylinders, 2 steps, right face) + # and it is mirrored about the axis + assert len(visible) == 14 + ys = sorted({round(line.p1[1], 6) for line in visible}) + assert ys == [-15.0, -12.5, -10.0, 0.0, 10.0, 12.5, 15.0] + assert view.bbox == (pytest.approx(-7.5), -15.0, pytest.approx(82.5), 15.0) + + +def test_a_bore_reaches_the_front_view_as_hidden_lines(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "front") + hidden = roles(view, "hidden") + assert len(hidden) == 2 + assert {round(line.p1[1], 6) for line in hidden} == {10.0, -10.0} + assert hidden[0].p1[0] == 0.0 and hidden[0].p2[0] == 30.0 + + +def test_a_chamfer_replaces_the_silhouette_corner_instead_of_being_drawn_over_it(): + chamfer = Chamfer(id="c1", at="start", size=2.0) + view = build_view(shaft(chamfer), "front") + upper = [line for line in roles(view, "visible") if line.p1[1] > 0 or line.p2[1] > 0] + starts = {(round(line.p1[0], 6), round(line.p1[1], 6)) for line in upper} + assert (0.0, 10.5) in starts + assert (0.0, 12.5) not in starts + assert any( + line.p1 == (0.0, 10.5) and line.p2 == (2.0, 12.5) for line in upper + ) + + +def test_a_fillet_puts_a_tangent_arc_into_the_silhouette(): + fillet = Fillet(id="f1", at="step:0", radius=2.0) + view = build_view(shaft(fillet), "front") + arcs = [p for p in view.prims if isinstance(p, Arc)] + assert len(arcs) == 2 # the fillet and its mirror + upper = next(a for a in arcs if a.center[1] > 0) + assert upper.center == (18.0, 14.5) + assert (upper.radius, upper.start_deg, upper.end_deg) == (2.0, 270.0, 360.0) + + +def test_an_axisymmetric_feature_is_mirrored_and_a_keyway_is_not(): + groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + keyway = Keyway(id="kw", segment=1, length=20.0, offset=5.0) + view = build_view(shaft(groove, keyway), "front") + groove_lines = [ + line + for line in view.prims + if isinstance(line, Line) and abs(abs(line.p1[1]) - 14.3) < 1e-6 + ] + assert {line.p1[1] > 0 for line in groove_lines} == {True, False} + floor = 15.0 - 4.0 # DIN 6885 depth_shaft for a 30 mm shaft + keyway_lines = [ + line + for line in view.prims + if isinstance(line, Line) and abs(line.p1[1] - floor) < 1e-6 + ] + assert keyway_lines and all(line.p1[1] > 0 for line in keyway_lines) + + +# -- the revolved end view --------------------------------------------------- + + +def test_the_end_view_marks_a_diameter_behind_a_larger_one_as_hidden(): + view = build_view(shaft(), "side", side="right") + circles = [p for p in view.prims if isinstance(p, Circle)] + radii = {(round(c.radius, 6), c.role) for c in circles} + assert (10.0, "visible") in radii # the near end + assert (15.0, "hidden") in radii # the middle step, behind it + assert (12.5, "hidden") not in radii # smaller than 15, never seen + assert len([p for p in view.prims if getattr(p, "role", None) == "center"]) == 2 + + +def test_looking_from_the_left_reverses_which_circle_is_visible(): + view = build_view(shaft(), "side", side="left") + circles = [p for p in view.prims if isinstance(p, Circle)] + radii = {(round(c.radius, 6), c.role) for c in circles} + assert (12.5, "visible") in radii + assert (15.0, "hidden") in radii + + +def test_a_revolved_top_view_is_refused_because_it_repeats_the_front(): + with pytest.raises(ValueError, match="identical to its front view"): + build_view(shaft(), "top") + + +# -- the prismatic views ----------------------------------------------------- + + +def test_a_prismatic_front_view_is_the_closed_outline(): + view = build_view(plate(), "front") + lines = roles(view, "visible") + assert len(lines) == 4 + assert view.bbox == (0.0, 0.0, 80.0, 50.0) + + +def test_a_corner_fillet_replaces_an_outline_vertex_with_an_arc(): + view = build_view(plate(CornerFillet(id="cf", vertex=1, radius=10.0)), "front") + arcs = [p for p in view.prims if isinstance(p, Arc)] + lines = roles(view, "visible") + assert len(arcs) == 1 + assert arcs[0].center == (pytest.approx(70.0), pytest.approx(10.0)) + assert len(lines) == 4 + assert not any( + abs(line.p1[0] - 80.0) < EPS and abs(line.p1[1]) < EPS for line in lines + ) + + +def test_a_prismatic_side_view_is_the_thickness_rectangle_with_a_hidden_line_per_hole(): + hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + view = build_view(plate(hole), "side") + visible = roles(view, "visible") + hidden = roles(view, "hidden") + assert len(visible) == 4 + assert view.bbox == (0.0, 0.0, 50.0, 8.0) + assert len(hidden) == 2 + assert {round(line.p1[0], 6) for line in hidden} == {20.0, 30.0} + + +def test_a_prismatic_top_view_projects_the_other_axis(): + hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + view = build_view(plate(hole), "top") + assert view.bbox == (0.0, 0.0, 80.0, 8.0) + hidden = roles(view, "hidden") + assert {round(line.p1[0], 6) for line in hidden} == {15.0, 25.0} + + +# -- the honesty rule -------------------------------------------------------- + + +def test_a_feature_that_projects_into_the_view_is_not_reported_as_omitted(): + side = build_view(shaft(Chamfer(id="c1", at="start", size=2.0)), "side") + assert side.omitted == () + front = build_view(shaft(CrossHole(id="xh", x=30.0, diameter=6.0)), "front") + assert front.omitted == () + bore = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)), "front") + assert bore.omitted == () + + +def test_a_bend_line_is_omitted_from_a_side_view_with_a_reason(): + from engineering.mech.features import BendLine + + bend = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0) + view = build_view(plate(bend), "side") + assert len(view.omitted) == 1 + assert view.omitted[0]["feature"] == "b1" + assert "side" in view.omitted[0]["reason"] + + +def test_the_view_collects_the_dimension_intents_of_its_own_features(): + keyway = Keyway(id="kw", segment=1, length=20.0) + view = build_view(shaft(keyway), "front") + assert [intent.feature for intent in view.dims] == ["kw"] + + +def test_an_unknown_view_kind_is_refused_by_name(): + with pytest.raises(ValueError, match="front"): + build_view(shaft(), "isometric") + + +# -- projection layout ------------------------------------------------------- + + +def test_first_angle_puts_the_right_hand_view_on_the_left_and_the_top_below(): + front = build_view(shaft(), "front") + end = build_view(shaft(), "side") + left = layout_origin(front, "side", side="right", projection="first", child=end) + width = end.bbox[2] - end.bbox[0] + height = end.bbox[3] - end.bbox[1] + assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width) + assert left[1] == pytest.approx((front.bbox[1] + front.bbox[3]) / 2.0 - height / 2.0) + below = layout_origin(front, "top", projection="first", child=end) + assert below[1] == pytest.approx(front.bbox[1] - 20.0 - height) + + +def test_third_angle_flips_both_placements(): + front = build_view(shaft(), "front") + end = build_view(shaft(), "side") + right = layout_origin(front, "side", side="right", projection="third", child=end) + assert right[0] == pytest.approx(front.bbox[2] + 20.0) + above = layout_origin(front, "top", projection="third", child=end) + assert above[1] == pytest.approx(front.bbox[3] + 20.0) + + +def test_without_a_child_the_layout_assumes_the_parent_size(): + front = build_view(shaft(), "front") + width = front.bbox[2] - front.bbox[0] + left = layout_origin(front, "side", side="right", projection="first") + assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width) + + +def test_an_unknown_projection_is_refused(): + front = build_view(shaft(), "front") + with pytest.raises(ValueError, match="first"): + layout_origin(front, "side", projection="second") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_views.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.views'`. + +- [ ] **Step 3: Write `engineering/mech/views.py`** + +```python +"""The view engine: a part model projected onto a plane, as primitive descriptions. + +Pure: no backend, no async, no DXF. A ``View`` carries the primitives, the +dimension intents its features asked for, the features it could **not** project +(the honesty rule of spec section 6 - nothing is silently dropped), and its own +bounding box, so the next view can be laid out against it. + +The one calculation everything comes out of for a revolved part is the **radial +profile**: the part cut into bands of constant (or linearly tapering) outer +radius and constant bore radius, with every feature's ``radial_edit`` applied. +The silhouette is that profile walked as a path; the section cut face (Task 5) +is the same profile walked as a closed loop. One calculation, so the outline +and the hatched area can never disagree. + +Line roles follow ISO 128 and are mapped to layers by +``engineering/mech/primitives.py``: the silhouette is ``visible``, a bore or a +feature behind material is ``hidden``, the axis and centre marks are +``center``, a bend line is ``phantom``. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, NamedTuple + +from engineering.mech.features import mirror_about_x +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + part_max_diameter, + segment_bounds, +) +from engineering.mech.primitives import DimIntent, Line, Prim, Pt, bbox + +_EPS = 1e-9 +_SNAP = 6 # decimals used to match a corner vertex to a corner edit + +VIEW_KINDS: tuple[str, ...] = ("front", "side", "top") +PROJECTIONS: tuple[str, ...] = ("first", "third") +SIDES: tuple[str, ...] = ("right", "left") + +#: How far the axis and the centre lines run past the material, as a fraction +#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun). +AXIS_OVERRUN = 0.10 + + +@dataclass(frozen=True) +class View: + kind: str + prims: tuple[Prim, ...] + dims: tuple[DimIntent, ...] + omitted: tuple[dict, ...] + bbox: tuple[float, float, float, float] + label: str | None = None + scale: float = 1.0 + + +class Band(NamedTuple): + """One axial slice of a revolved part with a constant bore and a linear outside.""" + + x0: float + x1: float + r_in: float + r_out0: float + r_out1: float + + +# -- the radial profile ------------------------------------------------------ + + +def radial_profile(part: RevolvedPart) -> tuple[Band, ...]: + """The part as bands, with every feature's ``radial_edit`` applied.""" + length = part_length(part) + cuts: set[float] = {0.0, length} + for x0, x1 in segment_bounds(part): + cuts.add(x0) + cuts.add(x1) + + edits: list[dict] = [] + for feature in part.features: + for edit in feature.radial_edit(part): + x0 = max(0.0, min(length, float(edit["x0"]))) + x1 = max(0.0, min(length, float(edit["x1"]))) + if x1 - x0 <= _EPS: + continue + edits.append({"kind": edit["kind"], "x0": x0, "x1": x1, "radius": float(edit["radius"])}) + cuts.add(x0) + cuts.add(x1) + + xs = sorted(cuts) + bands: list[Band] = [] + for a, b in zip(xs, xs[1:]): + if b - a <= _EPS: + continue + inset = (b - a) * 1e-6 + r_out0 = outer_radius_at(part, a + inset) + r_out1 = outer_radius_at(part, b - inset) + r_in = inner_radius_at(part, (a + b) / 2.0) + for edit in edits: + if edit["x0"] - _EPS <= a and b <= edit["x1"] + _EPS: + if edit["kind"] == "outer": + r_out0 = min(r_out0, edit["radius"]) + r_out1 = min(r_out1, edit["radius"]) + else: + r_in = max(r_in, edit["radius"]) + bands.append(Band(a, b, r_in, r_out0, r_out1)) + return tuple(bands) + + +def _outer_path(bands: tuple[Band, ...]) -> list[Pt]: + """The +Y half-outline, from the left face at the bore, over the outside, down + to the right face at the bore. The end faces are *part of the path*, so a + chamfer or fillet at an end corner substitutes into them instead of being + drawn across them - which is what stops the overshoot the `untrimmed_corner` + critique focus looks for.""" + points: list[Pt] = [(bands[0].x0, bands[0].r_in), (bands[0].x0, bands[0].r_out0)] + for band in bands: + if math.dist(points[-1], (band.x0, band.r_out0)) > _EPS: + points.append((band.x0, band.r_out0)) + points.append((band.x1, band.r_out1)) + points.append((bands[-1].x1, bands[-1].r_in)) + return points + + +def _bore_path(bands: tuple[Band, ...]) -> list[list[Pt]]: + """One path per contiguous run of bored bands; a blind end is capped.""" + runs: list[list[Pt]] = [] + current: list[Pt] = [] + for band in bands: + if band.r_in <= _EPS: + if current: + runs.append(current) + current = [] + continue + if not current: + current = [(band.x0, band.r_in)] + elif math.dist(current[-1], (band.x0, band.r_in)) > _EPS: + current.append((band.x0, band.r_in)) + current.append((band.x1, band.r_in)) + if current: + runs.append(current) + return runs + + +def _key(point: Pt) -> tuple[float, float]: + return (round(float(point[0]), _SNAP), round(float(point[1]), _SNAP)) + + +def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = False): + """Walk a vertex path, substituting corner edits, and emit Lines and Arcs. + + Returns ``(prims, used)`` - ``used`` is the set of edit ids that actually + found their corner, so the caller can report the ones that did not. + """ + by_corner: dict[tuple[float, float], dict] = {} + for edit in edits: + by_corner[_key(edit["corner"])] = edit + + sequence: list[tuple[str, Any]] = [] + used: set[str] = set() + for point in points: + edit = by_corner.get(_key(point)) + if edit is None: + sequence.append(("pt", (float(point[0]), float(point[1])))) + continue + used.add(edit["owner"]) + sequence.append(("pt", edit["before"])) + if edit["arc"] is not None: + sequence.append(("arc", edit["arc"])) + sequence.append(("pt", edit["after"])) + + if closed and sequence: + sequence.append(sequence[0]) + + prims: list[Prim] = [] + previous: Pt | None = None + for tag, item in sequence: + if tag == "pt": + if previous is not None and math.dist(previous, item) > _EPS: + prims.append(Line(previous, item, role)) + previous = item + else: + prims.append(item) + previous = None + return tuple(prims), used + + +def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The mirrored half-outline (end faces included), the bore, and any unplaced edit.""" + bands = radial_profile(part) + edits: list[dict] = [] + for feature in part.features: + edit = feature.corner_edit(part) + if edit is not None: + edits.append({**edit, "owner": feature.id}) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "hidden") + prims.extend(bore) + prims.extend(mirror_about_x(bore)) + + unplaced = tuple( + { + "feature": edit["owner"], + "reason": ( + "its silhouette corner was changed by another feature, so the " + "chamfer/fillet could not be placed" + ), + } + for edit in edits + if edit["owner"] not in used + ) + return tuple(prims), unplaced + + +def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The closed outline with every corner fillet/chamfer substituted in.""" + edits: list[dict] = [] + for feature in part.features: + edit = feature.outline_edit(part) + if edit is None: + continue + corner = part.outline[int(edit["vertex"]) % len(part.outline)] + edits.append({**edit, "corner": corner, "owner": feature.id}) + + prims, used = _emit_path(list(part.outline), edits, "visible", closed=True) + unplaced = tuple( + {"feature": edit["owner"], "reason": "its outline vertex was already replaced"} + for edit in edits + if edit["owner"] not in used + ) + return prims, unplaced + + +# -- view construction ------------------------------------------------------- + + +def _axis_prims(part: RevolvedPart) -> tuple[Prim, ...]: + length = part_length(part) + over = max(length, part_max_diameter(part)) * AXIS_OVERRUN + return (Line((-over, 0.0), (length + over, 0.0), "center"),) + + +def _end_circles(part: RevolvedPart, side: str) -> tuple[Prim, ...]: + from engineering.mech.primitives import Circle + + bands = radial_profile(part) + order = list(reversed(bands)) if side == "right" else list(bands) + + prims: list[Prim] = [] + seen_out = 0.0 + for index, band in enumerate(order): + radius = max(band.r_out0, band.r_out1) + if radius > seen_out + 1e-6: + prims.append(Circle((0.0, 0.0), radius, "visible" if index == 0 else "hidden")) + seen_out = radius + seen_in = 0.0 + for index, band in enumerate(order): + if band.r_in > seen_in + 1e-6: + prims.append(Circle((0.0, 0.0), band.r_in, "visible" if index == 0 else "hidden")) + seen_in = band.r_in + + over = seen_out * (1.0 + AXIS_OVERRUN) + prims.append(Line((-over, 0.0), (over, 0.0), "center")) + prims.append(Line((0.0, -over), (0.0, over), "center")) + return tuple(prims) + + +def _rectangle(u0: float, u1: float, v0: float, v1: float, role: str = "visible") -> tuple[Prim, ...]: + return ( + Line((u0, v0), (u1, v0), role), + Line((u1, v0), (u1, v1), role), + Line((u1, v1), (u0, v1), role), + Line((u0, v1), (u0, v0), role), + ) + + +def _feature_layer(part, kind: str, ctx: dict) -> tuple[list[Prim], list[DimIntent], list[dict]]: + prims: list[Prim] = [] + dims: list[DimIntent] = [] + omitted: list[dict] = [] + for feature in part.features: + own = feature.prims(part, kind, ctx) + if own and kind == "front" and getattr(feature, "axisymmetric", False): + own = tuple(own) + mirror_about_x(own) + prims.extend(own) + dims.extend(feature.dims(part, kind, ctx)) + if own: + continue + if feature.radial_edit(part) or feature.corner_edit(part) or feature.outline_edit(part): + continue + reason = feature.omission(part, kind) + if reason: + omitted.append({"feature": feature.id, "reason": reason}) + return prims, dims, omitted + + +def build_view( + part, + kind: str, + *, + side: str = "right", + plane: dict | None = None, + style: str = "full", + scale: float = 1.0, + projection: str = "first", + detail: dict | None = None, +) -> View: + """Project a part onto one view plane and return its primitive description.""" + name = str(kind or "").strip().lower() + if name not in VIEW_KINDS: + raise ValueError(f"build_view: kind {kind!r} is not one of {VIEW_KINDS}.") + where = str(side or "right").strip().lower() + if where not in SIDES: + raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.") + del plane, style, detail # the orthographic kinds take no plane or detail + + ctx = {"side": where, "scale": float(scale), "projection": str(projection)} + prims: list[Prim] = [] + omitted: list[dict] = [] + + if isinstance(part, RevolvedPart): + if name == "top": + raise ValueError( + "build_view: a revolved part's top view is identical to its front view; " + "use kind='front', or kind='section' for a cut." + ) + if name == "front": + body, unplaced = silhouette_prims(part) + prims.extend(body) + prims.extend(_axis_prims(part)) + omitted.extend(unplaced) + else: + prims.extend(_end_circles(part, where)) + elif isinstance(part, PrismaticPart): + x0, y0, x1, y1 = outline_bbox(part) + if name == "front": + body, unplaced = outline_prims(part) + prims.extend(body) + omitted.extend(unplaced) + elif name == "side": + prims.extend(_rectangle(y0, y1, 0.0, part.thickness)) + else: + prims.extend(_rectangle(x0, x1, 0.0, part.thickness)) + else: # pragma: no cover - build_part only makes these two + raise ValueError(f"build_view: {type(part).__name__} is not a part model.") + + feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx) + prims.extend(feature_prims) + omitted.extend(feature_omitted) + + return View( + kind=name, + prims=tuple(prims), + dims=tuple(dims), + omitted=tuple(omitted), + bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0), + label=None, + scale=float(scale), + ) + + +def layout_origin( + parent: View, + kind: str, + *, + side: str = "right", + projection: str = "first", + gap: float = 20.0, + child: View | None = None, +) -> Pt: + """Where a child view's bounding box lower-left corner goes, relative to its parent. + + First angle (ISO 128-30, the default here): the view obtained by looking + from the right is placed on the **left**, and the view from above is placed + **below**. Third angle flips both. With no ``child`` the child is assumed + the same size as the parent, which keeps the views aligned even before the + child has been built. + """ + projection_name = str(projection or "").strip().lower() + if projection_name not in PROJECTIONS: + raise ValueError(f"layout_origin: projection {projection!r} is not one of {PROJECTIONS}.") + px0, py0, px1, py1 = parent.bbox + if child is None: + width, height = px1 - px0, py1 - py0 + else: + cx0, cy0, cx1, cy1 = child.bbox + width, height = cx1 - cx0, cy1 - cy0 + mid_x = (px0 + px1) / 2.0 + mid_y = (py0 + py1) / 2.0 + first = projection_name == "first" + name = str(kind or "").strip().lower() + gap = float(gap) + + if name in ("side", "section"): + looking_from_right = str(side or "right").strip().lower() == "right" + to_the_left = looking_from_right == first + if to_the_left: + return (px0 - gap - width, mid_y - height / 2.0) + return (px1 + gap, mid_y - height / 2.0) + if name == "top": + if first: + return (mid_x - width / 2.0, py0 - gap - height) + return (mid_x - width / 2.0, py1 + gap) + if name == "detail": + return (px1 + gap, mid_y - height / 2.0) + raise ValueError( + f"layout_origin: kind {kind!r} has no projection position; expected " + "'side', 'top', 'section' or 'detail'." + ) +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_views.py -q` +Expected: PASS — 25 passed. + +Two assertions are computed rather than restated and may need the measured +number written in: the visible-line count in +`test_a_plain_shaft_front_view_is_a_mirrored_silhouette_on_an_axis` and the +`bbox` on the same test. If either differs, print the real values with + +``` +uv run --frozen python -c "from engineering.mech.part import RevolvedPart, Segment; from engineering.mech.views import build_view; v=build_view(RevolvedPart(name='s', segments=(Segment(20.0,25.0),Segment(40.0,30.0),Segment(15.0,20.0))), 'front'); print(len([p for p in v.prims if getattr(p,'role',None)=='visible']), v.bbox)" +``` + +and write the measured numbers into the test with a one-line comment saying +what they count. The counts are data; do not change the engine to hit a number +the plan guessed. + +- [ ] **Step 5: Re-export from the package surface** + +In `engineering/mech/__init__.py`, add (alphabetically): + +```python +from engineering.mech.views import ( + PROJECTIONS, + VIEW_KINDS, + Band, + View, + build_view, + layout_origin, + outline_prims, + radial_profile, + silhouette_prims, +) +``` + +and extend `__all__` with the same names. + +- [ ] **Step 6: Gates** + +``` +uv run --frozen ruff check engineering/mech/views.py engineering/mech/__init__.py tests/test_mech_views.py +uv run --frozen ruff format engineering/mech/views.py tests/test_mech_views.py +uv run --frozen pytest tests/test_mech_views.py tests/test_mech_features.py tests/test_mech_part.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +- [ ] **Step 7: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-4): the orthographic view engine - silhouette, hidden lines, layout + +engineering/mech/views.py projects a part model onto a view plane and returns +primitive descriptions, so a test asserts geometry rather than a screenshot. + +The one calculation everything comes out of for a revolved part is the radial +profile: the part cut into bands of constant bore and constant (or linearly +tapering) outside, with every feature's radial_edit applied. The front view is +that profile walked as a path and mirrored about the axis; the section cut face +of the next task is the same profile walked as a closed loop, which is what +stops the outline and the hatched area ever disagreeing. Chamfers and fillets do +not draw over the silhouette - they substitute their corner in it, so the +overshoot the critique focus looks for cannot be produced by construction, and a +corner edit that finds no corner is reported rather than dropped. + +The end view applies a real hidden-line rule: walking the bands from the viewing +end, a diameter larger than everything nearer than it is drawn hidden, a bore +behind material is hidden, and the near end's own circles are visible. Looking +from the left reverses it. A revolved part's top view is refused, because it +repeats the front. + +Prismatic parts get the outline in true shape with corner fillets and chamfers +substituted in, and side/top thickness rectangles - the case where a hand-drawing +model reliably forgets the hidden line per through feature, which here comes out +of the feature itself. + +The honesty rule of spec section 6 is enforced in one place: a feature that emits +no primitives and edits neither the radial profile nor a corner is listed in the +view's omitted tuple with its own reason. Nothing is silently dropped. + +layout_origin places views in first-angle positions by default (the view from the +right goes to the left, the view from above goes below) and flips both for third +angle. It takes one additive keyword beyond the pinned signature, child, because +placing a view to the left depends on that view's own width; without it the child +is assumed the parent's size and every pinned call still works. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 5: The view engine II — sections, cut faces, material hatch and detail views + +**Files:** +- Modify: `engineering/mech/views.py` (grown: `SECTION_PLANE`, `SECTION_STYLES`, + `cut_loops`, `section_view`, `detail_view`, `detail_marker_prims`; + `VIEW_KINDS` grows to five and `build_view` routes the two new kinds) +- Create: `tests/test_mech_sections.py` +- Modify: `engineering/mech/__init__.py` (re-export the new names) +- Test: `tests/test_mech_sections.py` + +**Interfaces:** +- Consumes (Task 4, committed): `View`, `Band`, `radial_profile`, + `silhouette_prims`, `outline_prims`, `build_view`, `layout_origin`, + `VIEW_KINDS`, `_rectangle`, `_feature_layer`. +- Consumes (Task 1, on `main`): + `engineering.mech.standards.materials.hatch_for(material) -> dict`; + `engineering.mech.primitives.{HatchArea, Text, bbox, scale, translate}`. +- Consumes (Task 3, committed): every feature's `cut_prims(part, plane, ctx)`, + `removal_box(part)`, `no_section_hatch`. +- Produces (Tasks 6 and 7 consume exactly these): + ```python + SECTION_PLANE = {"p1": (0.0, 0.0), "p2": (0.0, 0.0), "label": "A", + "direction": (0.0, -1.0), "style": "full"} + SECTION_STYLES: tuple[str, ...] # ("full", "half", "offset", "revolved") + CIRCLE_SEGMENTS: int # 180 - a circular cut-face loop is flattened at 2 deg + def normalise_plane(plane: dict | None) -> dict + def cut_loops(part, plane: dict, *, style: str = "full") -> tuple[tuple[tuple[Pt, ...], bool], ...] + # ((loop_points, hatched), ...) - `hatched` is False for a rib cut longitudinally + def section_view(part, plane: dict, *, style="full", scale=1.0, side="right") -> View + def detail_view(part, detail: dict, *, projection="first", side="right") -> View + def detail_marker_prims(detail: dict) -> tuple[Prim, ...] + VIEW_KINDS == ("front", "side", "top", "section", "detail") + ``` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_mech_sections.py`: + +```python +"""Sections, cut faces, material hatch and detail views. + +The cut face and the silhouette come out of the same radial profile, so the +tests assert that the hatched loop closes on the real geometry - including the +ISO 6410 rule that an internal thread's hatch runs to the major diameter, and +the ISO 128-3 rule that a rib cut longitudinally is not hatched. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.features import AxialBore, Hole, Pocket, Thread +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import Circle, HatchArea, Text +from engineering.mech.views import ( + CIRCLE_SEGMENTS, + SECTION_PLANE, + SECTION_STYLES, + VIEW_KINDS, + build_view, + cut_loops, + detail_marker_prims, + normalise_plane, + radial_profile, +) + +EPS = 1e-9 + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*features) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(features), + ) + + +def axis_plane(**kw) -> dict: + return normalise_plane({"p1": (0.0, 0.0), "p2": (100.0, 0.0), **kw}) + + +def polygon_area(points) -> float: + total = 0.0 + count = len(points) + for i in range(count): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % count] + total += x1 * y2 - x2 * y1 + return abs(total) / 2.0 + + +def test_the_engine_now_declares_five_kinds_and_four_section_styles(): + assert VIEW_KINDS == ("front", "side", "top", "section", "detail") + assert SECTION_STYLES == ("full", "half", "offset", "revolved") + assert set(SECTION_PLANE) == {"p1", "p2", "label", "direction", "style"} + + +def test_normalise_plane_fills_the_pinned_shape_and_refuses_a_zero_length_plane(): + plane = normalise_plane({"p1": (0.0, 0.0), "p2": (10.0, 0.0)}) + assert plane["label"] == "A" + assert plane["direction"] == (0.0, -1.0) + assert plane["style"] == "full" + with pytest.raises(ValueError, match="zero length"): + normalise_plane({"p1": (1.0, 1.0), "p2": (1.0, 1.0)}) + + +# -- revolved longitudinal sections ------------------------------------------ + + +def test_a_solid_shaft_full_section_hatches_both_halves_to_the_axis(): + loops = cut_loops(shaft(), axis_plane(), style="full") + assert len(loops) == 2 + assert all(hatched for _, hatched in loops) + upper, lower = (points for points, _ in loops) + assert max(y for _, y in upper) == 15.0 + assert min(y for _, y in upper) == 0.0 + assert min(y for _, y in lower) == -15.0 + # the cut face is the real cross-section: 20x12.5 + 40x15 + 15x10 per half + assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0) + + +def test_a_bore_is_not_hatched_because_the_loop_closes_on_it(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full") + assert polygon_area(upper) == pytest.approx(30.0 * (20.0 - 10.0)) + assert min(y for _, y in upper) == 10.0 + assert max(y for _, y in lower) == -10.0 + + +def test_a_half_section_hatches_one_half_only(): + loops = cut_loops(shaft(), axis_plane(style="half"), style="half") + assert len(loops) == 1 + assert min(y for _, y in loops[0][0]) == 0.0 + + +def test_iso6410_an_internal_thread_hatch_runs_to_the_major_diameter(): + thread = Thread(id="th", segment=0, designation="M20", length=25.0, internal=True) + # drilled to the ISO 6410 minor (0.8 x 20 = 16 mm); the hatch must stop at + # the 20 mm major, not at the drilled 16. + part = RevolvedPart(name="boss", segments=(Segment(30.0, 50.0, 16.0),), features=(thread,)) + bands = radial_profile(part) + threaded = [b for b in bands if b.x1 <= 25.0 + EPS] + assert {round(b.r_in, 6) for b in threaded} == {10.0} + unthreaded = [b for b in bands if b.x0 >= 25.0 - EPS] + assert {round(b.r_in, 6) for b in unthreaded} == {8.0} + (upper, _), _ = cut_loops(part, axis_plane(), style="full") + assert min(y for _, y in upper) == pytest.approx(10.0) + + +def test_the_section_view_carries_a_hatch_area_per_loop_with_the_parts_material(): + view = build_view(shaft(), "section", plane=axis_plane(), style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 2 + assert {h.material for h in hatches} == {"steel"} + assert view.label == "A-A" + + +def test_an_unknown_material_is_refused_before_a_section_is_built(): + part = RevolvedPart(name="x", segments=(Segment(10.0, 20.0),), material="unobtainium") + with pytest.raises(ValueError, match="unobtainium"): + build_view(part, "section", plane=axis_plane()) + + +def test_an_offset_plane_on_a_revolved_part_is_refused_with_the_alternative(): + with pytest.raises(ValueError) as excinfo: + build_view(shaft(), "section", plane=axis_plane(style="offset"), style="offset") + assert "style='full'" in str(excinfo.value) + + +# -- revolved transverse (cross) sections ------------------------------------ + + +def test_a_transverse_section_hatches_the_annulus_at_that_station(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0), "label": "B"}) + view = build_view(part, "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 1 + outer, island = hatches[0].loops + assert len(outer) == CIRCLE_SEGMENTS and len(island) == CIRCLE_SEGMENTS + assert polygon_area(outer) == pytest.approx(math.pi * 20.0**2, rel=2e-4) + assert polygon_area(island) == pytest.approx(math.pi * 10.0**2, rel=2e-4) + assert view.label == "B-B" + circles = [p for p in view.prims if isinstance(p, Circle)] + assert {round(c.radius, 6) for c in circles} == {20.0, 10.0} + + +def test_a_revolved_style_cross_section_draws_its_boundary_as_phantom(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) + view = build_view(part, "section", plane=plane, style="revolved") + circles = [p for p in view.prims if isinstance(p, Circle)] + assert {c.role for c in circles} == {"phantom"} + + +def test_a_half_transverse_section_is_refused_with_its_reason(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) + with pytest.raises(ValueError, match="half section"): + build_view(part, "section", plane=plane, style="half") + + +# -- prismatic sections ------------------------------------------------------ + + +def test_a_prismatic_full_section_is_the_chord_times_the_thickness(): + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0), "label": "C"}) + view = build_view(plate(), "section", plane=plane, style="full") + (hatch,) = [p for p in view.prims if isinstance(p, HatchArea)] + assert polygon_area(hatch.loops[0]) == pytest.approx(80.0 * 8.0) + assert view.bbox == (0.0, 0.0, 80.0, 8.0) + + +def test_a_through_hole_splits_the_cut_face_in_two(): + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 2 + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx((80.0 - 10.0) * 8.0) + + +def test_a_pocket_reduces_the_cut_face_to_the_remaining_thickness(): + pocket = Pocket( + id="p1", + outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)), + depth=3.0, + ) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(pocket), "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx(40.0 * 8.0 + 40.0 * 5.0) + + +def test_iso128_3_a_rib_cut_longitudinally_is_drawn_but_not_hatched(): + rib = Pocket( + id="rib", + outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)), + depth=3.0, + no_section_hatch=True, + ) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + loops = cut_loops(plate(rib), plane, style="full") + hatched = [points for points, flag in loops if flag] + unhatched = [points for points, flag in loops if not flag] + assert len(unhatched) == 1 + assert polygon_area(unhatched[0]) == pytest.approx(40.0 * 5.0) + assert sum(polygon_area(p) for p in hatched) == pytest.approx(40.0 * 8.0) + view = build_view(plate(rib), "section", plane=plane, style="full") + assert len(([p for p in view.prims if isinstance(p, HatchArea)])) == 2 + + +def test_an_offset_prismatic_section_accumulates_along_the_dog_leg(): + plane = normalise_plane( + { + "p1": (-10.0, 15.0), + "p2": (100.0, 35.0), + "via": [[30.0, 15.0], [30.0, 35.0]], + "style": "offset", + } + ) + view = build_view(plate(), "section", plane=plane, style="offset") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx(80.0 * 8.0) + + +def test_a_revolved_style_section_of_a_prismatic_part_is_refused(): + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + with pytest.raises(ValueError, match="revolved section"): + build_view(plate(), "section", plane=plane, style="revolved") + + +def test_a_plane_that_misses_the_part_is_refused_rather_than_hatched_empty(): + plane = normalise_plane({"p1": (-10.0, 200.0), "p2": (100.0, 200.0)}) + with pytest.raises(ValueError, match="does not cut"): + build_view(plate(), "section", plane=plane, style="full") + + +# -- detail views ------------------------------------------------------------ + + +def test_a_detail_scales_the_region_and_labels_both_places(): + detail = {"center": (20.0, 12.5), "radius": 8.0, "scale": 5.0, "label": "D"} + view = build_view(shaft(), "detail", detail=detail) + assert view.kind == "detail" + assert view.scale == 5.0 + assert view.label == "D (5:1)" + width = view.bbox[2] - view.bbox[0] + # straight geometry is trimmed to the circle, so the enlargement can never + # be wider than the circle itself at the detail scale + assert width <= 2.0 * 8.0 * 5.0 + 1e-6 + assert width > 8.0 * 5.0 + marker = detail_marker_prims(detail) + circle = next(p for p in marker if isinstance(p, Circle)) + text = next(p for p in marker if isinstance(p, Text)) + assert circle.center == (20.0, 12.5) and circle.radius == 8.0 + assert text.text == "D" + + +def test_a_detail_reports_the_features_that_fall_outside_its_circle(): + from engineering.mech.features import RetainingGroove + + groove = RetainingGroove(id="rg", x=70.0, d=20.0, m=1.6, d2=18.6) + detail = {"center": (5.0, 12.5), "radius": 6.0, "scale": 5.0, "label": "D"} + view = build_view(shaft(groove), "detail", detail=detail) + assert any(entry["feature"] == "rg" for entry in view.omitted) + assert "detail circle D" in next( + entry["reason"] for entry in view.omitted if entry["feature"] == "rg" + ) + + +def test_a_detail_without_a_radius_or_with_a_bad_scale_is_refused(): + with pytest.raises(ValueError, match="radius"): + build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "scale": 5.0}) + with pytest.raises(ValueError, match="scale"): + build_view( + shaft(), "detail", detail={"center": (20.0, 12.5), "radius": 8.0, "scale": 0.0} + ) + + +def test_a_section_without_a_plane_is_refused_by_name(): + with pytest.raises(ValueError, match="plane"): + build_view(shaft(), "section") + + +def test_an_axial_bore_shows_its_flat_bottom_in_the_section(): + bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0) + view = build_view(shaft(bore), "section", plane=axis_plane(), style="full") + (hatch_upper, _hatch_lower) = [p for p in view.prims if isinstance(p, HatchArea)] + assert min(y for _, y in hatch_upper.loops[0]) == 0.0 + assert 4.0 in {round(y, 6) for _, y in hatch_upper.loops[0]} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_sections.py -q` +Expected: FAIL at collection with +`ImportError: cannot import name 'CIRCLE_SEGMENTS' from 'engineering.mech.views'`. + +- [ ] **Step 3: Grow `engineering/mech/views.py` — the section machinery** + +Replace the `VIEW_KINDS` line with the five kinds and append the section +constants immediately after `AXIS_OVERRUN`: + +```python +VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail") +``` + +```python +#: The cutting-plane record `section_line` (Task 11, group A) produces and this +#: engine consumes. `via` is optional and only meaningful for an offset plane. +SECTION_PLANE = { + "p1": (0.0, 0.0), + "p2": (0.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", +} + +SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved") + +#: A circular cut-face boundary has to be handed to the hatch as points. 180 +#: segments is a 2 degree chord, i.e. 0.015% of the radius - stated rather than +#: hidden, and the same loop is what the hatch and the area check both read. +CIRCLE_SEGMENTS = 180 +``` + +Then append the section functions at the end of the module: + +```python +# -- sections ---------------------------------------------------------------- + + +def normalise_plane(plane: dict | None) -> dict: + """Fill the `SECTION_PLANE` shape and refuse a plane that has no direction.""" + if plane is None: + raise ValueError( + "section: a cutting plane is required - pass plane={'p1': ..., 'p2': ...}, " + "or take one from section_line()." + ) + if not isinstance(plane, dict): + raise ValueError(f"plane: expected a dict, got {type(plane).__name__}.") + unknown = sorted(set(plane) - set(SECTION_PLANE) - {"via"}) + if unknown: + raise ValueError(f"plane: unknown key(s) {unknown}; it takes {sorted(SECTION_PLANE)}.") + p1 = tuple(float(v) for v in plane.get("p1", SECTION_PLANE["p1"])) + p2 = tuple(float(v) for v in plane.get("p2", SECTION_PLANE["p2"])) + if math.dist(p1, p2) < 1e-6: + raise ValueError(f"plane: p1 and p2 are the same point, the plane has zero length ({p1}).") + style = str(plane.get("style", SECTION_PLANE["style"])).strip().lower() + if style not in SECTION_STYLES: + raise ValueError(f"plane.style: expected one of {SECTION_STYLES}, got {style!r}.") + via = tuple(tuple(float(v) for v in point) for point in plane.get("via", ())) + return { + "p1": p1, + "p2": p2, + "label": str(plane.get("label", SECTION_PLANE["label"])), + "direction": tuple(float(v) for v in plane.get("direction", SECTION_PLANE["direction"])), + "style": style, + "via": via, + } + + +def _inner_path(bands: tuple[Band, ...]) -> list[Pt]: + points: list[Pt] = [(bands[0].x0, bands[0].r_in)] + for band in bands: + if math.dist(points[-1], (band.x0, band.r_in)) > _EPS: + points.append((band.x0, band.r_in)) + points.append((band.x1, band.r_in)) + return points + + +def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]: + steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS) + return tuple( + ( + radius * math.cos(2.0 * math.pi * k / CIRCLE_SEGMENTS), + radius * math.sin(2.0 * math.pi * k / CIRCLE_SEGMENTS), + ) + for k in steps + ) + + +def _is_longitudinal(part: RevolvedPart, plane: dict) -> bool: + return abs(plane["p1"][1]) < 1e-6 and abs(plane["p2"][1]) < 1e-6 + + +def _subtract(intervals: list[tuple[float, float]], cut: tuple[float, float]): + out: list[tuple[float, float]] = [] + low, high = cut + for a, b in intervals: + if high <= a + _EPS or low >= b - _EPS: + out.append((a, b)) + continue + if low > a + _EPS: + out.append((a, low)) + if high < b - _EPS: + out.append((high, b)) + return [(a, b) for a, b in out if b - a > 1e-9] + + +def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]: + """Parameters along `unit` from `p1` where the line is inside the outline. + + Crossings are paired in order, which is exact for a simple polygon whose + edges the line crosses transversally. A line that grazes a vertex produces + an odd crossing count and is refused by the caller rather than paired wrong. + """ + ts: list[float] = [] + count = len(outline) + for index in range(count): + ax, ay = outline[index] + bx, by = outline[(index + 1) % count] + ex, ey = bx - ax, by - ay + denominator = ex * unit[1] - ey * unit[0] + if abs(denominator) < 1e-12: + continue + s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator + if -1e-9 <= s <= 1.0 + 1e-9: + px, py = ax + s * ex, ay + s * ey + ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1]) + ts = sorted({round(t, 9) for t in ts}) + if len(ts) % 2 != 0: + raise ValueError( + "section: the cutting plane grazes an outline vertex, so its crossings cannot be " + "paired. Move the plane off the vertex rather than accept a guessed cut face." + ) + return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)] + + +def _box_span(box, p1: Pt, unit: Pt, normal: Pt): + xs = (box[0], box[2]) + ys = (box[1], box[3]) + corners = [(x, y) for x in xs for y in ys] + distances = [(x - p1[0]) * normal[0] + (y - p1[1]) * normal[1] for x, y in corners] + if min(distances) > 1e-9 or max(distances) < -1e-9: + return None + spans = [(x - p1[0]) * unit[0] + (y - p1[1]) * unit[1] for x, y in corners] + return (min(spans), max(spans)) + + +def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]: + """The cutting legs of an offset plane. ISO 128-40: the offset itself is not + drawn, so with ``via`` present the odd-numbered legs are the transfer jogs and + contribute nothing to the cut face.""" + points = [plane["p1"], *plane["via"], plane["p2"]] + legs = [(points[i], points[i + 1]) for i in range(len(points) - 1)] + if plane["via"]: + legs = [leg for index, leg in enumerate(legs) if index % 2 == 0] + return legs + + +def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): + """``[(u0, u1, v0, v1, hatched), ...]`` - the cut rectangles along the plane.""" + legs = _plane_legs(plane) if style == "offset" else [(plane["p1"], plane["p2"])] + thickness = float(part.thickness) + rectangles: list[tuple[float, float, float, float, bool]] = [] + origin = 0.0 + for start, end in legs: + length = math.dist(start, end) + if length < 1e-9: + continue + unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length) + normal = (-unit[1], unit[0]) + intervals = _chords(part.outline, start, unit) + intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9] + intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals] + intervals = [(a, b) for a, b in intervals if b - a > 1e-9] + + pockets: list[tuple[tuple[float, float], float, bool]] = [] + for feature in part.features: + box = feature.removal_box(part) + if box is None: + continue + span = _box_span(box, start, unit, normal) + if span is None: + continue + span = (max(span[0], 0.0), min(span[1], length)) + if span[1] - span[0] <= 1e-9: + continue + if isinstance(feature, _PocketType): + pockets.append((span, thickness - float(feature.depth), feature.no_section_hatch)) + else: + intervals = _subtract(intervals, span) + + for pocket_span, floor, unhatched in pockets: + intervals = _subtract(intervals, pocket_span) + rectangles.append( + (origin + pocket_span[0], origin + pocket_span[1], 0.0, floor, not unhatched) + ) + for a, b in intervals: + rectangles.append((origin + a, origin + b, 0.0, thickness, True)) + origin += length + if rectangles: + # the section view has its own frame: u = 0 at the first cut. + shift = min(u0 for u0, _u1, _v0, _v1, _hatched in rectangles) + rectangles = [ + (u0 - shift, u1 - shift, v0, v1, hatched) + for u0, u1, v0, v1, hatched in rectangles + ] + return rectangles + + +def cut_loops(part, plane: dict, *, style: str = "full"): + """The closed cut-face loops, each flagged with whether it is hatched.""" + plane = normalise_plane(plane) + name = str(style or "full").strip().lower() + if name not in SECTION_STYLES: + raise ValueError(f"style: expected one of {SECTION_STYLES}, got {style!r}.") + + if isinstance(part, RevolvedPart): + bands = radial_profile(part) + if _is_longitudinal(part, plane): + if name == "offset": + raise ValueError( + "section: an offset plane has no meaning along a revolved part's axis; " + "use style='full' (or a transverse plane for a cross section)." + ) + upper = tuple(_outer_path(bands)) + tuple(reversed(_inner_path(bands))) + loops = [(upper, True)] + if name != "half": + loops.append((tuple((x, -y) for x, y in upper), True)) + return tuple(loops) + if name == "half": + raise ValueError( + "section: a half section is defined on a longitudinal plane through the axis; " + "a transverse plane takes style='full' or 'revolved'." + ) + station = plane["p1"][0] + r_out = outer_radius_at(part, station) + r_in = inner_radius_at(part, station) + loops = [(_circle_loop(r_out), True)] + if r_in > _EPS: + loops.append((_circle_loop(r_in, reverse=True), True)) + return tuple(loops) + + if name in ("half", "revolved"): + raise ValueError( + f"section: a {name} section is defined on a revolved part; a prismatic part takes " + "style='full' or 'offset'." + ) + rectangles = _prismatic_cut(part, plane, name) + if not rectangles: + raise ValueError( + f"section: the plane {plane['p1']} -> {plane['p2']} does not cut {part.name!r}." + ) + return tuple( + (((u0, v0), (u1, v0), (u1, v1), (u0, v1)), hatched) + for u0, u1, v0, v1, hatched in rectangles + ) + + +def section_view(part, plane: dict, *, style: str = "full", scale: float = 1.0, + side: str = "right") -> View: + """A cut view: the cut faces as hatch areas, plus the geometry behind them.""" + from engineering.mech.primitives import Circle, HatchArea + from engineering.mech.standards.materials import hatch_for + + plane = normalise_plane(plane) + name = str(style or plane["style"]).strip().lower() + hatch_for(part.material) # refuse an unknown material before anything is built + ctx = {"side": side, "scale": float(scale), "projection": "first", "plane": plane} + + loops = cut_loops(part, plane, style=name) + prims: list[Prim] = [] + omitted: list[dict] = [] + dims: list[DimIntent] = [] + + if isinstance(part, RevolvedPart) and _is_longitudinal(part, plane): + body, unplaced = silhouette_prims(part) + prims.extend(body) + prims.extend(_axis_prims(part)) + omitted.extend(unplaced) + for feature in part.features: + own = feature.cut_prims(part, plane, ctx) + if own and getattr(feature, "axisymmetric", False): + own = tuple(own) + mirror_about_x(own) + prims.extend(own) + dims.extend(feature.dims(part, "front", ctx)) + elif isinstance(part, RevolvedPart): + role = "phantom" if name == "revolved" else "visible" + station = plane["p1"][0] + r_out = outer_radius_at(part, station) + r_in = inner_radius_at(part, station) + prims.append(Circle((0.0, 0.0), r_out, role)) + if r_in > _EPS: + prims.append(Circle((0.0, 0.0), r_in, role)) + over = r_out * (1.0 + AXIS_OVERRUN) + prims.append(Line((-over, 0.0), (over, 0.0), "center")) + prims.append(Line((0.0, -over), (0.0, over), "center")) + else: + for points, _hatched in loops: + xs = [p[0] for p in points] + ys = [p[1] for p in points] + prims.extend(_rectangle(min(xs), max(xs), min(ys), max(ys))) + + hatched = [points for points, flag in loops if flag] + if isinstance(part, RevolvedPart) and not _is_longitudinal(part, plane): + # a transverse cut face is one region with the bore as its island + if hatched: + prims.append(HatchArea(tuple(hatched), part.material)) + else: + for points in hatched: + prims.append(HatchArea((points,), part.material)) + + label = f"{plane['label']}-{plane['label']}" + return View( + kind="section", + prims=tuple(prims), + dims=tuple(dims), + omitted=tuple(omitted), + bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0), + label=label, + scale=float(scale), + ) +``` + +Add, next to the other imports at the top of the module, the alias the +prismatic cut uses to recognise a pocket: + +```python +from engineering.mech.features import Pocket as _PocketType +``` + +`Any` is already imported from `typing` by Task 4, so nothing else moves. + +- [ ] **Step 4: Run the section tests to verify they pass so far** + +Run: `uv run --frozen pytest tests/test_mech_sections.py -q -k "not detail"` +Expected: PASS — 21 passed, 3 deselected. + +If `test_a_solid_shaft_full_section_hatches_both_halves_to_the_axis` reports a +different area, print the loop with + +``` +uv run --frozen python -c "from engineering.mech.part import RevolvedPart, Segment; from engineering.mech.views import cut_loops, normalise_plane; print(cut_loops(RevolvedPart(name='s', segments=(Segment(20.0,25.0),Segment(40.0,30.0),Segment(15.0,20.0))), normalise_plane({'p1':(0.0,0.0),'p2':(100.0,0.0)}))[0][0])" +``` + +and fix the engine, not the number: 20x12.5 + 40x15 + 15x10 is the real +half-section area of that shaft and a different answer is a bug. + +- [ ] **Step 5: Add the detail view to `engineering/mech/views.py`** + +First extend the module's primitives import, which `_clip_to_circle` needs: + +```python +from engineering.mech.primitives import Line, Poly, Prim, Pt, bbox +``` + +Then append: + +```python +# -- detail views ------------------------------------------------------------ + + +def _prim_bbox(prim) -> tuple[float, float, float, float]: + return bbox([prim]) + + +def _touches(prim, center: Pt, radius: float) -> bool: + x0, y0, x1, y1 = _prim_bbox(prim) + nearest_x = min(max(center[0], x0), x1) + nearest_y = min(max(center[1], y0), y1) + return math.dist((nearest_x, nearest_y), center) <= radius + 1e-9 + + +def _clip_segment(p1: Pt, p2: Pt, center: Pt, radius: float): + """The part of the segment inside the circle, or None.""" + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + fx, fy = p1[0] - center[0], p1[1] - center[1] + a = dx * dx + dy * dy + if a < 1e-18: + return (p1, p2) if math.dist(p1, center) <= radius + 1e-9 else None + b = 2.0 * (fx * dx + fy * dy) + c = fx * fx + fy * fy - radius * radius + discriminant = b * b - 4.0 * a * c + if discriminant <= 0.0: + return None + root = math.sqrt(discriminant) + t0 = max(0.0, (-b - root) / (2.0 * a)) + t1 = min(1.0, (-b + root) / (2.0 * a)) + if t1 - t0 <= 1e-9: + return None + return ((p1[0] + t0 * dx, p1[1] + t0 * dy), (p1[0] + t1 * dx, p1[1] + t1 * dy)) + + +def _clip_to_circle(prim, center: Pt, radius: float) -> tuple[Prim, ...]: + """Straight geometry is trimmed to the detail circle; curved geometry is kept + whole when it touches it. + + Trimming an arc would mean solving its angular range against the circle, and + a detail that silently re-cuts an arc is worse than one that carries a + slightly long arc into the enlargement - so the rule is stated rather than + approximated, and a detail is meant to be drawn inside its circle. + """ + if isinstance(prim, Line): + clipped = _clip_segment(prim.p1, prim.p2, center, radius) + return (Line(clipped[0], clipped[1], prim.role),) if clipped else () + if isinstance(prim, Poly): + points = list(prim.points) + if prim.closed and points: + points.append(points[0]) + out: list[Prim] = [] + for index in range(len(points) - 1): + clipped = _clip_segment(points[index], points[index + 1], center, radius) + if clipped: + out.append(Line(clipped[0], clipped[1], prim.role)) + return tuple(out) + return (prim,) if _touches(prim, center, radius) else () + + +def detail_marker_prims(detail: dict) -> tuple[Prim, ...]: + """ISO 128-34 detail circle and its letter, for the **parent** view.""" + from engineering.mech.primitives import Circle, Text + + center = tuple(float(v) for v in detail["center"]) + radius = float(detail["radius"]) + label = str(detail.get("label", "A")) + return ( + Circle(center, radius, "visible"), + Text((center[0] + radius + 2.0, center[1] + radius + 2.0), label, 5.0, 0.0, "text"), + ) + + +def detail_view(part, detail: dict, *, projection: str = "first", side: str = "right") -> View: + """A circular region of a parent view, enlarged, with the ISO 128-34 labels. + + Straight geometry is trimmed to the circle and curved geometry that touches + it is kept whole (see :func:`_clip_to_circle`); every feature whose own + primitives all fall outside is reported in ``omitted``. + """ + from engineering.mech.primitives import scale as scale_prims + from engineering.mech.primitives import translate + + if not isinstance(detail, dict): + raise ValueError(f"detail: expected a dict, got {type(detail).__name__}.") + if "radius" not in detail: + raise ValueError("detail.radius: a detail needs the radius of its circle.") + center = tuple(float(v) for v in detail.get("center", (0.0, 0.0))) + radius = float(detail["radius"]) + if radius <= _EPS: + raise ValueError(f"detail.radius: must be greater than zero, got {radius}.") + factor = float(detail.get("scale", 5.0)) + if factor <= _EPS: + raise ValueError(f"detail.scale: must be greater than zero, got {factor}.") + label = str(detail.get("label", "A")) + of = str(detail.get("of", "front")).strip().lower() + + parent = build_view(part, of, side=side, projection=projection) + kept: list[Prim] = [] + for prim in parent.prims: + kept.extend(_clip_to_circle(prim, center, radius)) + kept = tuple(kept) + if not kept: + raise ValueError( + f"detail: the circle at {center} r={radius} contains no geometry of the " + f"{of} view." + ) + + moved = translate(kept, -center[0], -center[1]) + enlarged = scale_prims(moved, factor, (0.0, 0.0)) + + omitted = list(parent.omitted) + for feature in part.features: + own = feature.prims(part, of, {"side": side, "scale": 1.0, "projection": projection}) + if own and not any(_touches(prim, center, radius) for prim in own): + omitted.append( + {"feature": feature.id, "reason": f"outside the detail circle {label}"} + ) + + return View( + kind="detail", + prims=tuple(enlarged), + dims=(), + omitted=tuple(omitted), + bbox=bbox(enlarged), + label=f"{label} ({factor:g}:1)", + scale=factor, + ) +``` + +- [ ] **Step 6: Route the two new kinds through `build_view`** + +In `build_view`, replace the line + +```python + del plane, style, detail # the orthographic kinds take no plane or detail +``` + +with + +```python + if name == "section": + return section_view(part, plane, style=style, scale=scale, side=where) + if name == "detail": + return detail_view(part, detail or {}, projection=projection, side=where) +``` + +and move the two new functions above `build_view` in the file, or leave them +below and keep the calls late-bound — both work, because Python resolves the +names at call time. Run `uv run --frozen ruff check engineering/mech/views.py` +either way. + +- [ ] **Step 7: Run the whole section suite** + +Run: `uv run --frozen pytest tests/test_mech_sections.py tests/test_mech_views.py -q` +Expected: PASS — 24 + 25 = 49 passed. + +- [ ] **Step 8: Re-export from the package surface** + +In `engineering/mech/__init__.py`, extend the `views` import block with + +```python + CIRCLE_SEGMENTS, + SECTION_PLANE, + SECTION_STYLES, + cut_loops, + detail_marker_prims, + detail_view, + normalise_plane, + section_view, +``` + +keeping it alphabetical, and add the same names to `__all__`. + +- [ ] **Step 9: Gates** + +``` +uv run --frozen ruff check engineering/mech/views.py engineering/mech/__init__.py tests/test_mech_sections.py +uv run --frozen ruff format engineering/mech/views.py tests/test_mech_sections.py +uv run --frozen pytest tests/test_mech_sections.py tests/test_mech_views.py tests/test_mech_features.py tests/test_mech_part.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +- [ ] **Step 10: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-5): sections, cut faces, material hatch and detail views + +engineering/mech/views.py grows the two remaining view kinds. A section's cut +faces come out of the same radial profile the silhouette does, so the hatched +area and the drawn outline are one calculation and cannot disagree: a solid +shaft's half-section loop measures exactly the sum of its segment rectangles, +and a bore closes the loop on itself rather than being hatched over. + +ISO 6410's section convention falls out of the model rather than being special- +cased: an internal thread's radial_edit already raises the bore to the major +diameter, so the hatch stops there. ISO 128-3's rib rule is a flag on the +feature - a pocket marked no_section_hatch is drawn and left unhatched, while +the rest of the cut face is hatched with the part's ISO 128-50 material. + +Four styles, each either implemented or refused by name with the alternative +rather than approximated: full and half on a longitudinal plane through a +revolved axis; full and revolved on a transverse plane (the annulus, flattened +at 2 degrees - 0.015% of the radius, stated in CIRCLE_SEGMENTS rather than +hidden); full and offset on a prismatic part, where through features split the +cut face and pockets reduce it to the remaining thickness. A plane that misses +the part, or that grazes an outline vertex so its crossings cannot be paired, is +refused instead of producing a guessed cut face. + +Details scale a circular region of a parent view, emit the ISO 128-34 circle and +letter for the parent and the "A (5:1)" label for the detail itself, and report +every feature that falls outside the circle in omitted - the honesty rule again. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 6: Dimensioning — intents per feature, overlap-free layout, hole tables, ISO 286 fits + +**Files:** +- Create: `engineering/mech/dimension.py` +- Create: `tests/test_mech_dimension.py` +- Modify: `engineering/mech/__init__.py` (re-export the dimension surface) +- Test: `tests/test_mech_dimension.py` + +**Interfaces:** +- Consumes (Task 1, on `main`): `engineering.mech.primitives.{DimIntent, Pt}`. +- Consumes (Task 2/3/4/5, committed): + `engineering.mech.part.{RevolvedPart, PrismaticPart, segment_bounds, part_length, + outline_bbox}`; `engineering.mech.features.Hole`; `engineering.mech.views.View`. +- Consumes (already in the repository, unchanged): + `engineering.fits.fit_lookup(code: str, nominal_mm: float) -> FitDeviation` + with `.code / .upper_mm / .lower_mm / .it_grade / .it_value_mm`; + `engineering.tolerances.build_dim_override(tol_upper, tol_lower, tol_mode, + text_override) -> tuple[dict, str | None]` (its lower-deviation convention is + *positive means a minus deviation*, which is why a resolved fit passes + `-deviation.lower_mm`, exactly as `server.py::_fit_to_tolerances` does). +- Produces (Task 7 consumes exactly these): + ```python + DIM_STYLES: tuple[str, ...] # ("chain", "baseline", "ordinate") + TEXT_WIDTH_FACTOR: float # 0.7 - mean glyph width / text height + def measured_value(intent: DimIntent) -> float + def intent_text(intent: DimIntent) -> str + def text_rects(intents, *, height: float = 3.5) -> tuple[tuple[float, float, float, float], ...] + def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict + # {"tol_upper", "tol_lower", "tol_mode", "text_override"} for the backend call + def dimension_intents(part, view: View, *, style="chain", + hole_table_threshold=8) -> tuple[DimIntent, ...] + def layout_dimensions(view: View, intents, *, offset=10.0, step=8.0, + height: float = 3.5) -> tuple[DimIntent, ...] + def hole_table_rows(part, view: View, *, hole_table_threshold=8) -> tuple[dict, ...] + # {"tag","x","y","d","qty","note"} - empty at or under the threshold + ``` + Two additive keyword-only arguments beyond the skeleton's pinned signatures, + both with defaults, so every call written against the pinned form keeps + working: `layout_dimensions(..., height=3.5)`, because the level pitch has to + clear the text height it is separating, and + `hole_table_rows(..., hole_table_threshold=8)`, so the threshold lives in one + place instead of being restated inside the function. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_mech_dimension.py`: + +```python +"""Dimension intents, ISO 286 fits, the hole table and the overlap-free layout. + +The layout test is the one that matters: it asserts the placed text rectangles +are pairwise disjoint, computed by the same helper the layout uses, so "no +overlapping dimension text" is a measured property rather than a claim. +""" + +from __future__ import annotations + +import itertools + +import pytest + +from engineering.mech.features import Hole, Keyway +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import DimIntent +from engineering.mech.dimension import ( + DIM_STYLES, + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + measured_value, + resolve_tolerance, + text_rects, +) +from engineering.mech.views import build_view + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*holes) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(holes), + ) + + +def grid(count: int, diameter: float = 6.0): + return tuple( + Hole(id=f"h{i}", x=5.0 + 7.0 * i, y=25.0, diameter=diameter) for i in range(count) + ) + + +def test_the_three_styles_are_the_ones_the_spec_names(): + assert DIM_STYLES == ("chain", "baseline", "ordinate") + + +def test_measured_value_and_text_follow_the_intent(): + linear = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)) + assert measured_value(linear) == 20.0 + assert intent_text(linear) == "20" + override = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), text_override="M20") + assert measured_value(override) == 40.0 + assert intent_text(override) == "M20" + + +# -- ISO 286 fits and ISO 129 tolerances ------------------------------------- + + +def test_a_h7_fit_on_a_40_mm_bore_resolves_to_plus_25_microns_over_zero(): + intent = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), fit="H7") + resolved = resolve_tolerance(intent) + assert resolved["tol_upper"] == pytest.approx(0.025) + assert resolved["tol_lower"] == pytest.approx(0.0) + assert resolved["tol_mode"] == "deviation" + assert resolved["text_override"] == "<> H7" + + +def test_a_shaft_fit_keeps_the_build_dim_override_sign_convention(): + intent = DimIntent(kind="diameter", p1=(-10.0, 0.0), p2=(10.0, 0.0), fit="g6") + resolved = resolve_tolerance(intent) + # ISO 286 g6 at 20 mm: es = -0.007, ei = -0.020; build_dim_override wants the + # lower deviation as a positive magnitude of the minus side. + assert resolved["tol_upper"] == pytest.approx(-0.007) + assert resolved["tol_lower"] == pytest.approx(0.020) + + +def test_an_explicit_tolerance_passes_through_and_is_validated(): + intent = DimIntent( + kind="linear", + p1=(0.0, 0.0), + p2=(30.0, 0.0), + tol={"mode": "symmetric", "upper": 0.1}, + ) + resolved = resolve_tolerance(intent) + assert resolved["tol_mode"] == "symmetric" + assert resolved["tol_upper"] == pytest.approx(0.1) + bad = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "loose"}) + with pytest.raises(ValueError, match="tol_mode"): + resolve_tolerance(bad) + + +def test_a_fit_and_an_explicit_tolerance_together_are_refused(): + intent = DimIntent( + kind="diameter", + p1=(-20.0, 0.0), + p2=(20.0, 0.0), + fit="H7", + tol={"mode": "symmetric", "upper": 0.1}, + ) + with pytest.raises(ValueError, match="not both"): + resolve_tolerance(intent) + + +def test_a_fit_outside_the_iso_286_tables_is_refused_by_name(): + intent = DimIntent(kind="diameter", p1=(-400.0, 0.0), p2=(400.0, 0.0), fit="H7") + with pytest.raises(ValueError, match="ISO 286"): + resolve_tolerance(intent) + + +def test_a_fit_with_no_measurable_nominal_is_refused_rather_than_guessed(): + intent = DimIntent(kind="diameter", p1=(5.0, 0.0), p2=(5.0, 0.0), fit="H7") + with pytest.raises(ValueError, match="nominal"): + resolve_tolerance(intent) + + +# -- collecting the intents -------------------------------------------------- + + +def test_the_front_view_of_a_shaft_gets_its_overall_length_and_each_diameter_once(): + view = build_view(shaft(), "front") + intents = dimension_intents(shaft(), view) + lengths = [i for i in intents if i.kind == "linear"] + diameters = [i for i in intents if i.kind == "diameter"] + assert any(measured_value(i) == 75.0 for i in lengths) + assert sorted({round(measured_value(i), 6) for i in diameters}) == [20.0, 25.0, 30.0] + + +def test_chain_and_baseline_place_the_same_stations_differently(): + part = shaft() + view = build_view(part, "front") + chain = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"] + baseline = [i for i in dimension_intents(part, view, style="baseline") if i.kind == "linear"] + assert {round(measured_value(i), 6) for i in chain} >= {20.0, 40.0, 15.0} + assert {round(measured_value(i), 6) for i in baseline} >= {20.0, 60.0, 75.0} + + +def test_ordinate_measures_every_station_from_the_origin(): + part = shaft() + view = build_view(part, "front") + intents = [i for i in dimension_intents(part, view, style="ordinate") if i.kind == "linear"] + assert all(i.p1[0] == 0.0 for i in intents) + + +def test_a_feature_intent_survives_collection_and_keeps_its_feature_id(): + part = shaft(Keyway(id="kw", segment=1, length=20.0)) + view = build_view(part, "front") + intents = dimension_intents(part, view) + assert any(i.feature == "kw" for i in intents) + + +def test_iso129_the_same_measurement_is_not_dimensioned_twice(): + part = shaft() + view = build_view(part, "front") + intents = dimension_intents(part, view) + keys = [ + (i.kind, round(i.p1[0], 6), round(i.p1[1], 6), round(i.p2[0], 6), round(i.p2[1], 6)) + for i in intents + ] + assert len(keys) == len(set(keys)) + + +def test_a_prismatic_front_view_gets_its_two_overall_sizes(): + view = build_view(plate(), "front") + intents = dimension_intents(plate(), view) + assert sorted({round(measured_value(i), 6) for i in intents if i.kind == "linear"}) == [ + 50.0, + 80.0, + ] + + +# -- the hole table ---------------------------------------------------------- + + +def test_under_the_threshold_the_holes_keep_their_own_diameters_and_there_is_no_table(): + part = plate(*grid(4)) + view = build_view(part, "front") + intents = dimension_intents(part, view, hole_table_threshold=8) + assert sum(1 for i in intents if i.kind == "diameter") == 4 + assert hole_table_rows(part, view) == () + + +def test_over_the_threshold_the_hole_diameters_move_into_a_table(): + part = plate(*grid(10)) + view = build_view(part, "front") + intents = dimension_intents(part, view, hole_table_threshold=8) + assert not any(i.kind == "diameter" for i in intents) + rows = hole_table_rows(part, view) + assert len(rows) == 10 + assert {row["tag"] for row in rows} == {"A"} + assert rows[0]["qty"] == 10 + assert rows[0]["d"] == 6.0 + assert set(rows[0]) == {"tag", "x", "y", "d", "qty", "note"} + + +def test_two_hole_sizes_get_two_tags(): + part = plate(*grid(6), *(Hole(id=f"b{i}", x=5.0 + 9.0 * i, y=40.0, diameter=10.0) for i in range(4))) + view = build_view(part, "front") + rows = hole_table_rows(part, view) + assert {row["tag"] for row in rows} == {"A", "B"} + by_tag = {row["tag"]: row["qty"] for row in rows} + assert by_tag == {"A": 6, "B": 4} + + +def test_a_tapped_hole_carries_its_designation_in_the_note(): + part = plate(*(Hole(id=f"t{i}", x=5.0 + 9.0 * i, y=25.0, diameter=10.0, thread="M10") + for i in range(9))) + view = build_view(part, "front") + rows = hole_table_rows(part, view) + assert rows[0]["note"] == "M10" + + +# -- the layout, and its overlap proof --------------------------------------- + + +def test_the_layout_places_every_intent_and_the_text_rectangles_are_disjoint(): + part = shaft(Keyway(id="kw", segment=1, length=20.0)) + view = build_view(part, "front") + intents = dimension_intents(part, view) + placed = layout_dimensions(view, intents) + assert len(placed) == len(intents) + assert all(intent.text_at is not None for intent in placed) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}" + + +def test_a_crowded_prismatic_view_still_lays_out_disjointly(): + part = plate(*grid(6)) + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view, hole_table_threshold=100)) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}" + + +def test_horizontal_dimensions_stack_below_the_view_and_vertical_ones_to_its_left(): + part = shaft() + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=8.0) + horizontal = [i for i in placed if i.kind == "linear" and abs(i.p1[1] - i.p2[1]) < 1e-9] + assert horizontal and all(i.text_at[1] <= view.bbox[1] - 10.0 + 1e-9 for i in horizontal) + diameters = [i for i in placed if i.kind == "diameter"] + assert diameters and all(i.text_at[0] >= view.bbox[2] + 10.0 - 1e-9 for i in diameters) + + +def test_the_step_is_never_smaller_than_the_text_it_has_to_clear(): + part = shaft() + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=1.0) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9 + + +def test_an_unknown_style_is_refused(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match="chain"): + dimension_intents(part, view, style="freehand") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_dimension.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.dimension'`. + +- [ ] **Step 3: Write `engineering/mech/dimension.py`** + +```python +"""Dimension intents for a view, laid out so no two texts overlap. + +Pure: no backend, no async, no DXF. The features declare what they want +dimensioned; this module adds the part's own sizes, removes the duplicates +(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129 +tolerances through the modules that already own them, and then *places* the +texts. + +The placement is not a matter of taste. Every intent's text is measured into a +rectangle by :func:`text_rects`, and an intent is put on the lowest free level +whose rectangle does not overlap one already placed on that level. Levels are +spaced by at least the text height, so rectangles on different levels cannot +touch either. The test asserts pairwise disjointness with the same helper, which +makes "no overlapping dimension text" measured rather than claimed - and it is +the same property the repository's `dim_overlap` critique focus checks on the +finished drawing. +""" + +from __future__ import annotations + +import math + +from engineering.fits import fit_lookup +from engineering.mech.features import Hole +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + outline_bbox, + part_length, + segment_bounds, +) +from engineering.mech.primitives import DimIntent +from engineering.tolerances import build_dim_override + +_EPS = 1e-9 + +DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate") + +#: Mean glyph width as a fraction of the text height, for measuring a label +#: into a rectangle. Deliberately generous: over-estimating the width can only +#: push two dimensions further apart. +TEXT_WIDTH_FACTOR = 0.7 + +#: Padding added around a measured label, in text heights. +TEXT_PADDING = 0.6 + + +def measured_value(intent: DimIntent) -> float: + """What the dimension measures, in drawing units.""" + p1, p2 = intent.p1, intent.p2 + if intent.kind == "linear": + dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1]) + return dx if dx >= dy else dy + return math.dist(p1, p2) + + +def _format(value: float) -> str: + return f"{round(value, 3):g}" + + +def intent_text(intent: DimIntent) -> str: + """The text a dimension will carry - the override, or the measurement.""" + if intent.text_override: + return intent.text_override + if intent.kind == "diameter": + return f"⌀{_format(measured_value(intent))}" + if intent.kind == "radius": + return f"R{_format(measured_value(intent))}" + return _format(measured_value(intent)) + + +def text_rects(intents, *, height: float = 3.5): + """``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``.""" + rects = [] + for intent in intents: + if intent.text_at is None: + raise ValueError( + f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; " + "run layout_dimensions first." + ) + label = intent_text(intent) + width = (len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height + tall = height * (1.0 + TEXT_PADDING) + cx, cy = float(intent.text_at[0]), float(intent.text_at[1]) + rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0)) + return tuple(rects) + + +# -- tolerances -------------------------------------------------------------- + + +def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict: + """Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments. + + Mirrors `server.py::_fit_to_tolerances`, including its sign convention: + ``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus + deviation, so a resolved fit passes ``-deviation.lower_mm``. + """ + has_tol = bool(intent.tol) + if intent.fit and has_tol: + raise ValueError( + f"{intent.feature or intent.kind}: pass either fit= or an explicit " + "tol, not both." + ) + if intent.fit: + size = float(nominal) if nominal is not None else measured_value(intent) + if size <= _EPS: + raise ValueError( + f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and " + f"the intent measures {size}. Pass nominal= explicitly." + ) + deviation = fit_lookup(intent.fit, size) + return { + "tol_upper": deviation.upper_mm, + "tol_lower": -deviation.lower_mm, + "tol_mode": "deviation", + "text_override": intent.text_override or f"<> {deviation.code}", + } + if has_tol: + tol = dict(intent.tol) + mode = str(tol.get("mode", "none")) + upper = tol.get("upper") + lower = tol.get("lower") + build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises + return { + "tol_upper": upper, + "tol_lower": lower, + "tol_mode": mode, + "text_override": intent.text_override, + } + return { + "tol_upper": None, + "tol_lower": None, + "tol_mode": "none", + "text_override": intent.text_override, + } + + +# -- collecting the intents -------------------------------------------------- + + +def _dedupe(intents) -> tuple[DimIntent, ...]: + seen: set[tuple] = set() + out: list[DimIntent] = [] + for intent in intents: + key = ( + intent.kind, + round(float(intent.p1[0]), 6), + round(float(intent.p1[1]), 6), + round(float(intent.p2[0]), 6), + round(float(intent.p2[1]), 6), + ) + if key in seen: + continue + seen.add(key) + out.append(intent) + return tuple(out) + + +def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]: + stations = [0.0] + [x1 for _, x1 in segment_bounds(part)] + intents: list[DimIntent] = [] + if style == "chain": + for a, b in zip(stations, stations[1:]): + intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part")) + else: # baseline and ordinate both measure from the left face + for station in stations[1:]: + intents.append( + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part") + ) + if style == "chain": + intents.append( + DimIntent( + kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part" + ) + ) + return intents + + +def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]: + intents: list[DimIntent] = [] + for (x0, x1), segment in zip(segment_bounds(part), part.segments): + mid = (x0 + x1) / 2.0 + radius = segment.d_outer / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(mid, radius), p2=(mid, -radius), feature="part") + ) + if segment.d_inner > _EPS: + bore = segment.d_inner / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(mid, bore), p2=(mid, -bore), feature="part") + ) + return intents + + +def _hole_groups(part) -> dict[tuple, list[Hole]]: + groups: dict[tuple, list[Hole]] = {} + for feature in getattr(part, "features", ()): + if not isinstance(feature, Hole): + continue + key = ( + round(float(feature.diameter), 6), + feature.thread or "", + None if feature.cbore_d is None else round(float(feature.cbore_d), 6), + None if feature.depth is None else round(float(feature.depth), 6), + ) + groups.setdefault(key, []).append(feature) + return groups + + +def dimension_intents( + part, + view, + *, + style: str = "chain", + hole_table_threshold: int = 8, +) -> tuple[DimIntent, ...]: + """Every dimension this view wants: the features' own, plus the part's sizes.""" + name = str(style or "").strip().lower() + if name not in DIM_STYLES: + raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.") + + intents: list[DimIntent] = list(view.dims) + kind = "front" if view.kind == "section" else view.kind + + if isinstance(part, RevolvedPart): + if kind == "front": + intents.extend(_revolved_axial(part, name)) + intents.extend(_revolved_diameters(part)) + elif kind == "side": + first = part.segments[0] + last = part.segments[-1] + near = last if str(view.kind) == "side" else first + radius = near.d_outer / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(-radius, 0.0), p2=(radius, 0.0), feature="part") + ) + if near.d_inner > _EPS: + bore = near.d_inner / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(-bore, 0.0), p2=(bore, 0.0), feature="part") + ) + elif isinstance(part, PrismaticPart): + x0, y0, x1, y1 = outline_bbox(part) + if kind == "front": + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part")) + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part")) + else: + u0, u1 = (y0, y1) if kind == "side" else (x0, x1) + intents.append( + DimIntent( + kind="linear", + p1=(u0, 0.0), + p2=(u0, float(part.thickness)), + feature="part", + ) + ) + del u1 + + groups = _hole_groups(part) + total_holes = sum(len(holes) for holes in groups.values()) + if total_holes > int(hole_table_threshold): + hole_ids = {hole.id for holes in groups.values() for hole in holes} + intents = [i for i in intents if i.feature not in hole_ids] + + return _dedupe(intents) + + +def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]: + """One row per hole, tagged and counted by group - empty under the threshold. + + ``view`` is part of the pinned signature and is accepted unused, so a later + view-specific filter (a hole that does not appear in this view) has one + place to go. + """ + del view + groups = _hole_groups(part) + total = sum(len(holes) for holes in groups.values()) + if total <= int(hole_table_threshold): + return () + rows: list[dict] = [] + for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))): + tag = chr(ord("A") + index) + holes = groups[key] + diameter, thread, cbore, depth = key + note_parts = [] + if thread: + note_parts.append(str(thread)) + if cbore is not None: + note_parts.append(f"c'bore ⌀{cbore:g}") + if depth is not None: + note_parts.append(f"deep {depth:g}") + note = " ".join(note_parts) + for hole in holes: + rows.append( + { + "tag": tag, + "x": float(hole.x), + "y": float(hole.y), + "d": float(diameter), + "qty": len(holes), + "note": note, + } + ) + return tuple(rows) + + +# -- the layout -------------------------------------------------------------- + + +def _axis_of(intent: DimIntent) -> str: + """"bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right".""" + if intent.kind in ("diameter", "radius", "angular"): + return "right" + dx = abs(intent.p2[0] - intent.p1[0]) + dy = abs(intent.p2[1] - intent.p1[1]) + return "bottom" if dx >= dy else "left" + + +def layout_dimensions(view, intents, *, offset: float = 10.0, step: float = 8.0, + height: float = 3.5) -> tuple[DimIntent, ...]: + """Give every intent a ``text_at`` such that no two text rectangles overlap.""" + x0, y0, x1, y1 = view.bbox + # A level must clear the text it carries, whatever the caller asked for. + pitch = max(float(step), height * (1.0 + TEXT_PADDING) + 1.0) + occupied: dict[tuple[str, int], list[tuple[float, float]]] = {} + placed: list[DimIntent] = [] + + for intent in intents: + axis = _axis_of(intent) + label = intent_text(intent) + width = (len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height + if axis == "bottom": + center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0 + else: + center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0 + span = (center - width / 2.0, center + width / 2.0) + + level = 0 + while True: + taken = occupied.setdefault((axis, level), []) + if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken): + taken.append(span) + break + level += 1 + + distance = float(offset) + level * pitch + if axis == "bottom": + text_at = (center, y0 - distance) + elif axis == "left": + text_at = (x0 - distance, center) + else: + text_at = (x1 + distance, center) + placed.append(intent._replace(text_at=text_at)) + + return tuple(placed) +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_dimension.py -q` +Expected: PASS — 23 passed. + +Two numbers in this test file are read out of `engineering/fits.py` rather than +restated, and both are already pinned there: H7 at 40 mm is +0.025/0 (this is +the `mech_iso286_on_dimension` correctness check the spec names) and g6 at +20 mm is -0.007/-0.020. If either differs, print it with +`uv run --frozen python -c "from engineering.fits import fit_lookup; print(fit_lookup('H7',40.0).to_dict(), fit_lookup('g6',20.0).to_dict())"` +and fix the *test* to the measured ISO 286 value; never change the fit tables. + +- [ ] **Step 5: Re-export from the package surface** + +In `engineering/mech/__init__.py` add (alphabetically): + +```python +from engineering.mech.dimension import ( + DIM_STYLES, + TEXT_WIDTH_FACTOR, + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + measured_value, + resolve_tolerance, + text_rects, +) +``` + +and extend `__all__` with the same names. + +- [ ] **Step 6: Gates** + +``` +uv run --frozen ruff check engineering/mech/dimension.py engineering/mech/__init__.py tests/test_mech_dimension.py +uv run --frozen ruff format engineering/mech/dimension.py tests/test_mech_dimension.py +uv run --frozen pytest tests/test_mech_dimension.py tests/test_mech_sections.py tests/test_mech_views.py tests/test_mech_features.py tests/test_mech_part.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +- [ ] **Step 7: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-6): dimension intents, ISO 286 fits and an overlap-free layout + +engineering/mech/dimension.py collects what the features asked to have +dimensioned, adds the part's own sizes (chain, baseline or ordinate for the +axial lengths, a diameter per segment and per bore, the two overall sizes of a +plate), and removes the duplicates - ISO 129-1 says each dimension appears once, +and a test asserts no measurement is placed twice. + +Fits and tolerances are resolved through the modules that already own them: +engineering.fits for an ISO 286 code and engineering.tolerances for an explicit +ISO 129 tolerance, including build_dim_override's sign convention (the lower +deviation is passed as the magnitude of the minus side, exactly as +server.py::_fit_to_tolerances does). A H7 bore at 40 mm resolves to +0.025/0 - +the correctness check the spec names. A fit together with an explicit tolerance, +a fit outside the authored ISO 286 range, and a fit on an intent with no +measurable nominal are each refused by name rather than guessed. + +The layout is the part that had to be measured rather than asserted: every +intent's text is turned into a rectangle by the same helper the test uses, and +an intent is placed on the lowest level whose rectangle is free, with the level +pitch clamped to clear the text height whatever the caller passed. The test then +asserts the placed rectangles are pairwise disjoint - including on a crowded +plate and with a deliberately too-small step. That is the same property the +repository's dim_overlap critique focus checks on the finished drawing. + +Over hole_table_threshold holes the per-hole diameters leave the view and become +a table instead, grouped by diameter, thread, counterbore and depth, one tag per +group and the quantity on every row. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 7: The async layer and SECTION 21 — mech_part_draw, mech_view_add, mech_dimension_part, mech_hole_pattern, mech_part_from_spec, mech_part_inspect + +**Files:** +- Create: `engineering/mech/draw.py` +- Create: `tests/test_mech_draw.py` +- Modify: `engineering/mech/__init__.py` (re-export the async layer) +- Modify: `server.py` (`mech` added to `_GROUP_TAG_PRIORITY` / `_GROUP_TAG_LABELS` / + `TOOL_PACK_NAMES` / `PACK_TOOL_NAMES`, plus SECTION 21 with the banner + `(6 tools)` and the six `@mcp.tool` registrations, immediately **above** the + `# ── RESOURCES ──` banner) +- Modify: `discovery/aliases.py` (one `ToolAliases` record per new tool) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release snapshot 204 → 210, advertised 199 → 205, the + `204 tools in 23 groups` caption → `210 tools in 24 groups`) +- Modify: `tests/test_tool_registry.py` (frozen group snapshot gains `"mech": 6`, + total 204 → 210) +- Modify: `tests/test_tool_packs.py` (the pack list gains `mech`; the group count + 23 → 24) +- Test: `tests/test_mech_draw.py` + +**Interfaces:** +- Consumes (Task 1, on `main`): + `engineering.mech.primitives.{Line, Arc, Circle, Poly, HatchArea, Text, DimIntent, + ROLE_LAYER, bbox, translate, rotate, scale}`; + `engineering.mech.xdata.{APP_ID, PAYLOAD_VERSION, to_values, decode}` — `to_values` + is the chunk-strings helper Task 1 ships for exactly this call, so this module + does not re-derive it from `encode`; + `engineering.mech.standards.materials.hatch_for`. +- Consumes (Tasks 2–6, committed): + `build_part`, `part_to_dict`, `validate_part`, `build_view`, `layout_origin`, + `detail_marker_prims`, `normalise_plane`, `dimension_intents`, + `layout_dimensions`, `hole_table_rows`, `resolve_tolerance`, + `engineering.mech.features.HolePattern`. +- Consumes (backend contract, unchanged — verified against + `backends/contracts/`): + `entity_create_line(x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, color=None, linetype=None)`, + `entity_create_arc(cx, cy, radius, start_angle, end_angle, layer=None, color=None)`, + `entity_create_circle(cx, cy, radius, layer=None, color=None)`, + `entity_create_polyline(points, closed=False, layer=None, color=None)`, + `entity_create_text(text, x, y, height=2.5, rotation=0.0, layer=None, color=None)`, + `entity_create_point(x, y, layer=None, color=None)`, + `entity_create_hatch(pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None)`, + `hatch_add_boundary(handle, edges)` with `{"type": "line", "start": [x, y], "end": [x, y]}`, + `entity_create_table(x, y, rows, headers=None, column_widths=None, row_height=7.0, text_height=2.5, title=None, layer="TEXT")`, + `dimension_linear(x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None, tol_upper=None, tol_lower=None, tol_mode="none", text_override=None)`, + `dimension_aligned(x1, y1, x2, y2, dim_x, dim_y, layer=None)`, + `dimension_radius(cx, cy, chord_x, chord_y, leader_length=10.0, layer=None, tol_*)`, + `dimension_diameter(x1, y1, x2, y2, leader_length=10.0, layer=None, tol_*)`, + `entity_set_xdata(handle, app_name, values)` / `entity_get_xdata(handle, app_name=None)`, + `entity_list(type_filter=None, layer_filter=None, limit=200, offset=0)`, + `transaction_begin() / transaction_commit() / transaction_rollback()`, + `get_plan_spec()` / `drawing_plan(intent, sheet_size, scale, layer_set)`. +- Produces. The `part` XDATA payload is the skeleton's pinned shape plus two + additive keys, both written by this task and read back by `read_part`, + `mech_part_inspect` and Task 15's critique index (every reader uses `.get`, so + a payload without them still decodes): + `{"v": 1, "kind": "part", "id": "", "view": "front", + "part": {...part_to_dict...}, "placement": {"at": [x, y], "rotation": deg, + "projection": "first"}, "views": [, ...]}`. `placement` is what + makes the WCS mapping re-readable months later, and `views` is what lets + `mech_view_add` lay a second view out against the first without the caller + re-describing either. + ```python + ANCHOR_LAYER: str # "MECH" + async def draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, + layer_prefix="") -> dict # {"handles", "counts"} + async def draw_part(backend, part, *, at=(0.0, 0.0), rotation=0.0, + views=("front",), projection="first", dimension=True, + style="chain", scale=1.0) -> dict # {"part_id","views","handles",...} + async def add_view(backend, part_id, kind, **kw) -> dict + async def dimension_part(backend, part_id, *, style="chain", + hole_table_threshold=8) -> dict + async def read_part(backend, part_id=None) -> dict # {"part_id","part","views",...} + async def draw_hole_pattern(backend, *, pattern, x, y, diameter, **kw) -> dict + async def draw_from_spec(backend, spec: dict) -> dict + ``` + Three additive keyword-only arguments beyond the skeleton's pinned + signatures, all with defaults: `draw_part(..., style="chain", scale=1.0)` so + a part can be drawn and dimensioned in one call, and + `dimension_part(..., hole_table_threshold=8, fits=None)` so an ISO 286 code + can be attached per feature id without re-describing the part. Every call + written against the pinned form keeps working. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_mech_draw.py`: + +```python +"""The async layer: primitives to entities, the part payload, and the sheet. + +Headless ezdxf against a real document - the `backend` fixture in +tests/conftest.py. The COM paths are the same code (draw.py composes the +generic contract members), which is the whole point of the pattern +`gear_draw_*` and `titleblock_apply_iso_a3` already use. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.draw import ( + ANCHOR_LAYER, + add_view, + dimension_part, + draw_from_spec, + draw_hole_pattern, + draw_part, + draw_prims, + read_part, +) +from engineering.mech.part import build_part +from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Poly, Text + +pytestmark = pytest.mark.asyncio + +SHAFT = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [ + {"length": 20.0, "d_outer": 25.0}, + {"length": 40.0, "d_outer": 30.0}, + {"length": 15.0, "d_outer": 20.0}, + ], + "features": [{"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0}], +} + +PLATE = { + "kind": "prismatic", + "name": "plate", + "material": "aluminium", + "thickness": 8.0, + "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]], + "features": [{"kind": "hole", "id": "h1", "x": 40.0, "y": 25.0, "diameter": 10.0}], +} + + +async def entities(backend, type_filter=None, layer=None): + return await backend.entity_list(type_filter=type_filter, layer_filter=layer, limit=2000) + + +async def test_draw_prims_puts_every_role_on_its_own_layer(backend): + result = await draw_prims( + backend, + [ + Line((0.0, 0.0), (10.0, 0.0), "visible"), + Line((0.0, 5.0), (10.0, 5.0), "hidden"), + Line((-2.0, 2.5), (12.0, 2.5), "center"), + Circle((5.0, 2.5), 1.0, "visible"), + Arc((5.0, 2.5), 2.0, 0.0, 90.0, "visible"), + Poly(((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), True, "visible"), + Text((5.0, 8.0), "A", 3.5, 0.0, "text"), + HatchArea((((0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)),), "steel"), + ], + ) + assert len(result["handles"]) == 8 + layers = {e.layer for e in await entities(backend)} + assert {"GEOMETRY", "HIDDEN", "CENTER", "TEXT", "HATCH"} <= layers + + +async def test_draw_prims_places_and_rotates_into_wcs(backend): + await draw_prims(backend, [Line((0.0, 0.0), (10.0, 0.0), "visible")], at=(100.0, 50.0)) + (line,) = await entities(backend, type_filter="LINE") + start = line.properties["start"] + assert (round(start[0], 6), round(start[1], 6)) == (100.0, 50.0) + + +async def test_draw_part_writes_the_model_back_onto_the_drawing(backend): + part = build_part(SHAFT) + result = await draw_part(backend, part, at=(50.0, 100.0)) + assert result["part_id"] + assert result["views"][0]["kind"] == "front" + assert result["handles"] + + anchors = await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + assert len(anchors) == 1 + assert anchors[0].handle == result["part_id"] + + read = await read_part(backend, result["part_id"]) + assert read["part"]["name"] == "shaft" + assert [f["id"] for f in read["part"]["features"]] == ["c1"] + assert build_part(read["part"]) == part + + +async def test_read_part_without_an_id_finds_the_only_part(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part) + read = await read_part(backend) + assert read["part_id"] == drawn["part_id"] + + +async def test_read_part_refuses_when_there_is_more_than_one_and_no_id(backend): + await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0)) + await draw_part(backend, build_part(PLATE), at=(200.0, 0.0)) + with pytest.raises(ValueError) as excinfo: + await read_part(backend) + assert "part_id" in str(excinfo.value) + + +async def test_two_views_are_placed_in_first_angle_positions(backend): + part = build_part(SHAFT) + result = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side")) + front, end = result["views"] + assert end["kind"] == "side" + # first angle: the view from the right sits to the LEFT of the front view + assert end["bbox"][2] <= front["bbox"][0] + 1e-6 + + +async def test_third_angle_puts_the_end_view_on_the_other_side(backend): + part = build_part(SHAFT) + result = await draw_part( + backend, part, at=(0.0, 0.0), views=("front", "side"), projection="third" + ) + front, end = result["views"] + assert end["bbox"][0] >= front["bbox"][2] - 1e-6 + + +async def test_add_view_reads_the_model_back_and_appends_a_section(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0)) + added = await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [0.0, 0.0], "p2": [100.0, 0.0], "label": "A"}, + ) + assert added["view"]["kind"] == "section" + assert added["view"]["label"] == "A-A" + assert len(await entities(backend, type_filter="HATCH")) == 2 + read = await read_part(backend, drawn["part_id"]) + assert [v["kind"] for v in read["views"]] == ["front", "section"] + + +async def test_a_detail_view_also_marks_its_circle_on_the_parent(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0)) + added = await add_view( + backend, + drawn["part_id"], + "detail", + detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"}, + ) + assert added["view"]["label"] == "D (5:1)" + texts = [e for e in await entities(backend, type_filter="TEXT")] + assert any(e.properties.get("text") == "D" for e in texts) + + +async def test_dimension_part_creates_real_dimension_entities(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False) + assert not await entities(backend, type_filter="DIMENSION") + result = await dimension_part(backend, drawn["part_id"]) + assert result["count"] > 0 + dims = await entities(backend, type_filter="DIMENSION") + assert len(dims) == result["count"] + assert {e.layer for e in dims} == {"DIM"} + + +async def test_an_iso_286_fit_reaches_the_dimension_as_a_real_tolerance(backend): + spec = { + "kind": "revolved", + "name": "bore", + "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}], + "features": [], + } + part = build_part(spec) + drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False) + result = await dimension_part(backend, drawn["part_id"], fits={"part": "H7"}) + assert result["count"] > 0 + assert any( + row.get("fit") == "H7" and row["tol_upper"] == pytest.approx(0.025) + for row in result["resolved"] + ) + + +async def test_a_hole_pattern_draws_its_holes_and_centre_marks(backend): + result = await draw_hole_pattern( + backend, pattern="polar", x=0.0, y=0.0, diameter=8.0, count=6, pcd=80.0 + ) + assert result["count"] == 6 + circles = await entities(backend, type_filter="CIRCLE") + assert len(circles) == 6 + assert len(await entities(backend, layer="CENTER")) >= 12 + + +async def test_a_hole_pattern_with_no_spacing_is_refused_before_anything_is_drawn(backend): + with pytest.raises(ValueError, match="spacing"): + await draw_hole_pattern(backend, pattern="linear", x=0.0, y=0.0, diameter=8.0, count=4) + assert not await entities(backend, type_filter="CIRCLE") + + +async def test_draw_from_spec_draws_a_whole_sheet_in_one_transaction(backend): + result = await draw_from_spec( + backend, + { + "sheet": {"intent": "mechanical sheet", "sheet_size": "A3", "scale": 1.0}, + "parts": [ + {"part": SHAFT, "at": [40.0, 200.0], "views": ["front", "side"]}, + {"part": PLATE, "at": [40.0, 60.0], "views": ["front"]}, + ], + }, + ) + assert len(result["parts"]) == 2 + assert len(await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)) == 2 + + +async def test_draw_from_spec_validates_every_part_before_it_draws_anything(backend): + bad = dict(SHAFT, segments=[{"length": 20.0, "d_outer": float("inf")}]) + with pytest.raises(ValueError) as excinfo: + await draw_from_spec( + backend, + {"parts": [{"part": PLATE, "at": [0.0, 0.0]}, {"part": bad, "at": [200.0, 0.0]}]}, + ) + assert "parts[1]" in str(excinfo.value) + assert "segments[0].d_outer" in str(excinfo.value) + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + + +async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monkeypatch): + calls = {"n": 0} + real = backend.entity_create_line + + async def explode(*args, **kwargs): + calls["n"] += 1 + if calls["n"] > 3: + raise RuntimeError("the seat went away") + return await real(*args, **kwargs) + + monkeypatch.setattr(backend, "entity_create_line", explode) + with pytest.raises(RuntimeError, match="the seat went away"): + await draw_from_spec(backend, {"parts": [{"part": SHAFT, "at": [0.0, 0.0]}]}) + monkeypatch.undo() + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + assert not await entities(backend, type_filter="LINE") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_draw.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.draw'`. + +- [ ] **Step 3: Write `engineering/mech/draw.py`** + +```python +"""The only module in `engineering/mech/` that touches a backend. + +Everything above it is pure: the part model, the features, the view engine and +the dimension layout take numbers and return primitive *descriptions*. This +module turns those descriptions into `entity_create_*` / `dimension_*` calls, +places them in WCS, and writes the part model back onto the drawing as +`ACADMCP_MECH` XDATA so `mech_view_add` can add a view months later without the +caller re-describing the part. + +It is not a contract member and neither are the tools above it - exactly like +`gear_draw_*`, `keyway_draw_*` and `titleblock_apply_iso_a3`, it composes the +generic members, so it is written once and works on both engines by +construction. +""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +import anyio + +from engineering.mech.dimension import ( + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + resolve_tolerance, +) +from engineering.mech.features import HolePattern +from engineering.mech.part import build_part, part_to_dict, validate_part +from engineering.mech.primitives import ( + ROLE_LAYER, + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Text, + rotate, + translate, +) +from engineering.mech.standards.materials import hatch_for +from engineering.mech.views import ( + View, + build_view, + detail_marker_prims, + layout_origin, + normalise_plane, +) +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values + +log = logging.getLogger(__name__) + +#: The layer the invisible per-part anchor POINT lives on. It carries the +#: ACADMCP_MECH payload and nothing else, so `read_part` can find every part in +#: a drawing with one filtered `entity_list`. +ANCHOR_LAYER = "MECH" + +DEFAULT_TEXT_HEIGHT = 3.5 +DEFAULT_GAP = 20.0 + + +# -- the XDATA adapter ------------------------------------------------------- +# +# `engineering/mech/xdata.encode` returns DXF (group code, value) rows, which is +# the on-disk shape. `entity_set_xdata` takes plain JSON values and assigns the +# codes itself through backends/xdata_specs.py, so the 1001 APPID row is the +# backend's business and only the 1000 chunks travel. `to_values(payload)` is +# that projection and it lives in `engineering/mech/xdata.py` (Task 1) — every +# writer in this repository calls it rather than re-deriving the split, so a +# change to the chunking is made in one place. + + +def _xdata_payload(values) -> dict: + return decode(values) + + +# -- primitives to entities -------------------------------------------------- + + +async def draw_prims( + backend, + prims, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + layer_prefix: str = "", +) -> dict: + """Draw primitive descriptions, placed at ``at`` and turned by ``rotation``.""" + placed = tuple(prims) + if abs(float(rotation)) > 1e-12: + placed = rotate(placed, float(rotation), (0.0, 0.0)) + if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12: + placed = translate(placed, float(at[0]), float(at[1])) + + handles: list[str] = [] + counts: dict[str, int] = {} + + def layer_of(role: str) -> str: + return f"{layer_prefix}{ROLE_LAYER[role]}" + + for prim in placed: + if isinstance(prim, Line): + info = await backend.entity_create_line( + prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], layer=layer_of(prim.role) + ) + elif isinstance(prim, Arc): + info = await backend.entity_create_arc( + prim.center[0], + prim.center[1], + prim.radius, + prim.start_deg, + prim.end_deg, + layer=layer_of(prim.role), + ) + elif isinstance(prim, Circle): + info = await backend.entity_create_circle( + prim.center[0], prim.center[1], prim.radius, layer=layer_of(prim.role) + ) + elif isinstance(prim, Poly): + info = await backend.entity_create_polyline( + [[x, y] for x, y in prim.points], prim.closed, layer=layer_of(prim.role) + ) + elif isinstance(prim, Text): + info = await backend.entity_create_text( + prim.text, + prim.at[0], + prim.at[1], + prim.height, + prim.rotation, + layer=layer_of(prim.role), + ) + elif isinstance(prim, HatchArea): + style = hatch_for(prim.material) + info = await backend.entity_create_hatch( + style["pattern"], + [[x, y] for x, y in prim.loops[0]], + style["scale"], + style["angle"], + layer=layer_of("hatch"), + ) + for island in prim.loops[1:]: + points = list(island) + edges = [ + { + "type": "line", + "start": [points[i][0], points[i][1]], + "end": [points[(i + 1) % len(points)][0], points[(i + 1) % len(points)][1]], + } + for i in range(len(points)) + ] + await backend.hatch_add_boundary(info.handle, edges) + else: # pragma: no cover - the Prim union is closed + raise ValueError(f"draw_prims: unknown primitive {type(prim).__name__}.") + handles.append(info.handle) + key = type(prim).__name__.lower() + counts[key] = counts.get(key, 0) + 1 + + return {"handles": handles, "counts": counts} + + +# -- dimensions -------------------------------------------------------------- + + +async def _draw_dimension(backend, intent, *, fit: str | None = None) -> tuple[str | None, dict]: + """One laid-out intent as a real DIMENSION. Returns (handle, the resolved row).""" + resolved = resolve_tolerance(intent._replace(fit=fit or intent.fit)) + row = { + "feature": intent.feature, + "kind": intent.kind, + "text": intent_text(intent), + "fit": fit or intent.fit, + **resolved, + } + text_at = intent.text_at or ( + (intent.p1[0] + intent.p2[0]) / 2.0, + (intent.p1[1] + intent.p2[1]) / 2.0, + ) + tol = { + "tol_upper": resolved["tol_upper"], + "tol_lower": resolved["tol_lower"], + "tol_mode": resolved["tol_mode"], + "text_override": resolved["text_override"], + } + + if intent.kind == "linear": + rotation = 0.0 if abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0]) else 90.0 + info = await backend.dimension_linear( + intent.p1[0], intent.p1[1], intent.p2[0], intent.p2[1], + text_at[0], text_at[1], rotation, ROLE_LAYER["dim"], **tol, + ) + elif intent.kind == "aligned": + if tol["tol_mode"] != "none" or tol["text_override"]: + angle = math.degrees( + math.atan2(intent.p2[1] - intent.p1[1], intent.p2[0] - intent.p1[0]) + ) + # dimension_aligned carries no tolerance arguments, so a toleranced + # aligned dimension is drawn as a rotated linear one - the same + # measurement, with the tolerance the caller asked for. + info = await backend.dimension_linear( + intent.p1[0], intent.p1[1], intent.p2[0], intent.p2[1], + text_at[0], text_at[1], angle, ROLE_LAYER["dim"], **tol, + ) + else: + info = await backend.dimension_aligned( + intent.p1[0], intent.p1[1], intent.p2[0], intent.p2[1], + text_at[0], text_at[1], ROLE_LAYER["dim"], + ) + elif intent.kind == "diameter": + info = await backend.dimension_diameter( + intent.p1[0], intent.p1[1], intent.p2[0], intent.p2[1], + 10.0, ROLE_LAYER["dim"], **tol, + ) + elif intent.kind == "radius": + info = await backend.dimension_radius( + intent.p1[0], intent.p1[1], intent.p2[0], intent.p2[1], + 10.0, ROLE_LAYER["dim"], **tol, + ) + else: + return None, {**row, "skipped": "an angular intent needs a vertex, which a DimIntent " + "does not carry; no feature in this release emits one"} + return info.handle, row + + +# -- the part payload -------------------------------------------------------- + + +def _view_record(view: View, offset: tuple[float, float], kw: dict) -> dict: + x0, y0, x1, y1 = view.bbox + return { + "kind": view.kind, + "label": view.label, + "scale": view.scale, + "offset": [float(offset[0]), float(offset[1])], + "bbox": [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]], + "kw": kw, + } + + +def _offset_for(view: View, lower_left: tuple[float, float]) -> tuple[float, float]: + return (lower_left[0] - view.bbox[0], lower_left[1] - view.bbox[1]) + + +def _placed(view: View, offset) -> View: + x0, y0, x1, y1 = view.bbox + return View( + kind=view.kind, + prims=view.prims, + dims=view.dims, + omitted=view.omitted, + bbox=(x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]), + label=view.label, + scale=view.scale, + ) + + +async def draw_part( + backend, + part, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + views=("front",), + projection: str = "first", + dimension: bool = True, + style: str = "chain", + scale: float = 1.0, +) -> dict: + """Draw a part's views, dimension them, and write the model onto the drawing.""" + validate_part(part) + kinds = [str(kind).strip().lower() for kind in (views or ("front",))] + if not kinds: + raise ValueError("views: name at least one view to draw.") + + handles: list[str] = [] + records: list[dict] = [] + omitted: list[dict] = [] + parent: View | None = None + parent_offset = (0.0, 0.0) + + for index, kind in enumerate(kinds): + view = build_view(part, kind, projection=projection, scale=scale) + if index == 0: + offset = _offset_for(view, (float(at[0]), float(at[1]))) + else: + lower_left = layout_origin( + _placed(parent, parent_offset), + kind, + projection=projection, + gap=DEFAULT_GAP, + child=view, + ) + offset = _offset_for(view, lower_left) + drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation) + handles.extend(drawn["handles"]) + omitted.extend(view.omitted) + records.append(_view_record(view, offset, {"projection": projection, "scale": scale})) + if index == 0: + parent, parent_offset = view, offset + + anchor = await backend.entity_create_point(float(at[0]), float(at[1]), layer=ANCHOR_LAYER) + payload = { + "v": PAYLOAD_VERSION, + "kind": "part", + "id": anchor.handle, + "view": records[0]["kind"], + "part": part_to_dict(part), + "placement": {"at": [float(at[0]), float(at[1])], "rotation": float(rotation), + "projection": projection}, + "views": records, + } + await backend.entity_set_xdata(anchor.handle, APP_ID, to_values(payload)) + + result = { + "part_id": anchor.handle, + "views": records, + "handles": handles, + "omitted": omitted, + "backend": backend.name, + } + if dimension: + dimensioned = await dimension_part(backend, anchor.handle, style=style) + result["dimensions"] = dimensioned["count"] + result["handles"].extend(dimensioned["handles"]) + return result + + +async def read_part(backend, part_id: str | None = None) -> dict: + """The part model written onto the drawing, read back out of its anchor.""" + if part_id: + raw = await backend.entity_get_xdata(part_id, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + raise ValueError(f"{part_id}: carries no {APP_ID} part payload.") + payload = _xdata_payload(values) + return {"part_id": part_id, **payload} + + anchors = await backend.entity_list( + type_filter="POINT", layer_filter=ANCHOR_LAYER, limit=1000 + ) + found: list[tuple[str, dict]] = [] + for anchor in anchors: + raw = await backend.entity_get_xdata(anchor.handle, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if values: + found.append((anchor.handle, _xdata_payload(values))) + if not found: + raise ValueError( + f"this drawing carries no {APP_ID} part payload; draw one with mech_part_draw." + ) + if len(found) > 1: + raise ValueError( + "this drawing carries " + f"{len(found)} parts ({', '.join(handle for handle, _ in found)}); " + "name one with part_id." + ) + handle, payload = found[0] + return {"part_id": handle, **payload} + + +async def add_view(backend, part_id: str, kind: str, **kw) -> dict: + """Add another view of a part already on the drawing, laid out against its first.""" + payload = await read_part(backend, part_id) + part = build_part(payload["part"]) + records = list(payload.get("views") or []) + if not records: + raise ValueError(f"{part_id}: the payload carries no view to lay this one out against.") + projection = str(payload.get("placement", {}).get("projection", "first")) + rotation = float(payload.get("placement", {}).get("rotation", 0.0)) + + plane = kw.pop("plane", None) + if plane is not None: + plane = normalise_plane(plane) + detail = kw.pop("detail", None) + gap = float(kw.pop("gap", DEFAULT_GAP)) + side = str(kw.pop("side", "right")) + style = str(kw.pop("style", "full")) + scale = float(kw.pop("scale", 1.0)) + if kw: + raise ValueError(f"add_view: unknown argument(s) {sorted(kw)}.") + + view = build_view( + part, kind, side=side, plane=plane, style=style, scale=scale, + projection=projection, detail=detail, + ) + + parent_record = records[0] + parent_view = build_view(part, parent_record["kind"], projection=projection) + lower_left = layout_origin( + _placed(parent_view, parent_record["offset"]), + kind, side=side, projection=projection, gap=gap, child=view, + ) + # step past every view already placed, so a third view never lands on the second + for record in records[1:]: + x0, y0, x1, y1 = record["bbox"] + if lower_left[0] < x1 + gap and lower_left[0] + (view.bbox[2] - view.bbox[0]) > x0 - gap: + lower_left = (x0 - gap - (view.bbox[2] - view.bbox[0]), lower_left[1]) + offset = _offset_for(view, lower_left) + + drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation) + marker_handles: list[str] = [] + if kind == "detail": + marker = detail_marker_prims(detail or {}) + marker_drawn = await draw_prims( + backend, marker, at=parent_record["offset"], rotation=rotation + ) + marker_handles = marker_drawn["handles"] + + records.append( + _view_record(view, offset, {"side": side, "style": style, "scale": scale, + "plane": plane, "detail": detail}) + ) + payload["views"] = records + await backend.entity_set_xdata( + part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"}) + ) + return { + "part_id": part_id, + "view": records[-1], + "handles": drawn["handles"] + marker_handles, + "omitted": list(view.omitted), + "backend": backend.name, + } + + +async def dimension_part( + backend, + part_id: str, + *, + style: str = "chain", + hole_table_threshold: int = 8, + fits: dict | None = None, +) -> dict: + """Dimension every view of a part, laid out so no two texts overlap.""" + payload = await read_part(backend, part_id) + part = build_part(payload["part"]) + records = payload.get("views") or [] + fits = dict(fits or {}) + + handles: list[str] = [] + resolved_rows: list[dict] = [] + skipped: list[dict] = [] + table_handle: str | None = None + + for record in records: + view = build_view( + part, + record["kind"], + projection=str(payload.get("placement", {}).get("projection", "first")), + plane=record.get("kw", {}).get("plane"), + style=record.get("kw", {}).get("style", "full"), + side=record.get("kw", {}).get("side", "right"), + detail=record.get("kw", {}).get("detail"), + ) + intents = dimension_intents( + part, view, style=style, hole_table_threshold=hole_table_threshold + ) + placed = layout_dimensions(view, intents) + offset = record["offset"] + for intent in placed: + moved = intent._replace( + p1=(intent.p1[0] + offset[0], intent.p1[1] + offset[1]), + p2=(intent.p2[0] + offset[0], intent.p2[1] + offset[1]), + text_at=(intent.text_at[0] + offset[0], intent.text_at[1] + offset[1]), + ) + handle, row = await _draw_dimension( + backend, moved, fit=fits.get(intent.feature) or fits.get(part.name) + ) + if handle is None: + skipped.append(row) + continue + handles.append(handle) + resolved_rows.append(row) + + rows = hole_table_rows(part, view, hole_table_threshold=hole_table_threshold) + if rows and table_handle is None: + info = await backend.entity_create_table( + record["bbox"][2] + DEFAULT_GAP, + record["bbox"][3], + [[r["tag"], f"{r['d']:g}", str(r["qty"]), r["note"]] for r in rows], + ["TAG", "DIA", "QTY", "NOTE"], + None, + 7.0, + 2.5, + "HOLE TABLE", + ROLE_LAYER["text"], + ) + table_handle = info.handle + handles.append(info.handle) + + return { + "part_id": part_id, + "count": len(handles) - (1 if table_handle else 0), + "handles": handles, + "resolved": resolved_rows, + "skipped": skipped, + "hole_table": table_handle, + "backend": backend.name, + } + + +# -- the standalone hole pattern --------------------------------------------- + + +async def draw_hole_pattern( + backend, + *, + pattern: str, + x: float, + y: float, + diameter: float, + count: int = 2, + spacing: float = 0.0, + count_y: int = 1, + spacing_y: float = 0.0, + pcd: float = 0.0, + start_angle: float = 0.0, + angle: float = 0.0, + layer: str | None = None, + centre_marks: bool = True, + dimension: bool = False, +) -> dict: + """Draw a hole pattern on any drawing - no part model needed.""" + expansion = HolePattern( + id="pattern", + pattern=pattern, + base={"x": float(x), "y": float(y), "diameter": float(diameter)}, + count=int(count), + spacing=float(spacing), + count_y=int(count_y), + spacing_y=float(spacing_y), + pcd=float(pcd), + start_angle=float(start_angle), + angle=float(angle), + ).expand() # validates the pattern before anything is drawn + + prims: list[Any] = [] + for hole in expansion: + radius = float(hole.diameter) / 2.0 + prims.append(Circle((float(hole.x), float(hole.y)), radius, "visible")) + if centre_marks: + reach = radius + 3.0 + prims.append( + Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center") + ) + prims.append( + Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center") + ) + drawn = await draw_prims(backend, prims) + if layer: + # the role map owns the layers; an explicit override moves the circles + # only, so the ISO 128-23 centre marks stay on CENTER. + for handle, prim in zip(drawn["handles"], prims): + if isinstance(prim, Circle): + await backend.entity_set_properties(handle, layer=layer) + + result = { + "count": len(expansion), + "handles": drawn["handles"], + "centers": [[float(h.x), float(h.y)] for h in expansion], + "backend": backend.name, + } + if dimension: + first = expansion[0] + radius = float(first.diameter) / 2.0 + handle, _row = await _draw_dimension( + backend, + DimIntent( + kind="diameter", + p1=(first.x - radius, first.y), + p2=(first.x + radius, first.y), + text_at=(first.x + radius + 12.0, first.y + 12.0), + text_override=f"{len(expansion)}x ⌀{float(first.diameter):g}", + feature="pattern", + ), + ) + if handle: + result["handles"].append(handle) + return result + + +# -- the whole sheet --------------------------------------------------------- + + +async def draw_from_spec(backend, spec: dict) -> dict: + """A whole mechanical sheet from one declarative spec, inside one transaction. + + Every part is validated before the transaction opens, so a refusal leaves + the drawing untouched. The rollback is shielded for the reason + `engineering/pid/spec.py` documents at length: the MCP SDK cancels a request + by cancelling an anyio scope, anyio cancellation is level-triggered, and a + bare ``await`` in the handler would itself be cancelled before the rollback + body ran - leaving half a sheet inside an open transaction. + """ + if not isinstance(spec, dict): + raise ValueError(f"spec: expected a dict, got {type(spec).__name__}.") + raw_parts = spec.get("parts") + if not isinstance(raw_parts, (list, tuple)) or not raw_parts: + raise ValueError("parts: name at least one part to draw.") + + jobs: list[dict] = [] + for index, item in enumerate(raw_parts): + if not isinstance(item, dict): + raise ValueError(f"parts[{index}]: expected a dict with a 'part' key.") + try: + part = build_part(item.get("part") or {}) + except ValueError as exc: + raise ValueError(f"parts[{index}]: {exc}") from exc + at = item.get("at") or [0.0, 0.0] + jobs.append( + { + "part": part, + "at": (float(at[0]), float(at[1])), + "rotation": float(item.get("rotation", 0.0)), + "views": tuple(item.get("views") or ("front",)), + "projection": str(item.get("projection", "first")), + "dimension": bool(item.get("dimension", True)), + "style": str(item.get("style", "chain")), + } + ) + + sheet = spec.get("sheet") or {} + if backend.get_plan_spec() is None: + await backend.drawing_plan( + sheet.get("intent", "mechanical part sheet"), + sheet.get("sheet_size", "A3"), + float(sheet.get("scale", 1.0)), + sheet.get("layer_set", "mech"), + ) + + begun = await backend.transaction_begin() + if not isinstance(begun, dict) or not begun.get("ok"): + raise RuntimeError(f"mech_part_from_spec: could not open a transaction: {begun}") + + drawn: list[dict] = [] + try: + for index, job in enumerate(jobs): + try: + drawn.append( + await draw_part( + backend, + job["part"], + at=job["at"], + rotation=job["rotation"], + views=job["views"], + projection=job["projection"], + dimension=job["dimension"], + style=job["style"], + ) + ) + except ValueError as exc: + raise ValueError(f"parts[{index}]: {exc}") from exc + except BaseException as exc: + cancelled = isinstance(exc, anyio.get_cancelled_exc_class()) + with anyio.CancelScope(shield=True): + try: + await backend.transaction_rollback() + except Exception as rollback_exc: + if not cancelled: + raise + log.error( + "mech_part_from_spec: rollback after a cancelled request failed (%s: %s); " + "the sheet may be half-drawn - check system_status and " + "transaction_rollback before retrying", + type(rollback_exc).__name__, + rollback_exc, + ) + raise + await backend.transaction_commit() + + return { + "parts": drawn, + "part_ids": [item["part_id"] for item in drawn], + "backend": backend.name, + } +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_draw.py -q` +Expected: PASS — 16 passed. + +Two contract shapes this step leans on were measured before the plan was +written and should be re-measured if anything fails: + +``` +uv run --frozen python -c "import inspect; from backends.contracts.entity_query import EntityQueryContract as C; print(inspect.signature(C.entity_set_properties))" +uv run --frozen python -c "from backends.base import EntityInfo; print(list(EntityInfo.__dataclass_fields__))" +``` + +They are `entity_set_properties(self, handle, layer=None, color=None, +linetype=None, lineweight=None, visible=None)` and +`EntityInfo(handle, type, layer, color, linetype, visible, properties)`. If +either differs, fix the *code* to the measured shape and record it in +`deviations`. + +- [ ] **Step 5: Register `mech` as a group and a pack in `server.py`** + +(a) In `_GROUP_TAG_PRIORITY`, insert `"mech",` on the line after `"pid",`. + +(b) In `_GROUP_TAG_LABELS`, insert `"mech": "mech",` on the line after +`"pid": "pid",`. + +(c) Replace the `TOOL_PACK_NAMES` line with: + +```python +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech") +``` + +(d) In `PACK_TOOL_NAMES`, add after the `"settings"` entry: + +```python + # SECTION 21 - the mechanical part model and its view engine (tracks B+G). + "mech": frozenset( + { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", + } + ), +``` + +Lean is **not** touched here: spec section 11 puts `mech_part_draw`, +`mech_view_add` and `mech_dimension_part` on the lean surface together with +group G's two sheet tools, and Task 15 (group F) makes that single edit once +across all four branches. Adding three of the five here would leave +`tests/test_tool_packs.py`'s lean count wrong on this branch for no gain. + +- [ ] **Step 6: Register SECTION 21 in `server.py`** + +Insert the section immediately above the `# ── RESOURCES ──` banner pair: + +```python +# --------------------------------------------------------------------------- +# ── SECTION 21: Mechanical Parts (6 tools) ────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Draw a machine part from a segment list or an outline plus typed features.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Draw Part", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_part_draw( + spec: Annotated[ + dict, + Field( + description=( + "The part: {kind: 'revolved'|'prismatic', name, material, " + "segments:[{length, d_outer, d_inner, taper_to}] | outline:[[x,y]] + thickness, " + "features:[{kind, id, ...}]}" + ) + ), + ], + at_x: Annotated[float, Field(default=0.0, description="WCS X of the first view's lower-left")] = 0.0, + at_y: Annotated[float, Field(default=0.0, description="WCS Y of the first view's lower-left")] = 0.0, + rotation: Annotated[float, Field(default=0.0, description="Degrees CCW applied to every view")] = 0.0, + views: Annotated[ + list[str] | None, + Field(default=None, description="Views to draw in order: front | side | top (default ['front'])"), + ] = None, + projection: Annotated[ + str, Field(default="first", description="first (ISO 128-30, default) | third") + ] = "first", + dimension: Annotated[ + bool, Field(default=True, description="Also dimension the part (ISO 129 + ISO 286 fits)") + ] = True, + style: Annotated[ + str, Field(default="chain", description="Axial dimension style: chain | baseline | ordinate") + ] = "chain", + ctx: Context = None, +) -> dict: + """One part model - a turned profile or a plate outline plus typed features - drawn as views. + + The model is written onto the drawing as `ACADMCP_MECH` XDATA on an anchor + POINT, so `mech_view_add` can add a section months later without the caller + re-describing the part, and `mech_part_inspect` reads it back. + + Refused **before anything is drawn**, by path: a non-finite or non-positive + number (`segments[2].d_outer`), a bore that is not smaller than its outside, + an outline with fewer than three distinct vertices or one that crosses + itself, an unknown material, a feature whose placement is off the part, two + features that would remove the same material (both names given), a thread + outside the transcribed ISO 261 table, a keyway outside DIN 6885's bore + range, gear teeth whose tip diameter disagrees with their segment. A DIN 509 + undercut, a DIN 471/472 groove, a DIN 332 centre hole and an ISO 3601-2 + O-ring groove need explicit dimensions in this build: those tables are not + transcribed and the lookup refuses by name rather than interpolate. A + feature that cannot be projected into a requested view is listed in + `omitted`, never dropped. + """ + from engineering.mech.draw import draw_part + from engineering.mech.part import build_part + + try: + part = build_part(spec) + except ValueError as exc: + raise ToolError(str(exc)) from exc + await ctx.info(f"mech_part_draw: {part.name} at ({at_x}, {at_y})") + try: + return await draw_part( + _backend(ctx), + part, + at=(at_x, at_y), + rotation=rotation, + views=tuple(views or ("front",)), + projection=projection, + dimension=dimension, + style=style, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Add another view of a drawn part: side, top, section (full/half/offset) or detail.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Add View", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_view_add( + part_id: Annotated[ + str | None, + Field(default=None, description="Anchor handle from mech_part_draw; omit if the drawing holds one part"), + ] = None, + kind: Annotated[ + str, Field(default="section", description="front | side | top | section | detail") + ] = "section", + side: Annotated[str, Field(default="right", description="Which end an end view looks from: right | left")] = "right", + style: Annotated[ + str, + Field(default="full", description="Section style: full | half | offset | revolved"), + ] = "full", + plane: Annotated[ + dict | None, + Field( + default=None, + description="Cutting plane {p1:[x,y], p2:[x,y], label, direction:[dx,dy], via:[[x,y]]} - required for a section", + ), + ] = None, + detail: Annotated[ + dict | None, + Field( + default=None, + description="Detail region {center:[x,y], radius, scale, label, of:'front'} - required for a detail", + ), + ] = None, + gap: Annotated[float, Field(default=20.0, description="Gap between this view and its parent (mm)")] = 20.0, + scale: Annotated[float, Field(default=1.0, description="View scale recorded on the view")] = 1.0, + ctx: Context = None, +) -> dict: + """Another view of a part already on the drawing, laid out on its projection axis. + + The part model comes back out of the drawing's `ACADMCP_MECH` XDATA, so the + caller never re-describes it, and the placement follows the projection angle + recorded when the part was drawn (first angle by default: the view from the + right goes to the left). + + Refused by name: a section without a plane, or with a plane of zero length, + a plane that misses the part, or one that grazes an outline vertex so its + crossings cannot be paired; a half or offset section on the wrong kind of + plane (the message names the style that does apply); a detail without a + radius or with a scale of zero; an unknown part_id, and `part_id=None` on a + drawing that holds more than one part. + """ + from engineering.mech.draw import add_view, read_part + + backend = _backend(ctx) + try: + resolved = part_id or (await read_part(backend))["part_id"] + await ctx.info(f"mech_view_add: {kind} on {resolved}") + return await add_view( + backend, resolved, kind, side=side, style=style, plane=plane, + detail=detail, gap=gap, scale=scale, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Dimension Part", "destructiveHint": False}, + tags={"mech", "dimension"}, +) +async def mech_dimension_part( + part_id: Annotated[ + str | None, + Field(default=None, description="Anchor handle from mech_part_draw; omit if the drawing holds one part"), + ] = None, + style: Annotated[ + str, Field(default="chain", description="Axial dimension style: chain | baseline | ordinate") + ] = "chain", + hole_table_threshold: Annotated[ + int, + Field(default=8, description="Above this many holes the diameters move into a hole table"), + ] = 8, + fits: Annotated[ + dict | None, + Field( + default=None, + description="ISO 286 fit per feature id, e.g. {'bearing_seat': 'k6', 'bore': 'H7'}", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Every dimension the part and its features ask for, laid out without overlaps. + + Axial lengths are chained, stacked from a baseline or measured from an + origin; diameters stack outside the silhouette; fillets get a radius. Each + measurement appears once (ISO 129-1). `fits` resolves an ISO 286 code + against the measured nominal through the repository's authored tables and + appends the code to the dimension text; an explicit `tol` on a feature's own + intent does the same through the ISO 129 path. + + Refused by name: a fit together with an explicit tolerance, a fit outside + the authored ISO 286 range (over 1 mm to 500 mm, IT4-IT11), a fit on an + intent with no measurable nominal, an unknown style, an unknown part_id, and + `part_id=None` on a drawing that holds more than one part. An angular intent + is reported in `skipped` rather than drawn, because a DimIntent carries no + vertex. + """ + from engineering.mech.draw import dimension_part, read_part + + backend = _backend(ctx) + try: + resolved = part_id or (await read_part(backend))["part_id"] + await ctx.info(f"mech_dimension_part: {resolved} ({style})") + return await dimension_part( + backend, resolved, style=style, + hole_table_threshold=hole_table_threshold, fits=fits, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a linear, grid or polar hole pattern with centre marks on any drawing.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Hole Pattern", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_hole_pattern( + pattern: Annotated[str, Field(description="linear | grid | polar")], + x: Annotated[float, Field(description="WCS X of the first hole (polar: the circle centre)")], + y: Annotated[float, Field(description="WCS Y of the first hole (polar: the circle centre)")], + diameter: Annotated[float, Field(description="Hole diameter in drawing units")], + count: Annotated[int, Field(default=2, description="Holes along the pattern (polar: around the circle)")] = 2, + spacing: Annotated[float, Field(default=0.0, description="Pitch between holes (linear, grid columns)")] = 0.0, + count_y: Annotated[int, Field(default=1, description="Rows, for a grid")] = 1, + spacing_y: Annotated[float, Field(default=0.0, description="Row pitch, for a grid")] = 0.0, + pcd: Annotated[float, Field(default=0.0, description="Pitch-circle diameter, for a polar pattern")] = 0.0, + start_angle: Annotated[float, Field(default=0.0, description="Angle of the first hole (polar), degrees CCW")] = 0.0, + angle: Annotated[float, Field(default=0.0, description="Direction of a linear pattern, degrees CCW")] = 0.0, + layer: Annotated[str | None, Field(default=None, description="Override the GEOMETRY layer for the circles")] = None, + centre_marks: Annotated[bool, Field(default=True, description="Draw ISO 128-23 centre marks")] = True, + dimension: Annotated[bool, Field(default=False, description="Add a '6x ⌀8' diameter callout")] = False, + ctx: Context = None, +) -> dict: + """A hole pattern on any drawing - the part model is not required. + + The same expansion `HolePattern` performs inside a part, exposed for a + drawing that was not built from the model. + + Refused before anything is drawn: an unknown pattern, a count below 1 (below + 2 for polar), a linear pattern with no spacing, a grid with more than one + column or row and no pitch for it, a polar pattern with no pitch-circle + diameter, and a non-finite or non-positive diameter. + """ + from engineering.mech.draw import draw_hole_pattern + + await ctx.info(f"mech_hole_pattern: {pattern} x{count}") + try: + return await draw_hole_pattern( + _backend(ctx), pattern=pattern, x=x, y=y, diameter=diameter, count=count, + spacing=spacing, count_y=count_y, spacing_y=spacing_y, pcd=pcd, + start_angle=start_angle, angle=angle, layer=layer, + centre_marks=centre_marks, dimension=dimension, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a whole mechanical sheet - several parts and their views - in one transaction.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Sheet From Spec", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_part_from_spec( + spec: Annotated[ + dict, + Field( + description=( + "{sheet: {intent, sheet_size, scale, layer_set}, " + "parts: [{part: {...}, at: [x, y], rotation, views: ['front','side'], " + "projection, dimension, style}]}" + ) + ), + ], + ctx: Context = None, +) -> dict: + """Several parts, their views and their dimensions, drawn inside one transaction. + + Every part is validated before the transaction opens, so a refusal leaves + the drawing untouched and names the offending path (`parts[1]: + segments[0].d_outer: ...`). If a draw fails or the request is cancelled + part-way, the transaction is rolled back under an anyio shield, so a + cancelled call cannot leave half a sheet inside an open transaction. + + Refused by name: an empty `parts` list, any part refusal from + `mech_part_draw`, and a transaction that cannot be opened. + """ + from engineering.mech.draw import draw_from_spec + + await ctx.info(f"mech_part_from_spec: {len((spec or {}).get('parts') or [])} part(s)") + try: + return await draw_from_spec(_backend(ctx), spec) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Read the part model back off the drawing: segments, features, views, omissions.", + cost="read", +) +@mcp.tool( + annotations={"title": "Mechanical: Inspect Part", "readOnlyHint": True}, + tags={"mech", "query"}, +) +async def mech_part_inspect( + part_id: Annotated[ + str | None, + Field(default=None, description="Anchor handle from mech_part_draw; omit if the drawing holds one part"), + ] = None, + ctx: Context = None, +) -> dict: + """The `ACADMCP_MECH` payload a drawn part carries, decoded. + + Never modifies the drawing. Returns the part model exactly as + `mech_part_draw` accepts it (`part`), the views drawn so far with their + placements, and the payload version. + + Refused by name: an unknown part_id, a handle that carries no + `ACADMCP_MECH` payload, a corrupt or foreign payload, and `part_id=None` on + a drawing that holds no part or more than one (the message lists the + handles). + """ + from engineering.mech.draw import read_part + + try: + return await read_part(_backend(ctx), part_id) + except ValueError as exc: + raise ToolError(str(exc)) from exc +``` + +- [ ] **Step 7: Add the alias records to `discovery/aliases.py`** + +Append this block inside `TOOL_ALIASES`, after the +`# ── Environment (track E, group V) ──` block and before the closing brace. +`SECTIONPLANE` is a new AutoCAD command in this corpus and routes only to +`mech_view_add`, so it does **not** join `SHARED_ACAD_COMMANDS`. + +```python + # ── Mechanical parts (SECTION 21, tracks B+G group M) ─────────────────── + "mech_part_draw": ToolAliases( + acad=(), + synonyms=( + "draw a shaft", + "stepped shaft", + "draw a machine part", + "turned part", + "draw a plate", + "flange", + "part from a segment list", + "shaft with a keyway", + "mil", + "mil ciz", + "kademeli mil", + "kama", + "pah", + "plaka", + "flans", + ), + ), + "mech_view_add": ToolAliases( + acad=("SECTIONPLANE",), + synonyms=( + "add a section view", + "section a-a", + "cross section", + "half section", + "offset section", + "detail view", + "blown-up detail", + "end view", + "side view", + "another view of the part", + "kesit", + "kesit al", + "gorunus", + "yan gorunus", + "detay", + ), + ), + "mech_dimension_part": ToolAliases( + acad=(), + synonyms=( + "dimension the part", + "put the dimensions on", + "chain dimensions", + "baseline dimensions", + "ordinate dimensions", + "hole table", + "iso 286 fit on a dimension", + "h7 bore", + "olculendir", + "olcu ver", + "tolerans", + ), + ), + "mech_hole_pattern": ToolAliases( + acad=("ARRAY",), + synonyms=( + "bolt circle", + "hole pattern", + "hole array", + "pitch circle of holes", + "grid of holes", + "delik deseni", + "civata dairesi", + "delik aynasi", + ), + ), + "mech_part_from_spec": ToolAliases( + acad=(), + synonyms=( + "draw the whole sheet", + "several parts at once", + "mechanical sheet from a spec", + "batch draw parts", + "parca sayfasi", + ), + ), + "mech_part_inspect": ToolAliases( + acad=(), + synonyms=( + "what part is this", + "read the part model back", + "part features", + "which views were drawn", + "parca bilgisi", + ), + ), +``` + +- [ ] **Step 8: Regenerate the inventory and bump the two documented counts** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` + +Then in `README.md` change the release-snapshot lines from + +``` +> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 204 is the **registered** count; a default install advertises 199 over `tools/list`, +``` + +to + +``` +> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 210 is the **registered** count; a default install advertises 205 over `tools/list`, +``` + +and change the caption under the "What you get" table from +`204 tools in 23 groups;` to `210 tools in 24 groups;`. + +Leave the collected-tests figure alone (Task 15 retakes it on main) and leave +the "186 AutoCAD command names and 933 synonym phrases … covering all 204 +tools" sentence alone too: all four group branches add alias records, so that +sentence is recomputed once after the merge, in Task 15. + +- [ ] **Step 9: Bump the frozen group-size snapshot** + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`, add +`"mech": 6,` to the asserted dict (alphabetically, between `"layouts"` and +`"page_setup"`), change the final assertion to +`assert sum(sizes.values()) == 210`, and append this sentence to the docstring: + +``` + `mech` appeared (0 -> 6) when v1.6's SECTION 21 opened with the part model + and its view engine (`mech_part_draw` / `mech_view_add` / + `mech_dimension_part` / `mech_hole_pattern` / `mech_part_from_spec` / + `mech_part_inspect`); the `mech` tag is ranked directly after `pid` in + `_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under + their secondary `create` / `dimension` / `query` tags. This snapshot is + branch-local: the merge task recomputes it once all four track B+G branches + have landed. +``` + +- [ ] **Step 10: Update `tests/test_tool_packs.py` for the fourth pack** + +Three edits, all mechanical: + +(a) Replace the `ALL_PACKS` line with: + +```python +ALL_PACKS = ["core", "pid", "settings", "mech"] +ALL_PACKS_SORTED = sorted(ALL_PACKS) +MECH_TOOLS = { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", +} +``` + +(b) In `test_pack_registry_names_real_tools_and_only_them`, change the first +assertion to `assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")` +and add `assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)`. + +(c) In `test_all_is_the_default_and_enables_every_pack`, change +`assert info["tool_packs"]["enabled"] == ALL_PACKS` to +`assert info["tool_packs"]["enabled"] == ALL_PACKS_SORTED` — `enabled` is +`sorted(packs)` while `available` keeps `TOOL_PACK_NAMES`' own order, and the +two only coincided while every pack name happened to be in alphabetical order. + +(d) In `test_the_three_track_e_sections_file_under_their_own_groups`, change +`assert len(groups) == 23` to `assert len(groups) == 24` and add +`assert set(groups["mech"]) == MECH_TOOLS`. + +- [ ] **Step 11: Re-export from the package surface** + +In `engineering/mech/__init__.py` add (alphabetically): + +```python +from engineering.mech.draw import ( + ANCHOR_LAYER, + add_view, + dimension_part, + draw_from_spec, + draw_hole_pattern, + draw_part, + draw_prims, + read_part, +) +``` + +and extend `__all__` with the same names. + +- [ ] **Step 12: Gates** + +Run each of these and require it clean: + +``` +uv run --frozen ruff check engineering/mech/ server.py discovery/aliases.py tests/test_mech_draw.py tests/test_tool_packs.py tests/test_tool_registry.py +uv run --frozen ruff format engineering/mech/draw.py tests/test_mech_draw.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_mech_draw.py tests/test_mech_dimension.py tests/test_mech_sections.py tests/test_mech_views.py tests/test_mech_features.py tests/test_mech_part.py tests/test_mech_threads.py tests/test_mech_standards_coverage.py -q +uv run --frozen pytest tests/test_tool_registry.py tests/test_tool_packs.py tests/test_discovery_aliases.py tests/test_capability_contract.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +No capability key is added by this task — every mechanical tool composes generic +contract members, which is why it works on both engines by construction — so +`tests/test_capability_contract.py` must pass untouched. The deselect is the +known-red collected-tests drift named in this plan's Global Constraints; record +it in `deviations`. + +- [ ] **Step 13: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-7): SECTION 21 - the mechanical part model as six tools + +engineering/mech/draw.py is the only module in the package that touches a +backend: it turns the pure engine's primitive descriptions into entity_create_* +and dimension_* calls, places them in WCS, and writes the part model back onto +the drawing as ACADMCP_MECH XDATA on an anchor POINT. It is not a contract +member and neither are the tools - exactly like gear_draw_* and +titleblock_apply_iso_a3, it composes the generic members, so it is written once +and works on both engines by construction and no capability key is needed. + +mech_part_draw takes one model - a turned profile or a plate outline plus typed +features - and draws its views in first-angle positions (third on request), +dimensions them and records the placement. mech_view_add reads the model back +out of the drawing and adds a side, top, section (full/half/offset/revolved) or +detail view months later without the caller re-describing the part. +mech_dimension_part lays out ISO 129 dimensions with no overlapping text, +resolves ISO 286 fits against the measured nominal, and moves the holes into a +table past a threshold. mech_hole_pattern exposes the same expansion for a +drawing that was never built from the model. mech_part_inspect reads the payload +back and never modifies the drawing. + +mech_part_from_spec draws a whole sheet inside one transaction. Every part is +validated before the transaction opens, so a refusal leaves the drawing +untouched and names the path (parts[1]: segments[0].d_outer: ...), and the +rollback is shielded with the shape engineering/pid/spec.py documents: anyio +cancellation is level-triggered, so a bare await in the handler would be +cancelled before the rollback body ran and leave half a sheet inside an open +transaction. + +SECTION 21 registers the six tools in the new mech pack, with alias records +(SECTIONPLANE, ARRAY plus shop-floor synonyms in English and Turkish - mil, +kademeli mil, kama, pah, kesit, gorunus), a regenerated tool inventory, the +README release snapshot at 210 tools in 24 groups, and the frozen group-size +snapshot gaining mech: 6. Lean is deliberately untouched here: spec section 11 +puts three of these on the lean surface together with group G's two sheet tools, +and Task 15 makes that edit once across all four branches. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 8: The standard-parts catalogue — ISO 4014/4017/4032/7089/4762 and ISO 15 bearings + +**Files:** +- Create: `engineering/mech/standards/fasteners.py` +- Create: `engineering/mech/standards/bearings.py` +- Test: `tests/test_mech_fasteners.py` +- Test: `tests/test_mech_bearings.py` + +**Interfaces:** +- Consumes (Task 1, already on `main`): `engineering.mech.standards.Coverage(low, high, unit="mm")`, `register(standard: str, rows: dict, coverage: Coverage, source: str) -> None`, `standards() -> tuple[str, ...]`. +- Produces (`engineering/mech/standards/fasteners.py`): + - `COARSE_PITCH: dict[str, float]`, `HEX_HEAD: dict[str, dict[str, float]]`, `HEX_NUT`, `WASHER`, `SOCKET_HEAD` + - `SOURCE_ISO_4014 / SOURCE_ISO_4017 / SOURCE_ISO_4032 / SOURCE_ISO_7089 / SOURCE_ISO_4762 / SOURCE_ISO_261: str` + - `nominal_diameter(size) -> float`, `thread_pitch(size) -> float` + - `hex_head(size) -> dict`, `hex_nut(size) -> dict`, `washer(size) -> dict`, `socket_head(size) -> dict` + - `thread_length(size, length: float, standard: str = "ISO 4014") -> float` + - `across_corners(s: float) -> float`, `sizes(standard: str) -> tuple[str, ...]` +- Produces (`engineering/mech/standards/bearings.py`): + - `DEEP_GROOVE: dict[str, dict[str, float]]`, `BEARING_SERIES: tuple[str, ...]` + - `CROSS_FRACTION: float`, `BALL_SYMBOL_FRACTION: float` + - `SOURCE_ISO_15 / SOURCE_ISO_8826_1 / SOURCE_ISO_8826_2: str` + - `bearing(designation) -> dict` returning `{"designation","series","source","d","D","B"}` + - `list_bearings(series: str | None = None) -> tuple[dict, ...]` + +- [ ] **Step 1: Write the failing test for the fastener tables** + +Create `tests/test_mech_fasteners.py`: + +```python +"""Provenance and coverage of the ISO fastener tables (the table rule, spec 5). + +Every value asserted here is a named row of a named table. A row that could not +be verified against the standard is not in the module at all, so the coverage +refusals below are as load-bearing as the values. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.standards import standards +from engineering.mech.standards.fasteners import ( + COARSE_PITCH, + HEX_NUT, + SOCKET_HEAD, + SOURCE_ISO_4014, + across_corners, + hex_head, + hex_nut, + nominal_diameter, + sizes, + socket_head, + thread_length, + thread_pitch, + washer, +) + + +def test_iso4014_named_rows_match_the_standard(): + """ISO 4014:2011 Table 1 - width across flats s and head height k.""" + assert (hex_head("M6")["s"], hex_head("M6")["k"]) == (10.0, 4.0) + assert (hex_head("M8")["s"], hex_head("M8")["k"]) == (13.0, 5.3) + assert (hex_head("M10")["s"], hex_head("M10")["k"]) == (16.0, 6.4) + assert (hex_head("M12")["s"], hex_head("M12")["k"]) == (18.0, 7.5) + assert (hex_head("M16")["s"], hex_head("M16")["k"]) == (24.0, 10.0) + assert (hex_head("M20")["s"], hex_head("M20")["k"]) == (30.0, 12.5) + assert (hex_head("M24")["s"], hex_head("M24")["k"]) == (36.0, 15.0) + assert (hex_head("M30")["s"], hex_head("M30")["k"]) == (46.0, 18.7) + assert (hex_head("M36")["s"], hex_head("M36")["k"]) == (55.0, 22.5) + + +def test_iso4017_shares_the_iso4014_head(): + """ISO 4017 screws and ISO 4014 bolts have the same head at every size here.""" + assert sizes("ISO 4017") == sizes("ISO 4014") + + +def test_iso4032_named_rows_match_the_standard(): + """ISO 4032:2012 Table 1 - nut height m (max).""" + assert hex_nut("M6")["m"] == 5.2 + assert hex_nut("M8")["m"] == 6.8 + assert hex_nut("M10")["m"] == 8.4 + assert hex_nut("M12")["m"] == 10.8 + assert hex_nut("M16")["m"] == 14.8 + assert hex_nut("M20")["m"] == 18.0 + assert hex_nut("M24")["m"] == 21.5 + assert hex_nut("M30")["m"] == 25.6 + assert hex_nut("M36")["m"] == 31.0 + for size in HEX_NUT: + assert hex_nut(size)["s"] == hex_head(size)["s"] + + +def test_iso7089_named_rows_match_the_standard(): + """ISO 7089:2000 Table 1 - d1, d2, h (nominal), normal series, grade A.""" + assert (washer("M6")["d1"], washer("M6")["d2"], washer("M6")["h"]) == (6.4, 12.0, 1.6) + assert (washer("M8")["d1"], washer("M8")["d2"], washer("M8")["h"]) == (8.4, 16.0, 1.6) + assert (washer("M10")["d1"], washer("M10")["d2"], washer("M10")["h"]) == (10.5, 20.0, 2.0) + assert (washer("M12")["d1"], washer("M12")["d2"], washer("M12")["h"]) == (13.0, 24.0, 2.5) + assert (washer("M16")["d1"], washer("M16")["d2"], washer("M16")["h"]) == (17.0, 30.0, 3.0) + assert (washer("M20")["d1"], washer("M20")["d2"], washer("M20")["h"]) == (21.0, 37.0, 3.0) + assert (washer("M24")["d1"], washer("M24")["d2"], washer("M24")["h"]) == (25.0, 44.0, 4.0) + assert (washer("M30")["d1"], washer("M30")["d2"], washer("M30")["h"]) == (31.0, 56.0, 4.0) + assert (washer("M36")["d1"], washer("M36")["d2"], washer("M36")["h"]) == (37.0, 66.0, 5.0) + + +def test_iso4762_named_rows_match_the_standard(): + """ISO 4762:2004 Table 1 - head dk (max), socket s, thread length b.""" + assert ( + socket_head("M3")["dk"], + socket_head("M3")["s"], + socket_head("M3")["b"], + ) == (5.5, 2.5, 18.0) + assert ( + socket_head("M5")["dk"], + socket_head("M5")["s"], + socket_head("M5")["b"], + ) == (8.5, 4.0, 22.0) + assert ( + socket_head("M8")["dk"], + socket_head("M8")["s"], + socket_head("M8")["b"], + ) == (13.0, 6.0, 28.0) + assert ( + socket_head("M12")["dk"], + socket_head("M12")["s"], + socket_head("M12")["b"], + ) == (18.0, 10.0, 36.0) + assert ( + socket_head("M20")["dk"], + socket_head("M20")["s"], + socket_head("M20")["b"], + ) == (30.0, 17.0, 52.0) + assert ( + socket_head("M24")["dk"], + socket_head("M24")["s"], + socket_head("M24")["b"], + ) == (36.0, 19.0, 60.0) + # ISO 4762 head height equals the nominal thread diameter at every size here. + for size in SOCKET_HEAD: + assert socket_head(size)["k"] == nominal_diameter(size) + + +def test_iso261_coarse_pitch_column(): + assert COARSE_PITCH == { + "M3": 0.5, + "M4": 0.7, + "M5": 0.8, + "M6": 1.0, + "M8": 1.25, + "M10": 1.5, + "M12": 1.75, + "M16": 2.0, + "M20": 2.5, + "M24": 3.0, + "M30": 3.5, + "M36": 4.0, + } + assert thread_pitch("M12") == 1.75 + assert nominal_diameter("M12") == 12.0 + + +def test_coverage_is_refused_at_both_ends_by_name(): + with pytest.raises(ValueError, match="ISO 4014"): + hex_head("M3") + with pytest.raises(ValueError, match="ISO 4014"): + hex_head("M42") + with pytest.raises(ValueError, match="ISO 4032"): + hex_nut("M42") + with pytest.raises(ValueError, match="ISO 7089"): + washer("M3") + with pytest.raises(ValueError, match="ISO 4762"): + socket_head("M2") + with pytest.raises(ValueError, match="ISO 4762"): + socket_head("M30") + + +def test_second_choice_sizes_are_absent_not_interpolated(): + for absent in ("M14", "M18", "M22", "M27", "M33"): + with pytest.raises(ValueError): + hex_head(absent) + with pytest.raises(ValueError): + hex_nut(absent) + + +def test_thread_length_follows_the_standards_own_b_rule(): + assert thread_length("M12", 60.0) == pytest.approx(30.0) # 2d + 6, l <= 125 + assert thread_length("M12", 150.0) == pytest.approx(36.0) # 2d + 12, 125 < l <= 200 + assert thread_length("M12", 260.0) == pytest.approx(49.0) # 2d + 25, l > 200 + assert thread_length("M12", 25.0) == pytest.approx(25.0) # never longer than the bolt + assert thread_length("M12", 60.0, "ISO 4017") == pytest.approx(60.0) + assert thread_length("M8", 30.0, "ISO 4762") == pytest.approx(28.0) + assert thread_length("M8", 20.0, "ISO 4762") == pytest.approx(20.0) + with pytest.raises(ValueError): + thread_length("M12", 0.0) + with pytest.raises(ValueError): + thread_length("M12", float("inf")) + with pytest.raises(ValueError, match="ISO 4014"): + thread_length("M12", 60.0, "DIN 931") + + +def test_across_corners_is_derived_geometry_not_a_transcribed_column(): + assert across_corners(18.0) == pytest.approx(2.0 * 18.0 / math.sqrt(3.0)) + assert across_corners(18.0) == pytest.approx(20.7846, abs=1e-4) + with pytest.raises(ValueError): + across_corners(0.0) + + +def test_every_table_registers_itself_with_the_shared_registry(): + for name in ("ISO 4014", "ISO 4017", "ISO 4032", "ISO 7089", "ISO 4762"): + assert name in standards() + assert "ISO 4014:2011" in SOURCE_ISO_4014 + + +def test_sizes_refuses_a_standard_this_module_does_not_carry(): + assert sizes("ISO 4014")[0] == "M5" + with pytest.raises(ValueError, match="ISO 4014"): + sizes("ISO 15") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_fasteners.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.standards.fasteners'` + +- [ ] **Step 3: Write `engineering/mech/standards/fasteners.py`** + +```python +"""ISO metric fastener tables - transcribed from the standards, never derived. + +SOURCE +------ +* ``HEX_HEAD`` ISO 4014:2011 Table 1 (hexagon head bolts, product grades A + and B) and ISO 4017:2014 Table 1 (hexagon head screws, fully threaded). The + two standards carry the same width across flats ``s`` and head height ``k`` + for every size shipped here, so one table serves both and the standard is a + property of the *part*, not a second copy of the table. +* ``HEX_NUT`` ISO 4032:2012 Table 1 (hexagon regular nuts, style 1): + width across flats ``s``, nut height ``m`` (max). +* ``WASHER`` ISO 7089:2000 Table 1 (plain washers, normal series, product + grade A): inside diameter ``d1``, outside diameter ``d2``, thickness ``h``. +* ``SOCKET_HEAD`` ISO 4762:2004 Table 1 (hexagon socket head cap screws): head + diameter ``dk`` (max), head height ``k`` (max), hexagon socket width across + flats ``s``, thread length ``b``. +* ``COARSE_PITCH`` ISO 261:1998, coarse pitch series. + +COVERAGE (deliberately narrow - the table rule of the mech spec, section 5) +--------------------------------------------------------------------------- +Only ISO 261 **first-choice** sizes are shipped: + +* ISO 4014 / 4017 / 4032 / 7089 : M5 M6 M8 M10 M12 M16 M20 M24 M30 M36 +* ISO 4762 : M3 M4 M5 M6 M8 M10 M12 M16 M20 M24 + +Second-choice sizes (M14, M18, M22, M27, M33) and everything beyond those +ranges are **not** shipped and are refused by name. A narrow table is merely +narrow; a wrong across-flats ships a wrong workshop drawing. + +Two columns are deliberately absent: + +* across-corners ``e`` - :func:`across_corners` returns the *nominal* + hexagon's ``2s/sqrt(3)`` instead. The standards' own ``e`` is a minimum + computed from ``s`` min and is product-grade dependent; a drawing of the + nominal part wants the nominal hexagon, so no ``e`` column is transcribed. +* the per-size nominal length ranges of ISO 4014 Table 1 - ``l`` is validated + as a finite positive number and the useful thread length comes from the + standard's own ``b`` rule in :func:`thread_length`. +""" + +from __future__ import annotations + +import math + +from . import Coverage, register + +SOURCE_ISO_4014 = "ISO 4014:2011 Table 1 - hexagon head bolts, product grades A and B" +SOURCE_ISO_4017 = "ISO 4017:2014 Table 1 - hexagon head screws, fully threaded" +SOURCE_ISO_4032 = "ISO 4032:2012 Table 1 - hexagon regular nuts, style 1" +SOURCE_ISO_7089 = "ISO 7089:2000 Table 1 - plain washers, normal series, product grade A" +SOURCE_ISO_4762 = "ISO 4762:2004 Table 1 - hexagon socket head cap screws" +SOURCE_ISO_261 = "ISO 261:1998 - ISO general purpose metric screw threads, coarse pitch series" + +#: ISO 261:1998 coarse pitch, one entry per size any table below carries. +COARSE_PITCH: dict[str, float] = { + "M3": 0.5, + "M4": 0.7, + "M5": 0.8, + "M6": 1.0, + "M8": 1.25, + "M10": 1.5, + "M12": 1.75, + "M16": 2.0, + "M20": 2.5, + "M24": 3.0, + "M30": 3.5, + "M36": 4.0, +} + +#: ISO 4014:2011 / ISO 4017:2014 Table 1 - s = across flats, k = head height. +HEX_HEAD: dict[str, dict[str, float]] = { + "M5": {"s": 8.0, "k": 3.5}, + "M6": {"s": 10.0, "k": 4.0}, + "M8": {"s": 13.0, "k": 5.3}, + "M10": {"s": 16.0, "k": 6.4}, + "M12": {"s": 18.0, "k": 7.5}, + "M16": {"s": 24.0, "k": 10.0}, + "M20": {"s": 30.0, "k": 12.5}, + "M24": {"s": 36.0, "k": 15.0}, + "M30": {"s": 46.0, "k": 18.7}, + "M36": {"s": 55.0, "k": 22.5}, +} + +#: ISO 4032:2012 Table 1 - s = across flats, m = nut height (max). +HEX_NUT: dict[str, dict[str, float]] = { + "M5": {"s": 8.0, "m": 4.7}, + "M6": {"s": 10.0, "m": 5.2}, + "M8": {"s": 13.0, "m": 6.8}, + "M10": {"s": 16.0, "m": 8.4}, + "M12": {"s": 18.0, "m": 10.8}, + "M16": {"s": 24.0, "m": 14.8}, + "M20": {"s": 30.0, "m": 18.0}, + "M24": {"s": 36.0, "m": 21.5}, + "M30": {"s": 46.0, "m": 25.6}, + "M36": {"s": 55.0, "m": 31.0}, +} + +#: ISO 7089:2000 Table 1 - d1 = bore, d2 = outside diameter, h = thickness. +WASHER: dict[str, dict[str, float]] = { + "M5": {"d1": 5.3, "d2": 10.0, "h": 1.0}, + "M6": {"d1": 6.4, "d2": 12.0, "h": 1.6}, + "M8": {"d1": 8.4, "d2": 16.0, "h": 1.6}, + "M10": {"d1": 10.5, "d2": 20.0, "h": 2.0}, + "M12": {"d1": 13.0, "d2": 24.0, "h": 2.5}, + "M16": {"d1": 17.0, "d2": 30.0, "h": 3.0}, + "M20": {"d1": 21.0, "d2": 37.0, "h": 3.0}, + "M24": {"d1": 25.0, "d2": 44.0, "h": 4.0}, + "M30": {"d1": 31.0, "d2": 56.0, "h": 4.0}, + "M36": {"d1": 37.0, "d2": 66.0, "h": 5.0}, +} + +#: ISO 4762:2004 Table 1 - dk = head diameter (max), k = head height (max), +#: s = hexagon socket across flats, b = thread length. +SOCKET_HEAD: dict[str, dict[str, float]] = { + "M3": {"dk": 5.5, "k": 3.0, "s": 2.5, "b": 18.0}, + "M4": {"dk": 7.0, "k": 4.0, "s": 3.0, "b": 20.0}, + "M5": {"dk": 8.5, "k": 5.0, "s": 4.0, "b": 22.0}, + "M6": {"dk": 10.0, "k": 6.0, "s": 5.0, "b": 24.0}, + "M8": {"dk": 13.0, "k": 8.0, "s": 6.0, "b": 28.0}, + "M10": {"dk": 16.0, "k": 10.0, "s": 8.0, "b": 32.0}, + "M12": {"dk": 18.0, "k": 12.0, "s": 10.0, "b": 36.0}, + "M16": {"dk": 24.0, "k": 16.0, "s": 14.0, "b": 44.0}, + "M20": {"dk": 30.0, "k": 20.0, "s": 17.0, "b": 52.0}, + "M24": {"dk": 36.0, "k": 24.0, "s": 19.0, "b": 60.0}, +} + +_TABLES: dict[str, tuple[dict[str, dict[str, float]], str]] = { + "ISO 4014": (HEX_HEAD, SOURCE_ISO_4014), + "ISO 4017": (HEX_HEAD, SOURCE_ISO_4017), + "ISO 4032": (HEX_NUT, SOURCE_ISO_4032), + "ISO 7089": (WASHER, SOURCE_ISO_7089), + "ISO 4762": (SOCKET_HEAD, SOURCE_ISO_4762), +} + +THREAD_LENGTH_STANDARDS = ("ISO 4014", "ISO 4017", "ISO 4762") + + +def _key(size) -> str: + key = str(size).strip().upper() + if not key: + raise ValueError("fastener size must be a non-empty string such as 'M12'") + return key + + +def _row(table: dict, standard: str, source: str, key: str) -> dict: + if key not in table: + raise ValueError( + f"{standard} is transcribed here for {', '.join(table)} only; {key!r} is " + f"outside that table and is never interpolated ({source})." + ) + return dict(table[key]) + + +def nominal_diameter(size) -> float: + """Nominal thread diameter in mm, e.g. ``'M12' -> 12.0``.""" + key = _key(size) + if key not in COARSE_PITCH: + raise ValueError( + f"{SOURCE_ISO_261} is transcribed here for {', '.join(COARSE_PITCH)}; " + f"{key!r} is outside it." + ) + return float(key[1:]) + + +def thread_pitch(size) -> float: + """ISO 261 coarse pitch in mm.""" + key = _key(size) + if key not in COARSE_PITCH: + raise ValueError( + f"{SOURCE_ISO_261} is transcribed here for {', '.join(COARSE_PITCH)}; " + f"{key!r} is outside it." + ) + return COARSE_PITCH[key] + + +def hex_head(size) -> dict: + """ISO 4014 / ISO 4017 head row plus the nominal diameter and pitch.""" + key = _key(size) + row = _row(HEX_HEAD, "ISO 4014 / ISO 4017", SOURCE_ISO_4014, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def hex_nut(size) -> dict: + """ISO 4032 nut row plus the nominal diameter and pitch.""" + key = _key(size) + row = _row(HEX_NUT, "ISO 4032", SOURCE_ISO_4032, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def washer(size) -> dict: + """ISO 7089 washer row plus the mating thread's nominal diameter and pitch.""" + key = _key(size) + row = _row(WASHER, "ISO 7089", SOURCE_ISO_7089, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def socket_head(size) -> dict: + """ISO 4762 socket head cap screw row plus the nominal diameter and pitch.""" + key = _key(size) + row = _row(SOCKET_HEAD, "ISO 4762", SOURCE_ISO_4762, key) + return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row} + + +def thread_length(size, length: float, standard: str = "ISO 4014") -> float: + """Useful thread length ``b`` for a nominal length ``l``, per the part standard. + + ISO 4014 fixes ``b`` by rule rather than by table: ``2d + 6`` for + ``l <= 125``, ``2d + 12`` for ``125 < l <= 200`` and ``2d + 25`` for + ``l > 200``. ISO 4017 screws are threaded over their whole length. ISO 4762 + tabulates ``b`` per size. In every case a screw shorter than its own ``b`` + is threaded to the head, so the result is capped at ``l``. + """ + key = _key(size) + name = " ".join(str(standard).split()) + if name not in THREAD_LENGTH_STANDARDS: + raise ValueError( + f"thread_length: standard must be one of {', '.join(THREAD_LENGTH_STANDARDS)}, " + f"got {standard!r}." + ) + if isinstance(length, bool) or not isinstance(length, (int, float)): + raise ValueError(f"thread_length: length must be a number, got {length!r}") + l_mm = float(length) + if not math.isfinite(l_mm) or l_mm <= 0.0: + raise ValueError(f"thread_length: length must be finite and > 0, got {length!r}") + if name == "ISO 4017": + hex_head(key) + return l_mm + if name == "ISO 4762": + return min(socket_head(key)["b"], l_mm) + diameter = nominal_diameter(key) + hex_head(key) # ISO 4014 coverage: refuse a size the head table does not carry + if l_mm <= 125.0: + b = 2.0 * diameter + 6.0 + elif l_mm <= 200.0: + b = 2.0 * diameter + 12.0 + else: + b = 2.0 * diameter + 25.0 + return min(b, l_mm) + + +def across_corners(s: float) -> float: + """Across-corners width of the *nominal* hexagon: ``2s/sqrt(3)``. + + Derived geometry, not a transcribed column - see the module docstring. + """ + if isinstance(s, bool) or not isinstance(s, (int, float)): + raise ValueError(f"across_corners: s must be a number, got {s!r}") + width = float(s) + if not math.isfinite(width) or width <= 0.0: + raise ValueError(f"across_corners: s must be finite and > 0, got {s!r}") + return 2.0 * width / math.sqrt(3.0) + + +def sizes(standard: str) -> tuple[str, ...]: + """The transcribed sizes of one fastener standard, in table order.""" + name = " ".join(str(standard).split()).upper() + if name not in _TABLES: + raise ValueError( + f"sizes: {standard!r} is not a fastener standard in this module; " + f"have {', '.join(_TABLES)}." + ) + return tuple(_TABLES[name][0]) + + +register("ISO 4014", HEX_HEAD, Coverage(5.0, 36.0), SOURCE_ISO_4014) +register("ISO 4017", HEX_HEAD, Coverage(5.0, 36.0), SOURCE_ISO_4017) +register("ISO 4032", HEX_NUT, Coverage(5.0, 36.0), SOURCE_ISO_4032) +register("ISO 7089", WASHER, Coverage(5.0, 36.0), SOURCE_ISO_7089) +register("ISO 4762", SOCKET_HEAD, Coverage(3.0, 24.0), SOURCE_ISO_4762) +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_fasteners.py -q` +Expected: PASS — 12 passed. + +- [ ] **Step 5: Write the failing test for the bearing table** + +Create `tests/test_mech_bearings.py`: + +```python +"""ISO 15 boundary dimensions and the ISO 8826 representation conventions.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.standards import standards +from engineering.mech.standards.bearings import ( + BALL_SYMBOL_FRACTION, + BEARING_SERIES, + CROSS_FRACTION, + DEEP_GROOVE, + SOURCE_ISO_15, + SOURCE_ISO_8826_1, + SOURCE_ISO_8826_2, + bearing, + list_bearings, +) + + +@pytest.mark.parametrize( + ("designation", "bore", "outer", "width"), + [ + ("6000", 10.0, 26.0, 8.0), + ("6004", 20.0, 42.0, 12.0), + ("6006", 30.0, 55.0, 13.0), + ("6010", 50.0, 80.0, 16.0), + ("6200", 10.0, 30.0, 9.0), + ("6203", 17.0, 40.0, 12.0), + ("6205", 25.0, 52.0, 15.0), + ("6208", 40.0, 80.0, 18.0), + ("6210", 50.0, 90.0, 20.0), + ("6300", 10.0, 35.0, 11.0), + ("6305", 25.0, 62.0, 17.0), + ("6309", 45.0, 100.0, 25.0), + ("6310", 50.0, 110.0, 27.0), + ], +) +def test_named_rows_match_iso15(designation, bore, outer, width): + row = bearing(designation) + assert (row["d"], row["D"], row["B"]) == (bore, outer, width) + assert row["designation"] == designation + assert row["source"] == SOURCE_ISO_15 + + +def test_bore_number_rule_holds_across_every_series(): + """ISO 15: bore 00/01/02/03 = 10/12/15/17 mm; 04 and up = 5 x bore number.""" + for series in ("60", "62", "63"): + assert bearing(f"{series}00")["d"] == 10.0 + assert bearing(f"{series}01")["d"] == 12.0 + assert bearing(f"{series}02")["d"] == 15.0 + assert bearing(f"{series}03")["d"] == 17.0 + for n in range(4, 11): + assert bearing(f"{series}{n:02d}")["d"] == 5.0 * n + + +def test_outside_diameter_and_width_grow_with_the_series(): + for n in range(0, 11): + light = bearing(f"60{n:02d}") + medium = bearing(f"62{n:02d}") + heavy = bearing(f"63{n:02d}") + assert light["d"] == medium["d"] == heavy["d"] + assert light["D"] < medium["D"] < heavy["D"] + assert light["B"] < medium["B"] < heavy["B"] + + +def test_coverage_refusals_name_the_table(): + with pytest.raises(ValueError, match="ISO 15"): + bearing("6011") + with pytest.raises(ValueError, match="ISO 15"): + bearing("6211") + with pytest.raises(ValueError, match="ISO 15"): + bearing("6311") + with pytest.raises(ValueError, match="ISO 15"): + bearing("61805") # the 618x light series is not transcribed + with pytest.raises(ValueError, match="ISO 15"): + bearing("6205-2RS") # a seal suffix is a variant this table does not carry + + +def test_representation_fractions_are_declared_conventions_not_table_values(): + assert 0.0 < CROSS_FRACTION < 1.0 + assert 0.0 < BALL_SYMBOL_FRACTION < 1.0 + assert "ISO 8826-1" in SOURCE_ISO_8826_1 + assert "ISO 8826-2" in SOURCE_ISO_8826_2 + assert "ISO 15:2017" in SOURCE_ISO_15 + + +def test_the_element_symbol_always_fits_inside_the_ring_rectangle(): + """The declared fractions must never draw a symbol outside the envelope.""" + for name in DEEP_GROOVE: + row = bearing(name) + half_width = row["B"] / 2.0 + half_gap = (row["D"] - row["d"]) / 4.0 + assert BALL_SYMBOL_FRACTION * half_gap < half_width + assert CROSS_FRACTION * half_width < half_width + assert CROSS_FRACTION * half_gap < half_gap + + +def test_list_bearings_filters_by_series(): + assert len(DEEP_GROOVE) == 33 + assert len(list_bearings()) == 33 + assert BEARING_SERIES == ("600x", "620x", "630x") + assert {row["designation"] for row in list_bearings("620x")} == { + f"62{n:02d}" for n in range(11) + } + with pytest.raises(ValueError, match="620x"): + list_bearings("622x") + + +def test_the_table_registers_itself(): + assert "ISO 15" in standards() +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_bearings.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.standards.bearings'` + +- [ ] **Step 7: Write `engineering/mech/standards/bearings.py`** + +```python +"""ISO 15 deep groove ball bearings, plus the ISO 8826 representation rules. + +SOURCE +------ +* ``DEEP_GROOVE`` - ISO 15:2017 Table 4: boundary dimensions ``d`` (bore), + ``D`` (outside diameter) and ``B`` (width) for the radial bearings of + dimension series 10 (600x), 02 (620x) and 03 (630x). +* ``SOURCE_ISO_8826_1`` - ISO 8826-1:1989, general simplified representation: + the bearing is drawn as its envelope outline with an upright cross inside + that does not touch the outline. +* ``SOURCE_ISO_8826_2`` - ISO 8826-2:1994, particular simplified + representation: the envelope outline plus a symbol for the rolling element. + +COVERAGE +-------- +600x, 620x and 630x, bore numbers 00..10 (d = 10..50 mm) - 33 rows. Bore +numbers 00/01/02/03 are 10/12/15/17 mm by the ISO 15 rule; 04 and up are +``5 x bore number``. Deliberately **not** transcribed, and refused by name: +bore numbers above 10, the wide series 622x/623x, the light series 618x/619x +and 16xx, the inch series, and every suffixed variant (``6205-2RS``, +``6205-Z``) - a suffix changes width and sealing, not a number this module may +guess. + +The chamfer dimensions ``r_s`` are not transcribed either: neither simplified +representation needs them, so no row carries a value nobody checked. + +CROSS_FRACTION and BALL_SYMBOL_FRACTION are **declared drawing conventions**, +not transcribed dimensions. ISO 8826-1 fixes the *form* of the cross and +requires only that it not touch the outline; ISO 15 carries no ball diameter +at all, so the element symbol is sized as a fraction of the ring gap rather +than invented as a real ball. +""" + +from __future__ import annotations + +from . import Coverage, register + +SOURCE_ISO_15 = ( + "ISO 15:2017 Table 4 - rolling bearings, radial bearings, boundary dimensions " + "(dimension series 10, 02, 03)" +) +SOURCE_ISO_8826_1 = ( + "ISO 8826-1:1989 - technical drawings, rolling bearings, general simplified representation" +) +SOURCE_ISO_8826_2 = ( + "ISO 8826-2:1994 - technical drawings, rolling bearings, particular simplified representation" +) + +#: Fraction of the half-width / half-gap used by the ISO 8826-1 upright cross. +#: A declared convention (see the module docstring), never a table value. +CROSS_FRACTION = 0.6 + +#: Fraction of the half-gap used by the ISO 8826-2 rolling-element symbol. +#: A declared convention, never a ball diameter. +BALL_SYMBOL_FRACTION = 0.6 + +BEARING_SERIES = ("600x", "620x", "630x") +_SERIES_PREFIX = {"60": "600x", "62": "620x", "63": "630x"} + +#: ISO 15:2017 Table 4 - d, D, B in millimetres. +DEEP_GROOVE: dict[str, dict[str, float]] = { + # dimension series 10 (600x) + "6000": {"d": 10.0, "D": 26.0, "B": 8.0}, + "6001": {"d": 12.0, "D": 28.0, "B": 8.0}, + "6002": {"d": 15.0, "D": 32.0, "B": 9.0}, + "6003": {"d": 17.0, "D": 35.0, "B": 10.0}, + "6004": {"d": 20.0, "D": 42.0, "B": 12.0}, + "6005": {"d": 25.0, "D": 47.0, "B": 12.0}, + "6006": {"d": 30.0, "D": 55.0, "B": 13.0}, + "6007": {"d": 35.0, "D": 62.0, "B": 14.0}, + "6008": {"d": 40.0, "D": 68.0, "B": 15.0}, + "6009": {"d": 45.0, "D": 75.0, "B": 16.0}, + "6010": {"d": 50.0, "D": 80.0, "B": 16.0}, + # dimension series 02 (620x) + "6200": {"d": 10.0, "D": 30.0, "B": 9.0}, + "6201": {"d": 12.0, "D": 32.0, "B": 10.0}, + "6202": {"d": 15.0, "D": 35.0, "B": 11.0}, + "6203": {"d": 17.0, "D": 40.0, "B": 12.0}, + "6204": {"d": 20.0, "D": 47.0, "B": 14.0}, + "6205": {"d": 25.0, "D": 52.0, "B": 15.0}, + "6206": {"d": 30.0, "D": 62.0, "B": 16.0}, + "6207": {"d": 35.0, "D": 72.0, "B": 17.0}, + "6208": {"d": 40.0, "D": 80.0, "B": 18.0}, + "6209": {"d": 45.0, "D": 85.0, "B": 19.0}, + "6210": {"d": 50.0, "D": 90.0, "B": 20.0}, + # dimension series 03 (630x) + "6300": {"d": 10.0, "D": 35.0, "B": 11.0}, + "6301": {"d": 12.0, "D": 37.0, "B": 12.0}, + "6302": {"d": 15.0, "D": 42.0, "B": 13.0}, + "6303": {"d": 17.0, "D": 47.0, "B": 14.0}, + "6304": {"d": 20.0, "D": 52.0, "B": 15.0}, + "6305": {"d": 25.0, "D": 62.0, "B": 17.0}, + "6306": {"d": 30.0, "D": 72.0, "B": 19.0}, + "6307": {"d": 35.0, "D": 80.0, "B": 21.0}, + "6308": {"d": 40.0, "D": 90.0, "B": 23.0}, + "6309": {"d": 45.0, "D": 100.0, "B": 25.0}, + "6310": {"d": 50.0, "D": 110.0, "B": 27.0}, +} + + +def bearing(designation) -> dict: + """Boundary dimensions of one deep groove ball bearing. + + Returns ``{"designation", "series", "source", "d", "D", "B"}``. A + designation outside the transcribed table is refused by name; nothing is + interpolated between series or bore numbers. + """ + key = " ".join(str(designation).split()).upper() + if key not in DEEP_GROOVE: + raise ValueError( + "ISO 15 deep groove ball bearings are transcribed here for the 600x, 620x " + f"and 630x series, bore numbers 00-10 (d = 10-50 mm); {key!r} is outside " + f"that table and is never interpolated ({SOURCE_ISO_15})." + ) + return { + "designation": key, + "series": _SERIES_PREFIX[key[:2]], + "source": SOURCE_ISO_15, + **DEEP_GROOVE[key], + } + + +def list_bearings(series: str | None = None) -> tuple[dict, ...]: + """Every transcribed bearing, optionally restricted to one series.""" + rows = tuple(bearing(name) for name in DEEP_GROOVE) + if series is None: + return rows + name = " ".join(str(series).split()).lower() + if name not in BEARING_SERIES: + raise ValueError( + f"list_bearings: series must be one of {', '.join(BEARING_SERIES)}, got {series!r}." + ) + return tuple(row for row in rows if row["series"] == name) + + +register("ISO 15", DEEP_GROOVE, Coverage(10.0, 50.0), SOURCE_ISO_15) +``` + +- [ ] **Step 8: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_bearings.py -q` +Expected: PASS — 20 passed (13 parametrised rows + 7 tests). + +- [ ] **Step 9: Gates** + +Run each of these and require it clean: + +``` +uv run --frozen ruff check engineering/mech/standards/fasteners.py engineering/mech/standards/bearings.py tests/test_mech_fasteners.py tests/test_mech_bearings.py +uv run --frozen ruff format engineering/mech/standards/fasteners.py engineering/mech/standards/bearings.py tests/test_mech_fasteners.py tests/test_mech_bearings.py +uv run --frozen pytest tests/test_mech_fasteners.py tests/test_mech_bearings.py -q +uv run --frozen pytest tests/test_capability_contract.py tests/test_tool_registry.py tests/test_discovery_aliases.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +No tool is registered in this task, so the inventory / README / registry-snapshot +gates do not apply yet; they run in Task 9. The deselect is the known-red +collected-tests drift named in this plan's Global Constraints — record it in +`deviations`. + +- [ ] **Step 10: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-8): ISO fastener and ISO 15 bearing tables, transcribed and pinned + +engineering/mech/standards/fasteners.py carries ISO 4014:2011 / ISO 4017:2014 +head dimensions (s, k), ISO 4032:2012 nut heights, ISO 7089:2000 washer +d1/d2/h and ISO 4762:2004 socket head cap screws, plus the ISO 261:1998 coarse +pitch column. bearings.py carries the ISO 15:2017 deep groove boundary +dimensions for 6000-6010, 6200-6210 and 6300-6310. + +Only the ISO 261 first-choice sizes ship. M14/M18/M22/M27/M33, everything above +M36 (above M24 for ISO 4762), the 618x/619x/16xx bearing series, bore numbers +above 10 and every suffixed bearing variant are refused by name rather than +interpolated: a narrow table is merely narrow, a wrong row ships a wrong +workshop drawing. Across-corners e is derived geometry (2s/sqrt(3)), so no e +column had to be transcribed at all, and the ISO 8826-1 cross and ISO 8826-2 +element-symbol fractions are declared drawing conventions, not table values. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 9: SECTION 22 — std_part_list, std_part_insert, std_feature_draw + +**Files:** +- Create: `engineering/mech/stdparts.py` +- Modify: `server.py` (SECTION 22, "(3 tools)" banner + the three `@mcp.tool` registrations, `mech` added to `_GROUP_TAG_PRIORITY` / `_GROUP_TAG_LABELS` / `TOOL_PACK_NAMES` / `PACK_TOOL_NAMES`) +- Modify: `discovery/aliases.py` (three alias records under a new `# ── Standard parts (SECTION 22) ──` block) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release-snapshot tool count 204 → 207; the "N tools in N groups" caption 204/23 → 207/24) +- Modify: `tests/test_tool_registry.py` (frozen group-size snapshot gains `"mech": 3`, total 204 → 207) +- Test: `tests/test_mech_stdparts.py` +- Test: `tests/test_mech_std_features.py` + +**Interfaces:** +- Consumes (Task 8, this group): `engineering.mech.standards.fasteners.{hex_head, hex_nut, washer, socket_head, thread_length, thread_pitch, across_corners, sizes, SOURCE_ISO_4014, SOURCE_ISO_4017, SOURCE_ISO_4032, SOURCE_ISO_7089, SOURCE_ISO_4762}`; `engineering.mech.standards.bearings.{bearing, list_bearings, CROSS_FRACTION, BALL_SYMBOL_FRACTION, SOURCE_ISO_15, SOURCE_ISO_8826_1, SOURCE_ISO_8826_2}`. +- Consumes (Task 1, on `main`): `engineering.mech.xdata.{APP_ID, PAYLOAD_VERSION, to_values, decode}` — `to_values(payload) -> list[str]` is the chunk-strings helper `entity_set_xdata` takes, so this module does not re-derive it from `encode` — and `engineering.mech.standards.lookup(standard, size, **kw) -> dict`. +- Consumes (backend contract, unchanged): `block_list()`, `block_define(name, entities, attdefs, base_x, base_y, overwrite, create_layers)`, `block_insert(name, x, y, sx, sy, rotation, attributes, layer)`, `entity_set_xdata(handle, app_name, values)`, `entity_create_line/circle/arc/polyline`. +- Produces (`engineering/mech/stdparts.py`): + - `FAMILY_STANDARDS: dict[str, str]`, `FAMILY_VIEWS: dict[str, tuple[str, ...]]`, `LENGTH_REQUIRED: frozenset[str]`, `STD_FEATURE_KINDS: tuple[str, ...]` — named apart from `engineering.mech.features.FEATURE_KINDS` (Task 3), which is a different and longer tuple: this one is the five *standard* features `std_feature_draw` can add to geometry that already exists. + - `parse_designation(designation: str) -> dict` + - `catalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...]` + - `block_spec(designation: str, view: str) -> dict` + - `async insert_std_part(backend, designation, *, at, view="side", rotation=0.0, layer="GEOMETRY", attributes=None, material=None) -> dict` — the skeleton's pinned signature plus one additive keyword-only `material`, because the pinned `std_part` XDATA payload carries a `material` field and `attributes={"MAT": ...}` is the roundabout way to supply it; every call written against the pinned signature still works unchanged. + - `feature_spec(kind: str, params: dict) -> dict`, `feature_prims(kind: str, params: dict) -> tuple[dict, ...]` + - `place_prims(prims, at, rotation=0.0) -> tuple[dict, ...]` + - `async draw_std_feature(backend, kind, at, params, *, rotation=0.0, layer=None) -> dict` +- Required row keys this task reads out of the shared registry (pinned here because the + skeleton does not pin them; a row missing one is refused by name, never guessed): + `DIN 471` / `DIN 472` → `m`, `d2`; `ISO 3601-2` → `b`, `h`; `DIN 332-A` → `d1`, `d2`, `t`; + `DIN 332-B` → `d1`, `d2`, `d3`, `t`; `DIN 509-E` / `DIN 509-F` → `profile` + (a sequence of `[along, depth]` pairs in mm from the shoulder corner). + +- [ ] **Step 1: Write the failing test for the catalogue and the block specs** + +Create `tests/test_mech_stdparts.py`: + +```python +"""The standard-parts catalogue: search, designations, block specs, insertion. + +Geometry is asserted as emitted primitive dicts - counts, layers, coordinates - +never as a screenshot. +""" + +from __future__ import annotations + +import math + +import pytest + +from backends.block_specs import validate_attdef_specs, validate_entity_specs +from engineering.mech.stdparts import ( + FAMILY_STANDARDS, + FAMILY_VIEWS, + THREAD_MINOR_RATIO, + block_spec, + catalogue, + parse_designation, +) + +R12 = 2.0 * 18.0 / math.sqrt(3.0) / 2.0 # across-corners radius of an M12 hex head +MINOR12 = THREAD_MINOR_RATIO * 12.0 / 2.0 # ISO 6410 minor radius of an M12 thread + + +def test_the_iso6410_minor_radius_is_four_point_eight(): + """Pinned as the builder computes it: 0.8 * 12 / 2 is 4.800000000000001 in binary.""" + assert MINOR12 == pytest.approx(4.8) + + +def test_the_catalogue_holds_every_transcribed_size(): + rows = catalogue() + assert len(rows) == 83 # 5 fastener standards x 10 sizes + 33 bearings + assert len(catalogue(standard="ISO 15")) == 33 + assert len(catalogue("M12")) == 5 + assert len(catalogue("6205")) == 1 + assert {row["standard"] for row in rows} == set(FAMILY_STANDARDS) + + +def test_a_catalogue_row_carries_what_a_drawing_needs(): + (row,) = catalogue("6205") + assert row["designation"] == "ISO 15 - 6205" + assert row["standard"] == "ISO 15" + assert row["family"] == "deep_groove_bearing" + assert row["views"] == ["simplified", "detailed"] + assert row["length_required"] is False + assert row["dims"] == {"d": 25.0, "D": 52.0, "B": 15.0} + assert "ISO 15:2017" in row["source"] + + bolt = next(r for r in catalogue(standard="ISO 4014") if r["size"] == "M12") + assert bolt["length_required"] is True + assert bolt["dims"]["s"] == 18.0 and bolt["dims"]["k"] == 7.5 + + +def test_catalogue_refuses_an_unknown_standard(): + with pytest.raises(ValueError, match="ISO 4014"): + catalogue(standard="DIN 931") + + +def test_parse_designation_normalises_and_splits(): + assert parse_designation("iso4014 - m12x60") == { + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "family": "hex_bolt", + "size": "M12x60", + "thread": "M12", + "length": 60.0, + } + assert parse_designation("6205")["designation"] == "ISO 15 - 6205" + assert parse_designation("ISO 4032 - M12")["length"] is None + + +def test_parse_designation_refuses_a_missing_or_stray_length(): + with pytest.raises(ValueError, match="nominal length"): + parse_designation("ISO 4014 - M12") + with pytest.raises(ValueError, match="takes no length"): + parse_designation("ISO 4032 - M12x60") + with pytest.raises(ValueError, match="catalogue standard"): + parse_designation("ISO 9999 - M12") + + +def test_an_unknown_size_is_refused_with_the_nearest_entries(): + with pytest.raises(ValueError, match=r"ISO 4014 - M12"): + block_spec("ISO 4014 - M13x60", "side") + with pytest.raises(ValueError, match=r"ISO 15 - 6210"): + block_spec("ISO 15 - 6211", "simplified") + with pytest.raises(ValueError, match="approximately"): + block_spec("ISO 4014 - M13x60", "side") + + +def test_hex_bolt_side_view_is_the_iso6410_representation(): + spec = block_spec("ISO 4014 - M12x60", "side") + assert spec["name"] == "STD_ISO_4014_M12X60_SIDE" + assert spec["view"] == "side" + assert spec["dims"]["b"] == pytest.approx(30.0) # 2d + 6 for l <= 125 + ents = spec["entities"] + assert len(ents) == 10 + assert ents[0]["type"] == "polyline" and ents[0]["closed"] is True + assert ents[0]["points"] == [ + [-7.5, -R12], + [0.0, -R12], + [0.0, R12], + [-7.5, R12], + ] + assert ents[3] == { + "type": "line", + "x1": 0.0, + "y1": 6.0, + "x2": 60.0, + "y2": 6.0, + "layer": "GEOMETRY", + } + # ISO 6410: minor diameter at 0.8 x major, over the useful thread length. + assert ents[6] == { + "type": "line", + "x1": 30.0, + "y1": MINOR12, + "x2": 60.0, + "y2": MINOR12, + "layer": "GEOMETRY", + } + # ISO 6410: the thread-length limit is a line to the major diameter. + assert ents[8] == { + "type": "line", + "x1": 30.0, + "y1": -6.0, + "x2": 30.0, + "y2": 6.0, + "layer": "GEOMETRY", + } + assert ents[9]["layer"] == "CENTER" + assert spec["bbox"] == pytest.approx((-10.5, -R12, 63.0, R12)) + + +def test_a_fully_threaded_screw_has_no_thread_limit_line(): + spec = block_spec("ISO 4017 - M12x60", "side") + assert len(spec["entities"]) == 9 + assert spec["dims"]["b"] == pytest.approx(60.0) + minor = [e for e in spec["entities"] if e.get("y1") == MINOR12] + assert minor[0]["x1"] == 0.0 and minor[0]["x2"] == 60.0 + + +def test_hex_top_view_uses_the_three_quarter_minor_arc(): + spec = block_spec("ISO 4014 - M12x60", "top") + ents = spec["entities"] + assert len(ents) == 5 + assert ents[0]["type"] == "polyline" and len(ents[0]["points"]) == 6 + assert ents[1] == {"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0, "layer": "GEOMETRY"} + assert ents[2] == { + "type": "arc", + "cx": 0.0, + "cy": 0.0, + "r": MINOR12, + "start_deg": 90.0, + "end_deg": 360.0, + "layer": "GEOMETRY", + } + assert ents[3]["layer"] == "CENTER" and ents[4]["layer"] == "CENTER" + + +def test_hex_nut_side_view_shows_the_thread_as_hidden_lines(): + spec = block_spec("ISO 4032 - M12", "side") + ents = spec["entities"] + assert len(ents) == 8 + hidden = [e for e in ents if e["layer"] == "HIDDEN"] + assert len(hidden) == 4 + assert {abs(e["y1"]) for e in hidden} == {6.0, MINOR12} + assert ents[0]["points"][1] == [10.8, -R12] # nut height m + + +def test_washer_views(): + side = block_spec("ISO 7089 - M12", "side") + assert len(side["entities"]) == 3 + assert side["entities"][0]["points"] == [[0.0, 6.5], [2.5, 6.5], [2.5, 12.0], [0.0, 12.0]] + top = block_spec("ISO 7089 - M12", "top") + assert top["entities"][0] == { + "type": "circle", + "cx": 0.0, + "cy": 0.0, + "r": 12.0, + "layer": "GEOMETRY", + } + assert top["entities"][1]["r"] == 6.5 + + +def test_socket_head_screw_views(): + side = block_spec("ISO 4762 - M8x30", "side") + assert len(side["entities"]) == 8 + assert side["dims"]["b"] == pytest.approx(28.0) + assert side["entities"][0]["points"][0] == [-8.0, -6.5] # k = 8, dk/2 = 6.5 + top = block_spec("ISO 4762 - M8x30", "top") + assert top["entities"][0]["r"] == 6.5 + assert len(top["entities"][1]["points"]) == 6 # the hexagon socket, s = 6 + + +def test_bearing_simplified_is_the_iso8826_1_outline_and_cross(): + spec = block_spec("ISO 15 - 6205", "simplified") + ents = spec["entities"] + assert len(ents) == 7 + assert ents[0]["points"] == [[-7.5, 12.5], [7.5, 12.5], [7.5, 26.0], [-7.5, 26.0]] + assert ents[1]["points"] == [[-7.5, -12.5], [7.5, -12.5], [7.5, -26.0], [-7.5, -26.0]] + assert ents[2] == { + "type": "line", + "x1": -4.5, + "y1": 19.25, + "x2": 4.5, + "y2": 19.25, + "layer": "GEOMETRY", + } + assert ents[3] == { + "type": "line", + "x1": 0.0, + "y1": 15.2, + "x2": 0.0, + "y2": 23.3, + "layer": "GEOMETRY", + } + assert ents[6]["layer"] == "CENTER" + + +def test_bearing_detailed_draws_the_element_symbol(): + spec = block_spec("ISO 15 - 6205", "detailed") + ents = spec["entities"] + assert len(ents) == 5 + assert ents[2] == { + "type": "circle", + "cx": 0.0, + "cy": 19.25, + "r": 4.05, + "layer": "GEOMETRY", + } + assert ents[3]["cy"] == -19.25 + + +def test_a_view_the_family_does_not_have_is_refused_and_side_falls_back(): + assert block_spec("ISO 15 - 6205", "side")["view"] == "simplified" + with pytest.raises(ValueError, match="simplified"): + block_spec("ISO 15 - 6205", "top") + with pytest.raises(ValueError, match="side"): + block_spec("ISO 4014 - M12x60", "simplified") + + +@pytest.mark.parametrize( + ("designation", "view"), + [ + ("ISO 4014 - M12x60", "side"), + ("ISO 4014 - M12x60", "top"), + ("ISO 4017 - M12x60", "side"), + ("ISO 4032 - M12", "side"), + ("ISO 4032 - M12", "top"), + ("ISO 7089 - M12", "side"), + ("ISO 7089 - M12", "top"), + ("ISO 4762 - M8x30", "side"), + ("ISO 4762 - M8x30", "top"), + ("ISO 15 - 6205", "simplified"), + ("ISO 15 - 6205", "detailed"), + ], +) +def test_every_block_spec_validates_against_block_define(designation, view): + spec = block_spec(designation, view) + validate_entity_specs(spec["entities"]) + validate_attdef_specs(spec["attdefs"]) + assert [att["tag"] for att in spec["attdefs"]] == ["DESIG", "STD", "SIZE", "MAT"] + assert spec["attdefs"][0]["default"] == spec["designation"] + assert spec["attdefs"][0]["invisible"] is False + assert all(att["invisible"] for att in spec["attdefs"][1:]) + assert spec["bbox"][0] < spec["bbox"][2] and spec["bbox"][1] < spec["bbox"][3] + assert set(FAMILY_VIEWS[spec["family"]]) >= {spec["view"]} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_stdparts.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.stdparts'` + +- [ ] **Step 3: Write the catalogue half of `engineering/mech/stdparts.py`** + +```python +"""The standard-parts catalogue: tables in, real blocks with attributes out. + +Two halves live here. + +**Standard parts.** :func:`catalogue` searches the authored tables of +``standards/fasteners.py`` and ``standards/bearings.py``; :func:`block_spec` +turns one designation and one view into the typed primitives and ATTDEFs +``block_define`` takes; :func:`insert_std_part` defines the block once, inserts +it and writes the ``std_part`` ACADMCP_MECH payload, so a parts list is a read +of the drawing rather than a naming convention. This is the Track A (P&ID) +pattern applied to fasteners and bearings. + +**Standard features.** :func:`draw_std_feature` adds a thread, a DIN 509 +undercut, a retaining-ring groove, a DIN 332 centre hole or an ISO 3601-2 +O-ring groove to geometry that already exists - a foreign drawing being +finished. The thread is the ISO 6410 representation and its pitch comes from +ISO 261; every other feature takes its dimensions from the shared standards +registry and is refused by name when the table is absent, when the size is +outside its coverage, or when the row lacks a dimension the feature needs. +Nothing here reconstructs a standard profile it was not handed. + +Primitives are the ``block_define`` spec dicts of ``backends/block_specs.py`` +(``{"type": "line", "x1": ..., "layer": ...}``) with an explicit ``layer``, so +one vocabulary serves both the block definitions and the feature drawing, and +the pure half is testable by comparing emitted dicts. +""" + +from __future__ import annotations + +import difflib +import math +import re +from typing import TYPE_CHECKING + +from engineering.mech.standards import lookup as standards_lookup +from engineering.mech.standards.bearings import ( + BALL_SYMBOL_FRACTION, + CROSS_FRACTION, + SOURCE_ISO_15, + bearing, + list_bearings, +) +from engineering.mech.standards.fasteners import ( + SOURCE_ISO_4014, + SOURCE_ISO_4017, + SOURCE_ISO_4032, + SOURCE_ISO_4762, + SOURCE_ISO_7089, + across_corners, + hex_head, + hex_nut, + sizes, + socket_head, + thread_length, + thread_pitch, + washer, +) +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, to_values + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +LAYER_VISIBLE = "GEOMETRY" +LAYER_CENTER = "CENTER" +LAYER_HIDDEN = "HIDDEN" +TEXT_HEIGHT = 2.5 +AXIS_OVERRUN = 3.0 +DEFAULT_VIEW = "side" + +#: ISO 6410 draws the minor diameter of a thread at 0.8 x the major diameter. +THREAD_MINOR_RATIO = 0.8 + +SOURCE_ISO_6410 = ( + "ISO 6410-1:1993 - technical drawings, screw threads and threaded parts, general conventions" +) + +FAMILY_STANDARDS: dict[str, str] = { + "ISO 4014": "hex_bolt", + "ISO 4017": "hex_screw", + "ISO 4032": "hex_nut", + "ISO 7089": "washer", + "ISO 4762": "socket_head_screw", + "ISO 15": "deep_groove_bearing", +} +STANDARD_SOURCE: dict[str, str] = { + "ISO 4014": SOURCE_ISO_4014, + "ISO 4017": SOURCE_ISO_4017, + "ISO 4032": SOURCE_ISO_4032, + "ISO 7089": SOURCE_ISO_7089, + "ISO 4762": SOURCE_ISO_4762, + "ISO 15": SOURCE_ISO_15, +} +FAMILY_VIEWS: dict[str, tuple[str, ...]] = { + "hex_bolt": ("side", "top"), + "hex_screw": ("side", "top"), + "hex_nut": ("side", "top"), + "washer": ("side", "top"), + "socket_head_screw": ("side", "top"), + "deep_groove_bearing": ("simplified", "detailed"), +} +LENGTH_REQUIRED = frozenset({"hex_bolt", "hex_screw", "socket_head_screw"}) + +_THREAD_RE = re.compile(r"^(M\d{1,3})(?:\s*[X*]\s*(\d{1,4}(?:\.\d+)?))?$", re.IGNORECASE) +_BEARING_RE = re.compile(r"^\d{4}$") + + +# ── primitive helpers (block_define spec dicts, block-local mm) ───────────── + + +def _line(x1, y1, x2, y2, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "line", + "x1": float(x1), + "y1": float(y1), + "x2": float(x2), + "y2": float(y2), + "layer": layer, + } + + +def _circle(cx, cy, r, layer: str = LAYER_VISIBLE) -> dict: + return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r), "layer": layer} + + +def _arc(cx, cy, r, start_deg, end_deg, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "arc", + "cx": float(cx), + "cy": float(cy), + "r": float(r), + "start_deg": float(start_deg), + "end_deg": float(end_deg), + "layer": layer, + } + + +def _poly(points, closed: bool = True, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "polyline", + "points": [[float(x), float(y)] for x, y in points], + "closed": bool(closed), + "layer": layer, + } + + +def _hex_points(radius: float, start_deg: float = 0.0): + return [ + ( + radius * math.cos(math.radians(start_deg + 60.0 * i)), + radius * math.sin(math.radians(start_deg + 60.0 * i)), + ) + for i in range(6) + ] + + +def _centre_mark(cx: float, cy: float, extent: float) -> list[dict]: + return [ + _line(cx - extent, cy, cx + extent, cy, LAYER_CENTER), + _line(cx, cy - extent, cx, cy + extent, LAYER_CENTER), + ] + + +# ── designations and the catalogue ────────────────────────────────────────── + + +def _normalise_standard(text: str) -> str: + return re.sub(r"^(ISO)\s*", r"\1 ", " ".join(str(text).split()).upper()) + + +def parse_designation(designation: str) -> dict: + """Split a catalogue designation into standard, family, size, thread and length. + + Accepts ``'ISO 4014 - M12x60'``, ``'iso4014-m12x60'`` and a bare four-digit + bearing number (``'6205'`` becomes ``'ISO 15 - 6205'``). Returns + ``{"designation", "standard", "family", "size", "thread", "length"}`` with + a canonical designation. Every refusal names what was wrong. + """ + text = " ".join(str(designation).split()) + if not text: + raise ValueError( + "std part designation must be a non-empty string, e.g. 'ISO 4014 - M12x60'" + ) + if _BEARING_RE.match(text): + text = f"ISO 15 - {text}" + head, separator, tail = text.partition("-") + if not separator: + raise ValueError( + f"{designation!r} is not a catalogue designation: expected " + "' - ', e.g. 'ISO 4014 - M12x60' or 'ISO 15 - 6205'." + ) + standard = _normalise_standard(head) + size = " ".join(tail.split()) + if standard not in FAMILY_STANDARDS: + raise ValueError( + f"{designation!r}: {standard!r} is not a catalogue standard. " + f"The catalogue holds {', '.join(FAMILY_STANDARDS)}." + ) + family = FAMILY_STANDARDS[standard] + if family == "deep_groove_bearing": + if not _BEARING_RE.match(size): + raise ValueError( + f"{designation!r}: an ISO 15 size is a four-digit bearing designation " + f"such as '6205', got {size!r}." + ) + return { + "designation": f"{standard} - {size}", + "standard": standard, + "family": family, + "size": size, + "thread": None, + "length": None, + } + match = _THREAD_RE.match(size) + if not match: + shape = "'M12' or 'M12x60'" if family in LENGTH_REQUIRED else "'M12'" + raise ValueError( + f"{designation!r}: a {standard} size is a metric thread such as {shape}, got {size!r}." + ) + thread = match.group(1).upper() + length = float(match.group(2)) if match.group(2) else None + if family in LENGTH_REQUIRED and length is None: + raise ValueError( + f"{designation!r}: {standard} needs a nominal length - write 'M12x60'. The " + "per-size length range is not transcribed, so any finite positive length is " + "accepted and the thread length follows the standard's own b rule." + ) + if family not in LENGTH_REQUIRED and length is not None: + raise ValueError(f"{designation!r}: {standard} takes no length, got {size!r}.") + canonical = thread if length is None else f"{thread}x{length:g}" + return { + "designation": f"{standard} - {canonical}", + "standard": standard, + "family": family, + "size": canonical, + "thread": thread, + "length": length, + } + + +def catalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...]: + """Every transcribed standard part, optionally filtered. + + ``standard`` restricts to one catalogue standard (refused by name when it + is not one). ``query`` is matched, case-insensitively, as a substring of + the designation, standard, size and family, so ``'M12'``, ``'ISO 4014'``, + ``'6205'`` and ``'washer'`` all work. + """ + rows: list[dict] = [] + getters = { + "hex_bolt": hex_head, + "hex_screw": hex_head, + "hex_nut": hex_nut, + "washer": washer, + "socket_head_screw": socket_head, + } + for name, family in FAMILY_STANDARDS.items(): + if family == "deep_groove_bearing": + for row in list_bearings(): + rows.append( + { + "designation": f"{name} - {row['designation']}", + "standard": name, + "family": family, + "size": row["designation"], + "views": list(FAMILY_VIEWS[family]), + "length_required": False, + "dims": {key: row[key] for key in ("d", "D", "B")}, + "source": row["source"], + } + ) + continue + getter = getters[family] + for size in sizes(name): + dims = getter(size) + rows.append( + { + "designation": f"{name} - {size}", + "standard": name, + "family": family, + "size": size, + "views": list(FAMILY_VIEWS[family]), + "length_required": family in LENGTH_REQUIRED, + "dims": {key: value for key, value in dims.items() if key != "size"}, + "source": STANDARD_SOURCE[name], + } + ) + if standard is not None: + want = _normalise_standard(standard) + if want not in FAMILY_STANDARDS: + raise ValueError( + f"catalogue: {standard!r} is not a catalogue standard; " + f"have {', '.join(FAMILY_STANDARDS)}." + ) + rows = [row for row in rows if row["standard"] == want] + if query: + needle = " ".join(str(query).split()).lower() + rows = [ + row + for row in rows + if needle + in f"{row['designation']} {row['standard']} {row['size']} {row['family']}".lower() + ] + return tuple(rows) + + +def _numeric_key(family: str, size: str) -> float | None: + if family == "deep_groove_bearing": + return float(size) if size.isdigit() else None + match = _THREAD_RE.match(size) + if not match: + return None + try: + return float(match.group(1)[1:]) + except ValueError: # pragma: no cover - the regex already fixed the shape + return None + + +def _suggest(parsed: dict) -> str: + rows = list(catalogue(standard=parsed["standard"])) + target = _numeric_key(parsed["family"], parsed["thread"] or parsed["size"]) + if target is not None: + ranked = sorted( + rows, + key=lambda row: abs((_numeric_key(row["family"], row["size"]) or 0.0) - target), + ) + else: + names = [row["designation"] for row in rows] + close = difflib.get_close_matches(parsed["designation"], names, n=3, cutoff=0.3) + ranked = [row for row in rows if row["designation"] in close] + return ", ".join(row["designation"] for row in ranked[:3]) + + +def _resolve_dims(parsed: dict) -> dict: + family = parsed["family"] + try: + if family in ("hex_bolt", "hex_screw"): + dims = hex_head(parsed["thread"]) + elif family == "hex_nut": + dims = hex_nut(parsed["thread"]) + elif family == "washer": + dims = washer(parsed["thread"]) + elif family == "socket_head_screw": + dims = socket_head(parsed["thread"]) + else: + dims = bearing(parsed["size"]) + except ValueError as exc: + raise ValueError( + f"{parsed['designation']!r} is not in the standard-parts catalogue. {exc} " + f"Nearest entries: {_suggest(parsed)}. Nothing is drawn approximately." + ) from exc + if family in ("hex_bolt", "hex_screw", "hex_nut"): + dims["e"] = across_corners(dims["s"]) + if family in LENGTH_REQUIRED: + if family == "socket_head_screw": + dims["b_table"] = dims["b"] + dims["l"] = parsed["length"] + dims["b"] = thread_length(parsed["thread"], parsed["length"], parsed["standard"]) + return dims + + +def _resolve_view(family: str, view) -> str: + valid = FAMILY_VIEWS[family] + name = " ".join(str(view).split()).lower() + if name in valid: + return name + if name == DEFAULT_VIEW: + return valid[0] + raise ValueError(f"{family}: view must be one of {', '.join(valid)}, got {view!r}.") + + +def _block_name(standard: str, size: str, view: str) -> str: + raw = f"STD_{standard}_{size}_{view}".upper() + return re.sub(r"[^A-Z0-9]+", "_", raw).strip("_") + + +def _attdefs(parsed: dict, bbox: tuple[float, float, float, float]) -> list[dict]: + top = bbox[1] - 2.0 * TEXT_HEIGHT + rows = ( + ("DESIG", "Designation", parsed["designation"], False), + ("STD", "Standard", parsed["standard"], True), + ("SIZE", "Size", parsed["size"], True), + ("MAT", "Material or property class", "", True), + ) + return [ + { + "tag": tag, + "prompt": prompt, + "default": default, + "x": 0.0, + "y": top - index * TEXT_HEIGHT * 1.6, + "height": TEXT_HEIGHT, + "align": "center", + "invisible": invisible, + } + for index, (tag, prompt, default, invisible) in enumerate(rows) + ] + + +# ── the view builders: (entities, bbox) in block-local mm ─────────────────── + + +def _hex_fastener_side(dims: dict, *, threaded_to_head: bool): + d, s, k = dims["d"], dims["s"], dims["k"] + length, b = dims["l"], dims["b"] + r = across_corners(s) / 2.0 + minor = THREAD_MINOR_RATIO * d / 2.0 + x0 = length - b + ents = [ + _poly([(-k, -r), (0.0, -r), (0.0, r), (-k, r)]), + _line(-k, r / 2.0, 0.0, r / 2.0), + _line(-k, -r / 2.0, 0.0, -r / 2.0), + _line(0.0, d / 2.0, length, d / 2.0), + _line(0.0, -d / 2.0, length, -d / 2.0), + _line(length, -d / 2.0, length, d / 2.0), + _line(x0, minor, length, minor), + _line(x0, -minor, length, -minor), + ] + if not threaded_to_head and x0 > 1e-9: + ents.append(_line(x0, -d / 2.0, x0, d / 2.0)) + ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-k - AXIS_OVERRUN, -r, length + AXIS_OVERRUN, r) + + +def _hex_fastener_top(dims: dict): + d, s = dims["d"], dims["s"] + r = across_corners(s) / 2.0 + ents = [ + _poly(_hex_points(r)), + _circle(0.0, 0.0, d / 2.0), + _arc(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0, 90.0, 360.0), + ] + ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN) + extent = r + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _hex_nut_side(dims: dict): + d, s, m = dims["d"], dims["s"], dims["m"] + r = across_corners(s) / 2.0 + minor = THREAD_MINOR_RATIO * d / 2.0 + ents = [ + _poly([(0.0, -r), (m, -r), (m, r), (0.0, r)]), + _line(0.0, r / 2.0, m, r / 2.0), + _line(0.0, -r / 2.0, m, -r / 2.0), + _line(0.0, d / 2.0, m, d / 2.0, LAYER_HIDDEN), + _line(0.0, -d / 2.0, m, -d / 2.0, LAYER_HIDDEN), + _line(0.0, minor, m, minor, LAYER_HIDDEN), + _line(0.0, -minor, m, -minor, LAYER_HIDDEN), + _line(-AXIS_OVERRUN, 0.0, m + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-AXIS_OVERRUN, -r, m + AXIS_OVERRUN, r) + + +def _hex_nut_top(dims: dict): + d, s = dims["d"], dims["s"] + r = across_corners(s) / 2.0 + ents = [ + _poly(_hex_points(r)), + _circle(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0), + _arc(0.0, 0.0, d / 2.0, 90.0, 360.0), + ] + ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN) + extent = r + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _washer_side(dims: dict): + d1, d2, h = dims["d1"], dims["d2"], dims["h"] + ents = [ + _poly([(0.0, d1 / 2.0), (h, d1 / 2.0), (h, d2 / 2.0), (0.0, d2 / 2.0)]), + _poly([(0.0, -d1 / 2.0), (h, -d1 / 2.0), (h, -d2 / 2.0), (0.0, -d2 / 2.0)]), + _line(-AXIS_OVERRUN, 0.0, h + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-AXIS_OVERRUN, -d2 / 2.0, h + AXIS_OVERRUN, d2 / 2.0) + + +def _washer_top(dims: dict): + d1, d2 = dims["d1"], dims["d2"] + ents = [_circle(0.0, 0.0, d2 / 2.0), _circle(0.0, 0.0, d1 / 2.0)] + ents += _centre_mark(0.0, 0.0, d2 / 2.0 + AXIS_OVERRUN) + extent = d2 / 2.0 + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _socket_head_side(dims: dict): + d, dk, k = dims["d"], dims["dk"], dims["k"] + length, b = dims["l"], dims["b"] + minor = THREAD_MINOR_RATIO * d / 2.0 + x0 = length - b + ents = [ + _poly([(-k, -dk / 2.0), (0.0, -dk / 2.0), (0.0, dk / 2.0), (-k, dk / 2.0)]), + _line(0.0, d / 2.0, length, d / 2.0), + _line(0.0, -d / 2.0, length, -d / 2.0), + _line(length, -d / 2.0, length, d / 2.0), + _line(x0, minor, length, minor), + _line(x0, -minor, length, -minor), + ] + if x0 > 1e-9: + ents.append(_line(x0, -d / 2.0, x0, d / 2.0)) + ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-k - AXIS_OVERRUN, -dk / 2.0, length + AXIS_OVERRUN, dk / 2.0) + + +def _socket_head_top(dims: dict): + dk, s = dims["dk"], dims["s"] + ents = [_circle(0.0, 0.0, dk / 2.0), _poly(_hex_points(across_corners(s) / 2.0))] + ents += _centre_mark(0.0, 0.0, dk / 2.0 + AXIS_OVERRUN) + extent = dk / 2.0 + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _bearing_simplified(dims: dict): + inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0 + ents = [ + _poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]), + _poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]), + ] + arm_x = CROSS_FRACTION * half + arm_y = CROSS_FRACTION * (outer - inner) / 2.0 + middle = (inner + outer) / 2.0 + for centre in (middle, -middle): + ents.append(_line(-arm_x, centre, arm_x, centre)) + ents.append(_line(0.0, centre - arm_y, 0.0, centre + arm_y)) + ents.append(_line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer) + + +def _bearing_detailed(dims: dict): + inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0 + radius = BALL_SYMBOL_FRACTION * (outer - inner) / 2.0 + middle = (inner + outer) / 2.0 + ents = [ + _poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]), + _poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]), + _circle(0.0, middle, radius), + _circle(0.0, -middle, radius), + _line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer) + + +_BUILDERS = { + ("hex_bolt", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=False), + ("hex_bolt", "top"): _hex_fastener_top, + ("hex_screw", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=True), + ("hex_screw", "top"): _hex_fastener_top, + ("hex_nut", "side"): _hex_nut_side, + ("hex_nut", "top"): _hex_nut_top, + ("washer", "side"): _washer_side, + ("washer", "top"): _washer_top, + ("socket_head_screw", "side"): _socket_head_side, + ("socket_head_screw", "top"): _socket_head_top, + ("deep_groove_bearing", "simplified"): _bearing_simplified, + ("deep_groove_bearing", "detailed"): _bearing_detailed, +} + + +def block_spec(designation: str, view: str) -> dict: + """Typed primitives and ATTDEFs for ``block_define``, in block-local mm. + + The block's origin is the part's own datum: the head bearing face for a + bolt or cap screw, the bearing face for a nut or washer, the centre for a + bearing, with the axis along +X. Every refusal fires before any geometry + is built. + """ + parsed = parse_designation(designation) + kind = _resolve_view(parsed["family"], view) + dims = _resolve_dims(parsed) + entities, bbox = _BUILDERS[(parsed["family"], kind)](dims) + return { + "name": _block_name(parsed["standard"], parsed["size"], kind), + "entities": entities, + "attdefs": _attdefs(parsed, bbox), + "base_x": 0.0, + "base_y": 0.0, + "bbox": bbox, + "view": kind, + "designation": parsed["designation"], + "standard": parsed["standard"], + "size": parsed["size"], + "family": parsed["family"], + "dims": dims, + "source": STANDARD_SOURCE[parsed["standard"]], + } +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_stdparts.py -q` +Expected: PASS — 27 passed (11 parametrised block specs + 16 tests). + +- [ ] **Step 5: Write the failing test for insertion and the XDATA payload** + +Append to `tests/test_mech_stdparts.py`: + +```python +@pytest.mark.asyncio +async def test_insert_defines_the_block_once_and_writes_the_payload(backend): + from engineering.mech.stdparts import insert_std_part + from engineering.mech.xdata import APP_ID, decode + + result = await insert_std_part(backend, "ISO 4014 - M12x60", at=(100.0, 50.0), material="8.8") + assert result["ok"] is True + assert result["block_name"] == "STD_ISO_4014_M12X60_SIDE" + assert result["defined"] is True + assert sorted(result["layers_created"]) == ["CENTER", "GEOMETRY"] + assert result["view"] == "side" + assert result["layer"] == "GEOMETRY" + assert result["dims"]["s"] == 18.0 + + stored = await backend.entity_get_xdata(result["handle"], APP_ID) + payload = decode([(1001, APP_ID)] + [(1000, value) for value in stored["xdata"][APP_ID]]) + assert payload == { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + assert await backend.block_get_attributes(result["handle"]) == { + "DESIG": "ISO 4014 - M12x60", + "STD": "ISO 4014", + "SIZE": "M12x60", + "MAT": "8.8", + } + + again = await insert_std_part(backend, "ISO 4014 - M12x60", at=(200.0, 50.0)) + assert again["defined"] is False and again["layers_created"] == [] + assert again["payload"]["material"] == "" + + +@pytest.mark.asyncio +async def test_bearing_inserts_with_its_own_view_name(backend): + from engineering.mech.stdparts import insert_std_part + + result = await insert_std_part(backend, "6205", at=(0.0, 0.0), rotation=90.0) + assert result["block_name"] == "STD_ISO_15_6205_SIMPLIFIED" + assert result["view"] == "simplified" + assert result["rotation"] == 90.0 + assert result["dims"]["B"] == 15.0 + + +@pytest.mark.asyncio +async def test_insert_refuses_before_writing_anything(backend): + from engineering.mech.stdparts import insert_std_part + + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="Nearest entries"): + await insert_std_part(backend, "ISO 4014 - M13x60", at=(0.0, 0.0)) + with pytest.raises(ValueError, match="finite"): + await insert_std_part(backend, "ISO 4014 - M12x60", at=(float("nan"), 0.0)) + with pytest.raises(ValueError, match="no attribute"): + await insert_std_part( + backend, "ISO 4014 - M12x60", at=(0.0, 0.0), attributes={"TORQUE": "80"} + ) + assert len(await backend.entity_list()) == before +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_stdparts.py -q -k "insert"` +Expected: FAIL with +`ImportError: cannot import name 'insert_std_part' from 'engineering.mech.stdparts'` + +- [ ] **Step 7: Add `insert_std_part` to `engineering/mech/stdparts.py`** + +Append to the module: + +```python +async def insert_std_part( + backend: AutoCADBackend, + designation: str, + *, + at, + view: str = DEFAULT_VIEW, + rotation: float = 0.0, + layer: str = LAYER_VISIBLE, + attributes: dict | None = None, + material: str | None = None, +) -> dict: + """Define the part's block once, insert it, write its ``std_part`` payload. + + ``at`` is the WCS point the block's own datum lands on. The default + ``view="side"`` falls back to a family's first view (``simplified`` for a + bearing) and the result reports which view was drawn; any other mismatch is + refused. Refusals - an unknown designation with the nearest catalogue + entries, a bad view, a non-finite coordinate or rotation, an attribute tag + the block does not have - all fire before the first entity is written. + """ + spec = block_spec(designation, view) + try: + x, y = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"insert_std_part: 'at' must be two numbers, got {at!r}") from exc + if not (math.isfinite(x) and math.isfinite(y)): + raise ValueError(f"insert_std_part: 'at' must be two finite numbers, got {at!r}") + angle = float(rotation) + if not math.isfinite(angle): + raise ValueError(f"insert_std_part: rotation must be finite, got {rotation!r}") + + values = {att["tag"]: att["default"] for att in spec["attdefs"]} + supplied = dict(attributes or {}) + if material is not None: + supplied["MAT"] = material + unknown = sorted(set(supplied) - set(values)) + if unknown: + raise ValueError( + f"insert_std_part: {spec['name']} has no attribute {', '.join(unknown)}; " + f"valid tags: {', '.join(values)}." + ) + values.update({str(key): str(value) for key, value in supplied.items()}) + + existing = {block.name for block in await backend.block_list()} + defined = spec["name"] not in existing + layers_created: list[str] = [] + if defined: + created = await backend.block_define( + spec["name"], + spec["entities"], + spec["attdefs"], + spec["base_x"], + spec["base_y"], + False, + True, + ) + layers_created = list(created.get("layers_created", [])) + ref = await backend.block_insert(spec["name"], x, y, 1.0, 1.0, angle, values, layer) + payload = { + "v": PAYLOAD_VERSION, + "kind": "std_part", + "designation": spec["designation"], + "standard": spec["standard"], + "size": spec["size"], + "qty": 1, + "material": values["MAT"], + } + await backend.entity_set_xdata(ref.handle, APP_ID, to_values(payload)) + return { + "ok": True, + "handle": ref.handle, + "block_name": spec["name"], + "defined": defined, + "layers_created": layers_created, + "designation": spec["designation"], + "standard": spec["standard"], + "size": spec["size"], + "family": spec["family"], + "view": spec["view"], + "layer": layer, + "at": [x, y], + "rotation": angle, + "dims": spec["dims"], + "source": spec["source"], + "payload": payload, + "backend": backend.name, + } +``` + +- [ ] **Step 8: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_stdparts.py -q` +Expected: PASS — 30 passed. + +- [ ] **Step 9: Write the failing test for the standard features** + +Create `tests/test_mech_std_features.py`: + +```python +"""std_feature_draw: the ISO 6410 thread, and grooves drawn from registry rows. + +The DIN 471 / DIN 509 / DIN 332 / ISO 3601-2 tables themselves land with Task 3 +(group M). These tests register **synthetic** tables under obviously fake +standard names, so what is pinned here is that the feature draws exactly the +dimensions the registry publishes - never a number this module invented. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.standards import Coverage, register, standards +from engineering.mech.stdparts import ( + STD_FEATURE_KINDS, + draw_std_feature, + feature_prims, + feature_spec, + place_prims, +) + + +@pytest.fixture(scope="module", autouse=True) +def synthetic_tables(): + """Round, obviously synthetic rows - NOT DIN or ISO values.""" + if "TEST 471" in standards(): + return + register( + "TEST 471", + {30.0: {"m": 2.0, "d2": 27.0}}, + Coverage(8.0, 100.0), + "synthetic test fixture - the real DIN 471 table lands with Task 3", + ) + register( + "TEST 472", + {30.0: {"m": 2.0, "d2": 33.0}}, + Coverage(8.0, 100.0), + "synthetic test fixture - the real DIN 472 table lands with Task 3", + ) + register( + "TEST 509", + {30.0: {"profile": [[0.0, 0.0], [0.0, 0.4], [2.0, 0.4], [2.5, 0.0]]}}, + Coverage(18.0, 80.0), + "synthetic test fixture - the real DIN 509 table lands with Task 3", + ) + register( + "TEST 332 A", + {2.5: {"d1": 2.5, "d2": 5.3, "t": 5.0}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real DIN 332-1 table lands with Task 3", + ) + register( + "TEST 332 B", + {2.5: {"d1": 2.5, "d2": 5.3, "d3": 6.3, "t": 6.0}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real DIN 332-1 table lands with Task 3", + ) + register( + "TEST 3601", + {3.53: {"b": 4.7, "h": 2.7}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real ISO 3601-2 table lands with Task 3", + ) + + +def test_feature_kinds_are_the_five_the_spec_names(): + assert STD_FEATURE_KINDS == ( + "thread", + "undercut", + "ring_groove", + "centre_hole", + "oring_groove", + ) + with pytest.raises(ValueError, match="oring_groove"): + feature_prims("spline", {}) + + +def test_external_thread_is_the_iso6410_representation(): + spec = feature_spec("thread", {"d": 20.0, "length": 30.0, "size": "M20"}) + assert spec["dims"]["pitch"] == 2.5 # ISO 261 coarse, from the fastener table + assert "ISO 6410" in spec["source"] + assert spec["prims"] == ( + {"type": "line", "x1": 0.0, "y1": 8.0, "x2": 30.0, "y2": 8.0, "layer": "GEOMETRY"}, + {"type": "line", "x1": 0.0, "y1": -8.0, "x2": 30.0, "y2": -8.0, "layer": "GEOMETRY"}, + {"type": "line", "x1": 30.0, "y1": -10.0, "x2": 30.0, "y2": 10.0, "layer": "GEOMETRY"}, + ) + + +def test_internal_thread_draws_the_major_diameter_instead(): + prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5, "internal": True}) + assert prims[0]["y1"] == 10.0 and prims[1]["y1"] == -10.0 + + +def test_a_thread_without_a_size_or_a_pitch_is_refused(): + with pytest.raises(ValueError, match="never guessed"): + feature_prims("thread", {"d": 20.0, "length": 30.0}) + with pytest.raises(ValueError, match="ISO 261"): + feature_prims("thread", {"d": 14.0, "length": 30.0, "size": "M14"}) + + +def test_ring_groove_is_drawn_from_the_registry_row(): + prims = feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 471"}) + assert len(prims) == 6 + assert prims[0] == { + "type": "line", + "x1": 0.0, + "y1": 15.0, + "x2": 0.0, + "y2": 13.5, + "layer": "GEOMETRY", + } + assert prims[1] == { + "type": "line", + "x1": 0.0, + "y1": 13.5, + "x2": 2.0, + "y2": 13.5, + "layer": "GEOMETRY", + } + assert prims[3]["y1"] == -15.0 + bore = feature_prims("ring_groove", {"d": 30.0, "kind": "bore", "standard": "TEST 472"}) + assert bore[1]["y1"] == 16.5 + + +def test_ring_groove_refuses_a_row_that_is_the_wrong_way_round(): + with pytest.raises(ValueError, match="not a shaft groove"): + feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 472"}) + with pytest.raises(ValueError, match="DIN 471"): + feature_prims("ring_groove", {"d": 30.0, "kind": "shaft"}) + + +def test_oring_groove_is_a_rectangular_groove_from_the_row(): + prims = feature_prims( + "oring_groove", {"d": 30.0, "cord": 3.53, "kind": "shaft", "standard": "TEST 3601"} + ) + assert len(prims) == 6 + assert prims[0]["y1"] == 15.0 and prims[0]["y2"] == pytest.approx(12.3) + assert prims[1]["x2"] == pytest.approx(4.7) + + +def test_centre_hole_form_a_is_a_sixty_degree_countersink(): + spec = feature_spec("centre_hole", {"size": 2.5, "form": "A", "standard": "TEST 332 A"}) + cone = math.sqrt(3.0) * (5.3 - 2.5) / 2.0 + assert spec["dims"]["cone"] == pytest.approx(cone) + prims = spec["prims"] + assert len(prims) == 5 + assert prims[0] == { + "type": "line", + "x1": 0.0, + "y1": 2.65, + "x2": pytest.approx(cone), + "y2": 1.25, + "layer": "GEOMETRY", + } + assert prims[2]["x2"] == 5.0 and prims[2]["y1"] == 1.25 + assert prims[4] == { + "type": "line", + "x1": 5.0, + "y1": -1.25, + "x2": 5.0, + "y2": 1.25, + "layer": "GEOMETRY", + } + + +def test_centre_hole_form_b_adds_the_protecting_chamfer(): + prims = feature_prims("centre_hole", {"size": 2.5, "form": "B", "standard": "TEST 332 B"}) + chamfer = (6.3 - 5.3) / (2.0 * math.sqrt(3.0)) + assert len(prims) == 7 + assert prims[0]["y1"] == 3.15 # d3 / 2 + assert prims[0]["x2"] == pytest.approx(chamfer) + + +def test_centre_hole_form_r_is_refused_not_approximated(): + with pytest.raises(ValueError, match="forms A and B"): + feature_prims("centre_hole", {"size": 2.5, "form": "R", "standard": "TEST 332 A"}) + + +def test_undercut_draws_the_profile_the_table_publishes(): + prims = feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 509"}) + assert len(prims) == 2 + assert prims[0] == { + "type": "polyline", + "points": [[0.0, 15.0], [0.0, 14.6], [2.0, 14.6], [2.5, 15.0]], + "closed": False, + "layer": "GEOMETRY", + } + assert prims[1]["points"][1] == [0.0, -14.6] + + +def test_undercut_refuses_a_row_without_a_profile(): + with pytest.raises(ValueError, match="carries no profile"): + feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 471"}) + with pytest.raises(ValueError, match="DIN 509"): + feature_prims("undercut", {"d": 30.0, "form": "E"}) + + +def test_place_prims_rotates_about_the_insertion_point(): + prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5}) + placed = place_prims(prims, (100.0, 50.0), 90.0) + assert placed[0]["x1"] == pytest.approx(92.0) + assert placed[0]["y1"] == pytest.approx(50.0) + assert placed[0]["x2"] == pytest.approx(92.0) + assert placed[0]["y2"] == pytest.approx(80.0) + with pytest.raises(ValueError, match="finite"): + place_prims(prims, (float("inf"), 0.0)) + + +@pytest.mark.asyncio +async def test_draw_std_feature_writes_the_primitives(backend): + result = await draw_std_feature( + backend, "thread", (100.0, 50.0), {"d": 20.0, "length": 30.0, "size": "M20"} + ) + assert result["ok"] is True + assert result["primitive_count"] == 3 + assert len(result["handles"]) == 3 + assert result["layers"] == ["GEOMETRY"] + assert result["dims"]["pitch"] == 2.5 + info = await backend.entity_get(result["handles"][0]) + assert info.layer == "GEOMETRY" + + +@pytest.mark.asyncio +async def test_draw_std_feature_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="never guessed"): + await draw_std_feature(backend, "thread", (0.0, 0.0), {"d": 20.0, "length": 30.0}) + with pytest.raises(ValueError, match="DIN 509"): + await draw_std_feature(backend, "undercut", (0.0, 0.0), {"d": 30.0}) + assert len(await backend.entity_list()) == before +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_std_features.py -q` +Expected: FAIL at collection with +`ImportError: cannot import name 'STD_FEATURE_KINDS' from 'engineering.mech.stdparts'` + +- [ ] **Step 11: Add the standard features to `engineering/mech/stdparts.py`** + +Append to the module: + +```python +# ── standard features drawn onto geometry that already exists ─────────────── +# +# Local frame for every feature: the origin is on the axis, +X runs along the +# axis in the direction the feature extends, +Y is radially outward. No feature +# draws a centre line - the geometry it is added to already has one. + +STD_FEATURE_KINDS = ("thread", "undercut", "ring_groove", "centre_hole", "oring_groove") + +FEATURE_SOURCE = { + "thread": SOURCE_ISO_6410, + "undercut": "DIN 509 relief grooves - profile from the registered DIN 509 table", + "ring_groove": "DIN 471 / DIN 472 retaining rings - dimensions from the registered table", + "centre_hole": "DIN 332-1 centre holes forms A and B - dimensions from the registered table", + "oring_groove": "ISO 3601-2 O-ring housings - dimensions from the registered table", +} + + +def _positive(params: dict, key: str, kind: str) -> float: + if key not in params: + raise ValueError(f"{kind}: params[{key!r}] is required") + value = params[key] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{kind}: params[{key!r}] must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{kind}: params[{key!r}] must be finite and > 0, got {value!r}") + return number + + +def _side(params: dict, kind: str) -> str: + where = " ".join(str(params.get("kind", "shaft")).split()).lower() + if where not in ("shaft", "bore"): + raise ValueError( + f"{kind}: params['kind'] must be 'shaft' or 'bore', got {params.get('kind')!r}" + ) + return where + + +def _table_row(standard: str, size, kind: str, required: tuple[str, ...]) -> dict: + try: + row = dict(standards_lookup(standard, size)) + except (KeyError, ValueError) as exc: + raise ValueError(f"{kind}: {standard} - {exc}") from exc + missing = [key for key in required if key not in row] + if missing: + raise ValueError( + f"{kind}: the {standard} row for {size} carries no {', '.join(missing)}. " + "std_feature_draw draws the dimensions the standard's own table module " + "publishes and never reconstructs them." + ) + return row + + +def _groove_prims(surface: float, floor: float, width: float) -> tuple[dict, ...]: + steps = ( + (0.0, surface, 0.0, floor), + (0.0, floor, width, floor), + (width, floor, width, surface), + ) + return tuple( + _line(x1, sign * y1, x2, sign * y2) for sign in (1.0, -1.0) for x1, y1, x2, y2 in steps + ) + + +def _thread_feature(params: dict): + d = _positive(params, "d", "thread") + length = _positive(params, "length", "thread") + internal = bool(params.get("internal", False)) + size = params.get("size") + if size: + pitch = thread_pitch(size) + elif "pitch" in params: + pitch = _positive(params, "pitch", "thread") + else: + raise ValueError( + "thread: give params['size'] (e.g. 'M20', whose pitch comes from ISO 261) or " + "an explicit params['pitch'] - the pitch is never guessed." + ) + thin = d / 2.0 if internal else THREAD_MINOR_RATIO * d / 2.0 + prims = ( + _line(0.0, thin, length, thin), + _line(0.0, -thin, length, -thin), + _line(length, -d / 2.0, length, d / 2.0), + ) + dims = {"d": d, "length": length, "pitch": pitch, "internal": internal} + return prims, dims, "ISO 6410" + + +def _ring_groove_feature(params: dict): + d = _positive(params, "d", "ring_groove") + where = _side(params, "ring_groove") + standard = " ".join( + str(params.get("standard") or ("DIN 471" if where == "shaft" else "DIN 472")).split() + ) + row = _table_row(standard, d, "ring_groove", ("m", "d2")) + width, groove_d = float(row["m"]), float(row["d2"]) + if not math.isfinite(width) or width <= 0.0: + raise ValueError(f"ring_groove: {standard} row for d={d} has groove width m={width}.") + if where == "shaft" and not 0.0 < groove_d < d: + raise ValueError( + f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a " + "shaft groove (a shaft groove needs d2 < d)." + ) + if where == "bore" and not groove_d > d: + raise ValueError( + f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a " + "bore groove (a bore groove needs d2 > d)." + ) + prims = _groove_prims(d / 2.0, groove_d / 2.0, width) + return prims, {"d": d, "m": width, "d2": groove_d, "kind": where}, standard + + +def _oring_groove_feature(params: dict): + d = _positive(params, "d", "oring_groove") + cord = _positive(params, "cord", "oring_groove") + where = _side(params, "oring_groove") + standard = " ".join(str(params.get("standard") or "ISO 3601-2").split()) + row = _table_row(standard, cord, "oring_groove", ("b", "h")) + width, depth = float(row["b"]), float(row["h"]) + if not math.isfinite(width) or width <= 0.0 or not math.isfinite(depth) or depth <= 0.0: + raise ValueError( + f"oring_groove: {standard} row for cord={cord} has b={width}, h={depth}; " + "both must be finite and > 0." + ) + if where == "shaft" and depth >= d / 2.0: + raise ValueError( + f"oring_groove: {standard} groove depth h={depth} is not inside a shaft of " + f"d={d} (the floor would be at or through the axis)." + ) + surface = d / 2.0 + floor = surface - depth if where == "shaft" else surface + depth + prims = _groove_prims(surface, floor, width) + return prims, {"d": d, "cord": cord, "b": width, "h": depth, "kind": where}, standard + + +def _centre_hole_feature(params: dict): + form = " ".join(str(params.get("form", "A")).split()).upper() + if form not in ("A", "B"): + raise ValueError( + "centre_hole: this tool draws DIN 332-1 forms A and B. Form R's radius " + f"profile is not transcribed here and is refused rather than approximated; " + f"got {form!r}." + ) + if params.get("size") is None: + raise ValueError("centre_hole: params['size'] is the DIN 332 pilot diameter d1, e.g. 2.5") + size = params["size"] + standard = " ".join(str(params.get("standard") or f"DIN 332-{form}").split()) + required = ("d1", "d2", "t") if form == "A" else ("d1", "d2", "d3", "t") + row = _table_row(standard, size, "centre_hole", required) + d1, d2, depth = float(row["d1"]), float(row["d2"]), float(row["t"]) + if not 0.0 < d1 < d2: + raise ValueError( + f"centre_hole: {standard} row for {size} has d1={d1}, d2={d2}; d1 < d2 is required." + ) + # 60 degrees included -> the axial run of the countersink is (d2-d1)/2 / tan(30). + cone = math.sqrt(3.0) * (d2 - d1) / 2.0 + start = 0.0 + prims: list[dict] = [] + dims = {"form": form, "d1": d1, "d2": d2, "t": depth, "cone": cone} + if form == "B": + d3 = float(row["d3"]) + if not d3 > d2: + raise ValueError( + f"centre_hole: {standard} row for {size} has d3={d3}; form B needs d3 > d2={d2}." + ) + # 120 degrees included -> (d3-d2)/2 / tan(60). + chamfer = (d3 - d2) / (2.0 * math.sqrt(3.0)) + prims += [ + _line(0.0, d3 / 2.0, chamfer, d2 / 2.0), + _line(0.0, -d3 / 2.0, chamfer, -d2 / 2.0), + ] + start = chamfer + dims["d3"] = d3 + dims["chamfer"] = chamfer + prims += [ + _line(start, d2 / 2.0, start + cone, d1 / 2.0), + _line(start, -d2 / 2.0, start + cone, -d1 / 2.0), + ] + if depth <= start + cone: + raise ValueError( + f"centre_hole: {standard} row for {size} has t={depth} mm, which is not deeper " + f"than the countersink ({start + cone:.3f} mm); t is measured from the end face." + ) + prims += [ + _line(start + cone, d1 / 2.0, depth, d1 / 2.0), + _line(start + cone, -d1 / 2.0, depth, -d1 / 2.0), + _line(depth, -d1 / 2.0, depth, d1 / 2.0), + ] + return tuple(prims), dims, standard + + +def _undercut_feature(params: dict): + d = _positive(params, "d", "undercut") + form = " ".join(str(params.get("form", "E")).split()).upper() + if form not in ("E", "F"): + raise ValueError(f"undercut: DIN 509 defines forms E and F; got {form!r}.") + standard = " ".join(str(params.get("standard") or f"DIN 509-{form}").split()) + row = _table_row(standard, d, "undercut", ("profile",)) + profile = row["profile"] + if not isinstance(profile, (list, tuple)) or len(profile) < 2: + raise ValueError( + f"undercut: the {standard} row for d={d} has a profile with fewer than two points." + ) + points: list[tuple[float, float]] = [] + for index, point in enumerate(profile): + if not isinstance(point, (list, tuple)) or len(point) != 2: + raise ValueError( + f"undercut: {standard} profile[{index}] must be an [along, depth] pair, " + f"got {point!r}" + ) + along, depth = float(point[0]), float(point[1]) + if not (math.isfinite(along) and math.isfinite(depth)): + raise ValueError(f"undercut: {standard} profile[{index}] must be finite.") + if depth < 0.0 or depth >= d / 2.0: + raise ValueError( + f"undercut: {standard} profile[{index}] depth {depth} is not inside a shaft " + f"of d={d} (needs 0 <= depth < {d / 2.0})." + ) + points.append((along, d / 2.0 - depth)) + prims = ( + _poly(points, closed=False), + _poly([(x, -y) for x, y in points], closed=False), + ) + dims = {"d": d, "form": form, "profile": [[float(a), float(b)] for a, b in profile]} + return prims, dims, standard + + +_FEATURES = { + "thread": _thread_feature, + "undercut": _undercut_feature, + "ring_groove": _ring_groove_feature, + "centre_hole": _centre_hole_feature, + "oring_groove": _oring_groove_feature, +} + + +def feature_spec(kind: str, params: dict) -> dict: + """Primitives, resolved dimensions and provenance for one standard feature.""" + name = " ".join(str(kind).split()).lower() + if name not in STD_FEATURE_KINDS: + raise ValueError( + f"std_feature_draw: kind must be one of {', '.join(STD_FEATURE_KINDS)}, got {kind!r}." + ) + prims, dims, standard = _FEATURES[name](dict(params or {})) + return { + "kind": name, + "prims": prims, + "dims": dims, + "standard": standard, + "source": FEATURE_SOURCE[name], + } + + +def feature_prims(kind: str, params: dict) -> tuple[dict, ...]: + """Just the primitives of :func:`feature_spec`, in the feature's local frame.""" + return feature_spec(kind, params)["prims"] + + +def place_prims(prims, at, rotation: float = 0.0) -> tuple[dict, ...]: + """Map local primitives into WCS: rotate about the origin, then translate to ``at``.""" + try: + x0, y0 = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"place_prims: 'at' must be two numbers, got {at!r}") from exc + if not (math.isfinite(x0) and math.isfinite(y0)): + raise ValueError(f"place_prims: 'at' must be two finite numbers, got {at!r}") + degrees = float(rotation) + if not math.isfinite(degrees): + raise ValueError(f"place_prims: rotation must be finite, got {rotation!r}") + cosine, sine = math.cos(math.radians(degrees)), math.sin(math.radians(degrees)) + + def point(x: float, y: float) -> tuple[float, float]: + return (x0 + x * cosine - y * sine, y0 + x * sine + y * cosine) + + placed: list[dict] = [] + for prim in prims: + moved = dict(prim) + kind = prim["type"] + if kind == "line": + moved["x1"], moved["y1"] = point(prim["x1"], prim["y1"]) + moved["x2"], moved["y2"] = point(prim["x2"], prim["y2"]) + elif kind == "circle": + moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"]) + elif kind == "arc": + moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"]) + moved["start_deg"] = prim["start_deg"] + degrees + moved["end_deg"] = prim["end_deg"] + degrees + elif kind == "polyline": + moved["points"] = [list(point(px, py)) for px, py in prim["points"]] + else: # pragma: no cover - the builders emit nothing else + raise ValueError(f"place_prims: cannot place a {kind!r} primitive") + placed.append(moved) + return tuple(placed) + + +async def draw_std_feature( + backend: AutoCADBackend, + kind: str, + at, + params: dict, + *, + rotation: float = 0.0, + layer: str | None = None, +) -> dict: + """Draw a standard feature onto geometry that already exists. + + ``at`` is a WCS point on the axis; ``rotation`` turns the local +X (along + the axis, in the direction the feature extends) counter-clockwise. ``layer`` + overrides the visible primitives only - CENTER and HIDDEN primitives keep + their own. Every refusal fires before the first entity is written. + """ + spec = feature_spec(kind, params) + placed = place_prims(spec["prims"], at, rotation) + handles: list[str] = [] + layers: list[str] = [] + for prim in placed: + target = layer if (layer and prim["layer"] == LAYER_VISIBLE) else prim["layer"] + if prim["type"] == "line": + info = await backend.entity_create_line( + prim["x1"], prim["y1"], prim["x2"], prim["y2"], layer=target + ) + elif prim["type"] == "circle": + info = await backend.entity_create_circle( + prim["cx"], prim["cy"], prim["r"], layer=target + ) + elif prim["type"] == "arc": + info = await backend.entity_create_arc( + prim["cx"], prim["cy"], prim["r"], prim["start_deg"], prim["end_deg"], layer=target + ) + else: + info = await backend.entity_create_polyline(prim["points"], prim["closed"], target) + handles.append(info.handle) + if target not in layers: + layers.append(target) + return { + "ok": True, + "kind": spec["kind"], + "handles": handles, + "primitive_count": len(handles), + "dims": spec["dims"], + "standard": spec["standard"], + "source": spec["source"], + "layers": layers, + "at": [float(at[0]), float(at[1])], + "rotation": float(rotation), + "backend": backend.name, + } +``` + +- [ ] **Step 12: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_std_features.py tests/test_mech_stdparts.py -q` +Expected: PASS — 45 passed (30 + 15). + +- [ ] **Step 13: Register SECTION 22 in `server.py`** + +Four small edits plus the section. + +(a) In `_GROUP_TAG_PRIORITY`, insert `"mech",` on the line after `"pid",`. + +(b) In `_GROUP_TAG_LABELS`, insert `"mech": "mech",` on the line after `"pid": "pid",`. + +(c) Replace the `TOOL_PACK_NAMES` line with: + +```python +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech") +``` + +(d) In `PACK_TOOL_NAMES`, add after the `"settings"` entry: + +```python + # SECTION 22 - the standard-parts catalogue (tracks B+G). + "mech": frozenset( + { + "std_part_list", + "std_part_insert", + "std_feature_draw", + } + ), +``` + +(e) Insert the section immediately above the `# ── RESOURCES ──` banner pair +(currently `server.py:8126-8127`): + +```python +# --------------------------------------------------------------------------- +# ── SECTION 22: Standard Parts (3 tools) ──────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.", + cost="read", +) +@mcp.tool( + annotations={"title": "Standard Parts: Catalogue", "readOnlyHint": True}, + tags={"mech", "query"}, +) +async def std_part_list( + query: Annotated[ + str | None, + Field( + default=None, + description=( + "Free text matched against designation, standard, size and family - " + "'M12', 'ISO 4014', '6205', 'washer'" + ), + ), + ] = None, + standard: Annotated[ + str | None, + Field( + default=None, + description="One of ISO 4014, ISO 4017, ISO 4032, ISO 7089, ISO 4762, ISO 15", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Catalogue rows with the dimensions a drawing needs, and their provenance. + + Every row is transcribed from the named standard's own table and carries + its `source`. Coverage is deliberately narrow: ISO 261 first-choice sizes + only (M5-M36, M3-M24 for ISO 4762) and ISO 15 bore numbers 00-10 in the + 600x / 620x / 630x series. A size outside a table is refused by name by + `std_part_insert` with the nearest entries, never interpolated. An unknown + `standard` is refused with the list of catalogue standards. + """ + from engineering.mech.stdparts import FAMILY_STANDARDS, catalogue + + rows = catalogue(query, standard) + return {"count": len(rows), "standards": list(FAMILY_STANDARDS), "parts": list(rows)} + + +@cad_tool( + summary=( + "Insert a standard part (ISO hex bolt, nut, washer, socket head cap screw, " + "deep groove ball bearing) as a tagged block." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "Standard Parts: Insert", "readOnlyHint": False}, + tags={"mech", "create"}, +) +async def std_part_insert( + designation: Annotated[ + str, + Field( + description=( + "'ISO 4014 - M12x60', 'ISO 4032 - M12', 'ISO 7089 - M12', " + "'ISO 4762 - M8x30', 'ISO 15 - 6205' (a bare '6205' also works)" + ) + ), + ], + x: Annotated[float, Field(description="Insertion X (WCS)")], + y: Annotated[float, Field(description="Insertion Y (WCS)")], + view: Annotated[ + str, + Field( + default="side", + description=( + "Fasteners: side | top. Bearings: simplified (ISO 8826-1) | detailed " + "(ISO 8826-2). The default 'side' falls back to the family's first view." + ), + ), + ] = "side", + rotation: Annotated[ + float, + Field(default=0.0, description="Degrees CCW; 0 puts the part's axis along +X"), + ] = 0.0, + layer: Annotated[ + str, + Field( + default="GEOMETRY", + description=( + "Layer for the INSERT; the block's own primitives keep GEOMETRY / " + "CENTER / HIDDEN" + ), + ), + ] = "GEOMETRY", + material: Annotated[ + str | None, + Field( + default=None, + description="Property class or material for the MAT attribute and the XDATA, e.g. '8.8'", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Define the part's block once, insert it, and write its `std_part` XDATA. + + The block carries DESIG / STD / SIZE / MAT attributes and an ACADMCP_MECH + payload, so a parts list is a read of the drawing rather than a naming + convention. Refusals, all before any write: a designation outside the + transcribed tables (named, with the nearest catalogue entries - nothing is + drawn approximately), a malformed designation, a missing or stray nominal + length, a view the family does not have, an attribute tag the block does + not carry, and a non-finite coordinate or rotation. + """ + from engineering.mech.stdparts import insert_std_part + + await ctx.info(f"standard part {designation} ({view}) at ({x}, {y})") + return await insert_std_part( + _backend(ctx), + designation, + at=(x, y), + view=view, + rotation=rotation, + layer=layer, + material=material, + ) + + +@cad_tool( + summary=( + "Draw a standard feature - thread, DIN 509 undercut, retaining-ring or O-ring " + "groove, DIN 332 centre hole - onto existing geometry." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "Standard Parts: Draw Feature", "readOnlyHint": False}, + tags={"mech", "create"}, +) +async def std_feature_draw( + kind: Annotated[ + str, + Field(description="thread | undercut | ring_groove | centre_hole | oring_groove"), + ], + x: Annotated[float, Field(description="Feature origin X (WCS): a point on the axis")], + y: Annotated[float, Field(description="Feature origin Y (WCS): a point on the axis")], + params: Annotated[ + dict, + Field( + description=( + "thread: {d, length, size|pitch, internal}; undercut: {d, form: E|F}; " + "ring_groove: {d, kind: shaft|bore}; centre_hole: {size, form: A|B}; " + "oring_groove: {d, cord, kind: shaft|bore}" + ) + ), + ], + rotation: Annotated[ + float, + Field( + default=0.0, + description="Degrees CCW applied to the local +X (along the axis, into the feature)", + ), + ] = 0.0, + layer: Annotated[ + str | None, + Field( + default=None, + description="Override the visible primitives' layer; CENTER and HIDDEN keep theirs", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Add a standard feature to geometry that already exists - a foreign drawing being finished. + + Local frame: `(x, y)` is on the axis, +X runs along the axis in the + direction the feature extends and +Y is radially outward. The thread is the + ISO 6410 representation (minor diameter at 0.8 x major, thread-length limit + to the major) and its pitch comes from ISO 261 via `size` or from an + explicit `pitch` - it is never guessed. Every other kind takes its + dimensions from the registered standards table and is refused by name when + that table is absent, when the size is outside its coverage, or when the row + lacks a dimension the feature needs: no value is interpolated and no profile + is reconstructed. DIN 332 form R is refused - only forms A and B are drawn. + """ + from engineering.mech.stdparts import draw_std_feature + + await ctx.info(f"standard feature {kind} at ({x}, {y})") + return await draw_std_feature( + _backend(ctx), kind, (x, y), params, rotation=rotation, layer=layer + ) +``` + +- [ ] **Step 14: Add the alias records to `discovery/aliases.py`** + +Append this block inside `TOOL_ALIASES`, after the +`# ── Environment (track E, group V) ──` block and before the closing brace. +No `SHARED_ACAD_COMMANDS` change is needed: `INSERT` is already listed there +(block_insert / entity_create_block_ref / pid_symbol_insert), and the other two +tools carry no AutoCAD command at all. + +```python + # ── Standard parts (SECTION 22, tracks B+G group S) ───────────────────── + "std_part_list": ToolAliases( + acad=(), + synonyms=( + "standard parts catalogue", + "what bolts do you have", + "bolt sizes", + "hex bolt table", + "bearing list", + "iso 4014 sizes", + "find a washer", + "civata listesi", + "rulman listesi", + ), + ), + "std_part_insert": ToolAliases( + acad=("INSERT",), + synonyms=( + "insert a bolt", + "place a screw", + "draw a nut", + "add a washer", + "put a bearing in", + "hex bolt", + "socket head cap screw", + "allen screw", + "deep groove ball bearing", + "standard part", + "civata", + "somun", + "rondela", + "rulman", + ), + ), + "std_feature_draw": ToolAliases( + acad=(), + synonyms=( + "draw a thread", + "thread representation", + "undercut", + "relief groove", + "retaining ring groove", + "circlip groove", + "snap ring groove", + "centre hole", + "center drill", + "o-ring groove", + "din 509", + "din 471", + "din 332", + "vida disi", + "segman kanali", + "punta deligi", + ), + ), +``` + +- [ ] **Step 15: Regenerate the inventory and bump the two documented counts** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` + +Then in `README.md` change the release-snapshot line from + +``` +> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 204 is the **registered** count; a default install advertises 199 over `tools/list`, +``` + +to + +``` +> **v1.5 release snapshot:** 207 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 207 is the **registered** count; a default install advertises 202 over `tools/list`, +``` + +and change the caption under the "What you get" table from +`204 tools in 23 groups;` to `207 tools in 24 groups;`. + +Leave the collected-tests figure alone (Task 15 retakes it on main) and leave +the "186 AutoCAD command names and 933 synonym phrases … covering all 204 +tools" sentence alone too: all four group branches add alias records, so that +sentence is recomputed once after the merge, in Task 15. + +- [ ] **Step 16: Bump the frozen group-size snapshot** + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`, add +`"mech": 3,` to the asserted dict (alphabetically, between `"layouts"` and +`"page_setup"`), change the final assertion to `assert sum(sizes.values()) == 207`, +and append this sentence to the docstring: + +``` + `mech` appeared (0 -> 3) when v1.6's SECTION 22 opened with the + standard-parts catalogue (`std_part_list` / `std_part_insert` / + `std_feature_draw`); the `mech` tag is ranked directly after `pid` in + `_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under + their secondary `query` / `create` tags. This snapshot is branch-local: + the merge task recomputes it once all four track B+G branches have landed. +``` + +- [ ] **Step 17: Gates** + +Run each of these and require it clean: + +``` +uv run --frozen ruff check engineering/mech/stdparts.py server.py discovery/aliases.py tests/test_mech_stdparts.py tests/test_mech_std_features.py +uv run --frozen ruff format engineering/mech/stdparts.py tests/test_mech_stdparts.py tests/test_mech_std_features.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_mech_stdparts.py tests/test_mech_std_features.py tests/test_mech_fasteners.py tests/test_mech_bearings.py -q +uv run --frozen pytest tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_capability_contract.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +The deselect is the known-red collected-tests drift named in this plan's Global +Constraints — record it in `deviations`. No capability key is added by this +task, so `tests/test_capability_contract.py` must pass untouched. + +- [ ] **Step 18: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-9): SECTION 22 - std_part_list, std_part_insert, std_feature_draw + +engineering/mech/stdparts.py turns the Task 8 tables into real blocks. catalogue() +searches 83 transcribed parts by designation, standard, size or family; +block_spec() emits the typed primitives and DESIG/STD/SIZE/MAT ATTDEFs +block_define takes, in side/top for fasteners and ISO 8826-1 simplified / +ISO 8826-2 detailed for bearings; insert_std_part() defines the block once, +inserts it and writes the std_part ACADMCP_MECH payload the skeleton pins, so +the parts list is a read of the drawing rather than a naming convention. +Threads follow ISO 6410 - minor at 0.8 x major, a thread-length limit line to +the major, a three-quarter minor arc in the end view. + +std_feature_draw() adds a thread, a DIN 509 undercut, a DIN 471/472 groove, a +DIN 332 centre hole or an ISO 3601-2 O-ring groove to geometry that already +exists. Its dimensions come from the shared standards registry, never from this +module: an absent table, a size outside coverage or a row missing a dimension is +refused by name, and the DIN 509 relief is drawn from the profile its table +publishes rather than reconstructed here. DIN 332 form R is refused outright. +An unknown designation is refused with the nearest catalogue entries; nothing is +drawn approximately. + +SECTION 22 registers the three tools in the new mech pack, with alias records +(INSERT plus shop-floor synonyms in English and Turkish), a regenerated tool +inventory, the README release snapshot at 207 tools in 24 groups, and the frozen +group-size snapshot gaining mech: 3. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 10: Surface texture (ISO 21920-1) and weld symbols (ISO 2553) + +**Files:** +- Create: `engineering/mech/annotate.py` +- Create: `tests/test_mech_annotate.py` +- Modify: `server.py` (append SECTION 23 with the banner `(2 tools)` and the + `surface_texture` / `weld_symbol` registrations, immediately **above** the + `# ── RESOURCES ──` banner) +- Modify: `discovery/aliases.py` (one `ToolAliases` record per new tool) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release-snapshot tool count 204 → 206, and the + `204 tools in 23 groups` line) +- Modify: `tests/test_tool_registry.py` (frozen group snapshot: `engineering` + 10 → 12, total 204 → 206) +- Test: `tests/test_mech_annotate.py` + +**Interfaces:** +- Consumes (Task 1, already on `main`): + `engineering.mech.primitives.ROLE_LAYER: dict[Role, str]`, + `Line(p1, p2, role="visible")`, `Arc(center, radius, start_deg, end_deg, role="visible")`, + `Circle(center, radius, role="visible")`, `Poly(points, closed=False, role="visible")`, + `Text(at, text, height=3.5, rotation=0.0, role="text")`, `Prim`, + `translate(prims, dx, dy) -> tuple[Prim, ...]`, + `rotate(prims, deg, about=(0.0, 0.0)) -> tuple[Prim, ...]`. +- Consumes (backend contracts, unchanged): + `entity_create_line(x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, color=None, linetype=None) -> EntityInfo`, + `entity_create_circle(cx, cy, radius, layer=None, color=None) -> EntityInfo`, + `entity_create_arc(cx, cy, radius, start_angle, end_angle, layer=None, color=None) -> EntityInfo`, + `entity_create_polyline(points, closed=False, layer=None, color=None) -> EntityInfo`, + `entity_create_text(text, x, y, height=2.5, rotation=0.0, layer=None, color=None) -> EntityInfo`. +- Produces (Task 11 and the merge consume these): + ```python + ISO1302_SYMBOL_PROPORTIONS: dict[float, tuple[float, float, float]] # h -> (d', H1, H2) + SURFACE_HEIGHTS: tuple[float, ...] + SURFACE_STANDARDS: tuple[str, ...] + LAY_SYMBOLS: dict[str, str] + MACHINING_KINDS: tuple[str, ...] + WELD_KINDS: tuple[str, ...] + WELD_SIDES: tuple[str, ...] + def symbol_proportions(height: float) -> tuple[float, float, float] + def surface_texture_prims(*, ra=None, rz=None, process=None, lay=None, + machining="any", all_around=False, height=3.5, + allowance=None, standard="ISO 21920-1") -> tuple[Prim, ...] + def weld_symbol_prims(*, kind, size=None, length=None, pitch=None, side="arrow", + field_weld=False, all_around=False, process=None, + height=3.5) -> tuple[Prim, ...] + def leader_prims(tip, to, *, arrow_size=2.5, role="dim") -> tuple[Prim, ...] + async def draw_annotation_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, + layer=None) -> dict # {"ok","handles","count"} + async def draw_surface_texture(backend, *, at, leader_to=None, ra=None, rz=None, + process=None, lay=None, machining="any", + all_around=False, allowance=None, height=3.5, + standard="ISO 21920-1", rotation=0.0, layer=None) -> dict + async def draw_weld_symbol(backend, *, at, kind, leader_to=None, size=None, length=None, + pitch=None, side="arrow", field_weld=False, all_around=False, + process=None, height=3.5, rotation=0.0, layer=None) -> dict + ``` + +- [ ] **Step 1: Write the failing test for the surface-texture geometry** + +Create `tests/test_mech_annotate.py`: + +```python +"""ISO 21920-1 / ISO 1302 surface texture and ISO 2553 weld symbols. + +Pure geometry is asserted as emitted primitive tuples (counts, roles, +coordinates to 1e-9); the async layer is asserted against a real ezdxf +document through the `backend` fixture. No screenshots anywhere. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.annotate import ( + ISO1302_SYMBOL_PROPORTIONS, + LAY_SYMBOLS, + MACHINING_KINDS, + SURFACE_HEIGHTS, + SURFACE_STANDARDS, + surface_texture_prims, + symbol_proportions, +) +from engineering.mech.primitives import Circle, Line, Text + +pytestmark = pytest.mark.asyncio + +EPS = 1e-9 +K = 1.0 / math.sqrt(3.0) # tan(30 deg): the 60 deg legs' horizontal run per unit rise + + +def _flat(points): + """Flatten a point sequence: pytest.approx refuses nested structures.""" + return tuple(float(value) for point in points for value in point) + + +async def test_the_seven_iso1302_proportion_rows_are_the_transcribed_ones(): + assert SURFACE_HEIGHTS == (2.5, 3.5, 5.0, 7.0, 10.0, 14.0, 20.0) + assert ISO1302_SYMBOL_PROPORTIONS[2.5] == (0.25, 3.5, 7.5) + assert ISO1302_SYMBOL_PROPORTIONS[3.5] == (0.35, 5.0, 10.5) + assert ISO1302_SYMBOL_PROPORTIONS[5.0] == (0.5, 7.0, 15.0) + assert ISO1302_SYMBOL_PROPORTIONS[7.0] == (0.7, 10.0, 21.0) + assert ISO1302_SYMBOL_PROPORTIONS[10.0] == (1.0, 14.0, 30.0) + assert ISO1302_SYMBOL_PROPORTIONS[14.0] == (1.4, 20.0, 42.0) + assert ISO1302_SYMBOL_PROPORTIONS[20.0] == (2.0, 28.0, 60.0) + # Every row: line width is h/10 and the minimum overall height is 3h. + for height, (width, _h1, h2) in ISO1302_SYMBOL_PROPORTIONS.items(): + assert width == pytest.approx(height / 10.0, abs=EPS) + assert h2 == pytest.approx(3.0 * height, abs=EPS) + + +async def test_a_text_height_outside_the_table_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + symbol_proportions(4.0) + message = str(excinfo.value) + assert "ISO 1302" in message and "4 mm" in message + assert "2.5, 3.5, 5, 7, 10, 14, 20" in message + + +async def test_the_basic_symbol_is_a_60_degree_vee_on_the_dim_role(): + prims = surface_texture_prims(height=3.5) + short, long_ = prims[0], prims[1] + assert isinstance(short, Line) and isinstance(long_, Line) + assert short.p1 == (0.0, 0.0) and long_.p1 == (0.0, 0.0) + assert short.p2 == pytest.approx((-5.0 * K, 5.0), abs=EPS) + assert long_.p2 == pytest.approx((10.5 * K, 10.5), abs=EPS) + assert short.role == "dim" and long_.role == "dim" + # The angle between the two legs is 60 degrees. + a = math.atan2(short.p2[1], short.p2[0]) + b = math.atan2(long_.p2[1], long_.p2[0]) + assert math.degrees(a - b) == pytest.approx(60.0, abs=1e-9) + # No bar, no circle: the basic symbol plus the extension line only. + assert [type(p).__name__ for p in prims] == ["Line", "Line", "Line"] + + +async def test_machining_required_closes_the_vee_with_the_bar(): + prims = surface_texture_prims(height=3.5, machining="required") + bar = prims[2] + assert isinstance(bar, Line) + assert bar.p1 == pytest.approx((-5.0 * K, 5.0), abs=EPS) + assert bar.p2 == pytest.approx((5.0 * K, 5.0), abs=EPS) + assert bar.role == "dim" + + +async def test_machining_prohibited_inscribes_the_circle_in_the_vee(): + prims = surface_texture_prims(height=3.5, machining="prohibited") + circle = prims[2] + assert isinstance(circle, Circle) + assert circle.center == pytest.approx((0.0, 10.0 / 3.0), abs=EPS) + assert circle.radius == pytest.approx(5.0 / 3.0, abs=EPS) + # Tangent to the bar height H1 and to both legs. + assert circle.center[1] + circle.radius == pytest.approx(5.0, abs=EPS) + + +async def test_ra_rz_process_lay_and_allowance_land_in_their_declared_positions(): + prims = surface_texture_prims( + height=3.5, ra=3.2, rz=12.5, process="milled", lay="perpendicular", allowance=0.5 + ) + texts = [p for p in prims if isinstance(p, Text)] + assert [t.text for t in texts] == ["Ra 3.2", "Rz 12.5", "milled", "⊥ 0.5"] + x_leg = 10.5 * K + for t in texts: + assert t.at[0] == pytest.approx(x_leg + 0.5 * 3.5, abs=EPS) + assert t.height == 3.5 and t.role == "text" + # Above the extension line the three lines stack upward at 1.5h pitch. + assert texts[0].at[1] == pytest.approx(10.5 + 0.35 * 3.5, abs=EPS) + assert texts[1].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 1.5 * 3.5, abs=EPS) + assert texts[2].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 3.0 * 3.5, abs=EPS) + # Lay and allowance sit below it, on one line. + assert texts[3].at[1] == pytest.approx(10.5 - 0.35 * 3.5 - 3.5, abs=EPS) + + +async def test_the_extension_line_grows_with_the_widest_text(): + prims = surface_texture_prims(height=3.5, ra=3.2) + extension = [p for p in prims if isinstance(p, Line)][-1] + x_leg = 10.5 * K + expected = 0.62 * 3.5 * len("Ra 3.2") + 3.5 + assert extension.p1 == pytest.approx((x_leg, 10.5), abs=EPS) + assert extension.p2 == pytest.approx((x_leg + expected, 10.5), abs=EPS) + # With no text at all it falls back to the declared minimum of 2h. + bare = [p for p in surface_texture_prims(height=3.5) if isinstance(p, Line)][-1] + assert bare.p2[0] - bare.p1[0] == pytest.approx(2.0 * 3.5, abs=EPS) + + +async def test_all_around_puts_the_circle_on_the_kink(): + prims = surface_texture_prims(height=3.5, all_around=True) + circle = [p for p in prims if isinstance(p, Circle)][0] + assert circle.center == pytest.approx((10.5 * K, 10.5), abs=EPS) + assert circle.radius == pytest.approx(0.35 * 3.5, abs=EPS) + + +async def test_iso1302_is_accepted_as_an_alias_designation_and_draws_the_same_symbol(): + assert SURFACE_STANDARDS == ("ISO 21920-1", "ISO 1302") + new = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 21920-1") + old = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 1302") + assert new == old + with pytest.raises(ValueError) as excinfo: + surface_texture_prims(height=3.5, standard="ASME B46.1") + assert "ISO 21920-1, ISO 1302" in str(excinfo.value) + + +async def test_an_undefined_lay_and_an_undefined_machining_are_refused_with_the_list(): + assert set(LAY_SYMBOLS) == { + "parallel", + "perpendicular", + "crossed", + "multidirectional", + "circular", + "radial", + "particulate", + } + assert MACHINING_KINDS == ("any", "required", "prohibited") + with pytest.raises(ValueError) as excinfo: + surface_texture_prims(lay="diagonal") + assert "circular, crossed, multidirectional" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + surface_texture_prims(machining="maybe") + assert "any, required, prohibited" in str(excinfo.value) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_annotate.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.annotate'` — the module +does not exist yet. + +- [ ] **Step 3: Create `engineering/mech/annotate.py` with the transcribed tables and the surface-texture geometry** + +```python +"""ISO annotation symbols: surface texture (ISO 21920-1 / ISO 1302) and welds (ISO 2553). + +Pure geometry. Every function here returns primitive *descriptions* from +`engineering.mech.primitives` in a symbol-local frame whose origin is the point +the leader attaches to; `draw_*` is the only async part and the only part that +touches a backend. That split is what lets a test assert the emitted tuples +instead of a screenshot. + +SOURCE - surface-texture symbol proportions: ISO 1302:2002, "Geometrical +Product Specifications (GPS) - Indication of surface texture in technical +product documentation", the table of graphical-symbol dimensions (text height +h -> line width d', short-leg height H1, minimum overall height H2). The seven +rows below are transcribed from it. ISO 21920-1:2021 superseded ISO 1302 +without changing the graphical symbol, so the same proportions serve both +designations. The clause/table *number* is deliberately not quoted: the values +are what this implementer can state, the numbering is not, and a fabricated +citation is worse than none. A text height outside the seven rows is refused +by name - never interpolated. + +SOURCE - direction-of-lay symbols: ISO 1302:2002, the seven symbols for the +direction of lay (=, perpendicular, X, M, C, R, P). A lay name outside those +seven is refused with the list. + +SOURCE - weld symbols: ISO 2553:2019, "Welding and allied processes - +Symbolic representation on drawings - Welded joints", elementary weld symbols, +plus its rules for the reference line, the dashed identification line, the +size written before the symbol, the length and pitch written after it, the +field-weld flag and the all-around circle at the kink, and the tail carrying +the process reference. SIX elementary symbols are transcribed here: square (I), +single-V, single-bevel, single-U, single-J and fillet. The rest of the +elementary table - spot, seam, plug/slot, backing run, surfacing, edge, +steep-flanked V and bevel, fold joint, inclined joint - is NOT shipped, +because their drawn form could not be verified; a weld kind outside the six is +refused with the list of the six. A narrow catalogue is merely narrow; a +guessed weld symbol is a wrong instruction to a welder. + +DECLARED, not transcribed. Neither standard dimensions these, so they are this +implementation's stated choices and are named as such wherever they are used: +the all-around circle radius, the field-weld flag, the identification line's +dash pattern and its offset from the reference line, the text line pitch and +the character-width estimate used to size the extension line and lay out the +weld annotation. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.mech.primitives import ( + ROLE_LAYER, + Arc, + Circle, + Line, + Poly, + Prim, + Text, + rotate, + translate, +) + +if TYPE_CHECKING: # pragma: no cover - typing only + from backends.base import AutoCADBackend + +__all__ = [ + "ISO1302_SYMBOL_PROPORTIONS", + "LAY_SYMBOLS", + "MACHINING_KINDS", + "SURFACE_HEIGHTS", + "SURFACE_STANDARDS", + "WELD_KINDS", + "WELD_SIDES", + "draw_annotation_prims", + "draw_surface_texture", + "draw_weld_symbol", + "leader_prims", + "surface_texture_prims", + "symbol_proportions", + "weld_symbol_prims", +] + +# ── ISO 1302:2002 graphical-symbol dimensions (transcribed) ───────────────── +# text height h -> (line width d', short-leg height H1, minimum height H2), mm. +ISO1302_SYMBOL_PROPORTIONS: dict[float, tuple[float, float, float]] = { + 2.5: (0.25, 3.5, 7.5), + 3.5: (0.35, 5.0, 10.5), + 5.0: (0.5, 7.0, 15.0), + 7.0: (0.7, 10.0, 21.0), + 10.0: (1.0, 14.0, 30.0), + 14.0: (1.4, 20.0, 42.0), + 20.0: (2.0, 28.0, 60.0), +} + +SURFACE_HEIGHTS: tuple[float, ...] = tuple(sorted(ISO1302_SYMBOL_PROPORTIONS)) + +# ISO 21920-1:2021 is the current designation; ISO 1302 is accepted as an alias +# for drawings still issued under the older one. The geometry is identical. +SURFACE_STANDARDS: tuple[str, ...] = ("ISO 21920-1", "ISO 1302") + +# ISO 1302:2002 direction-of-lay symbols (transcribed). +LAY_SYMBOLS: dict[str, str] = { + "parallel": "=", + "perpendicular": "⊥", + "crossed": "X", + "multidirectional": "M", + "circular": "C", + "radial": "R", + "particulate": "P", +} + +MACHINING_KINDS: tuple[str, ...] = ("any", "required", "prohibited") + +# DECLARED layout constants (not standard values). +ALL_AROUND_RADIUS_FACTOR = 0.35 # all-around circle radius, in text heights +TEXT_GAP_FACTOR = 0.35 # gap between the extension line and the nearest text +TEXT_PITCH_FACTOR = 1.5 # line pitch for stacked annotation text +CHAR_WIDTH_FACTOR = 0.62 # mean character advance, in text heights + +# tan(30 deg): the horizontal run of a 60 deg leg per unit of rise. +_LEG_RUN = 1.0 / math.sqrt(3.0) + + +def symbol_proportions(height: float) -> tuple[float, float, float]: + """(d', H1, H2) for a text height, straight out of the ISO 1302 table. + + A height the standard does not tabulate is refused: interpolating would + invent a proportion the standard never published. + """ + key = float(height) + if key not in ISO1302_SYMBOL_PROPORTIONS: + listed = ", ".join(f"{h:g}" for h in SURFACE_HEIGHTS) + raise ValueError( + f"ISO 1302 dimensions the surface-texture symbol only for text heights " + f"{listed} mm; {key:g} mm is outside the table and is not interpolated." + ) + return ISO1302_SYMBOL_PROPORTIONS[key] + + +def surface_texture_prims( + *, + ra: float | None = None, + rz: float | None = None, + process: str | None = None, + lay: str | None = None, + machining: str = "any", + all_around: bool = False, + height: float = 3.5, + allowance: float | None = None, + standard: str = "ISO 21920-1", +) -> tuple[Prim, ...]: + """The surface-texture symbol in a symbol-local frame, apex at the origin. + + The apex is the point a leader attaches to. The short leg runs up-left and + the long leg up-right, both at 60 degrees to the horizontal, so the + included angle is 60 degrees. `machining="required"` closes the vee with + the bar at H1; `machining="prohibited"` inscribes the circle in the vee. + """ + if standard not in SURFACE_STANDARDS: + raise ValueError( + f"surface texture standard {standard!r} is unknown; use one of " + f"{', '.join(SURFACE_STANDARDS)}." + ) + if machining not in MACHINING_KINDS: + raise ValueError( + f"machining {machining!r} is unknown; use one of {', '.join(MACHINING_KINDS)}." + ) + if lay is not None and lay not in LAY_SYMBOLS: + raise ValueError( + f"lay {lay!r} is not an ISO 1302 direction of lay; use one of " + f"{', '.join(sorted(LAY_SYMBOLS))}." + ) + _width, h1, h2 = symbol_proportions(height) + h = float(height) + short_top = (-h1 * _LEG_RUN, h1) + long_top = (h2 * _LEG_RUN, h2) + + prims: list[Prim] = [ + Line((0.0, 0.0), short_top, "dim"), + Line((0.0, 0.0), long_top, "dim"), + ] + if machining == "required": + prims.append(Line(short_top, (h1 * _LEG_RUN, h1), "dim")) + elif machining == "prohibited": + # Inscribed circle of the 60 deg vee: tangent to both legs and to the + # bar height H1, so its centre sits at 2*H1/3 with radius H1/3. + prims.append(Circle((0.0, 2.0 * h1 / 3.0), h1 / 3.0, "dim")) + + above: list[str] = [] + if ra is not None: + above.append(f"Ra {float(ra):g}") + if rz is not None: + above.append(f"Rz {float(rz):g}") + if process: + above.append(str(process)) + below: list[str] = [] + if lay is not None: + below.append(LAY_SYMBOLS[lay]) + if allowance is not None: + below.append(f"{float(allowance):g}") + below_text = " ".join(below) + + widest = max([len(text) for text in above] + [len(below_text)]) + extension = max(2.0 * h, CHAR_WIDTH_FACTOR * h * widest + h) + prims.append(Line(long_top, (long_top[0] + extension, h2), "dim")) + + x_text = long_top[0] + 0.5 * h + for index, line in enumerate(above): + prims.append( + Text((x_text, h2 + TEXT_GAP_FACTOR * h + index * TEXT_PITCH_FACTOR * h), line, h) + ) + if below_text: + prims.append(Text((x_text, h2 - TEXT_GAP_FACTOR * h - h), below_text, h)) + if all_around: + # DECLARED: the standard shows the circle at the kink between the long + # leg and the extension line but does not dimension it. + prims.append(Circle(long_top, ALL_AROUND_RADIUS_FACTOR * h, "dim")) + return tuple(prims) +``` + +- [ ] **Step 4: Run the surface-texture tests and see them pass** + +Run: `uv run --frozen pytest tests/test_mech_annotate.py -q` +Expected: 10 passed. + +- [ ] **Step 5: Write the failing test for the weld symbol** + +Append to `tests/test_mech_annotate.py`: + +```python +# ── ISO 2553 weld symbols ─────────────────────────────────────────────────── + +from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests + WELD_KINDS, + WELD_SIDES, + weld_symbol_prims, +) +from engineering.mech.primitives import Arc, Poly # noqa: E402 + + +async def test_only_the_six_transcribed_elementary_symbols_are_offered(): + assert WELD_KINDS == ("square", "v", "bevel", "u", "j", "fillet") + assert WELD_SIDES == ("arrow", "other", "both") + with pytest.raises(ValueError) as excinfo: + weld_symbol_prims(kind="spot") + message = str(excinfo.value) + assert "square, v, bevel, u, j, fillet" in message + assert "ISO 2553" in message + with pytest.raises(ValueError) as excinfo: + weld_symbol_prims(kind="fillet", side="both_sides") + assert "arrow, other, both" in str(excinfo.value) + + +async def test_the_reference_line_comes_first_and_the_identification_line_is_dashed(): + prims = weld_symbol_prims(kind="fillet", height=3.5) + reference = prims[0] + assert isinstance(reference, Line) + assert reference.p1 == (0.0, 0.0) + assert reference.p2[1] == 0.0 and reference.p2[0] > 0.0 + assert reference.role == "dim" + length = reference.p2[0] + dashes = [ + p + for p in prims + if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS) + ] + assert len(dashes) >= 2 + assert all(d.p2[1] == pytest.approx(-0.4 * 3.5, abs=EPS) for d in dashes) + assert dashes[0].p1[0] == pytest.approx(0.0, abs=EPS) + assert dashes[0].p2[0] - dashes[0].p1[0] == pytest.approx(0.8 * 3.5, abs=EPS) + assert dashes[1].p1[0] - dashes[0].p2[0] == pytest.approx(0.4 * 3.5, abs=EPS) + assert dashes[-1].p2[0] <= length + EPS + + +async def test_a_symmetrical_weld_omits_the_identification_line(): + prims = weld_symbol_prims(kind="v", side="both", height=3.5) + assert not [ + p + for p in prims + if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS) + ] + # One V above the reference line, one V below the identification offset. + vees = [p for p in prims if isinstance(p, Poly) and len(p.points) == 3 and not p.closed] + assert len(vees) == 2 + assert vees[0].points[1][1] == pytest.approx(0.0, abs=EPS) + assert vees[1].points[1][1] == pytest.approx(-0.4 * 3.5, abs=EPS) + + +async def test_the_fillet_triangle_stands_on_the_line_with_its_leg_on_the_left(): + prims = weld_symbol_prims(kind="fillet", height=3.5) + triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0] + x0 = triangle.points[0][0] + assert _flat(triangle.points) == pytest.approx( + (x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS + ) + assert triangle.role == "dim" + + +async def test_the_other_side_symbol_hangs_below_the_identification_line(): + prims = weld_symbol_prims(kind="fillet", side="other", height=3.5) + triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0] + x0 = triangle.points[0][0] + assert _flat(triangle.points) == pytest.approx( + (x0, -1.4, x0, -1.4 - 3.5, x0 + 3.5, -1.4), abs=EPS + ) + + +async def test_the_u_symbol_is_a_half_circle_between_two_uprights(): + prims = weld_symbol_prims(kind="u", height=3.5) + arc = [p for p in prims if isinstance(p, Arc)][0] + assert arc.radius == pytest.approx(1.75, abs=EPS) + assert (arc.start_deg, arc.end_deg) == (180.0, 360.0) + assert arc.center[1] == pytest.approx(1.75, abs=EPS) + mirrored = [p for p in weld_symbol_prims(kind="u", side="other", height=3.5) if isinstance(p, Arc)][0] + assert (mirrored.start_deg, mirrored.end_deg) == (0.0, 180.0) + assert mirrored.center[1] == pytest.approx(-1.4 - 1.75, abs=EPS) + + +async def test_size_is_written_before_the_symbol_and_length_and_pitch_after_it(): + prims = weld_symbol_prims(kind="fillet", size=5, length=50, pitch=100, height=3.5) + texts = [p for p in prims if isinstance(p, Text)] + assert [t.text for t in texts] == ["a5", "50 (100)"] + triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0] + x_symbol = triangle.points[0][0] + assert texts[0].at[0] < x_symbol + assert texts[1].at[0] > x_symbol + 3.5 + # A string size is written verbatim, so z-notation stays available. + verbatim = weld_symbol_prims(kind="fillet", size="z7", height=3.5) + assert [p.text for p in verbatim if isinstance(p, Text)] == ["z7"] + with pytest.raises(ValueError) as excinfo: + weld_symbol_prims(kind="fillet", pitch=100, height=3.5) + assert "pitch needs length" in str(excinfo.value) + + +async def test_field_weld_flag_and_all_around_circle_sit_on_the_kink(): + prims = weld_symbol_prims(kind="fillet", field_weld=True, all_around=True, height=3.5) + pole = [p for p in prims if isinstance(p, Line) and p.p1 == (0.0, 0.0) and p.p2[0] == 0.0][0] + assert pole.p2 == pytest.approx((0.0, 1.4 * 3.5), abs=EPS) + flag = [p for p in prims if isinstance(p, Poly) and p.closed and p.points[0][0] == 0.0][0] + assert _flat(flag.points) == pytest.approx( + ( + 0.0, + 1.4 * 3.5, + 0.7 * 3.5, + 1.4 * 3.5 - 0.25 * 3.5, + 0.0, + 1.4 * 3.5 - 0.5 * 3.5, + ), + abs=EPS, + ) + circle = [p for p in prims if isinstance(p, Circle)][0] + assert circle.center == (0.0, 0.0) + assert circle.radius == pytest.approx(0.4 * 3.5, abs=EPS) + + +async def test_a_process_reference_gets_the_tail_fork_at_the_far_end(): + prims = weld_symbol_prims(kind="fillet", process="ISO 4063 - 135", height=3.5) + reference = prims[0] + length = reference.p2[0] + fork = [ + p + for p in prims + if isinstance(p, Line) and p.p1 == pytest.approx((length, 0.0), abs=EPS) + ] + assert len(fork) == 2 + assert fork[0].p2 == pytest.approx((length + 0.8 * 3.5, 0.5 * 3.5), abs=EPS) + assert fork[1].p2 == pytest.approx((length + 0.8 * 3.5, -0.5 * 3.5), abs=EPS) + tail_text = [p for p in prims if isinstance(p, Text)][-1] + assert tail_text.text == "ISO 4063 - 135" + assert tail_text.at[0] == pytest.approx(length + 3.5, abs=EPS) +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_annotate.py -q` +Expected: FAIL at collection with +`ImportError: cannot import name 'WELD_KINDS' from 'engineering.mech.annotate'`. + +- [ ] **Step 7: Implement the weld symbol in `engineering/mech/annotate.py`** + +Append to the module (after `surface_texture_prims`): + +```python +# ── ISO 2553:2019 weld symbols ────────────────────────────────────────────── + +WELD_KINDS: tuple[str, ...] = ("square", "v", "bevel", "u", "j", "fillet") +WELD_SIDES: tuple[str, ...] = ("arrow", "other", "both") + +# Symbol width per kind, in text heights: the half symbols (bevel, J) are half +# as wide as the full ones they are half of. +_WELD_WIDTH_FACTOR: dict[str, float] = { + "square": 1.0, + "v": 1.0, + "bevel": 0.5, + "u": 1.0, + "j": 0.5, + "fillet": 1.0, +} + +# DECLARED layout constants for the weld annotation (ISO 2553 fixes none of +# these numerically; it fixes only what goes where). +IDENT_OFFSET_FACTOR = 0.4 # identification line offset below the reference line +IDENT_DASH_FACTOR = 0.8 # dash length of the identification line +IDENT_GAP_FACTOR = 0.4 # gap between its dashes +FLAG_POLE_FACTOR = 1.4 # field-weld flag pole height +KINK_CIRCLE_FACTOR = 0.4 # all-around circle radius at the kink +TAIL_FORK_FACTOR = 0.8 # tail fork length + + +def _weld_size_text(kind: str, size) -> str: + """The text written before the symbol. + + A string passes through verbatim, so `z7` (leg length) stays available; a + number on a fillet takes the `a` prefix, ISO 2553's design throat + thickness, because a bare number on a fillet weld is ambiguous. + """ + if size is None: + return "" + if isinstance(size, str): + return size.strip() + if kind == "fillet": + return f"a{float(size):g}" + return f"{float(size):g}" + + +def _elementary_prims( + kind: str, x0: float, y0: float, sense: float, h: float +) -> tuple[Prim, ...]: + """One elementary symbol standing on (x0, y0), growing in `sense` (+1/-1).""" + s = float(sense) + if kind == "square": + return ( + Line((x0 + 0.3 * h, y0), (x0 + 0.3 * h, y0 + s * h), "dim"), + Line((x0 + 0.7 * h, y0), (x0 + 0.7 * h, y0 + s * h), "dim"), + ) + if kind == "v": + return ( + Poly(((x0, y0 + s * h), (x0 + 0.5 * h, y0), (x0 + h, y0 + s * h)), False, "dim"), + ) + if kind == "bevel": + return ( + Line((x0, y0), (x0, y0 + s * h), "dim"), + Line((x0, y0), (x0 + 0.5 * h, y0 + s * h), "dim"), + ) + if kind == "u": + cy = y0 + s * 0.5 * h + start, end = (180.0, 360.0) if s > 0 else (0.0, 180.0) + return ( + Arc((x0 + 0.5 * h, cy), 0.5 * h, start, end, "dim"), + Line((x0, cy), (x0, y0 + s * h), "dim"), + Line((x0 + h, cy), (x0 + h, y0 + s * h), "dim"), + ) + if kind == "j": + cy = y0 + s * 0.5 * h + start, end = (270.0, 360.0) if s > 0 else (0.0, 90.0) + return ( + Arc((x0, cy), 0.5 * h, start, end, "dim"), + Line((x0 + 0.5 * h, cy), (x0 + 0.5 * h, y0 + s * h), "dim"), + Line((x0, y0), (x0, y0 + s * h), "dim"), + ) + # fillet + return (Poly(((x0, y0), (x0, y0 + s * h), (x0 + h, y0)), True, "dim"),) + + +def weld_symbol_prims( + *, + kind: str, + size=None, + length: float | None = None, + pitch: float | None = None, + side: str = "arrow", + field_weld: bool = False, + all_around: bool = False, + process: str | None = None, + height: float = 3.5, +) -> tuple[Prim, ...]: + """The ISO 2553 weld annotation in a local frame, kink at the origin. + + The kink is where the arrow line meets the reference line, so it is the + point a leader attaches to. The continuous reference line runs in +X; the + dashed identification line runs parallel below it and is omitted for a + symmetrical weld (`side="both"`), which ISO 2553:2019 permits. The + arrow-side symbol stands on the reference line, the other-side symbol + hangs below the identification line. + """ + if kind not in WELD_KINDS: + raise ValueError( + f"weld kind {kind!r} is not drawn by this server. The ISO 2553 elementary " + f"symbols transcribed here are: {', '.join(WELD_KINDS)}. The rest of the " + "elementary table is not shipped rather than guessed." + ) + if side not in WELD_SIDES: + raise ValueError(f"side {side!r} is unknown; use one of {', '.join(WELD_SIDES)}.") + h = float(height) + if not math.isfinite(h) or h <= 0.0: + raise ValueError(f"height must be a positive finite number; got {height!r}.") + if pitch is not None and length is None: + raise ValueError( + "pitch needs length: ISO 2553 writes the pitch in brackets after the weld length." + ) + + size_text = _weld_size_text(kind, size) + right_text = "" + if length is not None: + right_text = ( + f"{float(length):g}" + if pitch is None + else f"{float(length):g} ({float(pitch):g})" + ) + + ident_y = -IDENT_OFFSET_FACTOR * h + cursor = (1.0 if (field_weld or all_around) else 0.5) * h + x_size = cursor + if size_text: + cursor += CHAR_WIDTH_FACTOR * h * len(size_text) + 0.4 * h + x_symbol = cursor + cursor += _WELD_WIDTH_FACTOR[kind] * h + 0.4 * h + x_right = cursor + if right_text: + cursor += CHAR_WIDTH_FACTOR * h * len(right_text) + ref_length = max(cursor + 0.5 * h, 6.0 * h) + + prims: list[Prim] = [Line((0.0, 0.0), (ref_length, 0.0), "dim")] + + if side != "both": + x = 0.0 + dash, gap = IDENT_DASH_FACTOR * h, IDENT_GAP_FACTOR * h + while x < ref_length - 1e-9: + x_end = min(x + dash, ref_length) + prims.append(Line((x, ident_y), (x_end, ident_y), "dim")) + x = x_end + gap + + if side in ("arrow", "both"): + prims.extend(_elementary_prims(kind, x_symbol, 0.0, 1.0, h)) + if side in ("other", "both"): + prims.extend(_elementary_prims(kind, x_symbol, ident_y, -1.0, h)) + + if size_text: + prims.append(Text((x_size, 0.25 * h), size_text, h)) + if right_text: + prims.append(Text((x_right, 0.25 * h), right_text, h)) + + if field_weld: + top = FLAG_POLE_FACTOR * h + prims.append(Line((0.0, 0.0), (0.0, top), "dim")) + prims.append( + Poly( + ((0.0, top), (0.7 * h, top - 0.25 * h), (0.0, top - 0.5 * h)), + True, + "dim", + ) + ) + if all_around: + prims.append(Circle((0.0, 0.0), KINK_CIRCLE_FACTOR * h, "dim")) + + if process: + fork = TAIL_FORK_FACTOR * h + prims.append(Line((ref_length, 0.0), (ref_length + fork, 0.5 * h), "dim")) + prims.append(Line((ref_length, 0.0), (ref_length + fork, -0.5 * h), "dim")) + prims.append(Text((ref_length + h, -0.35 * h), str(process), h)) + return tuple(prims) +``` + +- [ ] **Step 8: Run the weld tests and see them pass** + +Run: `uv run --frozen pytest tests/test_mech_annotate.py -q` +Expected: 19 passed. + +- [ ] **Step 9: Write the failing test for the async drawing layer** + +Append to `tests/test_mech_annotate.py`: + +```python +# ── the async layer: primitives -> a real ezdxf document ──────────────────── + +from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests + draw_annotation_prims, + draw_surface_texture, + draw_weld_symbol, + leader_prims, +) +from engineering.mech.primitives import ROLE_LAYER # noqa: E402 + + +async def _types_and_layers(backend, handles): + rows = [] + for handle in handles: + info = await backend.entity_get(handle) + rows.append((info.type, info.layer)) + return rows + + +async def test_draw_annotation_prims_puts_every_role_on_its_own_layer(backend): + prims = ( + Line((0.0, 0.0), (10.0, 0.0), "dim"), + Circle((5.0, 5.0), 2.0, "dim"), + Text((0.0, 12.0), "Ra 3.2", 3.5), + ) + result = await draw_annotation_prims(backend, prims, at=(100.0, 50.0)) + assert result["ok"] is True and result["count"] == 3 + assert await _types_and_layers(backend, result["handles"]) == [ + ("LINE", ROLE_LAYER["dim"]), + ("CIRCLE", ROLE_LAYER["dim"]), + ("TEXT", ROLE_LAYER["text"]), + ] + line = await backend.entity_get(result["handles"][0]) + assert line.properties["start"] == pytest.approx([100.0, 50.0], abs=EPS) + assert line.properties["end"] == pytest.approx([110.0, 50.0], abs=EPS) + circle = await backend.entity_get(result["handles"][1]) + assert circle.properties["center"] == pytest.approx([105.0, 55.0], abs=EPS) + + +async def test_an_explicit_layer_overrides_every_role(backend): + result = await draw_annotation_prims( + backend, + (Line((0.0, 0.0), (1.0, 0.0), "dim"), Text((0.0, 2.0), "x", 3.5)), + layer="ANNOT", + ) + assert {layer for _type, layer in await _types_and_layers(backend, result["handles"])} == { + "ANNOT" + } + + +async def test_leader_prims_reuse_the_repository_arrowhead_proportions(): + line, head = leader_prims((0.0, 0.0), (10.0, 0.0), arrow_size=2.5) + assert isinstance(line, Line) and isinstance(head, Poly) and head.closed + assert _flat(head.points) == pytest.approx( + (0.0, 0.0, 2.5, 2.5 * 0.35, 2.5, -2.5 * 0.35), abs=EPS + ) + with pytest.raises(ValueError) as excinfo: + leader_prims((1.0, 1.0), (1.0, 1.0)) + assert "distinct" in str(excinfo.value) + + +async def test_draw_surface_texture_draws_the_symbol_and_its_leader(backend): + result = await draw_surface_texture( + backend, at=(40.0, 60.0), leader_to=(20.0, 50.0), ra=3.2, machining="required" + ) + assert result["ok"] is True and result["standard"] == "ISO 21920-1" + rows = await _types_and_layers(backend, result["handles"]) + # 3 vee/bar lines + extension line + the Ra text + leader line + arrowhead. + assert [t for t, _ in rows] == [ + "LINE", + "LINE", + "LINE", + "LINE", + "TEXT", + "LINE", + "LWPOLYLINE", + ] + apex = await backend.entity_get(result["handles"][0]) + assert apex.properties["start"] == pytest.approx([40.0, 60.0], abs=EPS) + leader = await backend.entity_get(result["handles"][5]) + assert leader.properties["start"] == pytest.approx([20.0, 50.0], abs=EPS) + assert leader.properties["end"] == pytest.approx([40.0, 60.0], abs=EPS) + + +async def test_draw_weld_symbol_refuses_an_unknown_kind_before_touching_the_drawing(backend): + before = (await backend.analysis_stats())["total_entities"] + with pytest.raises(ValueError): + await draw_weld_symbol(backend, at=(0.0, 0.0), kind="seam") + assert (await backend.analysis_stats())["total_entities"] == before + + +async def test_draw_weld_symbol_places_the_kink_at_the_given_point(backend): + result = await draw_weld_symbol( + backend, at=(10.0, 20.0), kind="fillet", size=5, length=50, leader_to=(0.0, 10.0) + ) + assert result["ok"] is True + reference = await backend.entity_get(result["handles"][0]) + assert reference.properties["start"] == pytest.approx([10.0, 20.0], abs=EPS) + assert reference.properties["end"][1] == pytest.approx(20.0, abs=EPS) + texts = [] + for handle in result["handles"]: + info = await backend.entity_get(handle) + if info.type == "TEXT": + texts.append(info.properties["text"]) + assert texts == ["a5", "50"] +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_annotate.py -q` +Expected: FAIL at collection with +`ImportError: cannot import name 'draw_annotation_prims' from 'engineering.mech.annotate'`. + +- [ ] **Step 11: Implement the async layer in `engineering/mech/annotate.py`** + +Append to the module: + +```python +# ── the async layer ───────────────────────────────────────────────────────── +# +# Task 7's `engineering/mech/draw.py` is a sibling branch (group M) and is not +# importable from here, so the annotation tools carry their own small renderer. +# It is deliberately named `draw_annotation_prims`, not `draw_prims`, so the +# two coexist after the merge instead of colliding on a re-export. + +_DRAWABLE = (Line, Circle, Arc, Poly, Text) + + +def leader_prims( + tip: tuple[float, float], + to: tuple[float, float], + *, + arrow_size: float = 2.5, + role: str = "dim", +) -> tuple[Prim, ...]: + """A plain leader: a line from `tip` to `to` plus a solid arrowhead at `tip`. + + `leader_create_mleader` is not used here: it refuses empty text + (`engineering/annotation.py::validate_mleader`), and a weld or surface + symbol needs the leader *without* an MTEXT label beside it. The arrowhead + reproduces the proportions that backend already draws (length `arrow_size`, + half-width 0.35 * `arrow_size`) so both routes look identical. + """ + x0, y0 = float(tip[0]), float(tip[1]) + x1, y1 = float(to[0]), float(to[1]) + dx, dy = x1 - x0, y1 - y0 + length = math.hypot(dx, dy) + if length <= 1e-12: + raise ValueError( + f"leader_to ({x0:g}, {y0:g}) and the symbol position ({x1:g}, {y1:g}) " + "must be distinct points." + ) + ux, uy = dx / length, dy / length + px, py = -uy, ux + a = float(arrow_size) + return ( + Line((x0, y0), (x1, y1), role), + Poly( + ( + (x0, y0), + (x0 + ux * a + px * a * 0.35, y0 + uy * a + py * a * 0.35), + (x0 + ux * a - px * a * 0.35, y0 + uy * a - py * a * 0.35), + ), + True, + role, + ), + ) + + +async def draw_annotation_prims( + backend: AutoCADBackend, + prims, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + layer: str | None = None, +) -> dict: + """Draw primitive descriptions through the generic entity contracts. + + Rotates about the symbol origin first, then translates to `at`, so every + coordinate that reaches the backend is already WCS. Each primitive lands on + `ROLE_LAYER[role]` unless `layer` overrides it. + """ + placed = tuple(prims) + for prim in placed: + if not isinstance(prim, _DRAWABLE): + raise TypeError( + f"draw_annotation_prims cannot draw a {type(prim).__name__}; it draws " + "Line, Circle, Arc, Poly and Text." + ) + if rotation: + placed = rotate(placed, float(rotation)) + placed = translate(placed, float(at[0]), float(at[1])) + + handles: list[str] = [] + for prim in placed: + target = layer or ROLE_LAYER[prim.role] + if isinstance(prim, Line): + info = await backend.entity_create_line( + prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], 0.0, 0.0, target + ) + elif isinstance(prim, Circle): + info = await backend.entity_create_circle( + prim.center[0], prim.center[1], prim.radius, target + ) + elif isinstance(prim, Arc): + info = await backend.entity_create_arc( + prim.center[0], + prim.center[1], + prim.radius, + prim.start_deg, + prim.end_deg, + target, + ) + elif isinstance(prim, Poly): + info = await backend.entity_create_polyline( + [[float(p[0]), float(p[1])] for p in prim.points], prim.closed, target + ) + else: # Text + info = await backend.entity_create_text( + prim.text, prim.at[0], prim.at[1], prim.height, prim.rotation, target + ) + handles.append(info.handle) + return {"ok": True, "handles": handles, "count": len(handles)} + + +async def draw_surface_texture( + backend: AutoCADBackend, + *, + at: tuple[float, float], + leader_to: tuple[float, float] | None = None, + ra: float | None = None, + rz: float | None = None, + process: str | None = None, + lay: str | None = None, + machining: str = "any", + all_around: bool = False, + allowance: float | None = None, + height: float = 3.5, + standard: str = "ISO 21920-1", + rotation: float = 0.0, + layer: str | None = None, +) -> dict: + """Place a surface-texture symbol with its apex at `at`.""" + prims = surface_texture_prims( + ra=ra, + rz=rz, + process=process, + lay=lay, + machining=machining, + all_around=all_around, + height=height, + allowance=allowance, + standard=standard, + ) + result = await draw_annotation_prims(backend, prims, at=at, rotation=rotation, layer=layer) + if leader_to is not None: + leader = leader_prims( + (float(leader_to[0]), float(leader_to[1])), + (float(at[0]), float(at[1])), + arrow_size=0.7 * float(height), + ) + drawn = await draw_annotation_prims(backend, leader, layer=layer) + result["handles"].extend(drawn["handles"]) + result["count"] = len(result["handles"]) + result["standard"] = standard + return result + + +async def draw_weld_symbol( + backend: AutoCADBackend, + *, + at: tuple[float, float], + kind: str, + leader_to: tuple[float, float] | None = None, + size=None, + length: float | None = None, + pitch: float | None = None, + side: str = "arrow", + field_weld: bool = False, + all_around: bool = False, + process: str | None = None, + height: float = 3.5, + rotation: float = 0.0, + layer: str | None = None, +) -> dict: + """Place an ISO 2553 weld annotation with its kink at `at`.""" + prims = weld_symbol_prims( + kind=kind, + size=size, + length=length, + pitch=pitch, + side=side, + field_weld=field_weld, + all_around=all_around, + process=process, + height=height, + ) + result = await draw_annotation_prims(backend, prims, at=at, rotation=rotation, layer=layer) + if leader_to is not None: + leader = leader_prims( + (float(leader_to[0]), float(leader_to[1])), + (float(at[0]), float(at[1])), + arrow_size=0.7 * float(height), + ) + drawn = await draw_annotation_prims(backend, leader, layer=layer) + result["handles"].extend(drawn["handles"]) + result["count"] = len(result["handles"]) + result["kind"] = kind + result["side"] = side + return result +``` + +- [ ] **Step 12: Run the whole test module and see it pass** + +Run: `uv run --frozen pytest tests/test_mech_annotate.py -q` +Expected: 26 passed. + +- [ ] **Step 13: Add the two alias records to `discovery/aliases.py`** + +Insert into `TOOL_ALIASES`, immediately after the `"gd_frame"` record (which +ends the GD&T block, just before the `# ── Dimensions ──` comment), so the +mechanical-annotation records sit together: + +```python + # ── Mechanical annotation (ISO 21920-1 / ISO 2553) ────────────────────── + "surface_texture": ToolAliases( + synonyms=( + "surface finish", + "surface roughness", + "surface texture symbol", + "roughness symbol", + "ra value", + "rz value", + "machining symbol", + "yuzey puruzlulugu", + "yuzey isleme sembolu", + ), + ), + "weld_symbol": ToolAliases( + synonyms=( + "weld symbol", + "welding symbol", + "fillet weld", + "butt weld", + "weld callout", + "field weld", + "weld all around", + "kaynak sembolu", + "kose kaynagi", + ), + ), +``` + +Both records carry an empty `acad`: plain AutoCAD has no surface-texture or +weld-symbol command (those are AutoCAD *Mechanical* commands, `AMSURFSYM` / +`AMWELDSYM`, which this corpus does not cover), and the corpus rule is that a +wrong alias misroutes a search. + +- [ ] **Step 14: Register SECTION 23 in `server.py`** + +Insert immediately **above** the `# ── RESOURCES ──` banner block (currently at +`server.py:8126`, the three-line `# ---` / `# ── RESOURCES ──` / `# ---` +comment), so SECTION 20's decorator count is unaffected: + +```python +# --------------------------------------------------------------------------- +# ── SECTION 23: Mechanical Annotation (2 tools) ───────────────────────────── +# --------------------------------------------------------------------------- +# ISO annotation symbols composed from LINE / ARC / CIRCLE / LWPOLYLINE / TEXT, +# so the same symbol lands on COM and ezdxf. The standards tables live in +# engineering/mech/annotate.py with their sources; a value outside a +# transcribed table is refused there, before the first entity is written. + + +@cad_tool( + summary="Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Surface Texture Symbol (ISO 21920-1)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def surface_texture( + x: Annotated[float, "Symbol apex X (WCS) — the point the leader attaches to."], + y: Annotated[float, "Symbol apex Y (WCS)."], + ra: Annotated[ + float | None, Field(default=None, description="Ra value in µm, written as 'Ra 3.2'.") + ] = None, + rz: Annotated[ + float | None, Field(default=None, description="Rz value in µm, written as 'Rz 12.5'.") + ] = None, + machining: Annotated[ + str, + Field( + default="any", + description=( + "any (basic vee) | required (bar across the vee, material removal " + "required) | prohibited (circle in the vee, material removal not allowed)." + ), + ), + ] = "any", + process: Annotated[ + str | None, "Manufacturing method, treatment or coating, e.g. 'milled'." + ] = None, + lay: Annotated[ + str | None, + Field( + default=None, + description=( + "Direction of lay: parallel, perpendicular, crossed, multidirectional, " + "circular, radial, particulate." + ), + ), + ] = None, + allowance: Annotated[ + float | None, "Machining allowance in mm, written below the extension line." + ] = None, + all_around: Annotated[bool, "Circle at the kink: the requirement applies all around."] = False, + leader_to: Annotated[ + list[float] | None, "[x, y] on the surface; draws a leader with an arrowhead there." + ] = None, + height: Annotated[ + float, + Field(default=3.5, gt=0, description="Text height (mm). ISO 1302 tabulates 2.5, 3.5, 5, 7, 10, 14, 20."), + ] = 3.5, + rotation: Annotated[float, "Rotate the whole symbol about its apex (degrees CCW)."] = 0.0, + standard: Annotated[ + str, "ISO 21920-1 (default) or ISO 1302 for drawings issued under the older designation." + ] = "ISO 21920-1", + layer: Annotated[str | None, "Override the role layers (DIM for geometry, TEXT for text)."] = None, + ctx: Context = None, +) -> dict: + """Draw the 60° surface-texture vee with its annotation in the standard positions. + + Refusals, all before the first entity reaches the drawing: a text height + outside the seven ISO 1302 rows (2.5/3.5/5/7/10/14/20 mm — never + interpolated), a `lay` that is not one of the seven ISO 1302 directions of + lay, a `machining` outside any/required/prohibited, and a `standard` other + than ISO 21920-1 or ISO 1302. + """ + from engineering.mech.annotate import draw_surface_texture + + await ctx.debug(f"Surface texture symbol at ({x}, {y})") + return await draw_surface_texture( + _backend(ctx), + at=(x, y), + leader_to=tuple(leader_to) if leader_to else None, + ra=ra, + rz=rz, + process=process, + lay=lay, + machining=machining, + all_around=all_around, + allowance=allowance, + height=height, + standard=standard, + rotation=rotation, + layer=layer, + ) + + +@cad_tool( + summary="Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Weld Symbol (ISO 2553)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def weld_symbol( + x: Annotated[float, "Kink X (WCS) — where the arrow line meets the reference line."], + y: Annotated[float, "Kink Y (WCS)."], + kind: Annotated[ + str, "Elementary symbol: square | v | bevel | u | j | fillet." + ] = "fillet", + size: Annotated[ + float | str | None, + Field( + default=None, + description=( + "Written before the symbol. A number on a fillet takes the ISO 2553 " + "design-throat prefix (5 → 'a5'); pass a string for leg length ('z7')." + ), + ), + ] = None, + length: Annotated[float | None, "Weld length (mm), written after the symbol."] = None, + pitch: Annotated[ + float | None, "Pitch (mm) of an intermittent weld, written in brackets after the length." + ] = None, + side: Annotated[ + str, + Field( + default="arrow", + description=( + "arrow (symbol on the reference line) | other (on the dashed " + "identification line) | both (symmetrical; the identification line is " + "omitted, per ISO 2553:2019)." + ), + ), + ] = "arrow", + field_weld: Annotated[bool, "Flag at the kink: weld made on site."] = False, + all_around: Annotated[bool, "Circle at the kink: weld all around."] = False, + process: Annotated[ + str | None, "Tail reference, e.g. an ISO 4063 process number." + ] = None, + leader_to: Annotated[ + list[float] | None, "[x, y] on the joint; draws a leader with an arrowhead there." + ] = None, + height: Annotated[float, Field(default=3.5, gt=0, description="Text height (mm).")] = 3.5, + rotation: Annotated[float, "Rotate the whole annotation about the kink (degrees CCW)."] = 0.0, + layer: Annotated[str | None, "Override the role layers (DIM for geometry, TEXT for text)."] = None, + ctx: Context = None, +) -> dict: + """Draw an ISO 2553 weld annotation: reference line, identification line, symbol, dimensions. + + Refusals, all before the first entity reaches the drawing: a `kind` outside + the six elementary symbols transcribed here (square, v, bevel, u, j, + fillet — the rest of the ISO 2553 table is deliberately not shipped rather + than guessed), a `side` outside arrow/other/both, a non-positive `height`, + and a `pitch` given without a `length`. + """ + from engineering.mech.annotate import draw_weld_symbol + + await ctx.debug(f"Weld symbol {kind} at ({x}, {y})") + return await draw_weld_symbol( + _backend(ctx), + at=(x, y), + kind=kind, + leader_to=tuple(leader_to) if leader_to else None, + size=size, + length=length, + pitch=pitch, + side=side, + field_weld=field_weld, + all_around=all_around, + process=process, + height=height, + rotation=rotation, + layer=layer, + ) +``` + +- [ ] **Step 15: Run the registration gates and update the three count files** + +Run, in order: + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py -q +``` + +Then edit the two frozen counts. In `tests/test_tool_registry.py`, inside +`test_tool_group_sizes_are_unchanged`, change `"engineering": 10,` to +`"engineering": 12,` and `assert sum(sizes.values()) == 204` to +`assert sum(sizes.values()) == 206`, and add this sentence to that test's +docstring, after the `pid` paragraph: + +``` + `engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with + `surface_texture` / `weld_symbol`; both are tagged `engineering` and `mech`, + and `mech` is not in `_GROUP_TAG_PRIORITY`, so they file under + `engineering` rather than opening a group of their own. +``` + +In `README.md`, change the release-snapshot line +`> **v1.5 release snapshot:** 204 tools ·` to `206 tools ·`, and the +`204 tools in 23 groups;` line to `206 tools in 23 groups;`. +Leave the discovery paragraph's "186 AutoCAD command names and 933 synonym +phrases covering all 204 tools" alone: those three figures are the alias +corpus's own totals, group F owns the corpus, and Task 15 retakes them on +main. Only the release-snapshot line is gated +(`scripts/check_doc_numbers.py::CHECKS`). + +Run: `uv run --frozen pytest tests/test_tool_registry.py tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: all pass. (`test_document_numbers_match_the_tree` is deselected only +for the known README "collected tests" drift named in Global Constraints; +`test_readme_snapshot_counts_match_registrations` and +`test_section_header_counts_match_decorators` must both pass here — the latter +is what proves the `(2 tools)` banner is true.) + +- [ ] **Step 16: Lint, format and run the full affected suite** + +``` +uv run --frozen ruff check engineering/mech/annotate.py server.py discovery/aliases.py tests/test_mech_annotate.py tests/test_tool_registry.py +uv run --frozen ruff format engineering/mech/annotate.py server.py discovery/aliases.py tests/test_mech_annotate.py tests/test_tool_registry.py +uv run --frozen pytest tests/test_mech_annotate.py tests/test_tool_registry.py tests/test_tool_inventory.py tests/test_discovery_aliases.py tests/test_capability_contract.py -q +``` + +Expected: ruff clean, all tests pass. + +- [ ] **Step 17: Commit** + +``` +git add engineering/mech/annotate.py tests/test_mech_annotate.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -F - <<'MSG' +feat(mech): SECTION 23 opens with surface_texture and weld_symbol + +ISO 1302:2002's seven transcribed symbol-proportion rows (text height -> +line width d', short-leg height H1, minimum height H2) drive the 60 deg +vee, the machining-required bar at H1, the machining-prohibited circle +inscribed in the vee, and the all-around circle at the kink; the Ra/Rz +values, the process, the lay symbol and the machining allowance land +around the extension line, which grows with the widest of them. A text +height outside the seven rows is refused by name rather than +interpolated, and ISO 1302 is accepted as an alias designation for +drawings still issued under it - the graphical symbol is the same one. + +ISO 2553:2019's reference line, dashed identification line (omitted for a +symmetrical weld, as the standard permits), size written before the +symbol, length and pitch written after it, field-weld flag, all-around +circle and process tail. Six elementary symbols are transcribed - square, +single-V, single-bevel, single-U, single-J, fillet. The rest of the +elementary table is deliberately not shipped: a weld kind outside the six +is refused with the list of the six, because a guessed weld symbol is a +wrong instruction to a welder. + +Both tools place the symbol on a plain leader - a line plus an arrowhead +with the proportions the backend's own mleader already draws - rather +than through leader_create_mleader, which refuses empty text and would +hang an MTEXT label off every symbol. + +Proportions neither standard dimensions (the all-around circles, the +flag, the identification line's dashes, the text pitch and the character +width estimate) are named constants documented as this implementation's +declared choices, never as transcribed values. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +``` + +--- + +### Task 11: SECTION 23 completion — centre marks, section line, material hatch + +**Files:** +- Create: `engineering/mech/marks.py` +- Create: `tests/test_mech_marks.py` +- Modify: `server.py` (SECTION 23 banner `(2 tools)` → `(5 tools)`; append + `centre_marks`, `section_line`, `hatch_material` inside SECTION 23) +- Modify: `discovery/aliases.py` (three `ToolAliases` records) +- Modify: `docs/tool-inventory.json` (regenerated) +- Modify: `README.md` (release-snapshot tool count 206 → 209, and the + `206 tools in 23 groups` line) +- Modify: `tests/test_tool_registry.py` (frozen group snapshot: `engineering` + 12 → 15, total 206 → 209) +- Test: `tests/test_mech_marks.py` + +**Interfaces:** +- Consumes (Task 1, on `main`): `engineering.mech.primitives.ROLE_LAYER`, + `Line`, `Text`, `Prim`; + `engineering.mech.standards.materials.hatch_for(material) -> dict` + (`{"pattern", "angle", "scale"}`) and `MATERIALS: tuple[str, ...]`. +- Consumes (Task 10, same branch): + `engineering.mech.annotate.draw_annotation_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer=None) -> dict`. +- Consumes (backend contracts, unchanged): + `entity_get(handle) -> EntityInfo` (CIRCLE/ARC carry + `properties["center"]` and `properties["radius"]`; `radius` is **absent** + when the entity's plane is tilted out of WCS XY), + `boundary_from_entities(handles, tolerance=1e-9) -> dict` + (`{"ok", "handle", …}`; a `@capability("boundary_trace")` member — the COM + engine refuses it), + `entity_create_hatch(pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None) -> EntityInfo`. +- Produces: + ```python + SECTION_STYLES: tuple[str, ...] # ("full", "half", "offset", "revolved") + CENTRE_STYLES: tuple[str, ...] # ("mark", "lines") + def centre_mark_prims(center, radius, *, style="mark", extension=3.0) -> tuple[Prim, ...] + def section_line_prims(p1, p2, *, label="A", direction=(0.0, -1.0), height=5.0) -> tuple[Prim, ...] + def section_plane(p1, p2, *, label="A", direction=(0.0, -1.0), style="full") -> dict + # exactly {"p1","p2","label","direction","style"} — the skeleton's SECTION_PLANE + async def draw_centre_marks(backend, *, handles=None, centers=None, style="mark", + extension=3.0, layer=None) -> dict + async def draw_section_line(backend, p1, p2, *, label="A", direction=(0.0, -1.0), + style="full", height=5.0, layer=None) -> dict + async def draw_material_hatch(backend, *, boundary=None, handles=None, material="steel", + scale=1.0, angle=None, layer=None) -> dict + ``` + +- [ ] **Step 1: Write the failing test for the centre-mark geometry** + +Create `tests/test_mech_marks.py`: + +```python +"""Centre marks (ISO 128-23), the cutting-plane line (ISO 128-40) and material hatch. + +Pure geometry asserted as emitted primitive tuples; the async layer against a +real ezdxf document through the `backend` fixture. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.marks import ( + CENTRE_STYLES, + centre_mark_prims, +) +from engineering.mech.primitives import Line + +pytestmark = pytest.mark.asyncio + +EPS = 1e-9 + + +def _flat(points): + """Flatten a point sequence: pytest.approx refuses nested structures.""" + return tuple(float(value) for point in points for value in point) + + +async def test_a_centre_mark_is_a_small_cross_on_the_centre_role(): + prims = centre_mark_prims((10.0, 20.0), 5.0, style="mark", extension=3.0) + assert len(prims) == 2 + horizontal, vertical = prims + assert isinstance(horizontal, Line) and isinstance(vertical, Line) + assert horizontal.p1 == pytest.approx((7.0, 20.0), abs=EPS) + assert horizontal.p2 == pytest.approx((13.0, 20.0), abs=EPS) + assert vertical.p1 == pytest.approx((10.0, 17.0), abs=EPS) + assert vertical.p2 == pytest.approx((10.0, 23.0), abs=EPS) + assert horizontal.role == "center" and vertical.role == "center" + + +async def test_centre_lines_cross_the_circle_and_overrun_it_by_the_extension(): + prims = centre_mark_prims((10.0, 20.0), 5.0, style="lines", extension=3.0) + horizontal, vertical = prims + assert horizontal.p1 == pytest.approx((2.0, 20.0), abs=EPS) + assert horizontal.p2 == pytest.approx((18.0, 20.0), abs=EPS) + assert vertical.p1 == pytest.approx((10.0, 12.0), abs=EPS) + assert vertical.p2 == pytest.approx((10.0, 28.0), abs=EPS) + assert horizontal.role == "center" and vertical.role == "center" + + +async def test_the_style_enum_is_closed_and_a_bad_radius_is_refused(): + assert CENTRE_STYLES == ("mark", "lines") + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 5.0, style="cross") + assert "mark, lines" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 0.0, style="lines") + assert "radius" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 5.0, extension=0.0) + assert "extension" in str(excinfo.value) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_marks.py -q` +Expected: FAIL at collection with +`ModuleNotFoundError: No module named 'engineering.mech.marks'`. + +- [ ] **Step 3: Create `engineering/mech/marks.py` with the centre-mark geometry** + +```python +"""Centre marks, the cutting-plane line and the material hatch. + +Pure geometry: these functions return primitive descriptions from +`engineering.mech.primitives`; the `draw_*` functions are the only async part +and the only part that touches a backend. + +SOURCE - centre marks and centre lines: ISO 128-23, "Technical drawings - +General principles of presentation - Lines on construction drawings", which +draws a centre line as a long-dash-dotted line that overruns the feature's +outline by a short amount. The *dash pattern itself is not written here*: the +prims are plain LINE entities on the CENTER layer, whose CENTER linetype +(bootstrapped by `drawing_new`) supplies the standard's dash proportions. That +is deliberate - transcribing a dash table this module cannot verify would put +an invented pattern next to a real one. + +SOURCE - the cutting-plane line: ISO 128-40, "Technical drawings - General +principles of presentation - Basic conventions for cuts and sections": a +long-dash-dotted line that is *wide* at its ends and at every change of +direction, an arrow at each end pointing in the direction of viewing, and the +same capital letter at both ends. Line width is a layer property here, so the +wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin +middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed +in the role vocabulary this repository already has. + +DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that +arrows are placed there; it does not dimension them. The end-segment length, +the arrow shaft length, the arrowhead size and the label offset below are this +implementation's stated choices, expressed in text heights. + +The material hatch is not authored here at all: it comes from +`engineering.mech.standards.materials.hatch_for`, which carries the ISO 128-50 +map and its own refusal. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Line, Prim, Text + +if TYPE_CHECKING: # pragma: no cover - typing only + from backends.base import AutoCADBackend + +__all__ = [ + "CENTRE_STYLES", + "SECTION_STYLES", + "centre_mark_prims", + "draw_centre_marks", + "draw_material_hatch", + "draw_section_line", + "section_line_prims", + "section_plane", +] + +CENTRE_STYLES: tuple[str, ...] = ("mark", "lines") + +# The four styles `views.build_view(kind="section", style=...)` accepts; the +# dict this module returns is fed straight to it. +SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved") + +# DECLARED layout constants for the cutting-plane line, in text heights. +END_SEGMENT_FACTOR = 2.0 # wide segment at each end +ARROW_SHAFT_FACTOR = 2.0 # arrow shaft length, perpendicular to the plane +ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere) +LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label + + +def centre_mark_prims( + center: tuple[float, float], + radius: float, + *, + style: str = "mark", + extension: float = 3.0, +) -> tuple[Prim, ...]: + """A centre mark or a pair of full centre lines for a circular feature. + + `style="mark"` draws the short cross AutoCAD's DIMCEN draws: arms of + `extension` either side of the centre. `style="lines"` draws the two full + centre lines, overrunning the circle by `extension` at each end, which is + ISO 128-23's short overrun of the outline. + """ + if style not in CENTRE_STYLES: + raise ValueError(f"centre style {style!r} is unknown; use one of {', '.join(CENTRE_STYLES)}.") + ext = float(extension) + if not math.isfinite(ext) or ext <= 0.0: + raise ValueError(f"extension must be a positive finite number; got {extension!r}.") + cx, cy = float(center[0]), float(center[1]) + if style == "mark": + arm = ext + else: + r = float(radius) + if not math.isfinite(r) or r <= 0.0: + raise ValueError( + f"style='lines' needs the feature's radius to know how far to overrun it; " + f"got radius={radius!r}." + ) + arm = r + ext + return ( + Line((cx - arm, cy), (cx + arm, cy), "center"), + Line((cx, cy - arm), (cx, cy + arm), "center"), + ) +``` + +- [ ] **Step 4: Run the centre-mark tests and see them pass** + +Run: `uv run --frozen pytest tests/test_mech_marks.py -q` +Expected: 3 passed. + +- [ ] **Step 5: Write the failing test for the cutting-plane line and the plane dict** + +Append to `tests/test_mech_marks.py`: + +```python +# ── ISO 128-40 cutting-plane line ─────────────────────────────────────────── + +from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests + SECTION_STYLES, + section_line_prims, + section_plane, +) +from engineering.mech.primitives import Poly, Text # noqa: E402 + + +async def test_the_cutting_plane_line_is_thick_at_the_ends_and_thin_between(): + prims = section_line_prims((0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0) + head_end, tail_end, middle = prims[0], prims[1], prims[2] + assert head_end.p1 == pytest.approx((0.0, 0.0), abs=EPS) + assert head_end.p2 == pytest.approx((10.0, 0.0), abs=EPS) + assert head_end.role == "visible" + assert tail_end.p1 == pytest.approx((90.0, 0.0), abs=EPS) + assert tail_end.p2 == pytest.approx((100.0, 0.0), abs=EPS) + assert tail_end.role == "visible" + assert middle.p1 == pytest.approx((10.0, 0.0), abs=EPS) + assert middle.p2 == pytest.approx((90.0, 0.0), abs=EPS) + assert middle.role == "center" + + +async def test_a_short_cutting_plane_is_one_wide_segment_with_no_thin_middle(): + prims = section_line_prims((0.0, 0.0), (15.0, 0.0), height=5.0) + assert prims[0].p1 == pytest.approx((0.0, 0.0), abs=EPS) + assert prims[0].p2 == pytest.approx((15.0, 0.0), abs=EPS) + assert prims[0].role == "visible" + assert not [p for p in prims if isinstance(p, Line) and p.role == "center"] + + +async def test_each_end_gets_an_arrow_pointing_the_viewing_direction_and_the_same_letter(): + prims = section_line_prims((0.0, 0.0), (100.0, 0.0), label="B", direction=(0.0, -1.0), height=5.0) + shafts = [p for p in prims if isinstance(p, Line) and p.p1[0] == p.p2[0]] + assert len(shafts) == 2 + # The shaft comes from outside and the head's tip sits on the line end. + assert shafts[0].p1 == pytest.approx((0.0, 10.0), abs=EPS) + assert shafts[0].p2 == pytest.approx((0.0, 0.0), abs=EPS) + assert shafts[1].p1 == pytest.approx((100.0, 10.0), abs=EPS) + assert shafts[1].p2 == pytest.approx((100.0, 0.0), abs=EPS) + heads = [p for p in prims if isinstance(p, Poly)] + assert len(heads) == 2 and all(h.closed for h in heads) + assert _flat(heads[0].points) == pytest.approx( + (0.0, 0.0, 1.4, 4.0, -1.4, 4.0), abs=EPS + ) + labels = [p for p in prims if isinstance(p, Text)] + assert [t.text for t in labels] == ["B", "B"] + assert labels[0].at == pytest.approx((0.0, 13.5), abs=EPS) + assert labels[1].at == pytest.approx((100.0, 13.5), abs=EPS) + assert all(t.height == 5.0 and t.role == "text" for t in labels) + + +async def test_a_viewing_direction_parallel_to_the_plane_is_refused(): + with pytest.raises(ValueError) as excinfo: + section_line_prims((0.0, 0.0), (100.0, 0.0), direction=(1.0, 0.0)) + assert "perpendicular" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + section_line_prims((0.0, 0.0), (0.0, 0.0), direction=(0.0, -1.0)) + assert "distinct" in str(excinfo.value) + + +async def test_section_plane_is_exactly_the_dict_the_view_engine_consumes(): + plane = section_plane((0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -2.0)) + assert plane == { + "p1": (0.0, 0.0), + "p2": (100.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", + } + assert list(plane) == ["p1", "p2", "label", "direction", "style"] + assert SECTION_STYLES == ("full", "half", "offset", "revolved") + with pytest.raises(ValueError) as excinfo: + section_plane((0.0, 0.0), (10.0, 0.0), style="broken") + assert "full, half, offset, revolved" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + section_plane((0.0, 0.0), (10.0, 0.0), label="") + assert "label" in str(excinfo.value) +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_marks.py -q` +Expected: FAIL at collection with +`ImportError: cannot import name 'SECTION_STYLES' from 'engineering.mech.marks'`. + +- [ ] **Step 7: Implement the cutting-plane line and the plane dict** + +Append to `engineering/mech/marks.py`: + +```python +def _unit(vx: float, vy: float, what: str) -> tuple[float, float]: + length = math.hypot(vx, vy) + if length <= 1e-12: + raise ValueError(f"{what} must be a non-zero vector; got ({vx:g}, {vy:g}).") + return vx / length, vy / length + + +def section_line_prims( + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + height: float = 5.0, +) -> tuple[Prim, ...]: + """The ISO 128-40 cutting-plane line: wide ends, arrows, the letter at both ends. + + `direction` is the direction of viewing and must not be parallel to the + plane. The arrowhead's tip sits on the end of the cutting-plane line and + its shaft extends away from the part, so the head reads as pointing the way + the section is viewed; the letter goes beyond the tail. + """ + if not str(label).strip(): + raise ValueError("a cutting-plane line needs a label; ISO 128-40 letters both ends.") + h = float(height) + if not math.isfinite(h) or h <= 0.0: + raise ValueError(f"height must be a positive finite number; got {height!r}.") + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + ux, uy = _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)") + dx, dy = _unit(float(direction[0]), float(direction[1]), "direction") + if abs(ux * dy - uy * dx) < 1e-6: + raise ValueError( + f"direction ({dx:g}, {dy:g}) is parallel to the cutting plane; the viewing " + "direction must be perpendicular to it." + ) + + span = math.hypot(x2 - x1, y2 - y1) + end = END_SEGMENT_FACTOR * h + prims: list[Prim] = [] + if span <= 2.0 * end + 1e-9: + prims.append(Line((x1, y1), (x2, y2), "visible")) + else: + a = (x1 + ux * end, y1 + uy * end) + b = (x2 - ux * end, y2 - uy * end) + prims.append(Line((x1, y1), a, "visible")) + prims.append(Line(b, (x2, y2), "visible")) + prims.append(Line(a, b, "center")) + + shaft = ARROW_SHAFT_FACTOR * h + head = ARROW_HEAD_FACTOR * h + px, py = -dy, dx + text = str(label).strip() + for tip in ((x1, y1), (x2, y2)): + tail = (tip[0] - dx * shaft, tip[1] - dy * shaft) + prims.append(Line(tail, tip, "visible")) + base = (tip[0] - dx * head, tip[1] - dy * head) + prims.append( + Poly( + ( + tip, + (base[0] + px * head * 0.35, base[1] + py * head * 0.35), + (base[0] - px * head * 0.35, base[1] - py * head * 0.35), + ), + True, + "visible", + ) + ) + for tip in ((x1, y1), (x2, y2)): + offset = shaft + LABEL_OFFSET_FACTOR * h + prims.append(Text((tip[0] - dx * offset, tip[1] - dy * offset), text, h)) + return tuple(prims) + + +def section_plane( + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + style: str = "full", +) -> dict: + """The plane definition `mech_view_add(kind="section")` consumes. + + Exactly the five keys the plan pins as SECTION_PLANE, in that order, with + `direction` normalised to a unit vector. Returning it from the tool that + draws the cutting-plane line is what makes the label on the plan and the + label on the section view the same string by construction. + """ + if style not in SECTION_STYLES: + raise ValueError(f"section style {style!r} is unknown; use one of {', '.join(SECTION_STYLES)}.") + text = str(label).strip() + if not text: + raise ValueError("a section plane needs a label; ISO 128-40 letters both ends.") + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)") + dx, dy = _unit(float(direction[0]), float(direction[1]), "direction") + return { + "p1": (x1, y1), + "p2": (x2, y2), + "label": text, + "direction": (dx, dy), + "style": style, + } +``` + +Also extend the module's imports at the top — replace +`from engineering.mech.primitives import Line, Prim, Text` with: + +```python +from engineering.mech.primitives import Line, Poly, Prim, Text +``` + +- [ ] **Step 8: Run the cutting-plane tests and see them pass** + +Run: `uv run --frozen pytest tests/test_mech_marks.py -q` +Expected: 8 passed. + +- [ ] **Step 9: Write the failing test for the async layer** + +Append to `tests/test_mech_marks.py`: + +```python +# ── the async layer ───────────────────────────────────────────────────────── + +from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests + draw_centre_marks, + draw_material_hatch, + draw_section_line, +) +from engineering.mech.primitives import ROLE_LAYER # noqa: E402 +from engineering.mech.standards.materials import MATERIALS, hatch_for # noqa: E402 + + +async def test_centre_marks_read_the_circles_real_radius_from_the_drawing(backend): + small = await backend.entity_create_circle(0.0, 0.0, 4.0, "GEOMETRY") + large = await backend.entity_create_circle(50.0, 0.0, 12.0, "GEOMETRY") + result = await draw_centre_marks( + backend, handles=[small.handle, large.handle], style="lines", extension=2.0 + ) + assert result["ok"] is True and result["count"] == 4 + assert result["sources"] == [ + {"handle": small.handle, "center": [0.0, 0.0], "radius": 4.0}, + {"handle": large.handle, "center": [50.0, 0.0], "radius": 12.0}, + ] + first = await backend.entity_get(result["handles"][0]) + assert first.layer == ROLE_LAYER["center"] + assert first.properties["start"] == pytest.approx([-6.0, 0.0], abs=EPS) + assert first.properties["end"] == pytest.approx([6.0, 0.0], abs=EPS) + third = await backend.entity_get(result["handles"][2]) + assert third.properties["start"] == pytest.approx([36.0, 0.0], abs=EPS) + assert third.properties["end"] == pytest.approx([64.0, 0.0], abs=EPS) + + +async def test_centre_marks_refuse_a_handle_that_is_not_a_circular_feature(backend): + line = await backend.entity_create_line(0.0, 0.0, 10.0, 0.0, 0.0, 0.0, "GEOMETRY") + before = (await backend.analysis_stats())["total_entities"] + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend, handles=[line.handle]) + assert line.handle in str(excinfo.value) and "LINE" in str(excinfo.value) + assert (await backend.analysis_stats())["total_entities"] == before + + +async def test_explicit_centres_are_accepted_and_lines_need_a_radius(backend): + result = await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="mark", extension=3.0) + assert result["count"] == 2 and result["sources"] == [ + {"handle": None, "center": [5.0, 5.0], "radius": None} + ] + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="lines") + assert "centers[0]" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend) + assert "handles" in str(excinfo.value) and "centers" in str(excinfo.value) + + +async def test_draw_section_line_returns_the_plane_dict_the_view_engine_consumes(backend): + result = await draw_section_line( + backend, (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0 + ) + assert result["ok"] is True + assert result["plane"] == { + "p1": (0.0, 0.0), + "p2": (100.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", + } + assert list(result["plane"]) == ["p1", "p2", "label", "direction", "style"] + layers = set() + for handle in result["handles"]: + layers.add((await backend.entity_get(handle)).layer) + assert layers == {ROLE_LAYER["visible"], ROLE_LAYER["center"], ROLE_LAYER["text"]} + + +async def test_material_hatch_uses_the_iso_128_50_map_and_scales_on_top_of_it(backend): + steel = hatch_for("steel") + result = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]], + material="steel", + scale=2.0, + ) + assert result["ok"] is True and result["material"] == "steel" + assert result["pattern"] == steel["pattern"] + assert result["scale"] == pytest.approx(steel["scale"] * 2.0, abs=EPS) + assert result["angle"] == pytest.approx(steel["angle"], abs=EPS) + hatch = backend._doc.entitydb.get(result["handle"]) + assert hatch.dxftype() == "HATCH" + assert hatch.dxf.pattern_name == steel["pattern"] + assert hatch.dxf.pattern_scale == pytest.approx(steel["scale"] * 2.0, abs=EPS) + assert hatch.dxf.pattern_angle == pytest.approx(steel["angle"], abs=EPS) + assert (await backend.entity_get(result["handle"])).layer == ROLE_LAYER["hatch"] + # An explicit angle overrides the material's own. + turned = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], + material="steel", + angle=90.0, + ) + assert turned["angle"] == pytest.approx(90.0, abs=EPS) + + +async def test_an_unknown_material_is_refused_with_the_list_before_any_write(backend): + assert "steel" in MATERIALS + before = (await backend.analysis_stats())["total_entities"] + with pytest.raises(ValueError): + await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], + material="unobtainium", + ) + assert (await backend.analysis_stats())["total_entities"] == before + + +async def test_a_boundary_that_is_not_a_closed_polygon_is_refused(backend): + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, boundary=[[0.0, 0.0], [10.0, 0.0]], material="steel") + assert "at least three" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, material="steel") + assert "boundary" in str(excinfo.value) and "handles" in str(excinfo.value) +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_marks.py -q` +Expected: FAIL at collection with +`ImportError: cannot import name 'draw_centre_marks' from 'engineering.mech.marks'`. + +- [ ] **Step 11: Implement the async layer in `engineering/mech/marks.py`** + +Append to the module: + +```python +# ── the async layer ───────────────────────────────────────────────────────── + +_CIRCULAR_TYPES = ("CIRCLE", "ARC") + + +async def draw_centre_marks( + backend: AutoCADBackend, + *, + handles: list[str] | None = None, + centers: list[list[float]] | None = None, + style: str = "mark", + extension: float = 3.0, + layer: str | None = None, +) -> dict: + """Centre marks or centre lines for circular features. + + With `handles`, the circles' real centre and radius are read back out of + the drawing through `entity_get` — the caller is never asked to restate a + radius it could get wrong. With `centers`, each row is `[x, y]` or + `[x, y, radius]`; `style="lines"` needs the radius, because the overrun is + measured from the outline. + """ + from engineering.mech.annotate import draw_annotation_prims + + if not handles and not centers: + raise ValueError("centre_marks needs either handles or centers; both are empty.") + if handles and centers: + raise ValueError("centre_marks takes handles or centers, not both.") + + sources: list[dict] = [] + if handles: + for handle in handles: + info = await backend.entity_get(handle) + if info.type not in _CIRCULAR_TYPES: + raise ValueError( + f"handle {handle} is a {info.type}; a centre mark needs a circular " + f"feature ({', '.join(_CIRCULAR_TYPES)})." + ) + radius = info.properties.get("radius") + if radius is None: + raise ValueError( + f"handle {handle} lies in a plane tilted out of WCS XY, so it has no " + "radius in this frame (capability 'ocs_tilted_plane'); its centre mark " + "cannot be drawn in xy." + ) + center = info.properties["center"] + sources.append( + { + "handle": info.handle, + "center": [float(center[0]), float(center[1])], + "radius": float(radius), + } + ) + else: + for index, row in enumerate(centers or []): + if len(row) < 2: + raise ValueError(f"centers[{index}] must be [x, y] or [x, y, radius].") + radius = float(row[2]) if len(row) > 2 else None + if style == "lines" and radius is None: + raise ValueError( + f"centers[{index}] has no radius, and style='lines' measures its overrun " + "from the outline; pass [x, y, radius] or use style='mark'." + ) + sources.append( + { + "handle": None, + "center": [float(row[0]), float(row[1])], + "radius": radius, + } + ) + + # Validate every source before the first entity is written. + batches = [ + centre_mark_prims( + (source["center"][0], source["center"][1]), + source["radius"] if source["radius"] is not None else 0.0, + style=style, + extension=extension, + ) + for source in sources + ] + handles_out: list[str] = [] + for prims in batches: + drawn = await draw_annotation_prims(backend, prims, layer=layer) + handles_out.extend(drawn["handles"]) + return { + "ok": True, + "handles": handles_out, + "count": len(handles_out), + "style": style, + "sources": sources, + } + + +async def draw_section_line( + backend: AutoCADBackend, + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + style: str = "full", + height: float = 5.0, + layer: str | None = None, +) -> dict: + """Draw the cutting-plane line and hand back the plane the view engine consumes.""" + from engineering.mech.annotate import draw_annotation_prims + + plane = section_plane(p1, p2, label=label, direction=direction, style=style) + prims = section_line_prims( + plane["p1"], plane["p2"], label=plane["label"], direction=plane["direction"], height=height + ) + result = await draw_annotation_prims(backend, prims, layer=layer) + result["plane"] = plane + result["label"] = plane["label"] + return result + + +async def draw_material_hatch( + backend: AutoCADBackend, + *, + boundary: list[list[float]] | None = None, + handles: list[str] | None = None, + material: str = "steel", + scale: float = 1.0, + angle: float | None = None, + layer: str | None = None, +) -> dict: + """Hatch a closed region with the ISO 128-50 pattern for a material. + + `hatch_for` owns the map and its refusal, so an unknown material name is + rejected here before anything is drawn. `scale` multiplies the material's + own pattern scale; `angle` replaces the material's own angle when given. + """ + from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER + from engineering.mech.standards.materials import hatch_for + + spec = hatch_for(material) # refuses an unknown material, naming the list + factor = float(scale) + if not math.isfinite(factor) or factor <= 0.0: + raise ValueError(f"scale must be a positive finite number; got {scale!r}.") + + if boundary is None and not handles: + raise ValueError("hatch_material needs either boundary points or handles; both are empty.") + if boundary is not None and handles: + raise ValueError("hatch_material takes boundary or handles, not both.") + + if boundary is not None: + points = [[float(p[0]), float(p[1])] for p in boundary] + if len(points) < 3: + raise ValueError( + f"a hatch boundary needs at least three points; got {len(points)}." + ) + else: + loop = await backend.boundary_from_entities(list(handles or [])) + if not loop.get("ok"): + raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}") + traced = await backend.entity_get(loop["handle"]) + points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]] + + pattern_scale = float(spec["scale"]) * factor + pattern_angle = float(spec["angle"]) if angle is None else float(angle) + info = await backend.entity_create_hatch( + spec["pattern"], + points, + pattern_scale, + pattern_angle, + layer or _ROLE_LAYER["hatch"], + ) + return { + "ok": True, + "handle": info.handle, + "material": material, + "pattern": spec["pattern"], + "scale": pattern_scale, + "angle": pattern_angle, + "points": len(points), + } +``` + +- [ ] **Step 12: Run the whole test module and see it pass** + +Run: `uv run --frozen pytest tests/test_mech_marks.py -q` +Expected: 15 passed. + +- [ ] **Step 13: Add the three alias records to `discovery/aliases.py`** + +Extend the "Mechanical annotation" block added in Task 10, after the +`"weld_symbol"` record: + +```python + "centre_marks": ToolAliases( + acad=("CENTERMARK", "CENTERLINE", "DIMCENTER"), + synonyms=( + "centre mark", + "center mark", + "centre line", + "center line of a hole", + "cross at the centre", + "eksen cizgisi", + "merkez isareti", + ), + ), + "section_line": ToolAliases( + acad=("SECTIONPLANE",), + synonyms=( + "cutting plane line", + "section line", + "section a-a", + "where to cut the part", + "view direction arrows", + "kesit cizgisi", + ), + ), + "hatch_material": ToolAliases( + synonyms=( + "material hatch", + "hatch as cast iron", + "section hatching for steel", + "hatch pattern for a material", + "malzeme taramasi", + ), + ), +``` + +`hatch_material` keeps an empty `acad`: `HATCH` and `BHATCH` already route to +`entity_create_hatch`, and `test_each_acad_command_routes_to_a_single_tool` +refuses a second owner unless the command joins `SHARED_ACAD_COMMANDS`. If the +merge finds group M has also claimed `SECTIONPLANE` for `mech_view_add`, add +`"SECTIONPLANE"` to `SHARED_ACAD_COMMANDS` (with a comment saying the command +is both "define the plane" and "add the view") rather than dropping it here. + +- [ ] **Step 14: Register the three tools and grow the SECTION 23 banner** + +In `server.py`, change the SECTION 23 banner line from + +```python +# ── SECTION 23: Mechanical Annotation (2 tools) ───────────────────────────── +``` + +to + +```python +# ── SECTION 23: Mechanical Annotation (5 tools) ───────────────────────────── +``` + +and append these three registrations at the end of SECTION 23 (still above the +`# ── RESOURCES ──` banner): + +```python +@cad_tool( + summary="Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Centre Marks (ISO 128-23)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def centre_marks( + handles: Annotated[ + list[str] | None, + "Handles of CIRCLEs/ARCs; their real centre and radius are read from the drawing.", + ] = None, + centers: Annotated[ + list[list[float]] | None, + "Explicit centres as [x, y] or [x, y, radius]; style='lines' needs the radius.", + ] = None, + style: Annotated[ + str, + Field( + default="mark", + description="mark (short cross at the centre) | lines (full centre lines).", + ), + ] = "mark", + extension: Annotated[ + float, + Field(default=3.0, gt=0, description="Arm length (mark) or overrun past the outline (lines), mm."), + ] = 3.0, + layer: Annotated[str | None, "Override the CENTER role layer."] = None, + ctx: Context = None, +) -> dict: + """Centre marks on the CENTER layer, whose linetype carries ISO 128-23's dash pattern. + + Pass `handles` and the radii are read back with `entity_get` rather than + restated — a radius from memory is the classic silent wrong number. + Refusals, all before the first entity is written: neither `handles` nor + `centers` given (or both), a handle that is not a CIRCLE or ARC, a circle + in a plane tilted out of WCS XY (capability `ocs_tilted_plane`, so it has + no radius in this frame), a `style` outside mark/lines, a non-positive + `extension`, and `style='lines'` on a centre given without a radius. + """ + from engineering.mech.marks import draw_centre_marks + + await ctx.debug(f"Centre marks style={style}") + return await draw_centre_marks( + _backend(ctx), + handles=handles, + centers=centers, + style=style, + extension=extension, + layer=layer, + ) + + +@cad_tool( + summary="Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Section Line (ISO 128-40)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def section_line( + x1: Annotated[float, "Cutting plane start X (WCS)."], + y1: Annotated[float, "Cutting plane start Y (WCS)."], + x2: Annotated[float, "Cutting plane end X (WCS)."], + y2: Annotated[float, "Cutting plane end Y (WCS)."], + label: Annotated[str, "Capital letter drawn at both ends, e.g. 'A'."] = "A", + direction: Annotated[ + list[float] | None, + "Viewing direction as [dx, dy]; must not be parallel to the cutting plane.", + ] = None, + style: Annotated[ + str, "full | half | offset | revolved — carried into the returned plane." + ] = "full", + height: Annotated[float, Field(default=5.0, gt=0, description="Label height (mm).")] = 5.0, + layer: Annotated[str | None, "Override the role layers (GEOMETRY/CENTER/TEXT)."] = None, + ctx: Context = None, +) -> dict: + """Draw the cutting-plane line and hand back the plane a section view consumes. + + Wide segments at the ends on GEOMETRY, the thin long-dash-dot middle on + CENTER, an arrow at each end whose head sits on the line end and points the + way the section is viewed, and the same letter beyond both arrows. + `payload["plane"]` is exactly `{p1, p2, label, direction, style}` — feed it + to the section view so the label on the plan and the label on the view + cannot disagree. Refusals, before any entity is written: `p1 == p2`, a zero + or parallel viewing direction, an empty `label`, a `style` outside + full/half/offset/revolved, and a non-positive `height`. + """ + from engineering.mech.marks import draw_section_line + + await ctx.debug(f"Section line {label} ({x1},{y1})-({x2},{y2})") + return await draw_section_line( + _backend(ctx), + (x1, y1), + (x2, y2), + label=label, + direction=tuple(direction) if direction else (0.0, -1.0), + style=style, + height=height, + layer=layer, + ) + + +@cad_tool( + summary="Hatch a region with the ISO 128-50 section pattern for a named material.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Material Hatch (ISO 128-50)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def hatch_material( + material: Annotated[str, "Material name; an unknown one is refused with the list."] = "steel", + boundary: Annotated[ + list[list[float]] | None, "Closed boundary as [[x, y], ...] — at least three points." + ] = None, + handles: Annotated[ + list[str] | None, "Entities to chain into one closed loop instead of a boundary." + ] = None, + scale: Annotated[ + float, + Field(default=1.0, gt=0, description="Multiplies the material's own pattern scale."), + ] = 1.0, + angle: Annotated[ + float | None, "Replaces the material's own pattern angle (degrees) when given." + ] = None, + layer: Annotated[str | None, "Override the HATCH role layer."] = None, + ctx: Context = None, +) -> dict: + """Section-hatch a cut face with the pattern ISO 128-50 gives its material. + + Refusals: an unknown material (named, with the full list, before anything + is drawn), neither `boundary` nor `handles` (or both), a boundary with + fewer than three points, and a non-positive `scale`. With `handles` the + loop is chained by `boundary_from_entities`, which the COM engine refuses + (capability `boundary_trace`) — pass `boundary` on a live seat, or run + headlessly. + """ + from engineering.mech.marks import draw_material_hatch + + await ctx.debug(f"Material hatch {material}") + return await draw_material_hatch( + _backend(ctx), + boundary=boundary, + handles=handles, + material=material, + scale=scale, + angle=angle, + layer=layer, + ) +``` + +- [ ] **Step 15: Run the registration gates and update the three count files** + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py -q +``` + +In `tests/test_tool_registry.py`, change `"engineering": 12,` to +`"engineering": 15,` and `assert sum(sizes.values()) == 206` to +`assert sum(sizes.values()) == 209`, and extend the sentence added in Task 10 +so it reads: + +``` + `engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with + `surface_texture` / `weld_symbol`, then 12 -> 15 when `centre_marks`, + `section_line` and `hatch_material` completed it; all five are tagged + `engineering` and `mech`, and `mech` is not in `_GROUP_TAG_PRIORITY`, so + they file under `engineering` rather than opening a group of their own. +``` + +In `README.md`, change the release-snapshot line to `209 tools ·` and the +`206 tools in 23 groups;` line to `209 tools in 23 groups;`. + +Run: `uv run --frozen pytest tests/test_tool_registry.py tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: all pass, including +`test_section_header_counts_match_decorators` — which is what proves the +`(5 tools)` banner matches the five `@mcp.tool` decorators under it. + +- [ ] **Step 16: Lint, format and run the branch's whole suite** + +``` +uv run --frozen ruff check engineering/mech/marks.py engineering/mech/annotate.py server.py discovery/aliases.py tests/test_mech_marks.py tests/test_mech_annotate.py tests/test_tool_registry.py +uv run --frozen ruff format engineering/mech/marks.py engineering/mech/annotate.py server.py discovery/aliases.py tests/test_mech_marks.py tests/test_mech_annotate.py tests/test_tool_registry.py +uv run --frozen pytest tests/test_mech_marks.py tests/test_mech_annotate.py tests/test_tool_registry.py tests/test_tool_inventory.py tests/test_discovery_aliases.py tests/test_capability_contract.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +Expected: ruff clean; the full suite green apart from the deselected +README "collected tests" drift named in Global Constraints (record it in +`deviations`). + +- [ ] **Step 17: Commit** + +``` +git add engineering/mech/marks.py tests/test_mech_marks.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -F - <<'MSG' +feat(mech): SECTION 23 completes with centre_marks, section_line, hatch_material + +centre_marks draws ISO 128-23 centre marks or full centre lines and reads +each circle's real centre and radius back out of the drawing through +entity_get instead of being told them - a radius restated from memory is +the silent wrong number this repository keeps refusing to ship. A handle +that is not a CIRCLE or ARC, and a circle in a plane tilted out of WCS XY +(which has no radius in this frame), are both refused by handle before +anything is written. The dash proportions are not transcribed here: the +prims are plain lines on CENTER, whose linetype already carries them. + +section_line draws the ISO 128-40 cutting-plane line - wide segments at +the ends on GEOMETRY, the thin long-dash-dot middle on CENTER, an arrow at +each end whose head sits on the line end and points the way the section is +viewed, the same letter beyond both arrows - and returns the five-key +plane dict {p1, p2, label, direction, style} that mech_view_add(kind= +"section") consumes, so the label on the plan and the label on the view +are the same string by construction. A viewing direction parallel to the +plane is refused rather than drawn. + +hatch_material hatches through standards.materials.hatch_for, so the +ISO 128-50 map and its refusal stay in one place; scale multiplies the +material's own pattern scale and angle replaces its angle. An unknown +material is refused with the list before any entity is written. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +``` + +--- + +### Task 12: ISO 5457 frames and ISO 7200 title blocks for A4-A0 + +**Files:** +- Create: `engineering/sheet/__init__.py` +- Create: `engineering/sheet/frames.py` +- Create: `engineering/sheet/titleblock.py` +- Modify: `engineering/titleblock.py` (the A3 layout moves into `engineering/sheet/titleblock.py`; what is left is the historical entry point — `apply_iso_a3_titleblock` forwards to `apply_titleblock(size="A3", projection=None, zones=False, marks=False)` and the module re-exports the constant names it has always exported) +- Modify: `engineering/__init__.py` (re-export `TitleBlockMetadata`, `apply_iso_a3_titleblock` unchanged; add `apply_titleblock`, `draw_sheet_frame`, `frame_metrics`, `frame_prims`, `titleblock_prims`) +- Modify: `server.py` (open SECTION 24 "Sheet & Delivery (2 tools)": `sheet_frame`, `titleblock_apply`) +- Modify: `discovery/aliases.py` (two alias records) +- Modify: `docs/tool-inventory.json` (regenerated) +- Modify: `README.md` (release-snapshot tool count and the three prose figures) +- Modify: `tests/test_tool_registry.py` (frozen group-size snapshot) +- Test: `tests/test_sheet_frames.py` +- Test: `tests/test_sheet_titleblock.py` + +**Interfaces:** +- Consumes (Task 1, `engineering/mech/primitives.py`): `Pt = tuple[float, float]`; `Role = Literal["visible","hidden","center","phantom","hatch","dim","text"]`; `class Line(NamedTuple): p1: Pt; p2: Pt; role: Role = "visible"`; `class Circle(NamedTuple): center: Pt; radius: float; role: Role = "visible"`; `class Poly(NamedTuple): points: tuple[Pt, ...]; closed: bool = False; role: Role = "visible"`; `class Text(NamedTuple): at: Pt; text: str; height: float = 3.5; rotation: float = 0.0; role: Role = "text"`; `Prim = Line | Arc | Circle | Poly | HatchArea | Text` +- Consumes (existing backend contract): `entity_create_line(x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, color=None) -> EntityInfo`; `entity_create_circle(cx, cy, radius, layer=None, color=None) -> EntityInfo`; `entity_create_polyline(points, closed=False, layer=None, color=None) -> EntityInfo`; `entity_create_text(text, x, y, height=2.5, rotation=0.0, layer=None, color=None) -> EntityInfo`; `layout_list() -> dict`; `layout_set_current(name) -> dict` +- Produces: + - `engineering/sheet/frames.py`: `SHEETS: dict[str, tuple[float, float]]`; `ZONE_DIVISIONS: dict[str, tuple[int, int]]`; `FILING_MARGIN = 20.0`; `EDGE_MARGIN = 10.0`; `ZONE_MODULE = 50.0`; `ZONE_TEXT_HEIGHT = 3.5`; `CENTRING_OVERSHOOT = 5.0`; `TRIM_MARK_LONG = 10.0`; `TRIM_MARK_SHORT = 5.0`; `SHEET_LAYER = "TITLEBLOCK"`; `SHEET_TEXT_LAYER = "TEXT"`; `SHEET_ROLE_LAYER: dict[str, str]`; `sheet_size(size: str, orientation: str = "landscape") -> tuple[float, float]`; `zone_divisions(side: float) -> int`; `frame_metrics(size: str, *, orientation: str = "landscape") -> dict`; `frame_prims(size: str, *, orientation: str = "landscape", zones: bool = True, marks: bool = True) -> tuple[Prim, ...]`; `centred_text(cx: float, cy: float, text: str, height: float) -> Text`; `async draw_sheet_prims(backend, prims, *, origin: Pt = (0.0, 0.0), layer_map: dict | None = None) -> list[str]`; `async enter_layout(backend, layout: str | None, *, caller: str) -> str`; `async draw_sheet_frame(backend, size, *, orientation="landscape", zones=True, marks=True, origin=(0.0, 0.0), layout=None) -> dict` + - `engineering/sheet/titleblock.py`: `TITLEBLOCK_FIELDS: tuple[str, ...]`; `VALUE_FIELDS: tuple[tuple[str, str], ...]`; `TB_WIDTH = 180.0`; `TB_HEIGHT = 60.0`; `TITLEBLOCK_RULE_COUNT = 11`; `TITLE_TEXT_INDEX = 11`; `PROJECTION_ANGLES = ("first", "third")`; `@dataclass class TitleBlockMetadata`; `titleblock_origin(size, *, orientation="landscape") -> Pt`; `projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]`; `titleblock_prims(size: str, fields, *, orientation="landscape", projection: str | None = "first") -> tuple[Prim, ...]`; `value_text_indices() -> dict[str, int]`; `async apply_titleblock(backend, *, size="A3", metadata, origin=(0.0,0.0), orientation="landscape", projection="first", frame=True, zones=False, marks=False, layout=None) -> dict` + +- [ ] **Step 1: Write the failing test for the ISO 5457 tables and metrics** + +Create `tests/test_sheet_frames.py`: + +```python +"""ISO 5457 sheet frames: sizes, margins, the zone grid, centring and trim marks. + +The frame is the one thing every drawing this server makes carries, so a wrong +margin or a wrong division count is visible on all of them. Every number +asserted here is transcribed in `engineering/sheet/frames.py` with its clause. +""" + +from __future__ import annotations + +import pytest + +from engineering.sheet.frames import ( + CENTRING_OVERSHOOT, + EDGE_MARGIN, + FILING_MARGIN, + SHEETS, + TRIM_MARK_LONG, + TRIM_MARK_SHORT, + ZONE_DIVISIONS, + ZONE_MODULE, + frame_metrics, + sheet_size, + zone_divisions, +) + +EPS = 1e-9 + + +def test_sheet_sizes_are_the_iso_216_a_series(): + assert SHEETS == { + "A4": (210.0, 297.0), + "A3": (297.0, 420.0), + "A2": (420.0, 594.0), + "A1": (594.0, 841.0), + "A0": (841.0, 1189.0), + } + + +def test_sheet_sizes_agree_with_the_page_setup_table(): + """Two tables of one standard in one repository is one too many unless + they are pinned to each other.""" + from engineering.standards.papers import PAPER_SIZES + + for name, (w, h) in SHEETS.items(): + assert PAPER_SIZES[f"ISO_{name}"] == (int(w), int(h)) + + +def test_landscape_swaps_the_portrait_pair(): + assert sheet_size("A3", "landscape") == (420.0, 297.0) + assert sheet_size("A3", "portrait") == (297.0, 420.0) + assert sheet_size("a3") == (420.0, 297.0) + + +def test_an_unknown_sheet_is_refused_by_name_with_the_coverage(): + with pytest.raises(ValueError) as excinfo: + sheet_size("A5") + message = str(excinfo.value) + assert "A5" in message + for name in SHEETS: + assert name in message + + +def test_an_unknown_orientation_is_refused(): + with pytest.raises(ValueError) as excinfo: + sheet_size("A3", "diagonal") + assert "diagonal" in str(excinfo.value) + + +def test_margins_are_the_iso_5457_filing_20_and_edge_10(): + assert (FILING_MARGIN, EDGE_MARGIN) == (20.0, 10.0) + m = frame_metrics("A3") + assert m["margins"] == {"left": 20.0, "right": 10.0, "top": 10.0, "bottom": 10.0} + assert m["frame"] == [20.0, 10.0, 410.0, 287.0] + assert m["drawing_area"] == [390.0, 277.0] + assert m["centre"] == [210.0, 148.5] + + +def test_a4_portrait_drawing_area_is_exactly_the_titleblock_width(): + """ISO 7200's 180 mm title block has to fit the smallest sheet.""" + m = frame_metrics("A4", orientation="portrait") + assert m["drawing_area"][0] == pytest.approx(180.0, abs=EPS) + + +@pytest.mark.parametrize( + ("size", "orientation", "columns", "rows"), + [ + ("A4", "portrait", 4, 6), + ("A4", "landscape", 6, 4), + ("A3", "landscape", 8, 6), + ("A3", "portrait", 6, 8), + ("A2", "landscape", 12, 8), + ("A2", "portrait", 8, 12), + ("A1", "landscape", 16, 12), + ("A0", "landscape", 24, 16), + ("A0", "portrait", 16, 24), + ], +) +def test_zone_division_counts_match_the_authored_table(size, orientation, columns, rows): + z = frame_metrics(size, orientation=orientation)["zones"] + assert (z["columns"], z["rows"]) == (columns, rows) + long_side, short_side = ZONE_DIVISIONS[size] + if orientation == "landscape": + assert (z["columns"], z["rows"]) == (long_side, short_side) + else: + assert (z["columns"], z["rows"]) == (short_side, long_side) + + +def test_zone_divisions_follow_the_even_rule_over_50_mm(): + assert ZONE_MODULE == 50.0 + assert zone_divisions(390.0) == 8 # 7.80 -> 8 + assert zone_divisions(277.0) == 6 # 5.54 -> 6 + assert zone_divisions(564.0) == 12 # 11.28 -> 12 + assert zone_divisions(1159.0) == 24 # 23.18 -> 24 + assert zone_divisions(821.0) == 16 # 16.42 -> 16 + + +def test_zone_labels_cover_every_cell_and_letters_start_at_the_top(): + z = frame_metrics("A0")["zones"] + assert z["numbers"][0] == "1" and z["numbers"][-1] == "24" + assert len(z["numbers"]) == 24 + assert z["letters"][0] == "A" and z["letters"][-1] == "P" + assert len(z["letters"]) == 16 + assert z["column_width"] == pytest.approx(1159.0 / 24.0, abs=EPS) + assert z["row_height"] == pytest.approx(821.0 / 16.0, abs=EPS) + + +def test_the_mark_constants_are_the_standard_s(): + assert CENTRING_OVERSHOOT == 5.0 + assert (TRIM_MARK_LONG, TRIM_MARK_SHORT) == (10.0, 5.0) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_frames.py -q` +Expected: FAIL — collection error, `ModuleNotFoundError: No module named 'engineering.sheet'`. + +- [ ] **Step 3: Create the sheet package and the ISO 5457 tables** + +Create `engineering/sheet/__init__.py`: + +```python +"""The sheet standard: ISO 5457 frames, ISO 7200 title blocks, ISO 7573 parts +lists, ISO 6433 balloons and the delivery formats.""" + +from engineering.sheet.frames import ( + CENTRING_OVERSHOOT, + EDGE_MARGIN, + FILING_MARGIN, + SHEET_LAYER, + SHEET_TEXT_LAYER, + SHEETS, + TRIM_MARK_LONG, + TRIM_MARK_SHORT, + ZONE_DIVISIONS, + ZONE_MODULE, + draw_sheet_frame, + draw_sheet_prims, + enter_layout, + frame_metrics, + frame_prims, + sheet_size, + zone_divisions, +) + +__all__ = [ + "CENTRING_OVERSHOOT", + "EDGE_MARGIN", + "FILING_MARGIN", + "SHEETS", + "SHEET_LAYER", + "SHEET_TEXT_LAYER", + "TRIM_MARK_LONG", + "TRIM_MARK_SHORT", + "ZONE_DIVISIONS", + "ZONE_MODULE", + "draw_sheet_frame", + "draw_sheet_prims", + "enter_layout", + "frame_metrics", + "frame_prims", + "sheet_size", + "zone_divisions", +] +``` + +Create `engineering/sheet/frames.py` with the tables and metrics (the primitive builders come in Step 7): + +```python +"""ISO 5457 drawing sheets: sizes, margins, frame, zone grid, centring and trim marks. + +SOURCE +------ +Sheet sizes ISO 216:2007, A series, trimmed sizes in millimetres. +Frame and margins ISO 5457:1999, 4.2 -- the drawing frame that bounds the + drawing space leaves a 20 mm filing margin on the binding + edge and 10 mm on the other three, for every size A4 to A0. +Centring marks ISO 5457:1999, 4.3 -- four marks at the middle of each side + of the trimmed sheet, running from the trimmed edge to 5 mm + inside the frame. +Grid reference ISO 5457:1999, 4.4 -- the number of divisions along a side is + an even number obtained from the length of that side of the + frame divided by 50 mm. Columns are numbered from the left, + rows are lettered from the top. +Trimming marks ISO 5457:1999, 4.5 -- one mark in each corner of the trimmed + sheet, two overlapping 10 mm x 5 mm rectangles. + +COVERAGE: A4, A3, A2, A1, A0. The B series, the elongated sizes (A3.1, A4.2, +...) and the untrimmed sizes are not transcribed; `sheet_size` refuses an +unknown name and lists the five it has. + +NOT IMPLEMENTED, deliberately, so nobody reads more into the geometry than is +in it: + +* ISO 5457's allowance for shorter *corner* fields in the grid. The divisions + drawn here are equal (frame side / n). The division COUNT -- the number a + zone reference quotes, and the thing that is visible on the sheet -- is the + standard's; the two end fields' boundary sits up to 5 mm from where a + corner-field layout would put it. +* The grid is drawn for A4 too, although ISO 5457 does not require one there. + Pass `zones=False`. +""" + +from __future__ import annotations + +import string +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "CENTRING_OVERSHOOT", + "EDGE_MARGIN", + "FILING_MARGIN", + "ORIENTATIONS", + "SHEETS", + "SHEET_LAYER", + "SHEET_ROLE_LAYER", + "SHEET_TEXT_LAYER", + "TRIM_MARK_LONG", + "TRIM_MARK_SHORT", + "ZONE_DIVISIONS", + "ZONE_MODULE", + "ZONE_TEXT_HEIGHT", + "centred_text", + "draw_sheet_frame", + "draw_sheet_prims", + "enter_layout", + "frame_metrics", + "frame_prims", + "sheet_size", + "zone_divisions", +] + +#: Portrait width x height in millimetres (ISO 216). Landscape swaps the pair. +SHEETS: dict[str, tuple[float, float]] = { + "A4": (210.0, 297.0), + "A3": (297.0, 420.0), + "A2": (420.0, 594.0), + "A1": (594.0, 841.0), + "A0": (841.0, 1189.0), +} + +FILING_MARGIN = 20.0 +EDGE_MARGIN = 10.0 +ZONE_MODULE = 50.0 +ZONE_TEXT_HEIGHT = 3.5 +CENTRING_OVERSHOOT = 5.0 +TRIM_MARK_LONG = 10.0 +TRIM_MARK_SHORT = 5.0 + +#: The sheet is not part geometry: borders, rules and marks go on TITLEBLOCK and +#: the lettering on TEXT, which is where `titleblock_apply_iso_a3` has always put +#: them and what `tests/test_engineering_titleblock.py` pins. +SHEET_LAYER = "TITLEBLOCK" +SHEET_TEXT_LAYER = "TEXT" + +SHEET_ROLE_LAYER: dict[str, str] = { + "visible": SHEET_LAYER, + "center": SHEET_LAYER, + "text": SHEET_TEXT_LAYER, +} + +#: (divisions along the long side, divisions along the short side), ISO 5457 4.4. +#: Each row reproduces the even-division rule applied to that side of the frame +#: -- `test_zone_division_counts_match_the_authored_table` asserts both, so the +#: table and the rule can never drift apart. +ZONE_DIVISIONS: dict[str, tuple[int, int]] = { + "A4": (6, 4), + "A3": (8, 6), + "A2": (12, 8), + "A1": (16, 12), + "A0": (24, 16), +} + +ORIENTATIONS = ("landscape", "portrait") +_LETTERS = string.ascii_uppercase + + +def sheet_size(size: str, orientation: str = "landscape") -> tuple[float, float]: + """Trimmed width x height in millimetres for a sheet name and orientation.""" + key = str(size).strip().upper() + if key not in SHEETS: + raise ValueError( + f"ISO 5457 sheet {size!r} is not in the table; covered sizes: {', '.join(SHEETS)}" + ) + if orientation not in ORIENTATIONS: + raise ValueError(f"orientation must be one of {ORIENTATIONS}, got {orientation!r}") + w, h = SHEETS[key] + return (h, w) if orientation == "landscape" else (w, h) + + +def zone_divisions(side: float) -> int: + """ISO 5457 4.4: an even number of divisions, `side` / 50 mm rounded to it. + + `round()` is banker's at an exact .5; no frame side of A4..A0 lands there + (the closest is A2 landscape's 400 / 100 = 4.0, which is exact), and a side + that did would round down -- the safer half of the ambiguity, because it + makes the fields longer rather than shorter. + """ + return max(int(2 * round(float(side) / (2.0 * ZONE_MODULE))), 2) + + +def frame_metrics(size: str, *, orientation: str = "landscape") -> dict: + """Every number the frame is built from, for a caller that wants to place + geometry inside it without re-deriving the margins.""" + w, h = sheet_size(size, orientation) + x0, y0 = FILING_MARGIN, EDGE_MARGIN + x1, y1 = w - EDGE_MARGIN, h - EDGE_MARGIN + columns = zone_divisions(x1 - x0) + rows = zone_divisions(y1 - y0) + if rows > len(_LETTERS): + raise ValueError(f"{size}: {rows} grid rows need more than {len(_LETTERS)} letters") + return { + "size": str(size).strip().upper(), + "orientation": orientation, + "sheet": [w, h], + "margins": { + "left": FILING_MARGIN, + "right": EDGE_MARGIN, + "top": EDGE_MARGIN, + "bottom": EDGE_MARGIN, + }, + "frame": [x0, y0, x1, y1], + "drawing_area": [x1 - x0, y1 - y0], + "centre": [w / 2.0, h / 2.0], + "zones": { + "columns": columns, + "rows": rows, + "column_width": (x1 - x0) / columns, + "row_height": (y1 - y0) / rows, + "numbers": [str(i + 1) for i in range(columns)], + "letters": [_LETTERS[i] for i in range(rows)], + }, + } +``` + +- [ ] **Step 4: Run it to verify the tables pass** + +Run: `uv run --frozen pytest tests/test_sheet_frames.py -q` +Expected: PASS, 19 passed (the nine parametrised division cases count separately). + +- [ ] **Step 5: Write the failing test for the frame primitives** + +Append to `tests/test_sheet_frames.py`: + +```python +# ── primitives ────────────────────────────────────────────────────────────── + +from engineering.mech.primitives import Line, Poly, Text # noqa: E402 +from engineering.sheet.frames import frame_prims # noqa: E402 + + +def _polys(prims): + return [p for p in prims if isinstance(p, Poly)] + + +def _lines(prims): + return [p for p in prims if isinstance(p, Line)] + + +def _texts(prims): + return [p for p in prims if isinstance(p, Text)] + + +def test_the_first_two_primitives_are_the_trimmed_sheet_and_the_frame(): + prims = frame_prims("A3", zones=False, marks=False) + assert len(prims) == 2 + sheet, frame = prims + assert isinstance(sheet, Poly) and sheet.closed and sheet.role == "visible" + assert sheet.points == ((0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0)) + assert frame.points == ((20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0)) + + +def test_the_zone_grid_has_a_tick_pair_per_interior_boundary_and_a_label_pair_per_cell(): + prims = frame_prims("A3", zones=True, marks=False) + # 7 interior column boundaries x 2 edges + 5 interior row boundaries x 2 edges + assert len(_lines(prims)) == (8 - 1) * 2 + (6 - 1) * 2 + # 8 columns x 2 edges + 6 rows x 2 edges + assert len(_texts(prims)) == 8 * 2 + 6 * 2 + assert {t.text for t in _texts(prims)} == { + "1", "2", "3", "4", "5", "6", "7", "8", "A", "B", "C", "D", "E", "F", + } + + +def test_zone_ticks_span_the_margin_band_only(): + prims = frame_prims("A3", zones=True, marks=False) + for line in [ln for ln in _lines(prims) if ln.p1[0] == ln.p2[0]]: + ys = sorted((line.p1[1], line.p2[1])) + assert ys in ([0.0, 10.0], [287.0, 297.0]), f"tick {line} leaves the margin band" + for line in [ln for ln in _lines(prims) if ln.p1[1] == ln.p2[1]]: + xs = sorted((line.p1[0], line.p2[0])) + assert xs in ([0.0, 20.0], [410.0, 420.0]), f"tick {line} leaves the margin band" + + +def test_the_first_zone_letter_is_at_the_top_of_the_sheet(): + prims = frame_prims("A3", zones=True, marks=False) + a_labels = [t for t in _texts(prims) if t.text == "A"] + f_labels = [t for t in _texts(prims) if t.text == "F"] + assert len(a_labels) == 2 and len(f_labels) == 2 + assert min(t.at[1] for t in a_labels) > max(t.at[1] for t in f_labels) + + +def test_centring_marks_run_from_the_trimmed_edge_to_5_mm_inside_the_frame(): + prims = frame_prims("A3", zones=False, marks=True) + centre_lines = [p for p in _lines(prims) if p.role == "center"] + assert len(centre_lines) == 4 + spans = {(tuple(c.p1), tuple(c.p2)) for c in centre_lines} + assert ((0.0, 148.5), (25.0, 148.5)) in spans + assert ((420.0, 148.5), (405.0, 148.5)) in spans + assert ((210.0, 0.0), (210.0, 15.0)) in spans + assert ((210.0, 297.0), (210.0, 282.0)) in spans + + +def test_each_corner_carries_two_overlapping_10_by_5_trim_rectangles(): + prims = frame_prims("A3", zones=False, marks=True) + rects = _polys(prims)[2:] # after the trimmed sheet and the frame + assert len(rects) == 8 + for rect in rects: + xs = [p[0] for p in rect.points] + ys = [p[1] for p in rect.points] + span = (abs(max(xs) - min(xs)), abs(max(ys) - min(ys))) + assert span in ((10.0, 5.0), (5.0, 10.0)), span + assert rect.closed +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_frames.py -q` +Expected: FAIL — collection error, `ImportError: cannot import name 'frame_prims' from 'engineering.sheet.frames'`. + +- [ ] **Step 7: Implement the frame primitives** + +Append to `engineering/sheet/frames.py`: + +```python +def _rect(x0: float, y0: float, x1: float, y1: float, role: str = "visible") -> Poly: + return Poly(points=((x0, y0), (x1, y0), (x1, y1), (x0, y1)), closed=True, role=role) + + +def centred_text(cx: float, cy: float, text: str, height: float) -> Text: + """`entity_create_text` carries no alignment parameter, so centring is done + by offsetting the left-baseline insertion point: 0.6 * height of advance per + character (the txt.shx / ISO 3098 width factor) and half a cap height.""" + return Text(at=(cx - 0.3 * height * len(text), cy - 0.5 * height), text=text, height=height) + + +def frame_prims( + size: str, + *, + orientation: str = "landscape", + zones: bool = True, + marks: bool = True, +) -> tuple[Prim, ...]: + """The ISO 5457 sheet as primitive descriptions, sheet-local, with (0, 0) at + the lower-left corner of the trimmed sheet. + + Order is pinned: [0] the trimmed sheet, [1] the drawing frame, then the zone + ticks, then the zone labels, then the four centring marks, then the eight + trimming rectangles. + """ + metrics = frame_metrics(size, orientation=orientation) + w, h = metrics["sheet"] + x0, y0, x1, y1 = metrics["frame"] + prims: list[Prim] = [_rect(0.0, 0.0, w, h), _rect(x0, y0, x1, y1)] + + if zones: + zone = metrics["zones"] + cw, rh = zone["column_width"], zone["row_height"] + for i in range(1, zone["columns"]): + x = x0 + i * cw + prims.append(Line(p1=(x, y1), p2=(x, h))) + prims.append(Line(p1=(x, 0.0), p2=(x, y0))) + for j in range(1, zone["rows"]): + y = y0 + j * rh + prims.append(Line(p1=(0.0, y), p2=(x0, y))) + prims.append(Line(p1=(x1, y), p2=(w, y))) + for i, label in enumerate(zone["numbers"]): + cx = x0 + (i + 0.5) * cw + prims.append(centred_text(cx, y1 + EDGE_MARGIN / 2.0, label, ZONE_TEXT_HEIGHT)) + prims.append(centred_text(cx, y0 / 2.0, label, ZONE_TEXT_HEIGHT)) + for j, label in enumerate(zone["letters"]): + # ISO 5457 4.4: rows are lettered from the top. + cy = y1 - (j + 0.5) * rh + prims.append(centred_text(FILING_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT)) + prims.append(centred_text(x1 + EDGE_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT)) + + if marks: + cx, cy = metrics["centre"] + prims.append(Line(p1=(0.0, cy), p2=(x0 + CENTRING_OVERSHOOT, cy), role="center")) + prims.append(Line(p1=(w, cy), p2=(x1 - CENTRING_OVERSHOOT, cy), role="center")) + prims.append(Line(p1=(cx, 0.0), p2=(cx, y0 + CENTRING_OVERSHOOT), role="center")) + prims.append(Line(p1=(cx, h), p2=(cx, y1 - CENTRING_OVERSHOOT), role="center")) + for sx, ox in ((1.0, 0.0), (-1.0, w)): + for sy, oy in ((1.0, 0.0), (-1.0, h)): + prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_LONG, oy + sy * TRIM_MARK_SHORT)) + prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_SHORT, oy + sy * TRIM_MARK_LONG)) + + return tuple(prims) +``` + +- [ ] **Step 8: Run it to verify the primitives pass** + +Run: `uv run --frozen pytest tests/test_sheet_frames.py -q` +Expected: PASS, 25 passed. + +- [ ] **Step 9: Write the failing test for the async frame drawer** + +Append to `tests/test_sheet_frames.py`: + +```python +# ── drawing ───────────────────────────────────────────────────────────────── + +from engineering.sheet.frames import draw_sheet_frame # noqa: E402 + + +@pytest.mark.asyncio +async def test_draw_sheet_frame_puts_the_border_on_titleblock_and_the_zones_on_text(backend): + result = await draw_sheet_frame(backend, "A3", zones=True, marks=True) + assert result["ok"] is True + assert result["size"] == "A3" + assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]} + outer = await backend.entity_get(result["outer_border"]) + inner = await backend.entity_get(result["inner_border"]) + assert outer.layer == "TITLEBLOCK" and inner.layer == "TITLEBLOCK" + texts = [ + e + for e in await backend.entity_list(type_filter="TEXT") + if e.properties["text"] == "A" + ] + assert texts and all(e.layer == "TEXT" for e in texts) + + +@pytest.mark.asyncio +async def test_draw_sheet_frame_offsets_every_primitive_by_the_origin(backend): + result = await draw_sheet_frame( + backend, "A4", orientation="portrait", zones=False, marks=False, origin=(100.0, 50.0) + ) + assert result["bbox"] == {"min": [100.0, 50.0], "max": [310.0, 347.0]} + assert len(result["handles"]) == 2 + + +@pytest.mark.asyncio +async def test_a_bad_size_is_refused_before_anything_is_drawn(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError): + await draw_sheet_frame(backend, "A5") + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_missing_layout_is_refused_before_anything_is_drawn(backend): + before = len(await backend.entity_list()) + with pytest.raises(RuntimeError) as excinfo: + await draw_sheet_frame(backend, "A3", layout="NoSuchSheet") + assert "NoSuchSheet" in str(excinfo.value) + assert "layout_create" in str(excinfo.value) + assert len(await backend.entity_list()) == before +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_frames.py -q` +Expected: FAIL — collection error, `ImportError: cannot import name 'draw_sheet_frame' from 'engineering.sheet.frames'`. + +- [ ] **Step 11: Implement the async drawer** + +Append to `engineering/sheet/frames.py`: + +```python +async def enter_layout(backend: AutoCADBackend, layout: str | None, *, caller: str) -> str: + """Make `layout` current and return the space to restore afterwards. + + A sheet border frames the printed sheet rather than the model, so every + sheet tool takes a layout; asking for a border must not silently leave the + caller standing on it, which is why the caller's space comes back. + """ + if layout is None: + return "Model" + listing = await backend.layout_list() + names = {str(entry) for entry in (listing.get("layouts") or [])} + if layout not in names: + raise RuntimeError( + f"{caller}: layout {layout!r} does not exist " + f"(have: {', '.join(sorted(names))}). Create it with layout_create first." + ) + previous = getattr(backend, "_current_space", "Model") + switched = await backend.layout_set_current(layout) + if not switched.get("ok", True): + raise RuntimeError(f"{caller}: {switched.get('error')}") + return previous + + +async def draw_sheet_prims( + backend: AutoCADBackend, + prims, + *, + origin: Pt = (0.0, 0.0), + layer_map: dict | None = None, +) -> list[str]: + """Draw sheet primitives at `origin` and return their handles, in order.""" + ox, oy = float(origin[0]), float(origin[1]) + layers = dict(SHEET_ROLE_LAYER if layer_map is None else layer_map) + handles: list[str] = [] + for prim in prims: + layer = layers.get(prim.role, SHEET_LAYER) + if isinstance(prim, Poly): + entity = await backend.entity_create_polyline( + points=[[ox + px, oy + py] for px, py in prim.points], + closed=prim.closed, + layer=layer, + ) + elif isinstance(prim, Line): + entity = await backend.entity_create_line( + ox + prim.p1[0], oy + prim.p1[1], ox + prim.p2[0], oy + prim.p2[1], layer=layer + ) + elif isinstance(prim, Circle): + entity = await backend.entity_create_circle( + ox + prim.center[0], oy + prim.center[1], prim.radius, layer=layer + ) + elif isinstance(prim, Text): + entity = await backend.entity_create_text( + text=prim.text, + x=ox + prim.at[0], + y=oy + prim.at[1], + height=prim.height, + rotation=prim.rotation, + layer=layer, + ) + else: + raise ValueError( + f"sheet primitives are Line, Circle, Poly and Text; got {type(prim).__name__}" + ) + handles.append(entity.handle) + return handles + + +async def draw_sheet_frame( + backend: AutoCADBackend, + size: str, + *, + orientation: str = "landscape", + zones: bool = True, + marks: bool = True, + origin: Pt = (0.0, 0.0), + layout: str | None = None, +) -> dict: + """Draw the ISO 5457 frame. Every refusal fires before the first entity.""" + metrics = frame_metrics(size, orientation=orientation) + prims = frame_prims(size, orientation=orientation, zones=zones, marks=marks) + previous = await enter_layout(backend, layout, caller="sheet_frame") + try: + handles = await draw_sheet_prims(backend, prims, origin=origin) + finally: + if layout is not None: + await backend.layout_set_current(previous) + ox, oy = float(origin[0]), float(origin[1]) + w, h = metrics["sheet"] + return { + "ok": True, + "size": metrics["size"], + "orientation": orientation, + "layout": layout or "Model", + "zones": zones, + "marks": marks, + "handles": handles, + "outer_border": handles[0], + "inner_border": handles[1], + "metrics": metrics, + "bbox": {"min": [ox, oy], "max": [ox + w, oy + h]}, + } +``` + +- [ ] **Step 12: Run it to verify the drawer passes** + +Run: `uv run --frozen pytest tests/test_sheet_frames.py -q` +Expected: PASS, 29 passed. + +- [ ] **Step 13: Write the failing test for the ISO 7200 title block** + +Create `tests/test_sheet_titleblock.py`. + +The A3 coordinates below were computed by hand from the layout the repository +has drawn since v1.0, so that folding it into the new module cannot move it: +`tb_x0 = 410 - 180 = 230`, `tb_y0 = 10`, `row4_top = 20`, `row3_top = 35`, +`row2_top = 50`, `r2_split1 = 320`, `r2_split2 = 365`, `r3_split1 = 290`, +`r3_split2 = 350`, `r4_split1 = 275`, `r4_split2 = 320`, `r4_split3 = 365`; +value pads 3.0 in x and 5.0 in y for rows 2 and 3, 1.5 in y for row 4; +`title_y = 50 + (20 - 5) / 2 = 57.5`. + +```python +"""ISO 7200 title blocks for A4-A0, and the A3 geometry that must not move.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Circle, Line, Poly, Text +from engineering.sheet.titleblock import ( + TB_HEIGHT, + TB_WIDTH, + TITLE_TEXT_INDEX, + TITLEBLOCK_FIELDS, + TITLEBLOCK_RULE_COUNT, + VALUE_FIELDS, + TitleBlockMetadata, + apply_titleblock, + projection_symbol_prims, + titleblock_origin, + titleblock_prims, + value_text_indices, +) + +EPS = 1e-9 + + +def _meta(**overrides) -> TitleBlockMetadata: + base = dict( + title="HELICAL GEAR", + drawing_no="AM-2026-001", + part_no="GR-24T-M3", + material="C45", + scale="1:1", + units="mm", + drawn_by="Umutcan", + checked_by="QA", + date="2026-04-28", + sheet="1/1", + revision="A", + company="Anka-Makine", + ) + base.update(overrides) + return TitleBlockMetadata(**base) + + +def test_the_block_is_the_iso_7200_180_mm_wide_and_sits_in_the_frame_corner(): + assert TB_WIDTH == 180.0 and TB_HEIGHT == 60.0 + assert titleblock_origin("A3") == (230.0, 10.0) + assert titleblock_origin("A4", orientation="portrait") == (20.0, 10.0) + assert titleblock_origin("A0") == (999.0, 10.0) + + +def test_every_declared_field_is_a_metadata_attribute(): + meta = _meta() + for field in TITLEBLOCK_FIELDS: + assert hasattr(meta, field), field + + +def test_the_a3_rules_are_exactly_where_they_have_always_been(): + prims = titleblock_prims("A3", _meta(), projection=None) + rules = prims[:TITLEBLOCK_RULE_COUNT] + assert isinstance(rules[0], Poly) and rules[0].closed + assert rules[0].points == ((230.0, 10.0), (410.0, 10.0), (410.0, 70.0), (230.0, 70.0)) + assert [(r.p1[1], r.p1[0], r.p2[0]) for r in rules[1:4]] == [ + (20.0, 230.0, 410.0), + (35.0, 230.0, 410.0), + (50.0, 230.0, 410.0), + ] + assert [(r.p1[0], r.p1[1], r.p2[1]) for r in rules[4:]] == [ + (320.0, 35.0, 50.0), + (365.0, 35.0, 50.0), + (290.0, 20.0, 35.0), + (350.0, 20.0, 35.0), + (275.0, 10.0, 20.0), + (320.0, 10.0, 20.0), + (365.0, 10.0, 20.0), + ] + + +def test_the_a3_value_texts_are_exactly_where_they_have_always_been(): + prims = titleblock_prims("A3", _meta(), projection=None) + index = value_text_indices() + assert prims[index["drawing_no"]] == Text(at=(233.0, 40.0), text="AM-2026-001", height=3.5) + assert prims[index["revision"]] == Text(at=(323.0, 40.0), text="A", height=3.5) + assert prims[index["sheet"]] == Text(at=(368.0, 40.0), text="1/1", height=3.5) + assert prims[index["part_no"]] == Text(at=(233.0, 25.0), text="GR-24T-M3", height=3.5) + assert prims[index["material"]] == Text(at=(293.0, 25.0), text="C45", height=3.5) + assert prims[index["scale"]] == Text(at=(353.0, 25.0), text="1:1", height=3.5) + assert prims[index["drawn_by"]] == Text(at=(233.0, 11.5), text="Umutcan", height=3.5) + assert prims[index["checked_by"]] == Text(at=(278.0, 11.5), text="QA", height=3.5) + assert prims[index["date"]] == Text(at=(323.0, 11.5), text="2026-04-28", height=3.5) + assert prims[index["company"]] == Text(at=(368.0, 11.5), text="Anka-Makine", height=3.5) + + +def test_the_title_sits_at_the_pinned_index_and_carries_the_text_verbatim(): + prims = titleblock_prims("A3", _meta(title="SPUR GEAR / M3 Z24"), projection=None) + title = prims[TITLE_TEXT_INDEX] + assert isinstance(title, Text) + assert title.text == "SPUR GEAR / M3 Z24" + assert title.height == 5.0 + assert title.at[1] == pytest.approx(57.5, abs=EPS) + + +def test_projection_none_draws_no_symbol_and_first_draws_one(): + plain = titleblock_prims("A3", _meta(), projection=None) + first = titleblock_prims("A3", _meta(), projection="first") + assert len(plain) == TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS) + assert len(first) == len(plain) + 4 + + +def test_first_angle_puts_the_circles_to_the_right_of_the_cone(): + first = projection_symbol_prims((0.0, 0.0), "first") + third = projection_symbol_prims((0.0, 0.0), "third") + cone_first = next(p for p in first if isinstance(p, Poly)) + circles_first = [p for p in first if isinstance(p, Circle)] + assert max(x for x, _ in cone_first.points) < min(c.center[0] for c in circles_first) + cone_third = next(p for p in third if isinstance(p, Poly)) + circles_third = [p for p in third if isinstance(p, Circle)] + assert min(x for x, _ in cone_third.points) > max(c.center[0] for c in circles_third) + assert {round(c.radius, 6) for c in circles_first} == {4.0, 2.0} + assert any(isinstance(p, Line) and p.role == "center" for p in first) + + +def test_the_cone_s_small_end_faces_the_circles_side_in_first_angle(): + """The derivation only holds if the view drawn as circles really is the + view from the cone's small end.""" + cone = next(p for p in projection_symbol_prims((0.0, 0.0), "first") if isinstance(p, Poly)) + left_x = min(x for x, _ in cone.points) + right_x = max(x for x, _ in cone.points) + left_height = max(y for x, y in cone.points if x == left_x) * 2 + right_height = max(y for x, y in cone.points if x == right_x) * 2 + assert left_height == pytest.approx(4.0, abs=EPS) + assert right_height == pytest.approx(8.0, abs=EPS) + + +def test_an_unknown_projection_angle_is_refused(): + with pytest.raises(ValueError) as excinfo: + titleblock_prims("A3", _meta(), projection="second") + assert "second" in str(excinfo.value) + + +def test_a_sheet_narrower_than_the_title_block_is_refused_by_name(): + """There is no such size in A4-A0; the guard exists so a future size cannot + ship a title block hanging off the paper.""" + from engineering.sheet import frames + + original = dict(frames.SHEETS) + frames.SHEETS["A9"] = (100.0, 140.0) + try: + with pytest.raises(ValueError) as excinfo: + titleblock_origin("A9", orientation="portrait") + assert "180" in str(excinfo.value) + finally: + frames.SHEETS.clear() + frames.SHEETS.update(original) + + +@pytest.mark.asyncio +async def test_apply_titleblock_returns_the_payload_the_a3_tool_always_returned(backend): + result = await apply_titleblock(backend, size="A3", metadata=_meta(), projection=None) + assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]} + assert set(result["value_texts"]) == { + "drawing_no", + "revision", + "sheet", + "part_no", + "material", + "scale", + "drawn_by", + "checked_by", + "date", + "company", + } + assert len(result["titleblock_lines"]) == TITLEBLOCK_RULE_COUNT + title = await backend.entity_get(result["title_text"]) + assert title.type == "TEXT" and title.properties["text"] == "HELICAL GEAR" + assert title.layer == "TEXT" + for handle in result["titleblock_lines"]: + assert (await backend.entity_get(handle)).layer == "TITLEBLOCK" + assert result["projection_symbol"] == [] + + +@pytest.mark.asyncio +async def test_apply_titleblock_draws_every_size_with_its_symbol(backend): + for size in ("A4", "A3", "A2", "A1", "A0"): + orientation = "portrait" if size == "A4" else "landscape" + result = await apply_titleblock( + backend, size=size, metadata=_meta(), orientation=orientation + ) + assert result["ok"] is True and result["size"] == size + assert len(result["projection_symbol"]) == 4 +``` + +- [ ] **Step 14: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_titleblock.py -q` +Expected: FAIL — collection error, `ModuleNotFoundError: No module named 'engineering.sheet.titleblock'`. + +- [ ] **Step 15: Implement the ISO 7200 title block for A4-A0** + +Create `engineering/sheet/titleblock.py`: + +```python +"""ISO 7200 title blocks for every ISO 5457 sheet from A4 to A0. + +SOURCE +------ +Width ISO 7200:2004 -- the title block is 180 mm wide and sits in the + lower right-hand corner of the drawing space, against the + frame. 180 mm is exactly the drawing-space width of a portrait + A4, which is why one block fits every size. +Data fields ISO 7200:2004 -- the mandatory fields are the legal owner + (`company`), the identification number (`drawing_no`), the + date of issue (`date`), the segment/sheet number (`sheet`) and + the title (`title`). The rest carried here are optional + fields: creator (`drawn_by`), approval person (`checked_by`), + version (`revision`), and the supplementary item data this + shop draws (`part_no`, `material`, `scale`, `units`). ISO + 7200's full list of optional fields is NOT transcribed -- only + the fields this block has a cell for. +Projection angle ISO 128-30 / ISO 5456-2 -- the truncated-cone symbol; see + `projection_symbol_prims` for the arrangement and where it + comes from. + +The row heights (20 / 15 / 15 / 10 mm) and the cell splits are this +repository's layout, not ISO 7200 values: the standard fixes the *data*, not +the ruling. They are exactly the ones `titleblock_apply_iso_a3` has drawn since +v1.0, so the A3 sheet this server makes has not moved. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text +from engineering.sheet.frames import ( + draw_sheet_prims, + enter_layout, + frame_metrics, + frame_prims, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "LABEL_HEIGHT", + "LABEL_PAD_X", + "LABEL_PAD_Y", + "PROJECTION_ANGLES", + "ROW1_H", + "ROW2_H", + "ROW3_H", + "ROW4_H", + "ROW4_VALUE_PAD_Y", + "TB_HEIGHT", + "TB_WIDTH", + "TITLEBLOCK_FIELDS", + "TITLEBLOCK_RULE_COUNT", + "TITLE_HEIGHT", + "TITLE_TEXT_INDEX", + "VALUE_FIELDS", + "VALUE_HEIGHT", + "VALUE_PAD_X", + "VALUE_PAD_Y", + "TitleBlockMetadata", + "apply_titleblock", + "projection_symbol_prims", + "titleblock_origin", + "titleblock_prims", + "value_text_indices", +] + +TB_WIDTH = 180.0 +TB_HEIGHT = 60.0 + +ROW1_H = 20.0 # title +ROW2_H = 15.0 # drawing_no | revision | sheet +ROW3_H = 15.0 # part_no | material | scale +ROW4_H = 10.0 # drawn_by | checked_by | date | company + +LABEL_HEIGHT = 2.0 +VALUE_HEIGHT = 3.5 +TITLE_HEIGHT = 5.0 +LABEL_PAD_X = 1.5 +LABEL_PAD_Y = 1.5 +VALUE_PAD_X = 3.0 +VALUE_PAD_Y = 5.0 +ROW4_VALUE_PAD_Y = 1.5 + +PROJECTION_ANGLES = ("first", "third") +PROJECTION_CONE_LENGTH = 8.0 +PROJECTION_R = 4.0 +PROJECTION_SMALL_R = 2.0 +PROJECTION_GAP = 4.0 + +#: Field -> the label printed in its cell, in the order the cells are drawn. +VALUE_FIELDS: tuple[tuple[str, str], ...] = ( + ("drawing_no", "DWG NO"), + ("revision", "REV"), + ("sheet", "SHEET"), + ("part_no", "PART NO"), + ("material", "MATERIAL"), + ("scale", "SCALE"), + ("drawn_by", "DRAWN"), + ("checked_by", "CHECKED"), + ("date", "DATE"), + ("company", "COMPANY"), +) + +#: Every ISO 7200 data field this block carries. `units` has no cell of its own +#: (the sheet is millimetres and the drawing says so), but it round-trips in the +#: payload so a reader is never told the drawing is unitless. +TITLEBLOCK_FIELDS: tuple[str, ...] = ( + "title", + "drawing_no", + "part_no", + "material", + "scale", + "units", + "drawn_by", + "checked_by", + "date", + "sheet", + "revision", + "company", +) + +#: The block frame plus the ten rules, at the head of the primitive tuple. +TITLEBLOCK_RULE_COUNT = 11 +TITLE_TEXT_INDEX = 11 + + +@dataclass +class TitleBlockMetadata: + """ISO 7200 data fields. Strings, written verbatim -- no transformation.""" + + title: str + drawing_no: str + part_no: str = "" + material: str = "" + scale: str = "1:1" + units: str = "mm" + drawn_by: str = "" + checked_by: str = "" + date: str = "" + sheet: str = "1/1" + revision: str = "A" + company: str = "Anka-Makine" + + +def titleblock_origin(size: str, *, orientation: str = "landscape") -> Pt: + """Lower-left corner of the title block, sheet-local.""" + metrics = frame_metrics(size, orientation=orientation) + x0, y0, x1, _y1 = metrics["frame"] + if x1 - x0 < TB_WIDTH - 1e-9: + raise ValueError( + f"{metrics['size']} {orientation}: the drawing area is {x1 - x0:.1f} mm wide, " + f"narrower than the ISO 7200 title block ({TB_WIDTH:.0f} mm)" + ) + return (x1 - TB_WIDTH, y0) + + +def projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]: + """The ISO 128-30 projection-angle symbol, centred on `at`. + + Arrangement, and where it comes from. The symbol is a truncated cone with a + horizontal axis and its small end facing left. The first- and third-angle + symbols are mirror images about a vertical axis, and which is which follows + from the projection rule itself: first-angle projection puts the view seen + from the left ON THE RIGHT, and the view of this cone seen from the left is + the small circle inside the large one. So FIRST angle draws the cone on the + left and the concentric circles on the right; THIRD angle is its mirror. + + The proportions (large circle diameter 8, small 4, cone 8 mm long) are this + module's; ISO 5456-2's proportion table is not transcribed. The arrangement + is the part that can be *wrong* on a drawing, and it is derived above. + """ + if angle not in PROJECTION_ANGLES: + raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}") + cx, cy = float(at[0]), float(at[1]) + half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0 + sign = 1.0 if angle == "first" else -1.0 + cone_far = -half * sign + cone_near = cone_far + sign * PROJECTION_CONE_LENGTH + circles_x = (half - PROJECTION_R) * sign + cone = Poly( + points=( + (cx + cone_far, cy - PROJECTION_SMALL_R), + (cx + cone_near, cy - PROJECTION_R), + (cx + cone_near, cy + PROJECTION_R), + (cx + cone_far, cy + PROJECTION_SMALL_R), + ), + closed=True, + ) + axis = Line(p1=(cx - half - 1.0, cy), p2=(cx + half + 1.0, cy), role="center") + return ( + cone, + Circle(center=(cx + circles_x, cy), radius=PROJECTION_R), + Circle(center=(cx + circles_x, cy), radius=PROJECTION_SMALL_R), + axis, + ) + + +def value_text_indices() -> dict[str, int]: + """Field -> its index in the tuple `titleblock_prims` returns. + + The cells are drawn label-then-value, starting right after the title, so + the value of the i-th field is at TITLE_TEXT_INDEX + 2i + 2. + """ + return {field: TITLE_TEXT_INDEX + 2 * i + 2 for i, (field, _label) in enumerate(VALUE_FIELDS)} + + +def titleblock_prims( + size: str, + fields, + *, + orientation: str = "landscape", + projection: str | None = "first", +) -> tuple[Prim, ...]: + """The title block as primitive descriptions, sheet-local. + + Order is pinned and read back by `value_text_indices`: the block frame, the + ten rules, the title, then (label, value) per cell in `VALUE_FIELDS` order, + then the four projection-symbol primitives when `projection` is not None. + """ + meta = fields if isinstance(fields, TitleBlockMetadata) else TitleBlockMetadata(**fields) + if projection is not None and projection not in PROJECTION_ANGLES: + raise ValueError( + f"projection must be one of {PROJECTION_ANGLES} or None, got {projection!r}" + ) + tb_x0, tb_y0 = titleblock_origin(size, orientation=orientation) + tb_x1, tb_y1 = tb_x0 + TB_WIDTH, tb_y0 + TB_HEIGHT + row4_top = tb_y0 + ROW4_H + row3_top = row4_top + ROW3_H + row2_top = row3_top + ROW2_H + r2_split1 = tb_x0 + TB_WIDTH * 0.5 + r2_split2 = tb_x0 + TB_WIDTH * 0.75 + r3_split1 = tb_x0 + TB_WIDTH / 3.0 + r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0 + r4_split1 = tb_x0 + TB_WIDTH * 0.25 + r4_split2 = tb_x0 + TB_WIDTH * 0.50 + r4_split3 = tb_x0 + TB_WIDTH * 0.75 + + prims: list[Prim] = [ + Poly( + points=((tb_x0, tb_y0), (tb_x1, tb_y0), (tb_x1, tb_y1), (tb_x0, tb_y1)), + closed=True, + ), + Line(p1=(tb_x0, row4_top), p2=(tb_x1, row4_top)), + Line(p1=(tb_x0, row3_top), p2=(tb_x1, row3_top)), + Line(p1=(tb_x0, row2_top), p2=(tb_x1, row2_top)), + Line(p1=(r2_split1, row3_top), p2=(r2_split1, row2_top)), + Line(p1=(r2_split2, row3_top), p2=(r2_split2, row2_top)), + Line(p1=(r3_split1, row4_top), p2=(r3_split1, row3_top)), + Line(p1=(r3_split2, row4_top), p2=(r3_split2, row3_top)), + Line(p1=(r4_split1, tb_y0), p2=(r4_split1, row4_top)), + Line(p1=(r4_split2, tb_y0), p2=(r4_split2, row4_top)), + Line(p1=(r4_split3, tb_y0), p2=(r4_split3, row4_top)), + ] + + title_x = tb_x0 + TB_WIDTH * 0.5 - (len(meta.title) * TITLE_HEIGHT * 0.3) + title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0 + prims.append(Text(at=(title_x, title_y), text=meta.title, height=TITLE_HEIGHT)) + + # field -> (cell left edge, the row's top rule, the value's baseline) + cells: dict[str, tuple[float, float, float]] = { + "drawing_no": (tb_x0, row2_top, row3_top + VALUE_PAD_Y), + "revision": (r2_split1, row2_top, row3_top + VALUE_PAD_Y), + "sheet": (r2_split2, row2_top, row3_top + VALUE_PAD_Y), + "part_no": (tb_x0, row3_top, row4_top + VALUE_PAD_Y), + "material": (r3_split1, row3_top, row4_top + VALUE_PAD_Y), + "scale": (r3_split2, row3_top, row4_top + VALUE_PAD_Y), + "drawn_by": (tb_x0, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "checked_by": (r4_split1, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "date": (r4_split2, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "company": (r4_split3, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + } + for field, label in VALUE_FIELDS: + left, row_top, value_y = cells[field] + prims.append( + Text( + at=(left + LABEL_PAD_X, row_top - LABEL_HEIGHT - LABEL_PAD_Y), + text=label, + height=LABEL_HEIGHT, + ) + ) + prims.append( + Text(at=(left + VALUE_PAD_X, value_y), text=getattr(meta, field), height=VALUE_HEIGHT) + ) + + if projection is not None: + prims.extend(projection_symbol_prims((tb_x0 + 15.0, row2_top + ROW1_H / 2.0), projection)) + return tuple(prims) + + +async def apply_titleblock( + backend: AutoCADBackend, + *, + size: str = "A3", + metadata: TitleBlockMetadata, + origin: Pt = (0.0, 0.0), + orientation: str = "landscape", + projection: str | None = "first", + frame: bool = True, + zones: bool = False, + marks: bool = False, + layout: str | None = None, +) -> dict: + """Draw the ISO 5457 frame (optionally) and the ISO 7200 title block. + + `projection=None`, `zones=False` and `marks=False` reproduce exactly what + `titleblock_apply_iso_a3` has always drawn. Every refusal -- an unknown + size, an unknown orientation, an unknown projection angle, a missing layout + -- fires before the first entity reaches the drawing. + """ + metrics = frame_metrics(size, orientation=orientation) + tb_prims = titleblock_prims(size, metadata, orientation=orientation, projection=projection) + sheet_prims = ( + frame_prims(size, orientation=orientation, zones=zones, marks=marks) if frame else () + ) + previous = await enter_layout(backend, layout, caller="titleblock_apply") + try: + frame_handles = await draw_sheet_prims(backend, sheet_prims, origin=origin) + tb_handles = await draw_sheet_prims(backend, tb_prims, origin=origin) + finally: + if layout is not None: + await backend.layout_set_current(previous) + + ox, oy = float(origin[0]), float(origin[1]) + w, h = metrics["sheet"] + symbol_start = TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS) + return { + "ok": True, + "size": metrics["size"], + "orientation": orientation, + "layout": layout or "Model", + "projection": projection, + "outer_border": frame_handles[0] if frame else None, + "inner_border": frame_handles[1] if frame else None, + "frame_handles": frame_handles, + "titleblock_lines": tb_handles[:TITLEBLOCK_RULE_COUNT], + "title_text": tb_handles[TITLE_TEXT_INDEX], + "value_texts": {f: tb_handles[i] for f, i in value_text_indices().items()}, + "projection_symbol": tb_handles[symbol_start:], + "metadata": asdict(metadata), + "bbox": {"min": [ox, oy], "max": [ox + w, oy + h]}, + } +``` + +- [ ] **Step 16: Run it to verify the title block passes** + +Run: `uv run --frozen pytest tests/test_sheet_titleblock.py -q` +Expected: PASS, 12 passed. + +- [ ] **Step 17: Fold the old A3 module into an alias, and run its tests unchanged** + +Replace the whole of `engineering/titleblock.py` with: + +```python +"""ISO 7200 / A3 title block -- the historical entry point. + +The layout moved to `engineering.sheet.titleblock`, which draws every ISO 5457 +size from A4 to A0. What is left here is the A3 alias: `apply_iso_a3_titleblock` +forwards to `apply_titleblock(size="A3", projection=None, zones=False, +marks=False)`, which is bit-for-bit the geometry this module drew before -- no +projection-angle symbol, no zone grid, no trimming marks -- so every caller and +every test that pinned it keeps working. The module-level constants are +re-exported for the same reason. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from engineering.sheet.frames import ( + EDGE_MARGIN, + FILING_MARGIN, + SHEET_LAYER, + SHEET_TEXT_LAYER, + sheet_size, +) +from engineering.sheet.titleblock import ( + LABEL_HEIGHT, + ROW1_H, + ROW2_H, + ROW3_H, + ROW4_H, + TB_HEIGHT, + TB_WIDTH, + TITLE_HEIGHT, + VALUE_HEIGHT, + TitleBlockMetadata, + apply_titleblock, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "BOTTOM_MARGIN", + "LABEL_HEIGHT", + "LAYER_BORDER", + "LAYER_TEXT", + "LEFT_MARGIN", + "RIGHT_MARGIN", + "ROW1_H", + "ROW2_H", + "ROW3_H", + "ROW4_H", + "SHEET_H", + "SHEET_W", + "TB_HEIGHT", + "TB_WIDTH", + "TITLE_HEIGHT", + "TOP_MARGIN", + "VALUE_HEIGHT", + "TitleBlockMetadata", + "apply_iso_a3_titleblock", +] + +SHEET_W, SHEET_H = sheet_size("A3", "landscape") +LEFT_MARGIN = FILING_MARGIN +RIGHT_MARGIN = EDGE_MARGIN +TOP_MARGIN = EDGE_MARGIN +BOTTOM_MARGIN = EDGE_MARGIN +LAYER_BORDER = SHEET_LAYER +LAYER_TEXT = SHEET_TEXT_LAYER + + +async def apply_iso_a3_titleblock( + backend: AutoCADBackend, + *, + metadata: TitleBlockMetadata, + origin: tuple[float, float] = (0.0, 0.0), + layout: str | None = None, +) -> dict: + """Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet). + + With `layout`, the sheet is drawn on that paper-space layout -- which is + where a title block belongs, since the border frames the printed sheet + rather than the model. The caller's current space is restored afterwards, so + asking for a sheet border does not silently move them onto the sheet. + Without `layout` it draws in whatever space is current, as it always did. + + Equivalent to `titleblock_apply(size="A3")` with the projection-angle + symbol, the zone grid and the trimming marks switched off. Reach for + `titleblock_apply` for any other size, or for the symbol. + """ + return await apply_titleblock( + backend, + size="A3", + metadata=metadata, + origin=origin, + orientation="landscape", + projection=None, + frame=True, + zones=False, + marks=False, + layout=layout, + ) +``` + +Run: `uv run --frozen pytest tests/test_engineering_titleblock.py tests/test_paper_space.py tests/test_sprint4_validator.py -q` +Expected: PASS — the seven title-block tests and the two suites that import the +module, unchanged. This is the regression gate for the fold. + +- [ ] **Step 18: Re-export the new names from `engineering/__init__.py`** + +In `engineering/__init__.py`, replace the line + +```python +from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock +``` + +with + +```python +from .sheet.frames import draw_sheet_frame, frame_metrics, frame_prims +from .sheet.titleblock import apply_titleblock, titleblock_prims +from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock +``` + +and append to `__all__`: + +```python + "apply_titleblock", + "draw_sheet_frame", + "frame_metrics", + "frame_prims", + "titleblock_prims", +``` + +Run: `uv run --frozen python -c "import engineering; print(engineering.apply_titleblock, engineering.apply_iso_a3_titleblock)"` +Expected: both functions print. + +- [ ] **Step 19: Register SECTION 24 with its first two tools** + +In `server.py`, immediately before the `# ── RESOURCES ───` banner, insert: + +```python +# --------------------------------------------------------------------------- +# ── SECTION 24: Sheet & Delivery (2 tools) ────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Draw an ISO 5457 sheet frame with the zone grid and centring marks.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: ISO 5457 Frame", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def sheet_frame( + size: Annotated[ + str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.") + ] = "A3", + orientation: Annotated[ + str, Field(default="landscape", description="'landscape' or 'portrait'.") + ] = "landscape", + zones: Annotated[ + bool, + Field( + default=True, + description="Draw the ISO 5457 grid reference system: letters down the vertical " + "edges from the top, numbers along the horizontal edges from the left.", + ), + ] = True, + marks: Annotated[ + bool, + Field( + default=True, + description="Draw the four centring marks and the eight corner trimming rectangles.", + ), + ] = True, + origin_x: Annotated[ + float, Field(default=0.0, description="X of the sheet's lower-left corner (WCS).") + ] = 0.0, + origin_y: Annotated[ + float, Field(default=0.0, description="Y of the sheet's lower-left corner (WCS).") + ] = 0.0, + layout: Annotated[ + str, + Field( + default="", + description="Paper-space layout to draw the sheet on (create it with " + "layout_create). Empty draws in the current space.", + ), + ] = "", + ctx: Context = None, +) -> dict: + """ISO 5457 drawing frame for A4-A0: a 20 mm filing margin, 10 mm on the + other three edges, the grid reference system and the centring/trimming marks. + + Borders, rules and marks land on TITLEBLOCK; the zone lettering on TEXT. + Refusals, all before the first entity is drawn: a size outside A4-A0 is + refused by name and the five covered sizes are listed; an orientation that + is neither 'landscape' nor 'portrait'; a `layout` that does not exist. The + zone divisions drawn are equal -- ISO 5457's shorter corner fields are not + implemented -- while the division counts are the standard's. + """ + from engineering.sheet.frames import draw_sheet_frame + + await ctx.info(f"Sheet frame {size} {orientation}") + return await draw_sheet_frame( + _backend(ctx), + size, + orientation=orientation, + zones=zones, + marks=marks, + origin=(origin_x, origin_y), + layout=layout or None, + ) + + +@cad_tool( + summary="Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: ISO 7200 Title Block", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def titleblock_apply( + title: Annotated[str, Field(description="Drawing title (verbatim, no transformation).")], + drawing_no: Annotated[str, Field(description="Identification number, e.g. 'AM-2026-001'.")], + size: Annotated[ + str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.") + ] = "A3", + orientation: Annotated[ + str, Field(default="landscape", description="'landscape' or 'portrait'.") + ] = "landscape", + projection: Annotated[ + str, + Field( + default="first", + description="Projection-angle symbol: 'first' (ISO/European, the default), " + "'third' (ANSI), or '' for no symbol.", + ), + ] = "first", + part_no: Annotated[str, Field(default="")] = "", + material: Annotated[str, Field(default="")] = "", + scale: Annotated[str, Field(default="1:1")] = "1:1", + units: Annotated[str, Field(default="mm")] = "mm", + drawn_by: Annotated[str, Field(default="")] = "", + checked_by: Annotated[str, Field(default="")] = "", + date: Annotated[str, Field(default="")] = "", + sheet: Annotated[str, Field(default="1/1")] = "1/1", + revision: Annotated[str, Field(default="A")] = "A", + company: Annotated[str, Field(default="Anka-Makine")] = "Anka-Makine", + frame: Annotated[ + bool, Field(default=True, description="Also draw the ISO 5457 frame around the block.") + ] = True, + zones: Annotated[ + bool, Field(default=False, description="Add the zone grid to that frame.") + ] = False, + marks: Annotated[ + bool, Field(default=False, description="Add the centring and trimming marks.") + ] = False, + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + layout: Annotated[ + str, + Field( + default="", + description="Paper-space layout to draw the sheet on; empty draws in the " + "current space.", + ), + ] = "", + ctx: Context = None, +) -> dict: + """ISO 7200 title block, 180 mm wide, in the lower-right corner of any + A4-A0 sheet. The title text is used verbatim. + + Carries the ISO 128-30 projection-angle symbol: 'first' draws the cone on + the left and the concentric circles on the right, 'third' is its mirror. + Refusals, all before the first entity: an unknown size (the five covered + sizes are listed), an unknown orientation, a projection that is not + 'first'/'third'/'', a `layout` that does not exist. `titleblock_apply_iso_a3` + is this tool with size='A3' and no symbol, kept so no caller breaks. + """ + from engineering.sheet.titleblock import TitleBlockMetadata, apply_titleblock + + await ctx.info(f"Title block {size} {orientation}: {title}") + metadata = TitleBlockMetadata( + title=title, + drawing_no=drawing_no, + part_no=part_no, + material=material, + scale=scale, + units=units, + drawn_by=drawn_by, + checked_by=checked_by, + date=date, + sheet=sheet, + revision=revision, + company=company, + ) + return await apply_titleblock( + _backend(ctx), + size=size, + metadata=metadata, + origin=(origin_x, origin_y), + orientation=orientation, + projection=projection or None, + frame=frame, + zones=zones, + marks=marks, + layout=layout or None, + ) +``` + +- [ ] **Step 20: Add the alias records** + +In `discovery/aliases.py`, inside `TOOL_ALIASES` directly after the +`"titleblock_apply_iso_a3"` record, add: + +```python + "sheet_frame": ToolAliases( + acad=("MVSETUP",), + synonyms=( + "sheet frame", + "drawing frame", + "drawing border", + "iso 5457", + "zone grid", + "grid reference system", + "centring marks", + "trimming marks", + "sheet margins", + "filing margin", + "pafta", + "cizim cercevesi", + ), + ), + "titleblock_apply": ToolAliases( + synonyms=( + "title block", + "iso 7200", + "drawing header", + "sheet title block", + "a0 title block", + "a1 title block", + "a2 title block", + "a4 title block", + "projection angle symbol", + "first angle symbol", + "third angle symbol", + "antet", + "baslik blogu", + ), + ), +``` + +The Turkish synonyms are written without diacritics because the BM25 corpus and +the golden queries are ASCII: `pafta` (sheet), `cizim cercevesi` (drawing +frame), `antet` / `baslik blogu` (title block) are what this shop types. + +Run: `uv run --frozen pytest tests/test_discovery_aliases.py -q` +Expected: PASS. + +- [ ] **Step 21: Regenerate the inventory and bump the count files** + +Run: + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen python -c "import json; print(json.load(open('docs/tool-inventory.json'))['totals'])" +``` + +Expected: `{'tools': 206, 'resources': 8, 'prompts': 5, 'groups': 23}`. + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: change +`"engineering": 10,` to `"engineering": 12,`; change the final +`assert sum(sizes.values()) == 204` to `== 206`; change the docstring's first +line `Frozen snapshot of the surface (204 tools, 23 groups).` to +`Frozen snapshot of the surface (206 tools, 23 groups).`; and insert this +paragraph immediately before the closing `Every other number here has been +unchanged since the snapshot was taken.` sentence: + +``` + `engineering` moved 10 -> 12 when v1.6's SECTION 24 opened with + `sheet_frame` and `titleblock_apply` (the ISO 5457 frame and the ISO 7200 + title block for every size A4-A0). Both are tagged `engineering` and + `sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under + `engineering` beside `titleblock_apply_iso_a3` rather than opening a + 24th group. +``` + +In `README.md`, recompute the alias figures first: + +``` +uv run --frozen python -c "from discovery.aliases import TOOL_ALIASES; a=sorted({c for r in TOOL_ALIASES.values() for c in r.acad}); s=[p for r in TOOL_ALIASES.values() for p in r.synonyms]; print(len(a), len(s), len(TOOL_ALIASES))" +``` + +then edit four places: the release-snapshot line `204 tools` -> `206 tools`; +the next line `204 is the **registered** count; a default install advertises +199` -> `206 ... advertises 201`; the alias sentence `186 AutoCAD command names +and 933 synonym phrases covering all 204 tools` -> the three numbers the command +above printed; the "What you get" caption `204 tools in 23 groups` -> +`206 tools in 23 groups`. + +- [ ] **Step 22: Run the gates** + +Run: + +``` +uv run --frozen ruff format engineering/sheet engineering/titleblock.py engineering/__init__.py server.py discovery/aliases.py tests/test_sheet_frames.py tests/test_sheet_titleblock.py tests/test_tool_registry.py +uv run --frozen ruff check engineering/sheet engineering/titleblock.py engineering/__init__.py server.py discovery/aliases.py tests/test_sheet_frames.py tests/test_sheet_titleblock.py tests/test_tool_registry.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_sheet_frames.py tests/test_sheet_titleblock.py tests/test_engineering_titleblock.py tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_capability_contract.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q +``` + +Expected: all green. If `tests/test_discovery_transform.py` reports a golden +case now resolving to `sheet_frame` or `titleblock_apply`, narrow *my* synonyms +(drop the phrase that stole the hit) — never edit the golden case. If +`uv run --frozen` fails with `os error 32` on `.venv/Scripts/autocad-mcp.exe`, a +running MCP server holds the launcher: use `uv run --frozen --no-sync …` for the +rest of the task and say so in `deviations`. + +- [ ] **Step 23: Commit** + +Run: + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -am "$(cat <<'EOF' +feat(sheet): ISO 5457 frames and ISO 7200 title blocks for A4-A0 + +SECTION 24 opens with sheet_frame and titleblock_apply. + +engineering/sheet/frames.py transcribes ISO 216's trimmed sizes, ISO 5457's +20 mm filing margin and 10 mm edges, the grid reference system (an even number +of divisions per side, that side of the frame over 50 mm; letters from the top, +numbers from the left), the centring marks (trimmed edge to 5 mm inside the +frame) and the 10 x 5 mm corner trimming marks. Every one of those numbers is +pinned by a test against the clause it comes from, because a wrong margin is +visible on every drawing this server makes. + +engineering/sheet/titleblock.py draws the ISO 7200 block -- 180 mm wide, which +is exactly the drawing-space width of a portrait A4, which is why one block +fits every size -- and carries the ISO 128-30 projection-angle symbol. Which +symbol is which is derived rather than remembered: first-angle projection puts +the view seen from the left on the right, and the view of the cone from its +small end is the small circle inside the large one, so first angle draws the +cone left and the circles right. + +The old A3 module folds in. apply_iso_a3_titleblock now forwards to +apply_titleblock(size="A3", projection=None, zones=False, marks=False), which is +the geometry it always drew, and tests/test_engineering_titleblock.py passes +unchanged -- that is the regression gate for the fold. + +Declared, not glossed: the zone divisions drawn here are equal, so ISO 5457's +allowance for shorter corner fields is not implemented; the division counts, +which are what a zone reference quotes, are the standard's. ISO 7200's full +optional-field list is not transcribed, only the fields this block has a cell +for. Coverage is A4-A0; an unknown size is refused by name with the five it has. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +EOF +)" +``` + +--- + +### Task 13: Revisions, the ISO 7573 parts list and ISO 6433 balloons + +**Files:** +- Create: `engineering/sheet/bom.py` +- Create: `engineering/sheet/revision.py` +- Modify: `engineering/sheet/__init__.py` (re-export the new public names) +- Modify: `server.py` (SECTION 24 banner grows to "(6 tools)": `revision_add`, `bom_extract`, `bom_table`, `balloon_add`) +- Modify: `discovery/aliases.py` (four alias records; `REVCLOUD` and `TABLE` join `SHARED_ACAD_COMMANDS`) +- Modify: `docs/tool-inventory.json` (regenerated) +- Modify: `README.md` (release-snapshot tool count and the three prose figures) +- Modify: `tests/test_tool_registry.py` (frozen group-size snapshot) +- Test: `tests/test_sheet_bom.py` +- Test: `tests/test_sheet_revision.py` + +**Interfaces:** +- Consumes (Task 1, `engineering/mech/primitives.py`): `Pt`, `Prim`, `class Line(NamedTuple): p1: Pt; p2: Pt; role: Role = "visible"`, `class Circle(NamedTuple): center: Pt; radius: float; role: Role = "visible"`, `class Poly(NamedTuple): points: tuple[Pt, ...]; closed: bool = False; role: Role = "visible"`, `class Text(NamedTuple): at: Pt; text: str; height: float = 3.5; rotation: float = 0.0; role: Role = "text"` +- Consumes (Task 1, `engineering/mech/xdata.py`): `APP_ID = "ACADMCP_MECH"`, `PAYLOAD_VERSION = 1`, `to_values(payload: dict) -> list[str]` (the chunk strings `entity_set_xdata(handle, app_name, values)` takes — `encode` returns `(code, value)` rows and is *not* what a backend call wants), `decode(rows) -> dict` (which reads either shape); `KINDS = ("part", "std_part", "balloon")`; the pinned payload shapes `{"v": 1, "kind": "std_part", "designation": ..., "standard": ..., "size": ..., "qty": 1, "material": ...}` and `{"v": 1, "kind": "balloon", "item": 3, "targets": ["1A2B", ...]}`. This task writes the balloon payload with one additive key — `"text"`, the handle of the numeral MTEXT — so a rerun can renumber a balloon in place instead of drawing a second one; every reader (`read_balloons` here, the `mech_bom_balloon_mismatch` focus in Task 15) uses `.get`, so a payload without it still decodes. The revision block is **not** an `ACADMCP_MECH` payload: `KINDS` is pinned to three and a fourth kind would be refused by `encode`, so `engineering/sheet/revision.py` carries its own application id (see its Produces entry below). +- Consumes (Task 12, `engineering/sheet/frames.py`): `draw_sheet_prims(backend, prims, *, origin=(0.0, 0.0), layer_map=None) -> list[str]`, `enter_layout(backend, layout, *, caller) -> str`, `centred_text(cx, cy, text, height) -> Text`, `frame_metrics(size, *, orientation="landscape") -> dict`, `SHEET_LAYER`, `SHEET_TEXT_LAYER` +- Consumes (Task 12, `engineering/sheet/titleblock.py`): `TB_HEIGHT = 60.0`, `TB_WIDTH = 180.0`, `titleblock_origin(size, *, orientation="landscape") -> Pt` +- Consumes (existing backend contract): `entity_list(type_filter=None, layer_filter=None, limit=200, offset=0) -> list[EntityInfo]`, `block_get_attributes(handle) -> dict`, `entity_get_xdata(handle, app_name=None) -> dict`, `entity_set_xdata(handle, app_name, values) -> dict`, `entity_create_table(x, y, rows, headers=None, column_widths=None, row_height=7.0, text_height=2.5, title=None, layer="TEXT") -> EntityInfo`, `entity_create_revcloud(points, segment_length, layer=None, closed=True) -> dict`, `entity_edit_text(handle, text=None, height=None, rotation=None) -> EntityInfo`, `capabilities() -> CapabilityMap` +- Produces: + - `engineering/sheet/bom.py`: `BOM_COLUMNS: tuple[str, ...]`; `DEFAULT_COLUMNS: tuple[str, ...]`; `COLUMN_LABELS: dict[str, str]`; `COLUMN_SHARES: dict[str, float]`; `LIST_WIDTH = 180.0`; `ROW_HEIGHT = 7.0`; `BALLOON_DOT_RADIUS = 0.5`; `rows_from_records(records, *, columns=None, group_by="designation") -> tuple[dict, ...]`; `table_layout(rows, *, at, direction="up", columns=None, row_height=7.0) -> dict`; `table_prims(rows, *, at, direction="up", standard="ISO 7573", height=7.0) -> tuple[Prim, ...]`; `balloon_prims(item: int, at: Pt, leader_to: Pt, *, radius=4.0, height=None) -> tuple[Prim, ...]`; `async extract_records(backend, *, layer=None, limit=200) -> tuple[dict, ...]`; `async read_balloons(backend) -> tuple[dict, ...]`; `async draw_bom_table(backend, rows, *, at, direction="up", columns=None, row_height=7.0, layer="TEXT", layout=None) -> dict`; `async add_balloon(backend, *, item, at, leader_to, targets=(), radius=4.0, layer="DIM", layout=None) -> dict` + - `engineering/sheet/revision.py`: `REVISION_COLUMNS: tuple[str, ...]`; `REVISION_LABELS: dict[str, str]`; `REVISION_SHARES: dict[str, float]`; `REV_RE`; `TAG_SIDE = 7.0`; `REV_APP_ID = "ACADMCP_SHEET_REV"`; `REV_PAYLOAD_VERSION = 1`; `rev_values(payload: dict) -> list[str]`; `rev_payload(values) -> dict` — the revision row is sheet furniture, not part geometry, and `engineering.mech.xdata.KINDS` is pinned to `("part", "std_part", "balloon")`, so it gets its own XDATA application instead of a fourth kind. `entity_set_xdata` takes the application name as its own argument, so the two live side by side on one entity without colliding; `revision_tag_prims(rev: str, at: Pt, *, side=TAG_SIDE) -> tuple[Prim, ...]`; `revision_heading_prims(*, at: Pt, height=7.0) -> tuple[Prim, ...]`; `revision_row_prims(row: dict, *, at: Pt, index: int, height=7.0) -> tuple[Prim, ...]`; `revision_block_prims(rows, *, at, height=7.0) -> tuple[Prim, ...]`; `async read_revisions(backend) -> tuple[dict, ...]`; `async add_revision(backend, *, rev, description, date="", by="", clouds=(), tags=(), size="A3", orientation="landscape", origin=(0.0, 0.0), at=None, cloud_segment=8.0, layout=None) -> dict` + +- [ ] **Step 1: Write the failing test for the ISO 7573 row and table layout** + +Create `tests/test_sheet_bom.py`: + +```python +"""ISO 7573 parts lists and ISO 6433 item references (balloons).""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Circle, Line, Text +from engineering.sheet.bom import ( + COLUMN_LABELS, + DEFAULT_COLUMNS, + LIST_WIDTH, + ROW_HEIGHT, + balloon_prims, + rows_from_records, + table_layout, + table_prims, +) + +EPS = 1e-9 + + +def _records(): + return [ + {"handle": "A1", "designation": "ISO 4014 - M12x60", "standard": "ISO 4014", + "material": "8.8", "qty": 1}, + {"handle": "A2", "designation": "ISO 4014 - M12x60", "standard": "ISO 4014", + "material": "8.8", "qty": 1}, + {"handle": "B1", "designation": "SHAFT 40h6", "standard": "", "material": "C45", + "qty": 1}, + {"handle": "C1", "designation": "ISO 4032 - M12", "standard": "ISO 4032", + "material": "8", "qty": 1}, + ] + + +def test_default_columns_are_the_iso_7573_minimum_plus_material(): + assert DEFAULT_COLUMNS == ("item", "qty", "designation", "standard", "material") + for column in DEFAULT_COLUMNS: + assert column in COLUMN_LABELS + + +def test_rows_group_by_designation_sum_the_quantity_and_number_from_one(): + rows = rows_from_records(_records()) + assert [r["item"] for r in rows] == [1, 2, 3] + by_designation = {r["designation"]: r for r in rows} + assert by_designation["ISO 4014 - M12x60"]["qty"] == 2 + assert by_designation["ISO 4014 - M12x60"]["handles"] == ("A1", "A2") + assert by_designation["SHAFT 40h6"]["qty"] == 1 + assert set(rows[0]) >= set(DEFAULT_COLUMNS) | {"handles"} + + +def test_rows_are_ordered_deterministically_whatever_order_they_arrive_in(): + forward = rows_from_records(_records()) + backward = rows_from_records(list(reversed(_records()))) + assert [r["designation"] for r in forward] == [r["designation"] for r in backward] + assert [r["item"] for r in forward] == [r["item"] for r in backward] + + +def test_group_by_none_keeps_one_row_per_insert(): + rows = rows_from_records(_records(), group_by=None) + assert [r["item"] for r in rows] == [1, 2, 3, 4] + assert all(r["qty"] == 1 for r in rows) + + +def test_an_unknown_column_is_refused_with_the_list(): + with pytest.raises(ValueError) as excinfo: + rows_from_records(_records(), columns=("item", "colour")) + message = str(excinfo.value) + assert "colour" in message + assert "designation" in message + + +def test_the_list_is_the_title_block_width_and_the_default_shares_sum_to_it(): + assert LIST_WIDTH == 180.0 + assert ROW_HEIGHT == 7.0 + layout = table_layout(rows_from_records(_records()), at=(230.0, 70.0)) + assert layout["width"] == pytest.approx(180.0, abs=EPS) + assert sum(layout["widths"]) == pytest.approx(180.0, abs=EPS) + assert layout["height"] == pytest.approx(4 * 7.0, abs=EPS) # 3 items + the heading + + +def test_direction_up_puts_the_heading_at_the_bottom_and_item_1_above_it(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 70.0), direction="up") + assert layout["cells"][-1][0] == COLUMN_LABELS["item"] + assert layout["cells"][-2][0] == "1" + assert layout["cells"][0][0] == "3" + # the table is drawn downwards from its insertion point, so the insertion + # sits a full table height above the anchor and the list grows upwards + assert layout["insert"] == (230.0, 70.0 + 4 * 7.0) + + +def test_direction_down_puts_the_heading_at_the_top(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 200.0), direction="down") + assert layout["cells"][0][0] == COLUMN_LABELS["item"] + assert layout["cells"][1][0] == "1" + assert layout["insert"] == (230.0, 200.0) + + +def test_an_unknown_direction_is_refused(): + with pytest.raises(ValueError) as excinfo: + table_layout(rows_from_records(_records()), at=(0.0, 0.0), direction="sideways") + assert "sideways" in str(excinfo.value) + + +def test_table_prims_draw_the_same_rectangle_the_layout_declares(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 70.0)) + prims = table_prims(rows, at=(230.0, 70.0)) + xs = [p[0] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)] + ys = [p[1] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)] + assert min(xs) == pytest.approx(230.0, abs=EPS) + assert max(xs) == pytest.approx(230.0 + layout["width"], abs=EPS) + assert min(ys) == pytest.approx(70.0, abs=EPS) + assert max(ys) == pytest.approx(70.0 + layout["height"], abs=EPS) + assert any(isinstance(p, Text) and p.text == "ISO 4014 - M12x60" for p in prims) + + +def test_an_unknown_parts_list_standard_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + table_prims(rows_from_records(_records()), at=(0.0, 0.0), standard="ASME Y14.34") + message = str(excinfo.value) + assert "ASME Y14.34" in message + assert "ISO 7573" in message + + +def test_a_balloon_is_a_circle_a_leader_a_dot_and_the_numeral(): + prims = balloon_prims(7, (100.0, 100.0), (80.0, 90.0)) + circles = [p for p in prims if isinstance(p, Circle)] + lines = [p for p in prims if isinstance(p, Line)] + texts = [p for p in prims if isinstance(p, Text)] + assert len(circles) == 2 and len(lines) == 1 and len(texts) == 1 + balloon = max(circles, key=lambda c: c.radius) + dot = min(circles, key=lambda c: c.radius) + assert balloon.center == (100.0, 100.0) and balloon.radius == 4.0 + assert dot.center == (80.0, 90.0) + assert texts[0].text == "7" + # ISO 6433: the leader starts on the balloon's boundary and ends on the item + start_distance = ( + (lines[0].p1[0] - 100.0) ** 2 + (lines[0].p1[1] - 100.0) ** 2 + ) ** 0.5 + assert start_distance == pytest.approx(4.0, abs=1e-9) + assert lines[0].p2 == (80.0, 90.0) + + +def test_a_leader_target_inside_the_balloon_is_refused(): + with pytest.raises(ValueError) as excinfo: + balloon_prims(3, (100.0, 100.0), (101.0, 100.0)) + assert "inside" in str(excinfo.value) + + +def test_a_non_positive_item_number_is_refused(): + with pytest.raises(ValueError) as excinfo: + balloon_prims(0, (0.0, 0.0), (20.0, 0.0)) + assert "0" in str(excinfo.value) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_bom.py -q` +Expected: FAIL — collection error, `ModuleNotFoundError: No module named 'engineering.sheet.bom'`. + +- [ ] **Step 3: Implement the pure parts-list and balloon geometry** + +Create `engineering/sheet/bom.py`: + +```python +"""ISO 7573 parts lists and ISO 6433 item references. + +SOURCE +------ +Parts list ISO 7573:2008 -- an item list carries at least an item reference + (the position number), a quantity, a designation and a reference + to the document or standard that defines the item. When the list + is drawn on the drawing it is placed directly above the title + block, has the same width as it, and the item numbers ascend + upwards so the list can be extended -- which puts the heading row + at the bottom, against the title block. `direction="down"` draws + the same list the other way up, for a list on its own sheet. +Balloons ISO 6433:2012 -- an item reference is a numeral placed outside the + outline of the item and connected to it by a leader line that + terminates in a dot on the item. + +NOT TRANSCRIBED, and therefore not asserted anywhere as if it were the +standard's: + +* The column widths. ISO 7573 fixes neither the widths nor the order; the + shares below total the 180 mm title-block width, which *is* the standard's + rule, and any column set is rescaled to that total. +* The row height. 7 mm is this repository's `entity_create_table` default. +* ISO 6433's rule that the numeral is at least twice the height of the + dimension characters. The numeral here is 1.4 * the balloon radius, so the + default radius of 4 mm gives a 5.6 mm numeral -- which clears 2 x 2.5 mm + dimension text but not 2 x 3.5 mm. A drawing dimensioned at 3.5 mm should + pass `radius=5.0`, and the docstring of `balloon_prims` says so. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Prim, Pt, Text +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values +from engineering.sheet.frames import SHEET_TEXT_LAYER, draw_sheet_prims, enter_layout + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "BALLOON_DOT_RADIUS", + "BOM_COLUMNS", + "COLUMN_LABELS", + "COLUMN_SHARES", + "DEFAULT_COLUMNS", + "DIRECTIONS", + "LIST_STANDARDS", + "LIST_WIDTH", + "ROW_HEIGHT", + "add_balloon", + "balloon_prims", + "draw_bom_table", + "extract_records", + "read_balloons", + "rows_from_records", + "table_layout", + "table_prims", +] + +LIST_WIDTH = 180.0 # ISO 7573: the item list is as wide as the title block. +ROW_HEIGHT = 7.0 +TEXT_HEIGHT = 2.5 +BALLOON_DOT_RADIUS = 0.5 +DIRECTIONS = ("up", "down") +LIST_STANDARDS = ("ISO 7573",) + +#: Every column this module knows how to print, in the order it prints them. +BOM_COLUMNS: tuple[str, ...] = ( + "item", + "qty", + "designation", + "standard", + "material", + "mass", + "remarks", +) + +#: The four ISO 7573 minimum columns plus the material this shop always wants. +DEFAULT_COLUMNS: tuple[str, ...] = ("item", "qty", "designation", "standard", "material") + +COLUMN_LABELS: dict[str, str] = { + "item": "POS", + "qty": "QTY", + "designation": "DESIGNATION", + "standard": "STANDARD", + "material": "MATERIAL", + "mass": "MASS", + "remarks": "REMARKS", +} + +#: Relative widths; `table_layout` rescales the chosen set to LIST_WIDTH. The +#: default five happen to total 180.0, so their scale factor is exactly 1. +COLUMN_SHARES: dict[str, float] = { + "item": 12.0, + "qty": 14.0, + "designation": 74.0, + "standard": 46.0, + "material": 34.0, + "mass": 22.0, + "remarks": 40.0, +} + + +def _check_columns(columns) -> tuple[str, ...]: + chosen = tuple(DEFAULT_COLUMNS if columns is None else columns) + unknown = [c for c in chosen if c not in BOM_COLUMNS] + if unknown: + raise ValueError( + f"unknown parts-list column(s) {', '.join(unknown)}; " + f"available: {', '.join(BOM_COLUMNS)}" + ) + if "item" not in chosen: + raise ValueError("ISO 7573: the item reference column 'item' cannot be dropped") + return chosen + + +def rows_from_records(records, *, columns=None, group_by: str | None = "designation"): + """Collapse extracted INSERT records into numbered parts-list rows. + + `group_by` is a record key (`"designation"`, `"standard"`, ...) or None for + one row per insert. Rows are sorted by the grouping value so a rerun on the + same drawing numbers the same parts the same way; `handles` carries every + INSERT the row stands for, which is what a balloon links to. + """ + chosen = _check_columns(columns) + if group_by is not None and group_by not in BOM_COLUMNS: + raise ValueError( + f"group_by must be None or one of {', '.join(BOM_COLUMNS)}; got {group_by!r}" + ) + buckets: dict[str, dict] = {} + order: list[str] = [] + for index, record in enumerate(records): + key = str(record.get(group_by, "")) if group_by is not None else f"#{index:06d}" + bucket = buckets.get(key) + if bucket is None: + bucket = {c: record.get(c, "") for c in chosen if c != "item"} + bucket["qty"] = 0 + bucket["handles"] = [] + bucket["_key"] = key + buckets[key] = bucket + order.append(key) + bucket["qty"] += int(record.get("qty", 1) or 1) + handle = record.get("handle") + if handle: + bucket["handles"].append(str(handle)) + if group_by is not None: + order.sort() + rows: list[dict] = [] + for item, key in enumerate(order, start=1): + bucket = buckets[key] + bucket.pop("_key", None) + bucket["handles"] = tuple(bucket["handles"]) + bucket["item"] = item + rows.append({c: bucket.get(c, "") for c in chosen} | {"handles": bucket["handles"]}) + return tuple(rows) + + +def table_layout(rows, *, at: Pt, direction: str = "up", columns=None, row_height: float = ROW_HEIGHT): + """Everything both renderers need, so the prims and the real TABLE agree. + + `at` is the anchor: the list's bottom-left corner for `direction="up"` (it + sits on the title block) and its top-left corner for `direction="down"`. + `insert` is where `entity_create_table` must start, since a TABLE is drawn + downwards from its insertion point. + """ + if direction not in DIRECTIONS: + raise ValueError(f"direction must be one of {DIRECTIONS}, got {direction!r}") + chosen = _check_columns(columns) + if row_height <= 0: + raise ValueError(f"row_height must be positive, got {row_height!r}") + raw = [COLUMN_SHARES[c] for c in chosen] + scale = LIST_WIDTH / sum(raw) + widths = [w * scale for w in raw] + heading = [COLUMN_LABELS[c] for c in chosen] + body = [[str(row.get(c, "")) for c in chosen] for row in rows] + cells = list(reversed(body)) + [heading] if direction == "up" else [heading] + body + height = row_height * len(cells) + ax, ay = float(at[0]), float(at[1]) + insert = (ax, ay + height) if direction == "up" else (ax, ay) + return { + "columns": chosen, + "labels": heading, + "widths": widths, + "width": sum(widths), + "height": height, + "row_height": row_height, + "direction": direction, + "anchor": (ax, ay), + "insert": insert, + "cells": cells, + } + + +def table_prims( + rows, + *, + at: Pt, + direction: str = "up", + standard: str = "ISO 7573", + height: float = ROW_HEIGHT, +) -> tuple[Prim, ...]: + """The parts list as primitive descriptions, for a caller that does not + want a real TABLE (and for the tests that pin the layout).""" + if standard not in LIST_STANDARDS: + raise ValueError( + f"parts-list standard {standard!r} is not transcribed; " + f"available: {', '.join(LIST_STANDARDS)}" + ) + layout = table_layout(rows, at=at, direction=direction, row_height=height) + ax, ay = layout["anchor"] + total_w, total_h = layout["width"], layout["height"] + prims: list[Prim] = [] + for index in range(len(layout["cells"]) + 1): + y = ay + index * layout["row_height"] + prims.append(Line(p1=(ax, y), p2=(ax + total_w, y))) + x = ax + prims.append(Line(p1=(x, ay), p2=(x, ay + total_h))) + for width in layout["widths"]: + x += width + prims.append(Line(p1=(x, ay), p2=(x, ay + total_h))) + pad = min(1.0, layout["row_height"] * 0.15) + for row_index, cells in enumerate(layout["cells"]): + # cells are top-to-bottom; the top row is the last one up the sheet + baseline = ay + (len(layout["cells"]) - row_index - 1) * layout["row_height"] + pad + x = ax + for column_index, value in enumerate(cells): + if value: + prims.append(Text(at=(x + pad, baseline), text=value, height=TEXT_HEIGHT)) + x += layout["widths"][column_index] + return tuple(prims) + + +def balloon_prims( + item: int, + at: Pt, + leader_to: Pt, + *, + radius: float = 4.0, + height: float | None = None, +) -> tuple[Prim, ...]: + """One ISO 6433 item reference: the balloon, the leader, the dot, the numeral. + + The numeral is 1.4 * `radius` high. ISO 6433 asks for at least twice the + dimension character height, so a sheet dimensioned at 3.5 mm wants + `radius=5.0` (a 7 mm numeral); the 4 mm default clears 2.5 mm dimensions. + """ + number = int(item) + if number <= 0: + raise ValueError(f"an ISO 6433 item reference is a positive integer; got {item!r}") + if radius <= 0: + raise ValueError(f"balloon radius must be positive; got {radius!r}") + text_height = float(radius) * 1.4 if height is None else float(height) + cx, cy = float(at[0]), float(at[1]) + tx, ty = float(leader_to[0]), float(leader_to[1]) + distance = math.hypot(tx - cx, ty - cy) + if distance <= radius: + raise ValueError( + f"balloon {number}: the leader target ({tx}, {ty}) is inside the balloon at " + f"({cx}, {cy}) r={radius}; ISO 6433 puts the reference outside the item" + ) + ux, uy = (tx - cx) / distance, (ty - cy) / distance + label = str(number) + return ( + Circle(center=(cx, cy), radius=float(radius)), + Line(p1=(cx + ux * radius, cy + uy * radius), p2=(tx, ty)), + Circle(center=(tx, ty), radius=BALLOON_DOT_RADIUS), + Text( + at=(cx - 0.3 * text_height * len(label), cy - 0.5 * text_height), + text=label, + height=text_height, + ), + ) +``` + +- [ ] **Step 4: Run it to verify the pure geometry passes** + +Run: `uv run --frozen pytest tests/test_sheet_bom.py -q` +Expected: PASS, 15 passed. + +- [ ] **Step 5: Write the failing test for the extractor, the real TABLE and the balloon link** + +Append to `tests/test_sheet_bom.py`: + +```python +# ── reading and drawing ───────────────────────────────────────────────────── + +from engineering.mech.xdata import APP_ID, to_values # noqa: E402 +from engineering.sheet.bom import ( # noqa: E402 + add_balloon, + draw_bom_table, + extract_records, + read_balloons, +) + + +async def _place_bolt(backend, tag: str, x: float) -> str: + await backend.block_define( + "ISO4014_M12", + [{"type": "circle", "cx": 0.0, "cy": 0.0, "radius": 6.0}], + [{"tag": "DESIGNATION", "x": 0.0, "y": -10.0, "height": 2.5, "default": ""}], + overwrite=True, + ) + insert = await backend.block_insert( + "ISO4014_M12", x, 0.0, attributes={"DESIGNATION": "ISO 4014 - M12x60"} + ) + await backend.entity_set_xdata( + insert.handle, + APP_ID, + to_values( + { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + ), + ) + return insert.handle + + +@pytest.mark.asyncio +async def test_extract_reads_the_xdata_payload_and_never_touches_the_drawing(backend): + handles = [await _place_bolt(backend, "a", 0.0), await _place_bolt(backend, "b", 50.0)] + before = len(await backend.entity_list()) + records = await extract_records(backend) + assert len(await backend.entity_list()) == before, "bom_extract must not modify the drawing" + assert {r["handle"] for r in records} == set(handles) + assert all(r["designation"] == "ISO 4014 - M12x60" for r in records) + assert all(r["standard"] == "ISO 4014" for r in records) + assert all(r["source"] == "xdata" for r in records) + + +@pytest.mark.asyncio +async def test_extract_falls_back_to_the_block_attributes_when_there_is_no_payload(backend): + await backend.block_define( + "PLATE", + [{"type": "circle", "cx": 0.0, "cy": 0.0, "radius": 3.0}], + [{"tag": "DESIGNATION", "x": 0.0, "y": -6.0, "height": 2.5, "default": ""}], + overwrite=True, + ) + await backend.block_insert("PLATE", 10.0, 10.0, attributes={"DESIGNATION": "PLATE 10"}) + records = await extract_records(backend) + plate = next(r for r in records if r["designation"] == "PLATE 10") + assert plate["source"] == "attributes" + assert plate["standard"] == "" + + +@pytest.mark.asyncio +async def test_the_table_is_a_real_table_entity_sitting_on_its_anchor(backend): + await _place_bolt(backend, "a", 0.0) + rows = rows_from_records(await extract_records(backend)) + result = await draw_bom_table(backend, rows, at=(230.0, 70.0), direction="up") + assert result["ok"] is True + assert result["rows"] == 1 + assert result["insert"] == (230.0, 70.0 + 2 * 7.0) + assert result["bbox"] == {"min": [230.0, 70.0], "max": [410.0, 84.0]} + table = await backend.entity_get(result["handle"]) + assert table.type in ("ACAD_TABLE", "INSERT", "TABLE") + + +@pytest.mark.asyncio +async def test_a_balloon_records_its_targets_and_a_rerun_renumbers_instead_of_duplicating(backend): + target = await _place_bolt(backend, "a", 0.0) + first = await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), + targets=(target,)) + assert first["renumbered"] is False + circles_after_first = len(await backend.entity_list(type_filter="CIRCLE")) + + second = await add_balloon(backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), + targets=(target,)) + assert second["renumbered"] is True + assert second["handle"] == first["handle"] + assert len(await backend.entity_list(type_filter="CIRCLE")) == circles_after_first + + balloons = await read_balloons(backend) + assert len(balloons) == 1 + assert balloons[0]["item"] == 5 + assert balloons[0]["targets"] == [target] + numeral = await backend.entity_get(balloons[0]["text"]) + assert numeral.properties["text"] == "5" + + +@pytest.mark.asyncio +async def test_reusing_an_item_number_for_different_targets_is_refused(backend): + one = await _place_bolt(backend, "a", 0.0) + two = await _place_bolt(backend, "b", 60.0) + await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(one,)) + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await add_balloon(backend, item=1, at=(100.0, 40.0), leader_to=(60.0, 0.0), + targets=(two,)) + assert "1" in str(excinfo.value) + assert len(await backend.entity_list()) == before +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_bom.py -q` +Expected: FAIL — collection error, `ImportError: cannot import name 'add_balloon' from 'engineering.sheet.bom'`. + +- [ ] **Step 7: Implement the extractor, the table drawer and the balloon** + +Append to `engineering/sheet/bom.py`: + +```python +#: Block attributes read when an INSERT carries no ACADMCP_MECH payload. +_ATTRIBUTE_KEYS = { + "designation": ("DESIGNATION", "DESIG", "NAME", "TAG"), + "standard": ("STANDARD", "STD", "NORM"), + "material": ("MATERIAL", "MAT"), + "size": ("SIZE",), + "qty": ("QTY", "QUANTITY"), + "remarks": ("REMARKS", "NOTE"), +} + + +def _from_attributes(attributes: dict) -> dict: + upper = {str(k).upper(): v for k, v in (attributes or {}).items()} + record: dict = {} + for field, keys in _ATTRIBUTE_KEYS.items(): + for key in keys: + if key in upper and str(upper[key]).strip(): + record[field] = str(upper[key]).strip() + break + return record + + +async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int = 200): + """Walk the INSERTs and return one parts-list record per block reference. + + Reads only: the entity listing, each INSERT's attributes and its + ACADMCP_MECH payload. Nothing is written, so this is safe on a drawing that + is not ours. `source` is "xdata" when the payload supplied the row and + "attributes" when the block's ATTRIBs did; a block with neither is skipped + rather than guessed at. + """ + inserts = await backend.entity_list(type_filter="INSERT", layer_filter=layer, limit=limit) + records: list[dict] = [] + for entity in inserts: + payload: dict = {} + try: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + payload = {} + if payload.get("kind") != "std_part": + payload = {} + attributes = {} + try: + attributes = (await backend.block_get_attributes(entity.handle)).get("attributes", {}) + except Exception: # noqa: BLE001 - a block with no ATTRIBs is not an error here + attributes = {} + fallback = _from_attributes(attributes) + designation = str(payload.get("designation") or fallback.get("designation") or "").strip() + if not designation: + continue + records.append( + { + "handle": entity.handle, + "block": entity.properties.get("block_name", ""), + "layer": entity.layer, + "designation": designation, + "standard": str(payload.get("standard") or fallback.get("standard") or ""), + "size": str(payload.get("size") or fallback.get("size") or ""), + "material": str(payload.get("material") or fallback.get("material") or ""), + "mass": str(payload.get("mass") or ""), + "remarks": str(payload.get("remarks") or fallback.get("remarks") or ""), + "qty": int(payload.get("qty") or fallback.get("qty") or 1), + "source": "xdata" if payload else "attributes", + } + ) + return tuple(records) + + +async def draw_bom_table( + backend: AutoCADBackend, + rows, + *, + at: Pt, + direction: str = "up", + columns=None, + row_height: float = ROW_HEIGHT, + layer: str = SHEET_TEXT_LAYER, + layout: str | None = None, +) -> dict: + """Draw the parts list as a real TABLE entity. + + `entity_create_table` grows downwards from its insertion point, so an + upward-growing ISO 7573 list is drawn from an insertion one table height + above the anchor, with the heading as the last (bottom) row. + """ + if not rows: + raise ValueError("bom_table: there are no parts-list rows to draw") + spec = table_layout(rows, at=at, direction=direction, columns=columns, row_height=row_height) + previous = await enter_layout(backend, layout, caller="bom_table") + try: + entity = await backend.entity_create_table( + spec["insert"][0], + spec["insert"][1], + spec["cells"], + headers=None, + column_widths=spec["widths"], + row_height=spec["row_height"], + text_height=TEXT_HEIGHT, + layer=layer, + ) + finally: + if layout is not None: + await backend.layout_set_current(previous) + ax, ay = spec["anchor"] + return { + "ok": True, + "handle": entity.handle, + "standard": "ISO 7573", + "direction": direction, + "columns": list(spec["columns"]), + "rows": len(rows), + "insert": spec["insert"], + "layout": layout or "Model", + "bbox": {"min": [ax, ay], "max": [ax + spec["width"], ay + spec["height"]]}, + } + + +async def read_balloons(backend: AutoCADBackend): + """Every ISO 6433 balloon this server drew, from its ACADMCP_MECH payload.""" + found: list[dict] = [] + for entity in await backend.entity_list(type_filter="CIRCLE"): + try: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + continue + if payload.get("kind") != "balloon": + continue + found.append( + { + "handle": entity.handle, + "item": int(payload.get("item", 0)), + "targets": list(payload.get("targets") or []), + "text": payload.get("text", ""), + } + ) + found.sort(key=lambda row: row["item"]) + return tuple(found) + + +async def add_balloon( + backend: AutoCADBackend, + *, + item: int, + at: Pt, + leader_to: Pt, + targets=(), + radius: float = 4.0, + layer: str = "DIM", + layout: str | None = None, +) -> dict: + """Draw (or renumber) one ISO 6433 item reference. + + A balloon already pointing at the same targets is *renumbered* -- its + numeral is edited and its payload rewritten -- so running the balloon pass + again after the parts list is regrouped does not litter the sheet with + duplicates. An item number already used for a different set of targets is + refused before anything is drawn: ISO 129-1 and ISO 6433 both want one + reference per item. + """ + number = int(item) + wanted = tuple(sorted(str(t) for t in targets)) + prims = balloon_prims(number, at, leader_to, radius=radius) # validates first + existing = await read_balloons(backend) + for balloon in existing: + if tuple(sorted(balloon["targets"])) == wanted and wanted: + if balloon["text"]: + await backend.entity_edit_text(balloon["text"], text=str(number)) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": balloon["text"], + } + await backend.entity_set_xdata(balloon["handle"], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": True, + "item": number, + "handle": balloon["handle"], + "text": balloon["text"], + "targets": list(wanted), + "layout": layout or "Model", + } + clash = next( + (b for b in existing if b["item"] == number and tuple(sorted(b["targets"])) != wanted), + None, + ) + if clash is not None: + raise ValueError( + f"item reference {number} is already used by balloon {clash['handle']} for " + f"targets {clash['targets']}; ISO 6433 gives each item one reference" + ) + + previous = await enter_layout(backend, layout, caller="balloon_add") + try: + handles = await draw_sheet_prims( + backend, prims, layer_map={"visible": layer, "text": layer, "center": layer} + ) + finally: + if layout is not None: + await backend.layout_set_current(previous) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": handles[3], + } + await backend.entity_set_xdata(handles[0], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": False, + "item": number, + "handle": handles[0], + "leader": handles[1], + "dot": handles[2], + "text": handles[3], + "targets": list(wanted), + "layout": layout or "Model", + } +``` + +- [ ] **Step 8: Run it to verify the reader and drawer pass** + +Run: `uv run --frozen pytest tests/test_sheet_bom.py -q` +Expected: PASS, 20 passed. If `entity_get(table.handle).type` is neither +`ACAD_TABLE`, `INSERT` nor `TABLE`, run +`uv run --frozen python -c "import asyncio,ezdxf;from backends.ezdxf_backend import EzdxfBackend;..."` +to read the real type and widen the assertion to the measured value rather than +changing the drawing code — and record the measurement in `deviations`. + +- [ ] **Step 9: Write the failing test for the revision block** + +Create `tests/test_sheet_revision.py`: + +```python +"""Revision rows, revision clouds and the triangular revision tags.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Poly, Text +from engineering.sheet.revision import ( + REVISION_COLUMNS, + REVISION_LABELS, + TAG_SIDE, + add_revision, + read_revisions, + revision_block_prims, + revision_heading_prims, + revision_row_prims, + revision_tag_prims, +) + +EPS = 1e-9 + + +def test_the_revision_columns_are_the_iso_7200_version_fields(): + assert REVISION_COLUMNS == ("rev", "description", "date", "by") + for column in REVISION_COLUMNS: + assert column in REVISION_LABELS + + +def test_a_tag_is_an_equilateral_triangle_carrying_the_letter(): + prims = revision_tag_prims("B", (100.0, 200.0)) + triangle = next(p for p in prims if isinstance(p, Poly)) + text = next(p for p in prims if isinstance(p, Text)) + assert triangle.closed and len(triangle.points) == 3 + xs = [p[0] for p in triangle.points] + assert max(xs) - min(xs) == pytest.approx(TAG_SIDE, abs=EPS) + assert text.text == "B" + + +def test_the_block_is_180_mm_wide_and_grows_downwards_from_its_anchor(): + rows = [ + {"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"}, + {"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"}, + ] + prims = revision_block_prims(rows, at=(230.0, 287.0)) + xs = [c[0] for p in prims if isinstance(p, Poly) for c in p.points] + xs += [v for p in prims if hasattr(p, "p1") for v in (p.p1[0], p.p2[0])] + assert min(xs) == pytest.approx(230.0, abs=EPS) + assert max(xs) == pytest.approx(410.0, abs=EPS) + ys = [v for p in prims if hasattr(p, "p1") for v in (p.p1[1], p.p2[1])] + assert max(ys) == pytest.approx(287.0, abs=EPS) + assert min(ys) == pytest.approx(287.0 - 3 * 7.0, abs=EPS) # heading + 2 rows + + +def test_the_block_built_row_by_row_is_the_block_built_at_once(): + """`add_revision` appends one band at a time; the whole-block builder is + what the tests read. They must be the same geometry or the second revision + would not line up with the first.""" + rows = [ + {"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"}, + {"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"}, + ] + at = (230.0, 287.0) + incremental = list(revision_heading_prims(at=at)) + for index, row in enumerate(rows): + incremental.extend(revision_row_prims(row, at=at, index=index)) + assert tuple(incremental) == revision_block_prims(rows, at=at) + + +def test_a_rows_first_text_primitive_is_its_rev_cell(): + """`add_revision` hangs the payload on that primitive, so this is the link + between the drawn row and the row a reader gets back.""" + band = revision_row_prims( + {"rev": "B", "description": "", "date": "", "by": ""}, at=(230.0, 287.0), index=0 + ) + first_text = next(p for p in band if isinstance(p, Text)) + assert first_text.text == "B" + + +@pytest.mark.parametrize("bad", ["", "revision b", "AB CD", "b!"]) +def test_a_malformed_revision_code_is_refused(bad): + with pytest.raises(ValueError) as excinfo: + revision_tag_prims(bad, (0.0, 0.0)) + assert "revision" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_a_revision_row_is_written_once_per_letter(backend): + first = await add_revision(backend, rev="B", description="bore 40H7", date="2026-03-11", + by="UC") + assert first["created"] is True + count = len(await backend.entity_list()) + again = await add_revision(backend, rev="B", description="bore 40H7 (again)", + date="2026-03-12", by="UC") + assert again["created"] is False + assert again["handle"] == first["handle"] + assert len(await backend.entity_list()) == count, "a repeated revision must draw nothing" + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["B"] + assert rows[0]["description"] == "bore 40H7" + + +@pytest.mark.asyncio +async def test_two_revisions_stack_and_read_back_in_order(backend): + await add_revision(backend, rev="A", description="first issue", date="2026-01-05", by="UC") + await add_revision(backend, rev="B", description="bore 40H7", date="2026-03-11", by="UC") + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["A", "B"] + + +@pytest.mark.asyncio +async def test_clouds_and_tags_are_drawn_when_asked(backend): + result = await add_revision( + backend, + rev="C", + description="slot widened", + clouds=[[10.0, 10.0, 60.0, 40.0]], + tags=[[65.0, 45.0]], + ) + assert len(result["clouds"]) == 1 + assert len(result["tags"]) == 1 + tag_texts = [ + e + for e in await backend.entity_list(type_filter="TEXT") + if e.properties["text"] == "C" + ] + assert tag_texts + + +@pytest.mark.asyncio +async def test_clouds_are_refused_before_any_row_when_the_engine_has_no_revcloud( + backend, monkeypatch +): + from backends.base import CapabilityMap, FeatureCapability + + original = backend.capabilities() + features = dict(original.features) + features["revcloud"] = FeatureCapability(False, reason="no_activex_member_command_only") + monkeypatch.setattr( + backend, "capabilities", lambda: CapabilityMap(backend=original.backend, features=features) + ) + before = len(await backend.entity_list()) + from backends.base import UnsupportedCapabilityError + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await add_revision(backend, rev="D", description="x", clouds=[[0.0, 0.0, 10.0, 10.0]]) + assert excinfo.value.capability == "revcloud" + assert len(await backend.entity_list()) == before + assert (await read_revisions(backend)) == () +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_revision.py -q` +Expected: FAIL — collection error, `ModuleNotFoundError: No module named 'engineering.sheet.revision'`. + +- [ ] **Step 11: Implement the revision block** + +Create `engineering/sheet/revision.py`: + +```python +"""The revision block, revision clouds and the triangular revision tags. + +SOURCE +------ +ISO 7200:2004 lists `version` (the revision code) among the title block's data +fields, and a drawing that has been revised records each version with its +description, date and issuer. ISO 7200 does NOT standardise the revision +block's geometry, so the block here reuses the ISO 7573 item-list ruling: the +same 180 mm width as the title block and the same 7 mm rows. That is a layout +decision, and it is written down here rather than asserted as a standard. + +The triangular revision tag and the revision cloud are drawing-office practice, +not ISO geometry: the cloud marks the changed region and the triangle carries +the revision code that the block's row explains. `entity_create_revcloud` draws +the cloud, so a revision *with* clouds is refused up front on an engine that +has no revcloud (the COM backend: `revcloud` is false there, no ActiveX member, +command-line only) rather than half-written. + +Placement: the block defaults to the upper-right corner of the drawing frame +and grows downwards, so it does not fight the ISO 7573 item list for the space +directly above the title block. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + +from backends.xdata_specs import MAX_BYTES, app_size +from engineering.mech.primitives import Line, Poly, Prim, Pt, Text +from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT, TEXT_HEIGHT +from engineering.sheet.frames import ( + SHEET_LAYER, + draw_sheet_prims, + enter_layout, + frame_metrics, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "REVISION_COLUMNS", + "REVISION_LABELS", + "REVISION_SHARES", + "REV_APP_ID", + "REV_PAYLOAD_VERSION", + "REV_RE", + "TAG_SIDE", + "add_revision", + "read_revisions", + "rev_payload", + "rev_values", + "revision_block_prims", + "revision_heading_prims", + "revision_row_prims", + "revision_tag_prims", +] + +REVISION_COLUMNS: tuple[str, ...] = ("rev", "description", "date", "by") +REVISION_LABELS: dict[str, str] = { + "rev": "REV", + "description": "DESCRIPTION", + "date": "DATE", + "by": "BY", +} +#: Widths in millimetres; they total the 180 mm the title block is wide. +REVISION_SHARES: dict[str, float] = { + "rev": 16.0, + "description": 96.0, + "date": 40.0, + "by": 28.0, +} +TAG_SIDE = 7.0 +TAG_TEXT_HEIGHT = 3.5 + +#: A revision code: one to three capitals or digits (ISO 7200's `version` +#: field is free text; this is the shop's form and keeps the tag legible). +REV_RE = re.compile(r"^[A-Z0-9]{1,3}$") + + +def _check_rev(rev: str) -> str: + code = str(rev).strip().upper() + if not REV_RE.fullmatch(code): + raise ValueError( + f"revision code {rev!r} must be one to three capitals or digits (A, B, 01, ...)" + ) + return code + + +def revision_tag_prims(rev: str, at: Pt, *, side: float = TAG_SIDE) -> tuple[Prim, ...]: + """An equilateral triangle carrying the revision code, apex up, `at` its + lower-left corner.""" + code = _check_rev(rev) + if side <= 0: + raise ValueError(f"revision tag side must be positive; got {side!r}") + x, y = float(at[0]), float(at[1]) + height = side * (3.0**0.5) / 2.0 + triangle = Poly( + points=((x, y), (x + side, y), (x + side / 2.0, y + height)), + closed=True, + ) + label = Text( + at=(x + side / 2.0 - 0.3 * TAG_TEXT_HEIGHT * len(code), y + height * 0.25), + text=code, + height=TAG_TEXT_HEIGHT, + ) + return (triangle, label) + + +def _band(cells: dict, *, ax: float, top: float, height: float) -> list[Prim]: + """One horizontal band: its bottom rule, its five verticals and its text. + + The band's top rule belongs to the band above it (or to + `revision_heading_prims`), so bands stack without drawing a rule twice -- + which is what lets `add_revision` append one band at a time and still + produce the block `revision_block_prims` describes. + """ + bottom = top - height + widths = [REVISION_SHARES[c] for c in REVISION_COLUMNS] + prims: list[Prim] = [Line(p1=(ax, bottom), p2=(ax + LIST_WIDTH, bottom))] + x = ax + prims.append(Line(p1=(x, top), p2=(x, bottom))) + for width in widths: + x += width + prims.append(Line(p1=(x, top), p2=(x, bottom))) + pad = min(1.0, height * 0.15) + x = ax + for column_index, column in enumerate(REVISION_COLUMNS): + value = str(cells.get(column, "")) + if value: + prims.append(Text(at=(x + pad, bottom + pad), text=value, height=TEXT_HEIGHT)) + x += widths[column_index] + return prims + + +def revision_heading_prims(*, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]: + """The block's top rule and its heading band. `at` is the TOP-left corner.""" + if height <= 0: + raise ValueError(f"row height must be positive; got {height!r}") + ax, ay = float(at[0]), float(at[1]) + heading = {c: REVISION_LABELS[c] for c in REVISION_COLUMNS} + return ( + Line(p1=(ax, ay), p2=(ax + LIST_WIDTH, ay)), + *_band(heading, ax=ax, top=ay, height=height), + ) + + +def revision_row_prims( + row: dict, *, at: Pt, index: int, height: float = ROW_HEIGHT +) -> tuple[Prim, ...]: + """One revision band, `index` rows below the heading. Its first Text is the + REV cell -- the primitive `add_revision` hangs the payload on.""" + if height <= 0: + raise ValueError(f"row height must be positive; got {height!r}") + if int(index) < 0: + raise ValueError(f"row index must be zero or more; got {index!r}") + ax, ay = float(at[0]), float(at[1]) + return tuple(_band(row, ax=ax, top=ay - (int(index) + 1) * height, height=height)) + + +#: The revision row's own XDATA application. +#: +#: `engineering/mech/xdata.py::KINDS` is pinned to ("part", "std_part", +#: "balloon") and `engineering/mech/critique.py` scans ACADMCP_MECH for exactly +#: those three on INSERTs and CIRCLEs. A revision row is neither, so it carries +#: its own application rather than a fourth kind that `encode` would refuse. +#: The chunking is the same rule the mech codec follows -- ASCII JSON split at +#: 255 characters -- and `entity_set_xdata(handle, app_name, values)` supplies +#: the 1001 application tag itself, so only the chunks travel. +REV_APP_ID = "ACADMCP_SHEET_REV" +REV_PAYLOAD_VERSION = 1 +_REV_CHUNK = 255 + + +def rev_values(payload: dict) -> list[str]: + """The chunk strings for one revision record. Refused before anything is written.""" + if not isinstance(payload, dict): + raise TypeError(f"{REV_APP_ID}: a payload must be a dict, got {type(payload).__name__}") + version = payload.get("v", REV_PAYLOAD_VERSION) + if version != REV_PAYLOAD_VERSION: + raise ValueError( + f"{REV_APP_ID}: payload version {version!r}; this build writes and reads " + f"version {REV_PAYLOAD_VERSION}" + ) + text = json.dumps( + {**payload, "v": REV_PAYLOAD_VERSION}, + separators=(",", ":"), + sort_keys=True, + ensure_ascii=True, + ) + values = [text[i : i + _REV_CHUNK] for i in range(0, len(text), _REV_CHUNK)] + size = app_size(REV_APP_ID, [(1000, value) for value in values]) + if size > MAX_BYTES: + raise ValueError( + f"{REV_APP_ID}: the record encodes to {size} bytes; the limit is 16 KB per " + "entity across every application -- shorten the description" + ) + return values + + +def rev_payload(values) -> dict: + """The record back, or a ValueError. Never a plausible-looking guess.""" + parts = [value if isinstance(value, str) else str(value[1]) for value in values] + try: + payload = json.loads("".join(parts)) + except json.JSONDecodeError as exc: + raise ValueError(f"{REV_APP_ID}: the chunk stream is not JSON ({exc.msg})") from exc + if not isinstance(payload, dict): + raise ValueError( + f"{REV_APP_ID}: the payload decoded to {type(payload).__name__}, not a dict" + ) + if payload.get("v") != REV_PAYLOAD_VERSION: + raise ValueError(f"{REV_APP_ID}: payload version {payload.get('v')!r} is not readable here") + return payload + + +def revision_block_prims(rows, *, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]: + """The whole revision block: `at` is its TOP-left corner, the heading is + the first band and the revisions follow downwards in the order given.""" + prims: list[Prim] = list(revision_heading_prims(at=at, height=height)) + for index, row in enumerate(rows): + prims.extend(revision_row_prims(row, at=at, index=index, height=height)) + return tuple(prims) + + +async def read_revisions(backend: AutoCADBackend): + """Every revision row this server wrote, from its ACADMCP_MECH payload.""" + found: list[dict] = [] + for entity in await backend.entity_list(type_filter="TEXT"): + try: + raw = await backend.entity_get_xdata(entity.handle, REV_APP_ID) + payload = rev_payload(raw.get("xdata", {}).get(REV_APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + continue + if not payload.get("rev"): + continue + found.append( + { + "handle": entity.handle, + "rev": str(payload.get("rev", "")), + "description": str(payload.get("description", "")), + "date": str(payload.get("date", "")), + "by": str(payload.get("by", "")), + } + ) + found.sort(key=lambda row: row["rev"]) + return tuple(found) + + +async def add_revision( + backend: AutoCADBackend, + *, + rev: str, + description: str, + date: str = "", + by: str = "", + clouds=(), + tags=(), + size: str = "A3", + orientation: str = "landscape", + origin: Pt = (0.0, 0.0), + at: Pt | None = None, + cloud_segment: float = 8.0, + layout: str | None = None, +) -> dict: + """Append one revision row, and optionally cloud and tag what changed. + + Idempotent per revision code: a code the drawing already carries returns + `created=False` and draws nothing at all, so re-running a revision pass + cannot stack duplicate rows. Refusals fire before the first entity: a + malformed code, a cloud region that is not four finite numbers, and -- on + an engine whose `revcloud` capability is false -- any request for clouds. + """ + code = _check_rev(rev) + regions = [[float(v) for v in region] for region in clouds] + for index, region in enumerate(regions): + if len(region) != 4: + raise ValueError(f"clouds[{index}] must be [x0, y0, x1, y1]; got {clouds[index]!r}") + points = [[float(p[0]), float(p[1])] for p in tags] + if regions: + feature = backend.capabilities().features.get("revcloud") + if feature is None or not feature.supported: + from backends.base import UnsupportedCapabilityError + + raise UnsupportedCapabilityError( + "revcloud", + f"add_revision: the {backend.name} backend cannot draw revision clouds " + "(no ActiveX member; REVCLOUD is command-line only). Re-run without " + "`clouds` to record the row, or draw the sheet on the ezdxf engine.", + ) + + existing = await read_revisions(backend) + match = next((row for row in existing if row["rev"] == code), None) + if match is not None: + return { + "ok": True, + "created": False, + "rev": code, + "handle": match["handle"], + "row": len(existing), + "clouds": [], + "tags": [], + "layout": layout or "Model", + } + + index = len(existing) + new_row = {"rev": code, "description": description, "date": date, "by": by} + metrics = frame_metrics(size, orientation=orientation) + _x0, _y0, x1, y1 = metrics["frame"] + anchor = ( + (float(origin[0]) + x1 - LIST_WIDTH, float(origin[1]) + y1) if at is None + else (float(at[0]), float(at[1])) + ) + # Append-only: the heading band is drawn once, on the first revision, and + # every later revision adds exactly one band below the last. Nothing is + # redrawn, so a revision can never stack a second copy of the block. + head_prims = list(revision_heading_prims(at=anchor)) if index == 0 else [] + row_prims = list(revision_row_prims(new_row, at=anchor, index=index)) + + previous = await enter_layout(backend, layout, caller="revision_add") + try: + block_handles = await draw_sheet_prims(backend, head_prims + row_prims) + cloud_handles = [] + for region in regions: + x0, y0, x1r, y1r = region + cloud = await backend.entity_create_revcloud( + [[x0, y0], [x1r, y0], [x1r, y1r], [x0, y1r]], + cloud_segment, + layer=SHEET_LAYER, + closed=True, + ) + cloud_handles.append(cloud.get("handle")) + tag_handles = [] + for point in points: + tag_handles.extend( + await draw_sheet_prims(backend, revision_tag_prims(code, (point[0], point[1]))) + ) + finally: + if layout is not None: + await backend.layout_set_current(previous) + + # The payload rides on the row band's first Text primitive, which is its + # REV cell -- `rev` is validated non-empty, so that cell is always drawn. + row_handles = block_handles[len(head_prims) :] + row_handle = next( + handle + for handle, prim in zip(row_handles, row_prims, strict=True) + if isinstance(prim, Text) + ) + await backend.entity_set_xdata( + row_handle, + REV_APP_ID, + rev_values( + { + "v": REV_PAYLOAD_VERSION, + "rev": code, + "description": description, + "date": date, + "by": by, + } + ), + ) + + return { + "ok": True, + "created": True, + "rev": code, + "handle": row_handle, + "row": index + 1, + "block_handles": block_handles, + "clouds": cloud_handles, + "tags": tag_handles, + "layout": layout or "Model", + "bbox": { + "min": [anchor[0], anchor[1] - ROW_HEIGHT * (index + 2)], + "max": [anchor[0] + LIST_WIDTH, anchor[1]], + }, + } +``` + +- [ ] **Step 12: Run it to verify the revision block passes** + +Run: `uv run --frozen pytest tests/test_sheet_revision.py -q` +Expected: PASS, 16 passed (the four parametrised bad codes count separately). +A revision recorded with no date or no issuer draws no text cell for it, which +is why the payload hangs on the *first* Text of the band rather than on a fixed +offset. If the test shows the payload on the wrong handle, print +`[(h, p.text) for h, p in zip(row_handles, row_prims) if isinstance(p, Text)]` +and fix the selection — never the assertion. + +- [ ] **Step 13: Re-export the new names** + +In `engineering/sheet/__init__.py`, add after the existing frames import: + +```python +from engineering.sheet.bom import ( + BOM_COLUMNS, + COLUMN_LABELS, + DEFAULT_COLUMNS, + LIST_WIDTH, + ROW_HEIGHT, + add_balloon, + balloon_prims, + draw_bom_table, + extract_records, + read_balloons, + rows_from_records, + table_prims, +) +from engineering.sheet.revision import ( + REVISION_COLUMNS, + add_revision, + read_revisions, + revision_block_prims, + revision_heading_prims, + revision_row_prims, + revision_tag_prims, +) +``` + +and extend `__all__` with those nineteen names, alphabetically (twelve from `bom`, seven from `revision`). + +Run: `uv run --frozen python -c "import engineering.sheet as s; print(len(s.__all__))"` +Expected: the count prints without an ImportError. + +- [ ] **Step 14: Register the four tools** + +In `server.py`, change the SECTION 24 banner to +`# ── SECTION 24: Sheet & Delivery (6 tools) ──────────────────────────────────` +and append after `titleblock_apply`: + +```python +@cad_tool( + summary="Append a revision row, with optional revision clouds and triangular tags.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: Add Revision", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def revision_add( + rev: Annotated[ + str, Field(description="Revision code: one to three capitals or digits (A, B, 01).") + ], + description: Annotated[str, Field(description="What changed, verbatim.")], + date: Annotated[str, Field(default="", description="Issue date, verbatim.")] = "", + by: Annotated[str, Field(default="", description="Who issued it.")] = "", + clouds: Annotated[ + list[list[float]] | None, + Field( + default=None, + description="Regions to cloud, each [x0, y0, x1, y1] in WCS. Refused before " + "anything is written on an engine without the revcloud capability.", + ), + ] = None, + tags: Annotated[ + list[list[float]] | None, + Field(default=None, description="Points [x, y] to put a triangular revision tag at."), + ] = None, + size: Annotated[ + str, Field(default="A3", description="Sheet the block is placed on: A4-A0.") + ] = "A3", + orientation: Annotated[str, Field(default="landscape")] = "landscape", + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + cloud_segment: Annotated[ + float, Field(default=8.0, gt=0, description="Revision-cloud arc segment length (mm).") + ] = 8.0, + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """Append one row to the revision block, creating the block on first use. + + Idempotent per revision code: a code the drawing already carries returns + `created=false` and draws nothing, so re-running a revision pass cannot + stack duplicates. The block defaults to the upper-right corner of the + drawing frame, growing downwards, so it does not collide with the ISO 7573 + parts list above the title block. Refusals, all before the first entity: a + malformed code, a cloud region that is not [x0, y0, x1, y1], and any + request for clouds on a backend whose `revcloud` capability is false (the + COM engine — REVCLOUD has no ActiveX member) — that one carries + `capability: "revcloud"`. + """ + from engineering.sheet.revision import add_revision + + await ctx.info(f"Revision {rev}: {description}") + return await add_revision( + _backend(ctx), + rev=rev, + description=description, + date=date, + by=by, + clouds=clouds or (), + tags=tags or (), + size=size, + orientation=orientation, + origin=(origin_x, origin_y), + cloud_segment=cloud_segment, + layout=layout or None, + ) + + +@cad_tool(summary="Read the parts list out of the drawing's block references.", cost="read") +@mcp.tool( + annotations={"title": "Sheet: Extract Parts List", "readOnlyHint": True}, + tags={"engineering", "sheet", "query"}, +) +async def bom_extract( + layer: Annotated[ + str, Field(default="", description="Only read INSERTs on this layer; empty reads all.") + ] = "", + group_by: Annotated[ + str, + Field( + default="designation", + description="Field to group identical parts by: designation, standard, material, " + "or '' for one row per insert.", + ), + ] = "designation", + columns: Annotated[ + list[str] | None, + Field( + default=None, + description="Columns to return; default item, qty, designation, standard, material.", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Walk the INSERTs and return ISO 7573 parts-list rows. Never modifies the + drawing. + + Each row's source is reported: `xdata` when the ACADMCP_MECH payload + supplied it (what `std_part_insert` writes), `attributes` when the block's + own ATTRIBs did. A block with neither a payload nor a DESIGNATION attribute + is skipped rather than guessed at, and `skipped` counts them. Refusals: an + unknown column or group_by field, named with the list of the ones there are. + """ + from engineering.sheet.bom import extract_records, rows_from_records + + backend = _backend(ctx) + records = await extract_records(backend, layer=layer or None) + rows = rows_from_records(records, columns=columns, group_by=group_by or None) + return { + "ok": True, + "standard": "ISO 7573", + "rows": [dict(row) | {"handles": list(row["handles"])} for row in rows], + "records": len(records), + "group_by": group_by or None, + } + + +@cad_tool(summary="Draw the parts list as a real TABLE above the title block.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Draw Parts List", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def bom_table( + rows: Annotated[ + list[dict] | None, + Field( + default=None, + description="Rows to draw; omit to extract them from the drawing first.", + ), + ] = None, + size: Annotated[str, Field(default="A3", description="Sheet the list is placed on.")] = "A3", + orientation: Annotated[str, Field(default="landscape")] = "landscape", + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + at_x: Annotated[ + float | None, + Field(default=None, description="Override the anchor X; default is the title block's."), + ] = None, + at_y: Annotated[float | None, Field(default=None)] = None, + direction: Annotated[ + str, + Field( + default="up", + description="'up' (ISO 7573 on a drawing: heading at the bottom, items ascending) " + "or 'down'.", + ), + ] = "up", + columns: Annotated[list[str] | None, Field(default=None)] = None, + row_height: Annotated[float, Field(default=7.0, gt=0)] = 7.0, + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """Draw the ISO 7573 item list as a real TABLE entity, 180 mm wide (the + title-block width), growing upwards from directly above the title block. + + With `direction="up"` the heading row sits against the title block and item + 1 is the row above it, so the list extends upwards as parts are added — + which is what ISO 7573 asks for on a drawing. Pass `rows` to draw a list + you already have, or omit it and the tool runs `bom_extract` first. + Refusals: no rows to draw, an unknown column, an unknown direction, a + `layout` that does not exist. + """ + from engineering.sheet.bom import ( + draw_bom_table, + extract_records, + rows_from_records, + ) + from engineering.sheet.titleblock import TB_HEIGHT, titleblock_origin + + backend = _backend(ctx) + if rows is None: + rows = list(rows_from_records(await extract_records(backend), columns=columns)) + tb_x, tb_y = titleblock_origin(size, orientation=orientation) + anchor = ( + origin_x + tb_x if at_x is None else at_x, + origin_y + tb_y + TB_HEIGHT if at_y is None else at_y, + ) + await ctx.info(f"Parts list: {len(rows)} rows at {anchor}") + return await draw_bom_table( + backend, + rows, + at=anchor, + direction=direction, + columns=columns, + row_height=row_height, + layout=layout or None, + ) + + +@cad_tool(summary="Add an ISO 6433 balloon linked to its parts-list row.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Add Balloon", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def balloon_add( + item: Annotated[int, Field(ge=1, description="Item reference number from the parts list.")], + x: Annotated[float, Field(description="Balloon centre X (WCS).")], + y: Annotated[float, Field(description="Balloon centre Y (WCS).")], + leader_x: Annotated[float, Field(description="X of the dot on the item.")], + leader_y: Annotated[float, Field(description="Y of the dot on the item.")], + targets: Annotated[ + list[str] | None, + Field( + default=None, + description="Handles of the INSERTs this item is; the link a rerun renumbers by.", + ), + ] = None, + radius: Annotated[ + float, + Field( + default=4.0, + gt=0, + description="Balloon radius (mm). The numeral is 1.4x this, so a sheet " + "dimensioned at 3.5 mm wants 5.0 to satisfy ISO 6433.", + ), + ] = 4.0, + layer: Annotated[str, Field(default="DIM")] = "DIM", + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """One ISO 6433 item reference: a numbered balloon, a leader, and a dot on + the item. + + The balloon carries an ACADMCP_MECH payload naming its targets, so running + the balloon pass again after the parts list is regrouped *renumbers* the + existing balloon (`renumbered: true`) instead of drawing a second one. + Refusals, all before the first entity: an item number below 1, a leader + target inside the balloon, and an item number already used by another + balloon for different targets — named with that balloon's handle. + """ + from engineering.sheet.bom import add_balloon + + await ctx.info(f"Balloon {item} at ({x}, {y})") + return await add_balloon( + _backend(ctx), + item=item, + at=(x, y), + leader_to=(leader_x, leader_y), + targets=tuple(targets or ()), + radius=radius, + layer=layer, + layout=layout or None, + ) +``` + +- [ ] **Step 15: Add the alias records and share the two claimed commands** + +In `discovery/aliases.py`, add `"REVCLOUD",` and `"TABLE",` to +`SHARED_ACAD_COMMANDS` (keeping the set alphabetical), and add to the +explanatory comment above it: + +``` +# REVCLOUD -- one command draws a bare cloud (entity_create_revcloud) or a +# revision cloud that also files a revision row (revision_add); a +# drafter typing it could mean either. +# TABLE -- one command makes an empty table (entity_create_table) or the +# ISO 7573 parts list (bom_table); both are correct destinations. +``` + +Then add to `TOOL_ALIASES`, after the `"titleblock_apply"` record: + +```python + "revision_add": ToolAliases( + acad=("REVCLOUD",), + synonyms=( + "revision", + "revision block", + "revision cloud", + "revision table", + "change note", + "engineering change", + "as built change", + "revizyon", + "degisiklik", + ), + ), + "bom_extract": ToolAliases( + synonyms=( + "bill of materials", + "parts list", + "bom", + "item list", + "count the parts", + "what parts are on this drawing", + "iso 7573", + "parca listesi", + "malzeme listesi", + ), + ), + "bom_table": ToolAliases( + acad=("TABLE",), + synonyms=( + "draw the parts list", + "parts list table", + "bom table", + "item list table", + "materials table", + "parca listesi tablosu", + ), + ), + "balloon_add": ToolAliases( + synonyms=( + "balloon", + "item reference", + "item number", + "position number", + "iso 6433", + "number the parts", + "balon", + "pozisyon numarasi", + ), + ), +``` + +Run: `uv run --frozen pytest tests/test_discovery_aliases.py -q` +Expected: PASS. + +- [ ] **Step 16: Regenerate the inventory and bump the count files** + +Run: + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen python -c "import json; print(json.load(open('docs/tool-inventory.json'))['totals'])" +``` + +Expected: `{'tools': 210, 'resources': 8, 'prompts': 5, 'groups': 23}`. + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: change +`"engineering": 12,` to `"engineering": 16,`; change `== 206` to `== 210`; +change the docstring's `(206 tools, 23 groups)` to `(210 tools, 23 groups)`; +and extend the SECTION 24 paragraph added in Task 12 with: + +``` + It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and + `balloon_add` joined the same section; `bom_extract` also carries `query`, + but `engineering` outranks it in `_GROUP_TAG_PRIORITY`. +``` + +In `README.md`: release-snapshot `206 tools` -> `210 tools`; `206 is the +**registered** count; a default install advertises 201` -> `210 ... advertises +205`; the "What you get" caption `206 tools in 23 groups` -> `210 tools in 23 +groups`; and the alias sentence's three numbers from + +``` +uv run --frozen python -c "from discovery.aliases import TOOL_ALIASES; a=sorted({c for r in TOOL_ALIASES.values() for c in r.acad}); s=[p for r in TOOL_ALIASES.values() for p in r.synonyms]; print(len(a), len(s), len(TOOL_ALIASES))" +``` + +- [ ] **Step 17: Run the gates** + +Run: + +``` +uv run --frozen ruff format engineering/sheet server.py discovery/aliases.py tests/test_sheet_bom.py tests/test_sheet_revision.py tests/test_tool_registry.py +uv run --frozen ruff check engineering/sheet server.py discovery/aliases.py tests/test_sheet_bom.py tests/test_sheet_revision.py tests/test_tool_registry.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_sheet_bom.py tests/test_sheet_revision.py tests/test_sheet_frames.py tests/test_sheet_titleblock.py tests/test_engineering_titleblock.py tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_discovery_transform.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q +``` + +Expected: all green. A golden query that now lands on one of the four new tools +is fixed by narrowing *my* synonyms, never by editing the golden case. + +- [ ] **Step 18: Commit** + +Run: + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -am "$(cat <<'EOF' +feat(sheet): revisions, the ISO 7573 parts list and ISO 6433 balloons + +SECTION 24 grows to six tools: revision_add, bom_extract, bom_table, +balloon_add. + +bom_extract reads. It walks the INSERTs through the generic members already in +the contract -- entity_list, block_get_attributes, entity_get_xdata -- and +returns parts-list rows from the ACADMCP_MECH std_part payload, falling back to +the block's own ATTRIBs and reporting which of the two each row came from. A +block with neither is skipped rather than guessed at, and the tool never writes +to the drawing. + +bom_table draws the rows as a real TABLE, 180 mm wide (ISO 7573: the item list +is as wide as the title block), growing upwards from directly above it with the +heading row against the block and item 1 the row above -- which is what ISO 7573 +asks for on a drawing. A TABLE is drawn downwards from its insertion point, so +the upward list is inserted a table height above its anchor with the heading as +the last row; one table_layout feeds both the real TABLE and the pure +table_prims, so the two renderings cannot disagree. + +balloon_add draws the ISO 6433 reference -- numeral, leader, dot on the item -- +and writes the balloon payload naming its targets. A rerun over the same +targets renumbers that balloon instead of drawing a second one, and an item +number already used for different targets is refused by name with the other +balloon's handle. + +revision_add appends a row to the revision block, is idempotent per revision +code (a repeat draws nothing at all), and optionally clouds the changed regions +and tags them with the triangle. Clouds are refused up front, carrying +capability "revcloud", on an engine that has no revcloud member -- the COM +backend -- rather than leaving half a revision behind. + +Declared: ISO 7200 does not standardise the revision block's geometry and +ISO 7573 does not fix the item list's column widths or row height, so both +reuse this repository's 180 mm / 7 mm ruling and say so in their SOURCE +docstrings. ISO 6433's "numeral at least twice the dimension character height" +is met by the numeral being 1.4x the balloon radius: the 4 mm default clears +2.5 mm dimension text, and balloon_add's docstring tells a sheet dimensioned at +3.5 mm to pass radius=5.0. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +EOF +)" +``` + +--- + +### Task 14: External references, images, data extract and DWG output + +**Files:** +- Create: `backends/contracts/refs.py` (`RefsContract`: `xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`) +- Create: `engineering/sheet/extract.py` +- Modify: `backends/contracts/__init__.py` (import, `__all__`) +- Modify: `backends/base.py` (compose `RefsContract` into `AutoCADBackend`) +- Modify: `backends/ezdxf_backend.py` (the four implementations + `dwg_write` / `xref_live` / `xlsx_write` in `capabilities()`) +- Modify: `backends/com_backend.py` (the four implementations + the same three keys) +- Modify: `engineering/sheet/__init__.py` (re-export the extract helpers) +- Modify: `server.py` (SECTION 24 banner grows to "(11 tools)": `xref_attach`, `xref_manage`, `image_attach`, `data_extract`, `drawing_export_dwg`) +- Modify: `discovery/aliases.py` (five alias records) +- Modify: `docs/tool-inventory.json` (regenerated) +- Modify: `README.md` (release-snapshot tool count and the three prose figures) +- Modify: `tests/test_tool_registry.py` (frozen group-size snapshot) +- Test: `tests/test_refs_contract.py` +- Test: `tests/test_sheet_extract.py` + +**Interfaces:** +- Consumes (Task 13, `engineering/sheet/bom.py`): `BOM_COLUMNS: tuple[str, ...]`, `COLUMN_LABELS: dict[str, str]`, `DEFAULT_COLUMNS: tuple[str, ...]`, `rows_from_records(records, *, columns=None, group_by="designation") -> tuple[dict, ...]`, `async extract_records(backend, *, layer=None, limit=200) -> tuple[dict, ...]` +- Consumes (existing): `backends.capability.UnsupportedCapabilityError(capability, error)`, `backends.base.FeatureCapability(supported, mode=None, reason=None)`, `backends.base.CapabilityMap`, `security.validate_path(path, *, allow_write=False)` +- Produces: + - `backends/contracts/refs.py`: `class RefsContract(ABC)` with `async xref_attach(self, path: str, at, scale: float, rotation: float, kind: str) -> dict`; `async xref_manage(self, name: str, action: str, new_path: str | None) -> dict`; `async image_attach(self, path: str, at, scale: float, rotation: float) -> dict`; `async drawing_export_dwg(self, path: str, version: str) -> dict`; `XREF_KINDS = ("attach", "overlay")`; `XREF_ACTIONS = ("list", "reload", "bind", "detach", "path")`; `DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")` + - `engineering/sheet/extract.py`: `EXTRACT_FORMATS = ("csv", "xlsx")`; `xlsx_available() -> bool`; `write_rows(rows, path, *, columns=None, fmt="csv") -> dict` + - Three capability keys in **both** maps: `dwg_write`, `xref_live`, `xlsx_write` + +**Measured before writing (AutoCAD 2026, `AutoCAD.Application` 25.1s, via the running seat's type library):** + +``` +ModelSpace.AttachExternalReference(PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay, Password) +ModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor, RotationAngle) +Block: IsXRef, Path, Name, Reload(), Unload(), Bind(bPrefixName), Detach(), XRefDatabase +Document.SaveAs(FullFileName, SaveAsType, vSecurityParams) +AcSaveAsType: ac2000_dwg=12, ac2004_dwg=24, ac2007_dwg=36, ac2010_dwg=48, ac2013_dwg=60, ac2018_dwg=64 (=acNative) +``` + +and (ezdxf 1.4.4 on this machine): + +``` +ezdxf.xref.attach(doc, *, block_name, filename, insert=(0,0,0), scale=1.0, rotation=0.0, overlay=False) -> Insert +ezdxf.lldxf.const: BLK_XREF=4, BLK_XREF_OVERLAY=8, BLK_EXTERNAL=16 + -- attach() leaves flags 20 for an attachment and 24 for an overlay, so + "is this an xref" is flags & (BLK_XREF | BLK_XREF_OVERLAY), not & BLK_XREF +doc.blocks.delete_block(name, safe=True); block.block.dxf.xref_path +doc.add_image_def(filename, size_in_pixel, name=None) -> ImageDef (doc.add_xref_def returns None -- use xref.attach) +msp.add_image(image_def, insert, size_in_units, rotation=0.0) -> Image +ezdxf.addons.odafc.is_installed() -> False on this machine +ezdxf.addons.odafc.export_dwg(doc, filename, version=None, *, audit=False, replace=False) +odafc.VERSION_MAP: R2000->ACAD2000 R2004->ACAD2004 R2007->ACAD2007 R2010->ACAD2010 R2013->ACAD2013 R2018->ACAD2018 +find_spec("openpyxl") is None on this machine; find_spec("PIL") is not None +``` + +- [ ] **Step 1: Write the failing test for the contract, the capability keys and the ezdxf paths** + +Create `tests/test_refs_contract.py`: + +```python +"""External references, raster images and DWG output on both engines. + +The three capability keys are the point of this file: a refusal the feature map +never mentions is a lie by omission, so every key raised here is asserted to be +declared on both backends. +""" + +from __future__ import annotations + +from importlib.util import find_spec +from pathlib import Path + +import pytest + +from backends.base import AutoCADBackend, UnsupportedCapabilityError +from backends.com_backend import ComBackend +from backends.contracts.refs import ( + DWG_VERSIONS, + XREF_ACTIONS, + XREF_KINDS, + RefsContract, +) +from backends.ezdxf_backend import EzdxfBackend + +TRACK_G_KEYS = ("dwg_write", "xref_live", "xlsx_write") + + +def _features(backend) -> dict: + return backend.capabilities().to_dict()["features"] + + +def test_the_contract_is_composed_into_both_backends(): + assert issubclass(AutoCADBackend, RefsContract) + assert issubclass(EzdxfBackend, RefsContract) + assert issubclass(ComBackend, RefsContract) + for name in ("xref_attach", "xref_manage", "image_attach", "drawing_export_dwg"): + assert callable(getattr(AutoCADBackend, name, None)), name + + +def test_the_three_keys_are_declared_in_both_capability_maps(): + ezdxf_features = _features(EzdxfBackend()) + com_features = _features(ComBackend()) + for key in TRACK_G_KEYS: + assert key in ezdxf_features, f"{key} undeclared on ezdxf" + assert key in com_features, f"{key} undeclared on com" + + +def test_com_declares_dwg_and_live_xrefs_and_ezdxf_does_not(): + com_features = _features(ComBackend()) + ezdxf_features = _features(EzdxfBackend()) + assert com_features["dwg_write"]["supported"] is True + assert com_features["xref_live"]["supported"] is True + assert ezdxf_features["xref_live"]["supported"] is False + assert "live" in (ezdxf_features["xref_live"]["reason"] or "") + + +def test_ezdxf_dwg_write_is_re_evaluated_from_the_converter_not_hardcoded(): + """The same shape as viewport_render's matplotlib check: the flag follows + the machine, and the reason names the way to change it.""" + from ezdxf.addons import odafc + + feature = _features(EzdxfBackend())["dwg_write"] + assert feature["supported"] is bool(odafc.is_installed()) + if not feature["supported"]: + assert "oda" in (feature["reason"] or "").lower() + + +def test_xlsx_write_follows_openpyxl_on_both_engines(): + present = find_spec("openpyxl") is not None + for backend in (EzdxfBackend(), ComBackend()): + feature = _features(backend)["xlsx_write"] + assert feature["supported"] is present, backend.name + if not present: + assert "openpyxl" in (feature["reason"] or "") + + +def test_the_vocabularies_are_closed(): + assert XREF_KINDS == ("attach", "overlay") + assert XREF_ACTIONS == ("list", "reload", "bind", "detach", "path") + assert DWG_VERSIONS == ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018") + + +# ── ezdxf ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def external(tmp_path: Path) -> str: + """A real DXF on disk for the xref to point at.""" + import ezdxf + + doc = ezdxf.new("R2018") + doc.modelspace().add_circle((0, 0), 5) + target = tmp_path / "BASE.dxf" + doc.saveas(target) + return str(target) + + +@pytest.mark.asyncio +async def test_attach_creates_an_xref_block_and_an_insert(backend, external): + result = await backend.xref_attach(external, (10.0, 20.0), 2.0, 30.0, "attach") + assert result["ok"] is True + assert result["name"] == "BASE" + assert result["kind"] == "attach" + insert = await backend.entity_get(result["handle"]) + assert insert.type == "INSERT" + assert insert.properties["block_name"] == "BASE" + + listing = await backend.xref_manage("", "list", None) + assert [row["name"] for row in listing["xrefs"]] == ["BASE"] + assert listing["xrefs"][0]["kind"] == "attach" + assert Path(listing["xrefs"][0]["path"]).name == "BASE.dxf" + assert listing["xrefs"][0]["inserts"] == 1 + + +@pytest.mark.asyncio +async def test_overlay_is_flagged_as_an_overlay(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "overlay") + listing = await backend.xref_manage("", "list", None) + assert listing["xrefs"][0]["kind"] == "overlay" + + +@pytest.mark.asyncio +async def test_attaching_the_same_name_twice_is_refused_before_writing(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert "BASE" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_missing_file_and_a_bad_kind_are_refused(backend, tmp_path): + with pytest.raises(ValueError) as missing: + await backend.xref_attach(str(tmp_path / "nope.dxf"), (0.0, 0.0), 1.0, 0.0, "attach") + assert "nope.dxf" in str(missing.value) + with pytest.raises(ValueError) as kind: + await backend.xref_attach(str(tmp_path), (0.0, 0.0), 1.0, 0.0, "underlay") + assert "underlay" in str(kind.value) + + +@pytest.mark.asyncio +async def test_path_rewrites_the_saved_path_and_detach_removes_the_block(backend, external, tmp_path): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + moved = str(tmp_path / "moved" / "BASE.dxf") + changed = await backend.xref_manage("BASE", "path", moved) + assert changed["ok"] is True + assert changed["path"] == moved + assert (await backend.xref_manage("", "list", None))["xrefs"][0]["path"] == moved + + detached = await backend.xref_manage("BASE", "detach", None) + assert detached["ok"] is True and detached["inserts_removed"] == 1 + assert (await backend.xref_manage("", "list", None))["xrefs"] == [] + + +@pytest.mark.asyncio +async def test_reload_and_bind_refuse_headlessly_with_the_xref_live_key(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + for action in ("reload", "bind"): + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.xref_manage("BASE", action, None) + assert excinfo.value.capability == "xref_live" + assert "com" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_an_unknown_action_and_an_unknown_name_are_refused(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + with pytest.raises(ValueError) as action: + await backend.xref_manage("BASE", "unload", None) + assert "unload" in str(action.value) + with pytest.raises(ValueError) as name: + await backend.xref_manage("NOPE", "detach", None) + assert "NOPE" in str(name.value) + + +@pytest.mark.asyncio +async def test_image_attach_places_the_raster_at_its_pixel_aspect(backend, tmp_path): + from PIL import Image + + png = tmp_path / "logo.png" + Image.new("RGB", (800, 400), "white").save(png) + result = await backend.image_attach(str(png), (5.0, 5.0), 0.1, 0.0) + assert result["ok"] is True + assert result["pixels"] == [800, 400] + assert result["size_mm"] == [80.0, 40.0] + image = await backend.entity_get(result["handle"]) + assert image.type == "IMAGE" + + +@pytest.mark.asyncio +async def test_a_non_image_file_is_refused_before_writing(backend, external): + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await backend.image_attach(external, (0.0, 0.0), 1.0, 0.0) + assert "BASE.dxf" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_headless_dwg_export_refuses_with_dwg_write_when_the_converter_is_absent( + backend, tmp_path +): + from ezdxf.addons import odafc + + if odafc.is_installed(): + pytest.skip("the ODA File Converter is installed on this machine") + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R2018") + assert excinfo.value.capability == "dwg_write" + assert "oda" in str(excinfo.value).lower() + assert not (tmp_path / "out.dwg").exists() + + +@pytest.mark.asyncio +async def test_an_unknown_dwg_version_is_refused_on_both_engines(backend, tmp_path): + with pytest.raises(ValueError) as excinfo: + await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R14") + message = str(excinfo.value) + assert "R14" in message + assert "R2018" in message + + +# ── COM, against fakes that model the measured ActiveX members ────────────── + + +def _com_backend(): + b = ComBackend() + + async def _run(func, *args, **kwargs): # bypass the single-thread executor + return func(*args, **kwargs) + + b._run = _run + return b + + +class _Block: + """Measured members of an AcadBlock that is an xref (AutoCAD 2026).""" + + def __init__(self, name, path="", is_xref=True): + self.Name = name + self.Path = path + self.IsXRef = is_xref + self.calls = [] + + def Reload(self): + self.calls.append("Reload") + + def Bind(self, bPrefixName): + self.calls.append(("Bind", bPrefixName)) + + def Detach(self): + self.calls.append("Detach") + + +class _Blocks: + def __init__(self, blocks): + self._blocks = list(blocks) + + def Item(self, index): + if isinstance(index, int): + return self._blocks[index] + for block in self._blocks: + if block.Name == index: + return block + raise KeyError(index) + + @property + def Count(self): + return len(self._blocks) + + def __iter__(self): + return iter(self._blocks) + + +class _Ref: + def __init__(self, handle="7F"): + self.Handle = handle + + +class _ModelSpace: + def __init__(self): + self.attached = [] + self.rasters = [] + + def AttachExternalReference( + self, PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay + ): + self.attached.append( + (PathName, Name, tuple(InsertionPoint), Xscale, Yscale, Zscale, Rotation, bOverlay) + ) + return _Ref("A1") + + def AddRaster(self, imageFileName, InsertionPoint, ScaleFactor, RotationAngle): + self.rasters.append((imageFileName, tuple(InsertionPoint), ScaleFactor, RotationAngle)) + return _Ref("B2") + + +class _ComDoc: + def __init__(self, blocks=()): + self.ModelSpace = _ModelSpace() + self.Blocks = _Blocks(blocks) + self.saved = [] + + def SaveAs(self, FullFileName, SaveAsType): + self.saved.append((FullFileName, SaveAsType)) + + +@pytest.mark.asyncio +async def test_com_attach_calls_the_measured_member_with_radians(monkeypatch, external): + import math + + import backends.com_backend as cb + + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().xref_attach(external, (10.0, 20.0), 2.0, 90.0, "overlay") + assert result["handle"] == "A1" + path, name, insert, sx, sy, sz, rotation, overlay = doc.ModelSpace.attached[0] + assert name == "BASE" and overlay is True + assert (sx, sy, sz) == (2.0, 2.0, 2.0) + assert rotation == pytest.approx(math.pi / 2) + assert insert[:2] == (10.0, 20.0) + + +@pytest.mark.asyncio +async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch): + import backends.com_backend as cb + + block = _Block("BASE", path="C:/refs/BASE.dwg") + doc = _ComDoc([block]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + b = _com_backend() + + assert (await b.xref_manage("BASE", "reload", None))["ok"] is True + assert (await b.xref_manage("BASE", "bind", None))["ok"] is True + assert (await b.xref_manage("BASE", "detach", None))["ok"] is True + assert block.calls == ["Reload", ("Bind", False), "Detach"] + + listing = await b.xref_manage("", "list", None) + assert listing["xrefs"][0]["name"] == "BASE" + assert listing["xrefs"][0]["path"] == "C:/refs/BASE.dwg" + + +@pytest.mark.asyncio +async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path): + import math + + from PIL import Image + + import backends.com_backend as cb + + png = tmp_path / "logo.png" + Image.new("RGB", (400, 200), "white").save(png) + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().image_attach(str(png), (1.0, 2.0), 0.5, 180.0) + assert result["handle"] == "B2" + name, insert, scale, rotation = doc.ModelSpace.rasters[0] + assert Path(name).name == "logo.png" + assert insert[:2] == (1.0, 2.0) + assert scale == 0.5 + assert rotation == pytest.approx(math.pi) + + +@pytest.mark.asyncio +async def test_com_dwg_export_uses_the_measured_acsaveastype_values(monkeypatch, tmp_path): + import backends.com_backend as cb + + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + b = _com_backend() + await b.drawing_export_dwg(str(tmp_path / "a.dwg"), "R2018") + await b.drawing_export_dwg(str(tmp_path / "b.dwg"), "R2000") + assert [entry[1] for entry in doc.saved] == [64, 12] +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_refs_contract.py -q` +Expected: FAIL — collection error, `ModuleNotFoundError: No module named 'backends.contracts.refs'`. + +- [ ] **Step 3: Write the contract** + +Create `backends/contracts/refs.py`: + +```python +"""External references, raster images and DWG output (v1.6 tracks B+G, group G). + +These are the only four operations of the sheet track that genuinely differ per +engine, so they are the only four that become contract members. Everything else +in `engineering/sheet/` composes the generic members and is written once. + +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +#: An xref is attached (its own xrefs come with it) or overlaid (they do not). +XREF_KINDS = ("attach", "overlay") + +#: `list`, `detach` and `path` are headless; `reload` and `bind` need a live +#: seat and are refused with the `xref_live` capability on the ezdxf engine. +XREF_ACTIONS = ("list", "reload", "bind", "detach", "path") + +#: DWG formats both engines name the same way. COM maps them to AcSaveAsType +#: (measured on AutoCAD 2026: 12/24/36/48/60/64); ezdxf maps them through +#: `ezdxf.addons.odafc.VERSION_MAP`, which covers exactly these six and more. +DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018") + + +class RefsContract(ABC): + @abstractmethod + async def xref_attach( + self, + path: str, + at, + scale: float, + rotation: float, + kind: str, + ) -> dict: + """Attach an external reference and insert it once. + + The block name is the referenced file's stem, which is what both + engines do. Returns ``{ok, name, path, kind, handle, at, scale, + rotation, backend}``. + + Refuses, before anything is written: a `kind` outside + :data:`XREF_KINDS`, a file that does not exist, a non-finite + coordinate/scale/rotation, a non-positive scale, and a block name the + drawing already holds (re-attaching would either silently replace the + definition or raise mid-write). + """ + ... + + @abstractmethod + async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict: + """One of :data:`XREF_ACTIONS` on one external reference. + + ``list`` ignores `name` and returns + ``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``path`` + rewrites the saved path to `new_path` and returns the new one. + ``detach`` removes every insert of the xref and its definition, and + reports ``inserts_removed``. ``reload`` and ``bind`` need a live seat's + xref manager: the headless engine raises + ``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending, + which is why the key exists; ``list``/``detach``/``path`` are *not* + behind it. + + Refuses an action outside the vocabulary, an unknown xref name, and a + ``path`` action with no `new_path`. + """ + ... + + @abstractmethod + async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict: + """Attach a raster image as an underlay. + + `scale` is drawing units per pixel, so the placed size is the image's + pixel size times `scale` and the aspect ratio is the file's. Returns + ``{ok, path, handle, at, scale, rotation, pixels: [w, h], + size_mm: [w, h], backend}``. + + Refuses a file that does not exist, a file no image reader can open + (naming it), a non-positive scale and a non-finite number -- all before + anything is written. + """ + ... + + @abstractmethod + async def drawing_export_dwg(self, path: str, version: str) -> dict: + """Write the current drawing as a real DWG. + + Returns ``{ok, path, version, bytes, backend}``. Refuses a `version` + outside :data:`DWG_VERSIONS` by name. Headlessly this needs the ODA + File Converter and otherwise raises + ``UnsupportedCapabilityError("dwg_write", ...)`` naming the install + route; on a live seat it is ``Document.SaveAs`` with the matching + ``AcSaveAsType``, which -- as AutoCAD's own SaveAs does -- leaves the + session bound to the file it just wrote. + """ + ... +``` + +In `backends/contracts/__init__.py`, add `from backends.contracts.refs import RefsContract` +in alphabetical position (after `premium`) and `"RefsContract",` to `__all__`. + +In `backends/base.py`, add `RefsContract,` to the `from backends.contracts import (...)` +list and to the `class AutoCADBackend(...)` base list, both in their existing +alphabetical position. + +- [ ] **Step 4: Run it to verify the contract half passes and the backends fail** + +Run: `uv run --frozen pytest tests/test_refs_contract.py -q` +Expected: FAIL — `TypeError: Can't instantiate abstract class EzdxfBackend with abstract methods drawing_export_dwg, image_attach, xref_attach, xref_manage`. This is the abstract-method build break the plan expects: `@capability` is deliberately not used here, because all four members are real on at least one engine. + +- [ ] **Step 5: Implement the four members on the ezdxf backend** + +In `backends/ezdxf_backend.py`, add near the other module-level helpers (beside +`_is_dwg_path`): + +```python +def _odafc_available() -> bool: + """Is the ODA File Converter on this machine right now? + + Re-evaluated per call, the way the matplotlib check behind + ``viewport_render`` is: installing the converter must change the answer + without editing the map. + """ + try: + from ezdxf.addons import odafc + + return bool(odafc.is_installed()) + except Exception: # noqa: BLE001 - an import failure is simply "not installed" + return False + + +def _image_pixels(path: str) -> tuple[int, int]: + """Pixel size of a raster, so the placed image keeps the file's aspect.""" + try: + from PIL import Image + except ImportError as exc: # pragma: no cover - Pillow ships with [pdf] + raise ValueError( + "image_attach needs Pillow to read the image's pixel size; " + 'install it with: pip install -e ".[pdf]"' + ) from exc + try: + with Image.open(path) as image: + return int(image.width), int(image.height) + except Exception as exc: + raise ValueError(f"image_attach: {path} is not an image this server can read ({exc})") from exc +``` + +and the four members beside the other drawing methods: + +```python + # ── external references, rasters and DWG (v1.6 track G) ────────────────── + + async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict: + from backends.contracts.refs import XREF_KINDS + + if kind not in XREF_KINDS: + raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}") + source = Path(path) + if not source.is_file(): + raise ValueError(f"xref_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"xref_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"xref_attach: scale must be positive, got {scale!r}") + name = source.stem.upper() + + def _sync(): + from ezdxf import xref + + doc = self._require_doc() + if name in doc.blocks: + raise ValueError( + f"xref_attach: the drawing already holds a block named {name!r}; " + "detach it first with xref_manage(action='detach')" + ) + insert = xref.attach( + doc, + block_name=name, + filename=str(source), + insert=(x, y), + scale=float(scale), + rotation=float(rotation), + overlay=(kind == "overlay"), + ) + self._mark_dirty() + return { + "ok": True, + "name": name, + "path": str(source), + "kind": kind, + "handle": insert.dxf.handle, + "at": [x, y], + "scale": float(scale), + "rotation": float(rotation), + "backend": self.name, + } + + return await self._async(_sync) + + async def xref_manage(self, name, action, new_path=None) -> dict: + from backends.contracts.refs import XREF_ACTIONS + + if action not in XREF_ACTIONS: + raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}") + if action in ("reload", "bind"): + raise UnsupportedCapabilityError( + "xref_live", + f"xref_manage(action={action!r}): the {self.name} backend has no live xref " + "manager. list, detach and path work headlessly; reload and bind need the " + "com backend (a running AutoCAD seat).", + ) + if action == "path" and not (new_path or "").strip(): + raise ValueError("xref_manage(action='path') needs new_path") + + def _sync(): + from ezdxf.lldxf import const + + doc = self._require_doc() + xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY + + def _rows(): + rows = [] + for block in doc.blocks: + flags = int(block.block.dxf.flags) + if not flags & xref_mask: + continue + rows.append( + { + "name": block.name, + "path": str(block.block.dxf.xref_path or ""), + "kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach", + "inserts": sum( + 1 + for entity in doc.modelspace() + if entity.dxftype() == "INSERT" + and entity.dxf.name == block.name + ), + } + ) + rows.sort(key=lambda row: row["name"]) + return rows + + if action == "list": + return {"ok": True, "xrefs": _rows(), "backend": self.name} + + key = str(name).strip() + row = next((r for r in _rows() if r["name"] == key), None) + if row is None: + raise ValueError( + f"xref_manage: {key!r} is not an external reference in this drawing " + f"(have: {', '.join(r['name'] for r in _rows()) or 'none'})" + ) + if action == "path": + doc.blocks.get(key).block.dxf.xref_path = str(new_path) + self._mark_dirty() + return {"ok": True, "name": key, "path": str(new_path), "backend": self.name} + + victims = [ + entity + for entity in doc.modelspace() + if entity.dxftype() == "INSERT" and entity.dxf.name == key + ] + for entity in victims: + doc.modelspace().delete_entity(entity) + doc.blocks.delete_block(key, safe=False) + self._mark_dirty() + return { + "ok": True, + "name": key, + "inserts_removed": len(victims), + "backend": self.name, + } + + return await self._async(_sync) + + async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict: + source = Path(path) + if not source.is_file(): + raise ValueError(f"image_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"image_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"image_attach: scale must be positive, got {scale!r}") + px, py = _image_pixels(str(source)) + width, height = px * float(scale), py * float(scale) + + def _sync(): + doc = self._require_doc() + image_def = doc.add_image_def(str(source), size_in_pixel=(px, py)) + image = self._msp().add_image( + image_def, (x, y), (width, height), float(rotation) + ) + self._mark_dirty() + return { + "ok": True, + "path": str(source), + "handle": image.dxf.handle, + "at": [x, y], + "scale": float(scale), + "rotation": float(rotation), + "pixels": [px, py], + "size_mm": [width, height], + "backend": self.name, + } + + return await self._async(_sync) + + async def drawing_export_dwg(self, path, version="R2018") -> dict: + from backends.contracts.refs import DWG_VERSIONS + + if version not in DWG_VERSIONS: + raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}") + if not _odafc_available(): + raise UnsupportedCapabilityError( + "dwg_write", + f"drawing_export_dwg: the {self.name} backend writes DWG only through the " + "ODA File Converter, which is not installed here. Install it from " + "https://www.opendesign.com/guestfiles/oda_file_converter and this call " + "starts working, or export from the com backend (a running AutoCAD seat). " + "drawing_export_dxf writes a DXF that AutoCAD opens unchanged.", + ) + + def _sync(): + from ezdxf.addons import odafc + + doc = self._require_doc() + self._refuse_path_held_elsewhere("drawing_export_dwg", path) + odafc.export_dwg(doc, path, version=version, replace=True) + return { + "ok": True, + "path": path, + "version": version, + "bytes": Path(path).stat().st_size, + "backend": self.name, + } + + return await self._async(_sync) +``` + +and add the three keys to `EzdxfBackend.capabilities()`, beside the track E +block: + +```python + # Track G keys. `dwg_write` is a runtime re-evaluation, not a + # hardcoded False: installing the converter must change the + # answer without editing this map, exactly as `viewport_render` + # follows matplotlib. + "dwg_write": ( + FeatureCapability(True, "odafc", reason="oda_file_converter_installed") + if _odafc_available() + else FeatureCapability( + False, + reason=( + "requires_oda_file_converter:" + "https://www.opendesign.com/guestfiles/oda_file_converter" + ), + ) + ), + "xref_live": FeatureCapability( + False, + reason="reload_and_bind_need_a_live_seat;list_detach_path_are_headless", + ), + "xlsx_write": ( + FeatureCapability(True, "openpyxl") + if find_spec("openpyxl") is not None + else FeatureCapability(False, reason="optional_dependency_missing:openpyxl") + ), +``` + +- [ ] **Step 6: Run the ezdxf half** + +Run: `uv run --frozen pytest tests/test_refs_contract.py -q -k "not com_"` +Expected: FAIL only on the COM tests' collection-time backend construction — +`TypeError: Can't instantiate abstract class ComBackend`. Every ezdxf test in +the file passes. + +- [ ] **Step 7: Implement the four members on the COM backend** + +In `backends/com_backend.py`, add beside the other drawing methods (the member +names below were read off the running AutoCAD 2026's type library; nothing here +is guessed): + +```python + # ── external references, rasters and DWG (v1.6 track G) ────────────────── + # + # Measured on AutoCAD 2026 (AutoCAD.Application 25.1s), from the live + # seat's type library: + # ModelSpace.AttachExternalReference(PathName, Name, InsertionPoint, + # Xscale, Yscale, Zscale, Rotation, bOverlay, Password) + # ModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor, + # RotationAngle) + # Block: IsXRef, Path, Name, Reload(), Unload(), Bind(bPrefixName), + # Detach(), XRefDatabase + # Document.SaveAs(FullFileName, SaveAsType, vSecurityParams) + # ActiveX angles are radians; the tool boundary is degrees, so every + # rotation crosses here and nowhere else. + + #: AcSaveAsType, read off the same type library. + _DWG_SAVE_AS = { + "R2000": 12, + "R2004": 24, + "R2007": 36, + "R2010": 48, + "R2013": 60, + "R2018": 64, + } + + async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict: + from backends.contracts.refs import XREF_KINDS + + if kind not in XREF_KINDS: + raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}") + source = Path(path) + if not source.is_file(): + raise ValueError(f"xref_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"xref_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"xref_attach: scale must be positive, got {scale!r}") + name = source.stem.upper() + factor = float(scale) + radians = math.radians(float(rotation)) + + def _sync(): + doc = _acad_doc() + reference = doc.ModelSpace.AttachExternalReference( + str(source), + name, + _apoint(x, y), + factor, + factor, + factor, + radians, + kind == "overlay", + ) + return { + "ok": True, + "name": name, + "path": str(source), + "kind": kind, + "handle": reference.Handle, + "at": [x, y], + "scale": factor, + "rotation": float(rotation), + "backend": self.name, + } + + return await self._run(_sync) + + async def xref_manage(self, name, action, new_path=None) -> dict: + from backends.contracts.refs import XREF_ACTIONS + + if action not in XREF_ACTIONS: + raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}") + if action == "path" and not (new_path or "").strip(): + raise ValueError("xref_manage(action='path') needs new_path") + + def _sync(): + doc = _acad_doc() + + def _xref_blocks(): + found = [] + for index in range(doc.Blocks.Count): + block = doc.Blocks.Item(index) + try: + if block.IsXRef: + found.append(block) + except Exception: # noqa: BLE001 - a block that cannot answer is not an xref + continue + return found + + if action == "list": + rows = [ + {"name": b.Name, "path": str(b.Path or ""), "kind": "attach", "inserts": None} + for b in _xref_blocks() + ] + rows.sort(key=lambda row: row["name"]) + return {"ok": True, "xrefs": rows, "backend": self.name} + + key = str(name).strip() + block = next((b for b in _xref_blocks() if b.Name == key), None) + if block is None: + raise ValueError( + f"xref_manage: {key!r} is not an external reference in this drawing" + ) + if action == "reload": + block.Reload() + return {"ok": True, "name": key, "action": action, "backend": self.name} + if action == "bind": + block.Bind(False) + return {"ok": True, "name": key, "action": action, "backend": self.name} + if action == "detach": + block.Detach() + return {"ok": True, "name": key, "inserts_removed": None, "backend": self.name} + block.Path = str(new_path) + return {"ok": True, "name": key, "path": str(new_path), "backend": self.name} + + return await self._run(_sync) + + async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict: + source = Path(path) + if not source.is_file(): + raise ValueError(f"image_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"image_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"image_attach: scale must be positive, got {scale!r}") + px, py = _image_pixels(str(source)) + factor = float(scale) + radians = math.radians(float(rotation)) + + def _sync(): + doc = _acad_doc() + raster = doc.ModelSpace.AddRaster(str(source), _apoint(x, y), factor, radians) + return { + "ok": True, + "path": str(source), + "handle": raster.Handle, + "at": [x, y], + "scale": factor, + "rotation": float(rotation), + "pixels": [px, py], + "size_mm": [px * factor, py * factor], + "backend": self.name, + } + + return await self._run(_sync) + + async def drawing_export_dwg(self, path, version="R2018") -> dict: + from backends.contracts.refs import DWG_VERSIONS + + if version not in DWG_VERSIONS: + raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}") + save_as_type = self._DWG_SAVE_AS[version] + + def _sync(): + doc = _acad_doc() + doc.SaveAs(path, save_as_type) + target = Path(path) + return { + "ok": True, + "path": path, + "version": version, + "bytes": target.stat().st_size if target.exists() else None, + "backend": self.name, + } + + return await self._run(_sync) +``` + +Add `_image_pixels` to `backends/com_backend.py` as well — copied, not imported +from the ezdxf backend, because the two backends must not depend on each other: + +```python +def _image_pixels(path: str) -> tuple[int, int]: + """Pixel size of a raster; the placed size is pixels x scale on both engines.""" + try: + from PIL import Image + except ImportError as exc: # pragma: no cover - Pillow ships with [pdf] + raise ValueError( + "image_attach needs Pillow to read the image's pixel size; " + 'install it with: pip install -e ".[pdf]"' + ) from exc + try: + with Image.open(path) as image: + return int(image.width), int(image.height) + except Exception as exc: + raise ValueError(f"image_attach: {path} is not an image this server can read ({exc})") from exc +``` + +and add the three keys to `ComBackend.capabilities()`: + +```python + # Track G keys. + "dwg_write": FeatureCapability(True, "native", reason="document_saveas"), + "xref_live": FeatureCapability( + True, "native", reason="block_reload_bind_detach_on_the_live_seat" + ), + "xlsx_write": ( + FeatureCapability(True, "openpyxl") + if find_spec("openpyxl") is not None + else FeatureCapability(False, reason="optional_dependency_missing:openpyxl") + ), +``` + +If `find_spec` is not already imported in `backends/com_backend.py`, add +`from importlib.util import find_spec` to its imports. + +- [ ] **Step 8: Run it to verify both engines pass** + +Run: `uv run --frozen pytest tests/test_refs_contract.py tests/test_capability_contract.py -q` +Expected: PASS. `xref_manage(action="list")` reports `inserts: None` on the COM +engine deliberately — ActiveX gives no insert count per xref and this server +does not invent one. If `xref.attach` raises on an existing block name before +our own guard does, keep the guard (it is the validate-before-write rule) and +record the redundancy in `deviations`. + +- [ ] **Step 9: Write the failing test for the CSV/XLSX writer** + +Create `tests/test_sheet_extract.py`: + +```python +"""Parts-list rows to CSV (always) and XLSX (with openpyxl).""" + +from __future__ import annotations + +import csv +from importlib.util import find_spec + +import pytest + +from backends.base import UnsupportedCapabilityError +from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available + + +def _rows(): + return ( + {"item": 1, "qty": 2, "designation": "ISO 4014 - M12x60", "standard": "ISO 4014", + "material": "8.8", "handles": ("A1", "A2")}, + {"item": 2, "qty": 1, "designation": "SHAFT 40h6", "standard": "", "material": "C45", + "handles": ("B1",)}, + ) + + +def test_the_formats_are_closed_and_csv_is_always_one_of_them(): + assert EXTRACT_FORMATS == ("csv", "xlsx") + + +def test_csv_writes_the_heading_row_and_one_row_per_part(tmp_path): + target = tmp_path / "bom.csv" + result = write_rows(_rows(), str(target), fmt="csv") + assert result["ok"] is True and result["rows"] == 2 and result["format"] == "csv" + with open(target, newline="", encoding="utf-8") as handle: + written = list(csv.reader(handle)) + assert written[0] == ["POS", "QTY", "DESIGNATION", "STANDARD", "MATERIAL"] + assert written[1] == ["1", "2", "ISO 4014 - M12x60", "ISO 4014", "8.8"] + assert written[2][2] == "SHAFT 40h6" + assert len(written) == 3 + + +def test_a_chosen_column_set_is_honoured(tmp_path): + target = tmp_path / "short.csv" + write_rows(_rows(), str(target), columns=("item", "designation"), fmt="csv") + with open(target, newline="", encoding="utf-8") as handle: + written = list(csv.reader(handle)) + assert written[0] == ["POS", "DESIGNATION"] + assert all(len(row) == 2 for row in written) + + +def test_an_unknown_format_is_refused_with_the_list(tmp_path): + with pytest.raises(ValueError) as excinfo: + write_rows(_rows(), str(tmp_path / "x.ods"), fmt="ods") + message = str(excinfo.value) + assert "ods" in message and "csv" in message and "xlsx" in message + + +def test_no_rows_is_refused_rather_than_writing_an_empty_file(tmp_path): + target = tmp_path / "empty.csv" + with pytest.raises(ValueError): + write_rows((), str(target), fmt="csv") + assert not target.exists() + + +def test_xlsx_available_agrees_with_the_installed_package(): + assert xlsx_available() is (find_spec("openpyxl") is not None) + + +def test_xlsx_refuses_with_the_capability_key_and_names_the_extra(tmp_path): + if xlsx_available(): + result = write_rows(_rows(), str(tmp_path / "bom.xlsx"), fmt="xlsx") + assert result["ok"] is True and (tmp_path / "bom.xlsx").exists() + return + target = tmp_path / "bom.xlsx" + with pytest.raises(UnsupportedCapabilityError) as excinfo: + write_rows(_rows(), str(target), fmt="xlsx") + assert excinfo.value.capability == "xlsx_write" + assert "openpyxl" in str(excinfo.value) + assert "csv" in str(excinfo.value), "a refusal that does not name the way out is half a refusal" + assert not target.exists() +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_sheet_extract.py -q` +Expected: FAIL — collection error, `ModuleNotFoundError: No module named 'engineering.sheet.extract'`. + +- [ ] **Step 11: Implement the writer** + +Create `engineering/sheet/extract.py`: + +```python +"""Parts-list rows to a file: CSV always, XLSX when openpyxl is installed. + +CSV needs nothing but the standard library, so the deliverable is never blocked +-- which is exactly why `xlsx_write` can be a capability rather than a hard +dependency. The refusal names both the missing package and the format that +always works. +""" + +from __future__ import annotations + +import csv +from importlib.util import find_spec +from pathlib import Path + +from backends.capability import UnsupportedCapabilityError +from engineering.sheet.bom import BOM_COLUMNS, COLUMN_LABELS, DEFAULT_COLUMNS + +__all__ = ["EXTRACT_FORMATS", "write_rows", "xlsx_available"] + +EXTRACT_FORMATS = ("csv", "xlsx") + + +def xlsx_available() -> bool: + """Is openpyxl importable right now? Re-evaluated per call, so installing + the package changes the answer without a restart.""" + return find_spec("openpyxl") is not None + + +def _prepare(rows, columns): + chosen = tuple(DEFAULT_COLUMNS if columns is None else columns) + unknown = [c for c in chosen if c not in BOM_COLUMNS] + if unknown: + raise ValueError( + f"unknown parts-list column(s) {', '.join(unknown)}; " + f"available: {', '.join(BOM_COLUMNS)}" + ) + body = [[str(row.get(column, "")) for column in chosen] for row in rows] + if not body: + raise ValueError("data_extract: there are no parts-list rows to write") + return chosen, [COLUMN_LABELS[c] for c in chosen], body + + +def write_rows(rows, path: str, *, columns=None, fmt: str = "csv") -> dict: + """Write the rows to `path`. Refuses before the file is created.""" + if fmt not in EXTRACT_FORMATS: + raise ValueError(f"format must be one of {', '.join(EXTRACT_FORMATS)}; got {fmt!r}") + chosen, heading, body = _prepare(rows, columns) + if fmt == "xlsx" and not xlsx_available(): + raise UnsupportedCapabilityError( + "xlsx_write", + "data_extract(format='xlsx') needs openpyxl, which is not installed. " + 'Install it with: pip install openpyxl (or pip install -e ".[full]"), ' + "or ask for format='csv', which always works and carries the same rows.", + ) + + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + if fmt == "csv": + with open(target, "w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(heading) + writer.writerows(body) + else: + from openpyxl import Workbook + + book = Workbook() + sheet = book.active + sheet.title = "Parts list" + sheet.append(heading) + for row in body: + sheet.append(row) + book.save(target) + + return { + "ok": True, + "path": str(target), + "format": fmt, + "columns": list(chosen), + "rows": len(body), + "bytes": target.stat().st_size, + } +``` + +In `engineering/sheet/__init__.py`, add: + +```python +from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available +``` + +and the three names to `__all__`. + +- [ ] **Step 12: Run it to verify the writer passes** + +Run: `uv run --frozen pytest tests/test_sheet_extract.py -q` +Expected: PASS, 7 passed — with the XLSX case taking the refusal branch, since +`find_spec("openpyxl")` is None on this machine. + +- [ ] **Step 13: Register the five tools** + +In `server.py`, change the SECTION 24 banner to +`# ── SECTION 24: Sheet & Delivery (11 tools) ─────────────────────────────────` +and append after `balloon_add`: + +```python +@cad_tool(summary="Attach a DWG/DXF as an external reference (attachment or overlay).", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Attach Xref", "destructiveHint": False}, + tags={"drawing", "block"}, +) +async def xref_attach( + path: Annotated[str, Field(description="Full path of the drawing to reference.")], + x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0, + y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0, + scale: Annotated[float, Field(default=1.0, gt=0, description="Uniform scale.")] = 1.0, + rotation: Annotated[ + float, Field(default=0.0, description="Rotation in degrees, CCW from +X.") + ] = 0.0, + kind: Annotated[ + str, + Field( + default="attach", + description="'attach' (the reference's own xrefs come with it) or 'overlay' " + "(they do not, so a circular reference cannot form).", + ), + ] = "attach", + ctx: Context = None, +) -> dict: + """Attach an external reference and insert it once. Both engines. + + The block takes the referenced file's stem as its name. Refusals, all + before anything is written: a `kind` that is not 'attach' or 'overlay', a + file that does not exist, a non-positive or non-finite scale, and a block + name the drawing already holds — detach it first with + `xref_manage(action='detach')`. + """ + validated = validate_path(path) + await ctx.info(f"Xref {kind}: {validated}") + return await _backend(ctx).xref_attach(str(validated), (x, y), scale, rotation, kind) + + +@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True}, + tags={"drawing", "block"}, +) +async def xref_manage( + action: Annotated[ + str, + Field(description="list | reload | bind | detach | path."), + ], + name: Annotated[ + str, Field(default="", description="Xref name; ignored by 'list'.") + ] = "", + new_path: Annotated[ + str, Field(default="", description="Required by 'path': the reference's new location.") + ] = "", + ctx: Context = None, +) -> dict: + """One operation on one external reference. + + `list`, `detach` and `path` work on both engines. `reload` and `bind` need + a live AutoCAD seat's xref manager: headlessly they are refused with + `capability: "xref_live"` rather than pretended, and the refusal says which + engine does them. Other refusals: an action outside the five, an unknown + xref name, a 'path' action with no `new_path`. On the live engine a listed + row's `inserts` is null — ActiveX reports no per-xref insert count and this + server does not invent one. + """ + await ctx.info(f"Xref {action} {name or '(all)'}") + validated = str(validate_path(new_path)) if new_path else None + return await _backend(ctx).xref_manage(name, action, validated) + + +@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Attach Image", "destructiveHint": False}, + tags={"drawing", "create"}, +) +async def image_attach( + path: Annotated[str, Field(description="Full path of the raster image.")], + x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0, + y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0, + scale: Annotated[ + float, + Field( + default=1.0, + gt=0, + description="Drawing units per pixel: an 800x400 image at 0.1 is 80x40 mm.", + ), + ] = 1.0, + rotation: Annotated[float, Field(default=0.0, description="Rotation in degrees.")] = 0.0, + ctx: Context = None, +) -> dict: + """Attach a raster underlay at the file's own aspect ratio. Both engines. + + `scale` is drawing units per pixel, so the placed size is the image's pixel + size times `scale` and the payload reports both. Refusals, all before + anything is written: a file that does not exist, a file no image reader can + open (named), a non-positive or non-finite scale. + """ + validated = validate_path(path) + await ctx.info(f"Image underlay: {validated}") + return await _backend(ctx).image_attach(str(validated), (x, y), scale, rotation) + + +@cad_tool(summary="Write the parts list to CSV, or to XLSX when openpyxl is present.", cost="safe") +@mcp.tool( + annotations={"title": "Sheet: Extract Data", "destructiveHint": False}, + tags={"engineering", "sheet", "query"}, +) +async def data_extract( + path: Annotated[str, Field(description="File to write, ending .csv or .xlsx.")], + fmt: Annotated[ + str, + Field(default="csv", description="'csv' (always available) or 'xlsx' (needs openpyxl)."), + ] = "csv", + layer: Annotated[ + str, Field(default="", description="Only read INSERTs on this layer; empty reads all.") + ] = "", + group_by: Annotated[ + str, + Field(default="designation", description="Group identical parts by this field, or ''."), + ] = "designation", + columns: Annotated[list[str] | None, Field(default=None)] = None, + ctx: Context = None, +) -> dict: + """The same rows `bom_extract` returns, written to a file. + + Refusals: a format that is not 'csv' or 'xlsx'; a drawing with no + parts-list rows (nothing is written); and `fmt='xlsx'` without openpyxl, + which carries `capability: "xlsx_write"` and names both the package to + install and the CSV route that always works. + """ + from engineering.sheet.bom import extract_records, rows_from_records + from engineering.sheet.extract import write_rows + + backend = _backend(ctx) + validated = validate_path(path, allow_write=True) + records = await extract_records(backend, layer=layer or None) + rows = rows_from_records(records, columns=columns, group_by=group_by or None) + await ctx.info(f"Data extract: {len(rows)} rows -> {validated}") + return write_rows(rows, str(validated), columns=columns, fmt=fmt) + + +@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe") +@mcp.tool( + annotations={"title": "Drawing: Export DWG", "destructiveHint": False}, + tags={"drawing"}, +) +async def drawing_export_dwg( + path: Annotated[str, Field(description="Full path of the .dwg to write.")], + version: Annotated[ + str, + Field( + default="R2018", + description="R2000 | R2004 | R2007 | R2010 | R2013 | R2018.", + ), + ] = "R2018", + ctx: Context = None, +) -> dict: + """Write a real DWG — not a DXF under a .dwg name. + + On a live AutoCAD seat this is `Document.SaveAs` with the matching + AcSaveAsType, which (as AutoCAD's own SaveAs does) leaves the session bound + to the file it just wrote. Headlessly it needs the ODA File Converter; when + that is absent the call is refused with `capability: "dwg_write"` and the + refusal names the install route and `drawing_export_dxf`, which writes a + DXF AutoCAD opens unchanged. Check `system_capabilities` first: the ezdxf + flag is re-evaluated at call time, so installing the converter changes it + without a restart. An unknown version is refused by name with the six. + """ + validated = validate_path(path, allow_write=True) + await ctx.info(f"DWG export {version}: {validated}") + return await _backend(ctx).drawing_export_dwg(str(validated), version) +``` + +- [ ] **Step 14: Add the alias records** + +In `discovery/aliases.py`, add to `TOOL_ALIASES` after the `"balloon_add"` record: + +```python + "xref_attach": ToolAliases( + acad=("XATTACH",), + synonyms=( + "attach an xref", + "external reference", + "xref", + "reference another drawing", + "overlay a drawing", + "harici referans", + ), + ), + "xref_manage": ToolAliases( + acad=("XREF", "XBIND"), + synonyms=( + "list the xrefs", + "reload an xref", + "bind an xref", + "detach an xref", + "repath an xref", + "xref manager", + "broken xref path", + ), + ), + "image_attach": ToolAliases( + acad=("IMAGEATTACH",), + synonyms=( + "attach an image", + "raster underlay", + "insert a png", + "background image", + "scanned drawing", + "resim ekle", + ), + ), + "data_extract": ToolAliases( + acad=("DATAEXTRACTION", "EATTEXT"), + synonyms=( + "export the parts list", + "extract attributes", + "bom to csv", + "bom to excel", + "data extraction", + "excele aktar", + ), + ), + "drawing_export_dwg": ToolAliases( + acad=("SAVEAS",), + synonyms=( + "export dwg", + "save as dwg", + "write a dwg", + "autocad 2018 format", + "downgrade to r2000", + "dwg olarak kaydet", + ), + ), +``` + +`SAVEAS` is already in `SHARED_ACAD_COMMANDS`, so a third owner needs no change +there. + +Run: `uv run --frozen pytest tests/test_discovery_aliases.py -q` +Expected: PASS. + +- [ ] **Step 15: Regenerate the inventory and bump the count files** + +Run: + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen python -c "import json; print(json.load(open('docs/tool-inventory.json'))['totals'])" +``` + +Expected: `{'tools': 215, 'resources': 8, 'prompts': 5, 'groups': 23}`. + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: change +`"engineering": 16,` to `"engineering": 17,` (`data_extract`), `"drawing": 11,` +to `"drawing": 14,` (`xref_attach`, `xref_manage`, `drawing_export_dwg`) and +`"entity_creation": 18,` to `"entity_creation": 19,` (`image_attach`); change +`== 210` to `== 215`; change the docstring's `(210 tools, 23 groups)` to +`(215 tools, 23 groups)`; and extend the SECTION 24 paragraph with: + +``` + The section's last five tools file elsewhere on purpose: `xref_attach`, + `xref_manage` and `drawing_export_dwg` are tagged `drawing` (11 -> 14), + `image_attach` is tagged `create` (entity_creation 18 -> 19) and + `data_extract` is tagged `engineering` (16 -> 17). They are whole-drawing + and entity operations, not sheet furniture, and `_GROUP_TAG_PRIORITY` + files them where a caller would look. +``` + +In `README.md`: release-snapshot `210 tools` -> `215 tools`; `210 is the +**registered** count; a default install advertises 205` -> `215 ... advertises +210`; the "What you get" caption `210 tools in 23 groups` -> `215 tools in 23 +groups`; and the alias sentence's three numbers from + +``` +uv run --frozen python -c "from discovery.aliases import TOOL_ALIASES; a=sorted({c for r in TOOL_ALIASES.values() for c in r.acad}); s=[p for r in TOOL_ALIASES.values() for p in r.synonyms]; print(len(a), len(s), len(TOOL_ALIASES))" +``` + +- [ ] **Step 16: Run the gates** + +Run: + +``` +uv run --frozen ruff format backends engineering/sheet server.py discovery/aliases.py tests/test_refs_contract.py tests/test_sheet_extract.py tests/test_tool_registry.py +uv run --frozen ruff check backends engineering/sheet server.py discovery/aliases.py tests/test_refs_contract.py tests/test_sheet_extract.py tests/test_tool_registry.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_refs_contract.py tests/test_sheet_extract.py tests/test_capability_contract.py tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_discovery_transform.py -q +uv run --frozen pytest tests/test_sheet_frames.py tests/test_sheet_titleblock.py tests/test_sheet_bom.py tests/test_sheet_revision.py tests/test_engineering_titleblock.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q +``` + +Expected: all green. `tests/test_com_backend.py` and `tests/test_sprint4_com.py` +construct `ComBackend` at import time, so an abstract member left unimplemented +takes the whole suite red — if that happens, the missing member is named in the +`TypeError` and belongs in Step 7, not behind `@capability`. + +- [ ] **Step 17: Commit** + +Run: + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -am "$(cat <<'EOF' +feat(refs): xrefs, raster underlays, data extract and real DWG output + +SECTION 24 completes at eleven tools with xref_attach, xref_manage, +image_attach, data_extract and drawing_export_dwg, on the four contract members +that genuinely differ per engine -- backends/contracts/refs.py. Everything else +in the sheet track composes the generic members and is written once. + +The ActiveX members were read off the running AutoCAD 2026 seat's type library +before a line of the COM path was written, and the measurement is in the code +beside the calls: ModelSpace.AttachExternalReference(PathName, Name, +InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay), +ModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor, +RotationAngle), a Block's IsXRef/Path/Reload()/Bind(bPrefixName)/Detach(), and +Document.SaveAs(FullFileName, SaveAsType) with AcSaveAsType 12/24/36/48/60/64. +This repository has been bitten four times by a fake that invented a member; the +COM fakes in the tests model those names and nothing else. ActiveX angles are +radians, so the degree-to-radian conversion happens at that boundary and +nowhere else. + +Headlessly, ezdxf.xref.attach carries the attachment (measured: block flags 20 +for an attachment and 24 for an overlay, so "is this an xref" is +flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every +overlay), add_image_def + add_image the raster, with Pillow reading the pixel +size so the placed image keeps the file's aspect. + +Three capability keys, declared in both maps: + + dwg_write -- measured absent here (odafc.is_installed() is False), so the + ezdxf entry is a runtime re-evaluation like viewport_render's + matplotlib check rather than a hardcoded False: installing the + ODA File Converter starts the call working with no edit to the + map. The refusal names the install route and drawing_export_dxf. + xref_live -- reload and bind need a live seat's xref manager and are refused + headlessly; list, detach and path are headless and are not + behind the key. + xlsx_write -- openpyxl is optional and absent here; CSV is always available, + so the refusal never blocks the deliverable and says so. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +EOF +)" +``` + +--- + +### Task 15: Integration on main — the six mech_* critique focuses, the mech pack, lean, aliases, golden queries, count files + +**Files:** +- Create: `engineering/mech/critique.py` +- Create: `tests/test_mech_critique.py` +- Modify: `engineering/plan_spec.py` (six names into the `CritiqueFocus` Literal and into `ALL_CRITIQUE_FOCUSES`) +- Modify: `engineering/critique.py` (register `MECH_DISPATCH` the way `PID_DISPATCH` is registered) +- Modify: `server.py` (`TOOL_PACK_NAMES` gains `"mech"`; `PACK_TOOL_NAMES["mech"]`; five names into `LEAN_TOOL_NAMES`; `_GROUP_TAG_PRIORITY` / `_GROUP_TAG_LABELS` gain `mech` and `sheet`; the two focus lists in `drawing_critique` and `drawing_finalize`) +- Modify: `discovery/aliases.py` (`SHARED_ACAD_COMMANDS` gains the commands the merged records actually share) +- Modify: `tests/data/discovery_golden.py` (ten tuning cases) +- Modify: `tests/test_tool_packs.py` (pack list, lean 55 → 60, groups 23 → 25) +- Modify: `tests/test_tool_registry.py` (frozen group-size snapshot) +- Modify: `docs/tool-inventory.json` (regenerated) +- Modify: `README.md` (release-snapshot tools / collected tests, the `` pack line) +- Test: `tests/test_mech_critique.py` + +**Interfaces:** +- Consumes: `engineering/mech/primitives.py::ROLE_LAYER` and `engineering/mech/xdata.py::APP_ID, decode, to_values` (Task 1); `engineering/plan_spec.py::Issue(severity, focus, message, handles=[], detail={})`; `backend.entity_list(type_filter=None, layer_filter=None, limit=200, offset=0) -> list[EntityInfo]`; `backend.entity_get_xdata(handle, app_name=None) -> {"handle", "xdata": {app: [values]}, "backend"}`; `EntityInfo.properties` keys measured on both engines — `bounding_box: {"min": [x, y], "max": [x, y]}` on every entity, `center`/`radius` on CIRCLE and ARC, `start`/`end` on LINE, `insertion`/`block_name` on INSERT, `defpoint` on a DIMENSION. +- Produces: `engineering/mech/critique.py::MECH_FOCUSES: tuple[str, ...]`, `build_index(backend) -> dict`, `has_mech_content(index) -> bool`, `issues_for(focus: str, index: dict) -> list[Issue]`, `MECH_DISPATCH: dict[str, callable]` whose values are `async check(backend, shared) -> list[Issue]` with `check.needs_shared = True`; `server.PACK_TOOL_NAMES["mech"]` (14 names) and `server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")`. + +- [ ] **Step 1: Write the failing test — the enum, the silence rule and a positive/negative fixture per focus** + +Create `tests/test_mech_critique.py`: + +```python +"""Six mechanical focuses, each with a positive and a negative fixture. + +All six are silent on a drawing with no ACADMCP_MECH payload on it, so +`focus=None` stays cheap for a P&ID sheet or a plain DXF. + +A fixture writes the payload onto a small CIRCLE on layer 0 rather than onto a +block reference. The index reads INSERT and CIRCLE alike (a balloon is a circle +with a leader), and a circle keeps the fixtures free of block definitions — +what is under test here is the reading, not the drawing. +""" + +from __future__ import annotations + +import pytest + +from engineering.layers import ensure_engineering_layers +from engineering.mech.critique import ( + MECH_FOCUSES, + build_index, + has_mech_content, + issues_for, +) +from engineering.mech.xdata import APP_ID, to_values +from engineering.plan_spec import ALL_CRITIQUE_FOCUSES + +pytestmark = pytest.mark.asyncio + +SHAFT = { + "name": "shaft", + "material": "steel", + "segments": [{"length": 60.0, "d_outer": 40.0, "d_inner": 20.0}], + "features": [], +} + + +async def _anchor(backend, payload, x=0.0, y=0.0): + ent = await backend.entity_create_circle(x, y, 0.5, layer="0") + await backend.entity_set_xdata(ent.handle, APP_ID, to_values(payload)) + return ent.handle + + +def _focus(index, name): + return issues_for(name, index) + + +def test_the_six_focuses_are_the_spec_ones_and_are_in_the_closed_enum(): + assert MECH_FOCUSES == ( + "mech_missing_centreline", + "mech_unhatched_section", + "mech_view_misaligned", + "mech_duplicate_dimension", + "mech_thread_unrepresented", + "mech_bom_balloon_mismatch", + ) + for focus in MECH_FOCUSES: + assert focus in ALL_CRITIQUE_FOCUSES + + +async def test_every_focus_is_silent_on_a_drawing_with_no_mechanical_content(backend): + """A circle with no centre mark, two identical dimensions and no hatch — + every trigger present, and not one issue, because nothing on the sheet + claims to be a mechanical part.""" + await ensure_engineering_layers(backend) + await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY") + await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM") + await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM") + index = await build_index(backend) + assert has_mech_content(index) is False + for focus in MECH_FOCUSES: + assert _focus(index, focus) == [] + + +async def test_missing_centreline_fires_on_a_bare_circle(backend): + await ensure_engineering_layers(backend) + await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}) + await backend.entity_create_circle(100.0, 100.0, 10.0, layer="GEOMETRY") + issues = _focus(await build_index(backend), "mech_missing_centreline") + assert len(issues) == 1 + assert issues[0].focus == "mech_missing_centreline" + assert "centre mark" in issues[0].message + + +async def test_missing_centreline_is_quiet_once_the_cross_is_drawn(backend): + await ensure_engineering_layers(backend) + await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}) + await backend.entity_create_circle(100.0, 100.0, 10.0, layer="GEOMETRY") + await backend.entity_create_line(87.0, 100.0, 113.0, 100.0, layer="CENTER") + await backend.entity_create_line(100.0, 87.0, 100.0, 113.0, layer="CENTER") + assert _focus(await build_index(backend), "mech_missing_centreline") == [] + + +async def test_unhatched_section_fires_on_a_section_view_with_no_hatch(backend): + await ensure_engineering_layers(backend) + await _anchor( + backend, + {"v": 1, "kind": "part", "id": "P1", "view": "section", "part": SHAFT}, + x=0.0, + y=0.0, + ) + issues = _focus(await build_index(backend), "mech_unhatched_section") + assert len(issues) == 1 + assert issues[0].severity == "error" + assert "hatched cut face" in issues[0].message + + +async def test_unhatched_section_is_quiet_when_the_cut_face_is_hatched(backend): + await ensure_engineering_layers(backend) + await _anchor( + backend, + {"v": 1, "kind": "part", "id": "P1", "view": "section", "part": SHAFT}, + x=0.0, + y=0.0, + ) + await backend.entity_create_hatch( + "ANSI31", [[10.0, 10.0], [20.0, 10.0], [20.0, 20.0], [10.0, 20.0]], layer="HATCH" + ) + assert _focus(await build_index(backend), "mech_unhatched_section") == [] + + +async def test_view_misaligned_fires_when_a_side_view_leaves_the_projection_axis(backend): + await ensure_engineering_layers(backend) + await _anchor( + backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}, 0.0, 0.0 + ) + await _anchor( + backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": SHAFT}, 150.0, 5.0 + ) + issues = _focus(await build_index(backend), "mech_view_misaligned") + assert len(issues) == 1 + assert issues[0].detail["axis"] == "y" + assert issues[0].detail["delta_mm"] == 5.0 + + +async def test_view_misaligned_is_quiet_on_an_aligned_pair(backend): + await ensure_engineering_layers(backend) + await _anchor( + backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}, 0.0, 0.0 + ) + await _anchor( + backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": SHAFT}, 150.0, 0.0 + ) + assert _focus(await build_index(backend), "mech_view_misaligned") == [] + + +async def test_duplicate_dimension_fires_on_the_same_dimension_twice(backend): + await ensure_engineering_layers(backend) + await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}) + await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM") + await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM") + issues = _focus(await build_index(backend), "mech_duplicate_dimension") + assert len(issues) == 1 + assert len(issues[0].handles) == 2 + + +async def test_duplicate_dimension_is_quiet_on_two_different_dimensions(backend): + await ensure_engineering_layers(backend) + await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}) + await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM") + await backend.dimension_linear(0, 0, 40, 0, 20, -35, layer="DIM") + assert _focus(await build_index(backend), "mech_duplicate_dimension") == [] + + +THREADED = { + "name": "stud", + "material": "steel", + "segments": [{"length": 60.0, "d_outer": 20.0}], + "features": [{"kind": "thread", "id": "t1", "designation": "M20x1.5", "segment": 0}], +} + + +async def test_thread_unrepresented_fires_when_only_the_major_diameter_is_drawn(backend): + await ensure_engineering_layers(backend) + await _anchor( + backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": THREADED} + ) + await backend.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY") + issues = _focus(await build_index(backend), "mech_thread_unrepresented") + assert len(issues) == 1 + assert issues[0].detail["minor_mm"] == 16.0 + assert "ISO 6410" in issues[0].message + + +async def test_thread_unrepresented_is_quiet_once_the_minor_circle_is_drawn(backend): + await ensure_engineering_layers(backend) + await _anchor( + backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": THREADED} + ) + await backend.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY") + await backend.entity_create_arc(0.0, 0.0, 8.0, 0.0, 270.0, layer="GEOMETRY") + assert _focus(await build_index(backend), "mech_thread_unrepresented") == [] + + +BOLT = { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", +} + + +async def test_bom_balloon_mismatch_fires_on_a_balloon_pointing_at_nothing(backend): + await ensure_engineering_layers(backend) + await _anchor(backend, BOLT, 0.0, 0.0) + await _anchor( + backend, {"v": 1, "kind": "balloon", "item": 1, "targets": ["DEAD"]}, 40.0, 40.0 + ) + issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch") + assert any("not a part on this sheet" in issue.message for issue in issues) + + +async def test_bom_balloon_mismatch_fires_on_a_part_with_no_balloon(backend): + await ensure_engineering_layers(backend) + bolt = await _anchor(backend, BOLT, 0.0, 0.0) + await _anchor(backend, {**BOLT, "size": "M16x60", "designation": "ISO 4014 - M16x60"}, 60.0, 0.0) + await _anchor( + backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [bolt]}, 40.0, 40.0 + ) + issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch") + assert any("has no balloon" in issue.message for issue in issues) + + +async def test_bom_balloon_mismatch_fires_when_the_declared_quantity_disagrees(backend): + await ensure_engineering_layers(backend) + first = await _anchor(backend, {**BOLT, "qty": 4}, 0.0, 0.0) + await _anchor( + backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [first]}, 40.0, 40.0 + ) + issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch") + assert any("declares 4" in issue.message for issue in issues) + + +async def test_bom_balloon_mismatch_is_quiet_on_a_matched_sheet(backend): + await ensure_engineering_layers(backend) + bolt = await _anchor(backend, BOLT, 0.0, 0.0) + await _anchor( + backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [bolt]}, 40.0, 40.0 + ) + assert _focus(await build_index(backend), "mech_bom_balloon_mismatch") == [] + + +async def test_the_dispatch_entries_share_one_index_per_run(backend): + """Six focuses must cost one read of the drawing, like the P&ID ones.""" + from engineering.mech.critique import MECH_DISPATCH + + await ensure_engineering_layers(backend) + await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}) + shared: dict = {} + for focus in MECH_FOCUSES: + check = MECH_DISPATCH[focus] + assert check.needs_shared is True + await check(backend, shared) + assert list(shared) == ["mech_index"] +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_mech_critique.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.mech.critique'`. + +- [ ] **Step 3: Write `engineering/mech/critique.py`** + +```python +"""The six mechanical critique focuses (spec §13). + +Every check is pure over one index built once per critique run and shared +through ``run_critique``'s context, so six focuses cost one read of the +drawing. A drawing with no ``ACADMCP_MECH`` payload on it yields no issues at +all: a P&ID sheet or a plain DXF must not be scored against mechanical +conventions, and ``focus=None`` has to stay cheap for them. + +What the index reads, and why only that: every mechanical payload this server +writes lands on an INSERT (a view anchor, a standard part) or on a CIRCLE (a +balloon is a circle with a leader), so XDATA is read for those two types only. +The rest of the drawing comes from one ``entity_list``, whose ``EntityInfo`` +already carries each entity's WCS ``bounding_box`` on both engines. + +Two narrowings are deliberate, and neither is hidden: + +* ``mech_view_misaligned`` checks the projection *axis*, not the quadrant. The + pinned part payload records the view kind and the part id — not the + projection angle, and not which side a side view was taken from — so the + quadrant rule has nothing true to read. Guessing it would report a correct + drawing as wrong, or the reverse. +* ``mech_duplicate_dimension`` reports two dimensions of the same type that + occupy the same place: the failure a second run of ``dimension_auto``, or a + manual dimension over an automatic one, produces. A semantic restatement (a + chain that also carries the overall length) is not detected, because no + engine exposes a dimension's measured feature. + +Where a box has to be guessed at all — the region of a view — it is guessed +*generously*. A check that misses a bad drawing costs one review; a check that +calls a correct drawing bad gets switched off. +""" + +from __future__ import annotations + +import math +import re +from typing import TYPE_CHECKING + +from engineering.plan_spec import Issue + +from .primitives import ROLE_LAYER +from .xdata import APP_ID, decode + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +MECH_FOCUSES = ( + "mech_missing_centreline", + "mech_unhatched_section", + "mech_view_misaligned", + "mech_duplicate_dimension", + "mech_thread_unrepresented", + "mech_bom_balloon_mismatch", +) +_KEY = "mech_index" + +#: The entity types that can carry an ACADMCP_MECH payload. +_XDATA_TYPES = ("INSERT", "CIRCLE") +#: Half a millimetre is visible on a printed sheet. +ALIGN_TOL_MM = 0.5 +#: How close a CENTER line must pass to a circle's centre to be its mark. +CENTRE_TOL_MM = 0.5 +#: ISO 6410 draws a thread's minor diameter at 0.8 x the nominal. +THREAD_MINOR_RATIO = 0.8 +#: Relative tolerance when matching drawn geometry against a computed diameter. +DIAMETER_TOL = 0.02 +#: Padding added to a view's computed extents before looking inside it. +VIEW_PAD_MM = 10.0 + +_DIM_TYPES = { + "DIMENSION", + "DIMLINEAR", + "DIMALIGNED", + "DIMANGULAR", + "DIMRADIUS", + "DIMDIAMETER", + "DIMORDINATE", +} +_METRIC_THREAD = re.compile(r"^M\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE) + + +# ── the index ─────────────────────────────────────────────────────────────── + + +def _anchor_point(ent) -> tuple[float, float] | None: + props = getattr(ent, "properties", None) or {} + for key in ("insertion", "center"): + value = props.get(key) + if value and len(value) >= 2: + return (float(value[0]), float(value[1])) + box = props.get("bounding_box") + if box: + return ( + (float(box["min"][0]) + float(box["max"][0])) / 2.0, + (float(box["min"][1]) + float(box["max"][1])) / 2.0, + ) + return None + + +def _bbox_center(ent) -> tuple[float, float] | None: + box = (getattr(ent, "properties", None) or {}).get("bounding_box") + if not box: + return None + return ( + (float(box["min"][0]) + float(box["max"][0])) / 2.0, + (float(box["min"][1]) + float(box["max"][1])) / 2.0, + ) + + +async def build_index(backend: AutoCADBackend) -> dict: + """One read of the drawing: every entity, plus every ACADMCP_MECH payload.""" + try: + entities = await backend.entity_list(limit=10000) + except Exception: + entities = [] + records: list[dict] = [] + for ent in entities: + if str(ent.type).upper() not in _XDATA_TYPES: + continue + try: + raw = await backend.entity_get_xdata(ent.handle, APP_ID) + except Exception: + continue + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + continue + try: + payload = decode(values) + except ValueError: + # A foreign or corrupt payload is not mechanical content; the codec + # already refused it, and guessing at it here would undo that. + continue + at = _anchor_point(ent) + if at is None: + continue + records.append({"handle": ent.handle, "payload": payload, "at": at}) + return {"entities": entities, "records": records} + + +def has_mech_content(index: dict) -> bool: + """True when at least one entity carries an ACADMCP_MECH payload.""" + return bool(index.get("records")) + + +def _of_kind(index: dict, kind: str) -> list[dict]: + return [r for r in index["records"] if r["payload"].get("kind") == kind] + + +def _view_kind(record: dict) -> str: + return str(record["payload"].get("view") or "").split(":")[0].strip().lower() + + +def _extents(part: dict) -> tuple[float, float] | None: + """``(length, height)`` of a part model, read defensively. + + The pinned ``Segment`` is a NamedTuple ``(length, d_outer, d_inner, + taper_to)``, so a serialised segment is either a mapping or that sequence. + Both are read rather than assuming which one ``part_to_dict`` chose. + """ + if not isinstance(part, dict): + return None + segments = part.get("segments") + if segments: + length = 0.0 + dmax = 0.0 + for seg in segments: + if isinstance(seg, dict): + run = float(seg.get("length", 0.0) or 0.0) + outer = float(seg.get("d_outer", 0.0) or 0.0) + taper = seg.get("taper_to") + else: + row = list(seg) + run = float(row[0]) if row else 0.0 + outer = float(row[1]) if len(row) > 1 else 0.0 + taper = row[3] if len(row) > 3 else None + length += run + dmax = max(dmax, outer, float(taper) if taper else 0.0) + return (length, dmax) + outline = part.get("outline") + if outline: + xs = [float(p[0]) for p in outline] + ys = [float(p[1]) for p in outline] + return (max(xs) - min(xs), max(ys) - min(ys)) + return None + + +def _view_box(record: dict) -> tuple[float, float, float, float] | None: + """A deliberately generous box around a view's anchor. + + The anchor may be the view's origin or its centre depending on how the + view was placed, so the box spans the part's full extents on *both* sides + plus a pad. It answers only "is there any hatch near this section", which + is the question that matters. + """ + extents = _extents(record["payload"].get("part") or {}) + if extents is None: + return None + x, y = record["at"] + half_w = extents[0] + VIEW_PAD_MM + half_h = max(extents[1], extents[0]) + VIEW_PAD_MM + return (x - half_w, y - half_h, x + half_w, y + half_h) + + +def _inside(point, box) -> bool: + if point is None or box is None: + return False + return box[0] <= point[0] <= box[2] and box[1] <= point[1] <= box[3] + + +def _distance_to_segment(point, start, end) -> float: + if not start or not end: + return math.inf + px, py = point + ax, ay = float(start[0]), float(start[1]) + bx, by = float(end[0]), float(end[1]) + dx, dy = bx - ax, by - ay + length_sq = dx * dx + dy * dy + if length_sq <= 0.0: + return math.hypot(px - ax, py - ay) + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length_sq)) + return math.hypot(px - (ax + t * dx), py - (ay + t * dy)) + + +# ── the six checks ────────────────────────────────────────────────────────── + + +def _missing_centreline(index: dict) -> list[Issue]: + """ISO 128-23: a circular feature carries a centre mark.""" + geometry = {ROLE_LAYER["visible"].upper(), ROLE_LAYER["hidden"].upper()} + centre_layer = ROLE_LAYER["center"].upper() + marks = [ + e + for e in index["entities"] + if str(e.type).upper() == "LINE" and str(e.layer).upper() == centre_layer + ] + out: list[Issue] = [] + for ent in index["entities"]: + if str(ent.type).upper() != "CIRCLE" or str(ent.layer).upper() not in geometry: + continue + centre = (ent.properties or {}).get("center") + if not centre: + continue + point = (float(centre[0]), float(centre[1])) + crossing = [ + m + for m in marks + if _distance_to_segment( + point, (m.properties or {}).get("start"), (m.properties or {}).get("end") + ) + <= CENTRE_TOL_MM + ] + if len(crossing) < 2: + out.append( + Issue( + "warning", + "mech_missing_centreline", + f"Circle {ent.handle} at ({point[0]:.3f}, {point[1]:.3f}) has no ISO " + f"128-23 centre mark ({len(crossing)} of the two crossing " + f"{centre_layer} lines found).", + [ent.handle], + {"hint": "centre_marks(handles=[...]) draws them", "found": len(crossing)}, + ) + ) + return out + + +def _unhatched_section(index: dict) -> list[Issue]: + hatches = [e for e in index["entities"] if str(e.type).upper() == "HATCH"] + out: list[Issue] = [] + for record in _of_kind(index, "part"): + if _view_kind(record) != "section": + continue + box = _view_box(record) + if any(_inside(_bbox_center(h), box) for h in hatches): + continue + part_id = record["payload"].get("id") + out.append( + Issue( + "error", + "mech_unhatched_section", + f"Section view {record['handle']} of part {part_id!r} has no hatched cut " + "face; ISO 128-50 hatches every cut surface.", + [record["handle"]], + {"hint": "hatch_material(handles=[...], material=...)", "hatches": len(hatches)}, + ) + ) + return out + + +def _view_misaligned(index: dict) -> list[Issue]: + out: list[Issue] = [] + by_part: dict[str, list[dict]] = {} + for record in _of_kind(index, "part"): + by_part.setdefault(str(record["payload"].get("id") or ""), []).append(record) + for part_id, records in sorted(by_part.items()): + fronts = [r for r in records if _view_kind(r) == "front"] + if not fronts: + continue + front = fronts[0] + fx, fy = front["at"] + for record in records: + kind = _view_kind(record) + x, y = record["at"] + if kind in ("side", "section"): + delta = abs(y - fy) + axis = "y" + elif kind == "top": + delta = abs(x - fx) + axis = "x" + else: + continue + if delta <= ALIGN_TOL_MM: + continue + out.append( + Issue( + "warning", + "mech_view_misaligned", + f"The {kind} view {record['handle']} of part {part_id!r} sits " + f"{delta:.3f} mm off the projection axis of its front view " + f"{front['handle']}.", + [record["handle"], front["handle"]], + { + "axis": axis, + "delta_mm": round(delta, 4), + "tolerance_mm": ALIGN_TOL_MM, + }, + ) + ) + return out + + +def _duplicate_dimension(index: dict) -> list[Issue]: + seen: dict[tuple, str] = {} + out: list[Issue] = [] + for ent in index["entities"]: + if str(ent.type).upper() not in _DIM_TYPES: + continue + props = ent.properties or {} + box = props.get("bounding_box") + if not box: + continue + defpoint = props.get("defpoint") + key = ( + str(ent.type).upper(), + tuple( + round(float(v), 3) + for v in (box["min"][0], box["min"][1], box["max"][0], box["max"][1]) + ), + tuple(round(float(v), 3) for v in defpoint[:2]) if defpoint else None, + ) + if key in seen: + out.append( + Issue( + "warning", + "mech_duplicate_dimension", + f"Dimensions {seen[key]} and {ent.handle} occupy the same place and " + "measure the same feature; ISO 129-1 gives each feature one dimension.", + [seen[key], ent.handle], + {"hint": "delete one, or dimension the feature in a single view"}, + ) + ) + else: + seen[key] = ent.handle + return out + + +def _thread_diameter(feature: dict) -> float | None: + for key in ("d", "diameter", "nominal", "nominal_mm"): + value = feature.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + match = _METRIC_THREAD.match(str(feature.get("designation") or "")) + return float(match.group(1)) if match else None + + +def _has_diameter(index: dict, target: float) -> bool: + """Is anything on the sheet drawn at this diameter? + + A round feature answers as a CIRCLE or ARC of half the diameter; the axial + view answers as two horizontal lines that far apart. + """ + tol = max(DIAMETER_TOL * target, 1e-6) + horizontals: list[float] = [] + for ent in index["entities"]: + kind = str(ent.type).upper() + props = ent.properties or {} + if kind in ("CIRCLE", "ARC"): + radius = props.get("radius") + if radius is not None and abs(2.0 * float(radius) - target) <= tol: + return True + elif kind == "LINE": + start, end = props.get("start"), props.get("end") + if start and end and abs(float(start[1]) - float(end[1])) <= 1e-6: + horizontals.append(float(start[1])) + for i, y1 in enumerate(horizontals[:500]): + for y2 in horizontals[i + 1 : 500]: + if abs(abs(y1 - y2) - target) <= tol: + return True + return False + + +def _thread_unrepresented(index: dict) -> list[Issue]: + out: list[Issue] = [] + for record in _of_kind(index, "part"): + part = record["payload"].get("part") or {} + for feature in part.get("features") or (): + if not isinstance(feature, dict): + continue + if not str(feature.get("kind") or "").lower().startswith("thread"): + continue + nominal = _thread_diameter(feature) + if nominal is None: + continue + minor = THREAD_MINOR_RATIO * nominal + if _has_diameter(index, minor): + continue + name = feature.get("id") or feature.get("designation") or "thread" + out.append( + Issue( + "error", + "mech_thread_unrepresented", + f"Thread {name!r} on part {record['payload'].get('id')!r} is drawn " + f"without its ISO 6410 minor diameter ({minor:.3f} mm = 0.8 x " + f"{nominal:.3f} mm).", + [record["handle"]], + {"nominal_mm": round(nominal, 4), "minor_mm": round(minor, 4)}, + ) + ) + return out + + +def _bom_balloon_mismatch(index: dict) -> list[Issue]: + out: list[Issue] = [] + balloons = _of_kind(index, "balloon") + parts = _of_kind(index, "std_part") + if not balloons and not parts: + return out + part_handles = {r["handle"] for r in parts} | {r["handle"] for r in _of_kind(index, "part")} + balloon_targets: set[str] = set() + by_item: dict[str, list[dict]] = {} + for record in balloons: + item = record["payload"].get("item") + targets = [str(t) for t in (record["payload"].get("targets") or [])] + by_item.setdefault(str(item), []).append(record) + resolved = [t for t in targets if t in part_handles] + balloon_targets.update(resolved) + if not resolved: + out.append( + Issue( + "error", + "mech_bom_balloon_mismatch", + f"Balloon {item!r} ({record['handle']}) points at " + f"{targets or 'nothing'}, which is not a part on this sheet.", + [record["handle"]], + {"targets": targets}, + ) + ) + for item, records in sorted(by_item.items()): + if len(records) > 1: + out.append( + Issue( + "warning", + "mech_bom_balloon_mismatch", + f"Item number {item} is carried by {len(records)} balloons; ISO 6433 " + "gives one item reference per parts-list row.", + [r["handle"] for r in records], + {"item": item, "count": len(records)}, + ) + ) + if balloons: + for record in parts: + if record["handle"] in balloon_targets: + continue + out.append( + Issue( + "warning", + "mech_bom_balloon_mismatch", + f"{record['payload'].get('designation')} ({record['handle']}) has no " + "balloon; ISO 7573 gives every parts-list row an item reference.", + [record["handle"]], + {"designation": record["payload"].get("designation")}, + ) + ) + inserted: dict[str, int] = {} + declared: dict[str, int] = {} + for record in parts: + designation = str(record["payload"].get("designation")) + inserted[designation] = inserted.get(designation, 0) + 1 + declared[designation] = declared.get(designation, 0) + int( + record["payload"].get("qty") or 1 + ) + for designation, count in sorted(inserted.items()): + if declared[designation] != count: + out.append( + Issue( + "warning", + "mech_bom_balloon_mismatch", + f"{designation} is inserted {count} time(s) but its XDATA declares " + f"{declared[designation]}.", + [r["handle"] for r in parts if r["payload"].get("designation") == designation], + {"inserted": count, "declared": declared[designation]}, + ) + ) + return out + + +_CHECKS = { + "mech_missing_centreline": _missing_centreline, + "mech_unhatched_section": _unhatched_section, + "mech_view_misaligned": _view_misaligned, + "mech_duplicate_dimension": _duplicate_dimension, + "mech_thread_unrepresented": _thread_unrepresented, + "mech_bom_balloon_mismatch": _bom_balloon_mismatch, +} + + +def issues_for(focus: str, index: dict) -> list[Issue]: + if not has_mech_content(index): + return [] + return _CHECKS[focus](index) + + +def _make(focus: str): + async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]: + index = shared.get(_KEY) + if index is None: + index = shared[_KEY] = await build_index(backend) + return issues_for(focus, index) + + check.needs_shared = True # type: ignore[attr-defined] + check.__name__ = f"check_{focus}" + return check + + +MECH_DISPATCH = {focus: _make(focus) for focus in MECH_FOCUSES} +``` + +- [ ] **Step 4: Register the six focuses in the closed enum and the dispatch** + +In `engineering/plan_spec.py`, extend the `CritiqueFocus` Literal — after `"pid_unconnected_equipment",` inside the Literal add: + +```python + "mech_missing_centreline", + "mech_unhatched_section", + "mech_view_misaligned", + "mech_duplicate_dimension", + "mech_thread_unrepresented", + "mech_bom_balloon_mismatch", +``` + +and the same six names, in the same order, after `"pid_unconnected_equipment",` in `ALL_CRITIQUE_FOCUSES`. + +In `engineering/critique.py`, directly under the existing P&ID registration: + +```python +from .pid.critique import PID_DISPATCH # noqa: E402 (registered after the mechanical focuses) + +_FOCUS_DISPATCH.update(PID_DISPATCH) + +from .mech.critique import MECH_DISPATCH # noqa: E402 (same reason: after the base focuses) + +_FOCUS_DISPATCH.update(MECH_DISPATCH) +``` + +- [ ] **Step 5: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_mech_critique.py tests/test_pid_critique.py tests/test_meta_tools.py -q` +Expected: all pass. `tests/test_pid_critique.py` and `tests/test_meta_tools.py` are in the list because `focus=None` now runs six more checks: if any of them raised or reported on a P&ID sheet, it would show here. + +- [ ] **Step 6: Add the `mech` pack and the five lean names in `server.py`** + +Replace `TOOL_PACK_NAMES = ("core", "pid", "settings")` with: + +```python +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech") +``` + +and add to `PACK_TOOL_NAMES`, after the `"settings"` entry: + +```python + # SECTIONS 21-23 — the mechanical part drawer, the standard-parts catalogue + # and the ISO annotation symbols (spec §11). SECTION 24 (the sheet, the + # parts list, xrefs and DWG) stays in `core`: a frame, a title block, a + # parts list and an xref are universal drafting, not a vertical. + "mech": frozenset( + { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", + "std_part_list", + "std_part_insert", + "std_feature_draw", + "surface_texture", + "weld_symbol", + "centre_marks", + "section_line", + "hatch_material", + } + ), +``` + +and add to `LEAN_TOOL_NAMES`, before the closing brace: + +```python + # Mechanical + sheet (tracks B/G) — the five a lean client needs to + # draw a part and put it on a sheet. The catalogue, the annotation + # symbols and the delivery tools stay full-profile: a lean client + # draws the part and leaves the paperwork to a full one. The first + # three are `mech`-pack tools, so TOOL_PACKS=core hides them from a + # lean surface too; sheet_frame and titleblock_apply are core. + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "sheet_frame", + "titleblock_apply", +``` + +- [ ] **Step 7: Give the two new sections their own groups in `server.py`** + +In `_GROUP_TAG_PRIORITY`, insert `"mech"` and `"sheet"` directly after `"pid"` (so a mechanical tool that also carries `create` or `dimension` files under its own section, the way the track-E tags work): + +```python +_GROUP_TAG_PRIORITY = ( + "pid", + "mech", + "sheet", + "style", +``` + +— that is, the two new tags are inserted between `"pid"` and `"style"`; every other entry of the tuple keeps its position. + +and in `_GROUP_TAG_LABELS`, after the `"pid"` entry: + +```python + "mech": "mechanical", + "sheet": "sheet", +``` + +- [ ] **Step 8: Name the six focuses where the server describes the focus list** + +In `drawing_critique`'s `focus` Field description, replace the closing sentence so it reads: + +```python + description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, " + "duplicate_entities, construction_left, gdt, the P&ID focuses " + "pid_dangling_line, pid_duplicate_tag, pid_incompatible_connection, " + "pid_untagged_instrument, pid_illegal_tag, pid_unconnected_equipment " + "(silent on a drawing with no P&ID symbols), and the mechanical focuses " + "mech_missing_centreline, mech_unhatched_section, mech_view_misaligned, " + "mech_duplicate_dimension, mech_thread_unrepresented, " + "mech_bom_balloon_mismatch (silent on a drawing with no mechanical part). " + "None = run all.", +``` + +and in `drawing_finalize`'s docstring, replace `pid_unconnected_equipment — silent on a sheet with no P&ID symbols),` with: + +``` + pid_unconnected_equipment — silent on a sheet with no P&ID symbols, plus the + mechanical focuses mech_missing_centreline, mech_unhatched_section, + mech_view_misaligned, mech_duplicate_dimension, mech_thread_unrepresented, + mech_bom_balloon_mismatch — silent on a sheet with no mechanical part), +``` + +- [ ] **Step 9: Update the pack test to the new surface** + +In `tests/test_tool_packs.py`: + +- add, after `SETTINGS_TOOLS`: + +```python +MECH_TOOLS = { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", + "std_part_list", + "std_part_insert", + "std_feature_draw", + "surface_texture", + "weld_symbol", + "centre_marks", + "section_line", + "hatch_material", +} + +SHEET_TOOLS = { + "sheet_frame", + "titleblock_apply", + "revision_add", + "bom_extract", + "bom_table", + "balloon_add", + "data_extract", + "xref_attach", + "xref_manage", + "image_attach", + "drawing_export_dwg", +} + +LEAN_MECH_ESSENTIALS = { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "sheet_frame", + "titleblock_apply", +} +``` + +- change `ALL_PACKS = ["core", "pid", "settings"]` to `ALL_PACKS = ["core", "pid", "settings", "mech"]`; +- in `test_pack_registry_names_real_tools_and_only_them`, change the tuple assertion to `("core", "pid", "settings", "mech")` and add: + +```python + assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS) + assert len(MECH_TOOLS) == 14 + assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"]) + assert not (SHEET_TOOLS & set().union(*server.PACK_TOOL_NAMES.values())), ( + "SECTION 24 is core drafting, not a vertical pack" + ) +``` + +- in `test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup`, add `assert MECH_TOOLS <= disabled` and `assert not (SHEET_TOOLS & disabled)`; +- in `test_all_is_the_default_and_enables_every_pack`, change the last assertion to `assert info["tool_packs"]["enabled"] == sorted(ALL_PACKS)` and add `assert not (MECH_TOOLS & disabled)`; +- in `test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack`, change the docstring's `lean = 55` to `lean = 60`, change `len(server.LEAN_TOOL_NAMES) == 55` to `== 60`, change the final assertion to `assert info["enabled_count"] == 60 - 6, "lean minus the three pid and three mech tools"`, and add `assert LEAN_MECH_ESSENTIALS <= server.LEAN_TOOL_NAMES`; +- in `test_the_three_track_e_sections_file_under_their_own_groups`, change `assert len(groups) == 23` to `assert len(groups) == 25` and add: + +```python + assert set(groups["mechanical"]) == MECH_TOOLS + assert set(groups["sheet"]) == SHEET_TOOLS +``` + +- [ ] **Step 10: Run the pack and profile gates** + +Run: `uv run --frozen pytest tests/test_tool_packs.py tests/test_tool_profiles.py -q` +Expected: all pass. If `enabled_count` is not `60 - 6`, print the real number with `uv run --frozen python -c "import asyncio, config, server; config.settings.tool_packs='core'; print(asyncio.run(server._apply_tool_profile('lean'))['enabled_count'])"` and write the measured number with a comment naming which tools the packs removed — the count is data, not a target. + +- [ ] **Step 11: Add the ten golden tuning cases** + +In `tests/data/discovery_golden.py`, append to `TUNING_CASES`, before the closing `)`: + +```python + # v1.6 tracks B + G — the mechanical part drawer and the sheet standard. + # Written before the merged alias records were measured against them; a + # miss here is fixed by adding vocabulary to that tool's record in + # discovery/aliases.py, never by moving the expectation. Two Turkish + # queries are in deliberately: this shop searches in Turkish, and the + # alias corpus is the only place that vocabulary can live. + GoldenCase("XATTACH", "xref_attach", "command"), + GoldenCase("IMAGEATTACH", "image_attach", "command"), + GoldenCase("draw a stepped shaft from a segment list", "mech_part_draw", "paraphrase"), + GoldenCase("add a section view of the part", "mech_view_add", "paraphrase"), + GoldenCase("kaynak sembolü", "weld_symbol", "synonym"), + GoldenCase("yüzey pürüzlülüğü", "surface_texture", "synonym"), + GoldenCase("iso 5457 sheet frame", "sheet_frame", "synonym"), + GoldenCase("put an m12 hex bolt on the drawing", "std_part_insert", "paraphrase"), + GoldenCase("draw the parts list table on the sheet", "bom_table", "paraphrase"), + GoldenCase("save this drawing as a dwg", "drawing_export_dwg", "paraphrase"), +``` + +and update the module docstring's first line from `Eighty-one cases (66 tuning + 15 holdout)` to `Ninety-one cases (76 tuning + 15 holdout)`. + +- [ ] **Step 12: Make each golden query reachable, then measure** + +The corpus is what BM25 indexes, so every query above must have its distinctive phrase in its tool's record. Check and close the gap mechanically: + +``` +uv run --frozen python -c " +from discovery.aliases import TOOL_ALIASES +from tests.data.discovery_golden import TUNING_CASES +new = [c for c in TUNING_CASES if c.expect in { + 'xref_attach','image_attach','mech_part_draw','mech_view_add','weld_symbol', + 'surface_texture','sheet_frame','std_part_insert','bom_table','drawing_export_dwg'}] +for c in new: + rec = TOOL_ALIASES.get(c.expect) + text = ' '.join((*(rec.acad if rec else ()), *(rec.synonyms if rec else ()))).lower() + missing = [w for w in c.query.lower().split() if len(w) > 3 and w not in text] + print(c.expect, c.query, '->', 'OK' if not missing else f'add: {missing}') +" +``` + +For every `add:` row, append the whole query phrase (lowercase, trimmed) to that tool's `synonyms` tuple in `discovery/aliases.py`. Then measure: + +Run: `uv run --frozen pytest tests/test_discovery_transform.py -q` +Expected: every `test_a_golden_case_resolves` parameter passes and the tuning/holdout bars hold. A case that still misses is fixed with more vocabulary, never by editing the expectation. + +- [ ] **Step 13: Resolve the AutoCAD commands the merged records now share** + +The four branches each wrote their own alias records, so some commands are now claimed by two tools (`TABLE` by `entity_create_table` and `bom_table`, `REVCLOUD` by `entity_create_revcloud` and `revision_add`, `HATCH`/`BHATCH` by `entity_create_hatch` and `hatch_material`). Compute the real list rather than assuming it: + +``` +uv run --frozen python -c " +from discovery.aliases import TOOL_ALIASES, SHARED_ACAD_COMMANDS +own = {} +for tool, rec in TOOL_ALIASES.items(): + for cmd in rec.acad: own.setdefault(cmd, []).append(tool) +shared = {c: sorted(n) for c, n in own.items() if len(n) > 1} +print('now shared:', sorted(shared)) +print('to add :', sorted(set(shared) - SHARED_ACAD_COMMANDS)) +print('stale :', sorted(SHARED_ACAD_COMMANDS - set(shared))) +" +``` + +Add every name in `to add` to `SHARED_ACAD_COMMANDS` in alphabetical position, each with a one-line reason in the comment block above the set — for the three expected ones: + +``` +# HATCH, BHATCH -- one command fills a boundary with a named pattern +# (entity_create_hatch) or with the ISO 128-50 pattern for a material +# (hatch_material); a drafter typing it could mean either. +# REVCLOUD -- a bare revision cloud (entity_create_revcloud) or the clouds a +# revision row draws over the regions it changed (revision_add). +# TABLE -- a free TABLE entity (entity_create_table) or the ISO 7573 parts +# list (bom_table), which is a TABLE with a standard's columns. +``` + +If `stale` is non-empty, a command stopped being shared and must come *out* of the set — `test_shared_command_allowlist_has_no_stale_entries` fails otherwise. + +Run: `uv run --frozen pytest tests/test_discovery_aliases.py -q` +Expected: all pass, including `test_every_registered_tool_has_an_alias_record` for all 229 tools. + +- [ ] **Step 14: Regenerate the inventory and retake the frozen registry snapshot** + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen python -c " +import asyncio, server +sizes = {k: len(v) for k, v in asyncio.run(server._tool_groups()).items()} +print(sizes); print('total', sum(sizes.values())) +" +``` + +Expected: 229 tools in 25 groups, with `mechanical: 14` and `sheet: 11` joining and every other number unchanged. Write the printed dict into `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`, change `assert sum(sizes.values()) == 204` to `== 229`, change the docstring's opening line to `Frozen snapshot of the surface (229 tools, 25 groups).` and append to that docstring: + +``` + Two groups appeared with v1.6's tracks B and G — `mechanical` (14, + SECTIONS 21-23: the part drawer, the standard-parts catalogue and the ISO + annotation symbols) and `sheet` (11, SECTION 24: frames, title blocks, + revisions, the parts list, balloons, xrefs, images and DWG). Both tags are + ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so a part tool that + also carries `create` and an annotation tool that also carries `dimension` + file under their own section. `mechanical` is exactly + `PACK_TOOL_NAMES["mech"]`; `sheet` is core-pack, because a frame, a title + block, a parts list and an xref are universal drafting. +``` + +- [ ] **Step 15: Retake the README figures the tree derives** + +``` +uv run --frozen python scripts/check_doc_numbers.py --list +``` + +Update `README.md` from the printed values: the `> **v1.5 release snapshot:**` line (tools and collected tests), and the `` line under "What you get" — `204 tools in 23 groups` becomes `229 tools in 25 groups`, and the pack sentence becomes: + +``` +229 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. +``` + +Then run `uv run --frozen python scripts/check_doc_numbers.py` again and expect exit 0: this task changes no benchmark figure, so the A/B rows (`correctness_suite.py` still holds the 32 checks the published report was run over) stay clean, and the only numbers that moved are the two just rewritten. Any remaining `DRIFT` line names the file and the figure; fix that figure, never the checker. + +- [ ] **Step 16: Gates** + +``` +uv run --frozen ruff format engineering/mech/critique.py engineering/critique.py engineering/plan_spec.py server.py discovery/aliases.py tests/test_mech_critique.py tests/test_tool_packs.py tests/test_tool_registry.py tests/data/discovery_golden.py +uv run --frozen ruff check engineering server.py discovery tests +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest -q +``` + +Expected: ruff clean; the inventory check reports no drift; the whole suite green including `tests/test_release_consistency.py` in full — the collected-tests figure was retaken in Step 15, so the test deselected in every worktree task passes again here. + +- [ ] **Step 17: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(mech): six mech_* critique focuses, the mech pack, lean 60, aliases and the counts + +The four track-B/G branches are merged; this is the integration that makes the +25 new tools one surface instead of four. + +- engineering/mech/critique.py: the six focuses of spec 13, dispatched the way + the P&ID ones are. One index per critique run, shared through run_critique's + context, so six focuses cost one read of the drawing; a drawing with no + ACADMCP_MECH payload yields nothing at all, which is what keeps focus=None + cheap for a P&ID sheet or a plain DXF. Each focus has a positive and a + negative fixture. + Two narrowings are in the module docstring rather than hidden: + mech_view_misaligned checks the projection axis and not the quadrant, + because the pinned payload carries no projection angle to check it against, + and mech_duplicate_dimension catches two dimensions in the same place rather + than a semantic restatement, because no engine exposes what a dimension + measured. +- TOOL_PACK_NAMES gains "mech" (the 14 tools of SECTIONS 21-23). SECTION 24 + stays core: a frame, a title block, a parts list and an xref are universal + drafting, not a vertical. Lean gains five and reaches 60. +- _GROUP_TAG_PRIORITY gains mech and sheet directly after pid, so the two new + sections file under their own groups instead of scattering across + entity_creation and dimensions. 229 tools in 25 groups. +- Ten golden tuning cases, two of them Turkish, and the AutoCAD commands the + merged alias records now genuinely share (HATCH/BHATCH, REVCLOUD, TABLE) + moved into SHARED_ACAD_COMMANDS with a reason each. +- Inventory regenerated, the frozen group-size snapshot retaken from the live + registry, and the README release-snapshot figures taken from + scripts/check_doc_numbers.py rather than typed. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 16: Evidence — benchmark mech_assembly, six correctness checks, the live COM smoke, docs and the full gate + +**Files:** +- Create: `benchmarks/tasks_v5.py` +- Create: `tests/test_benchmark_v5.py` +- Create: `scripts/smoke_mech_com.py` +- Modify: `benchmarks/adapters/autocad_mcp_pro.py` (import from `tasks_v5`, add `_task_mech_assembly`) +- Modify: `benchmarks/correctness_suite.py` (32 → 39 checks: the spec's six plus `mech_thread_unrepresented_is_caught`, argued in Step 6) +- Modify: `benchmarks/token_suite.py` (a `TOOL_PACKS=core,mech` idle row) +- Modify: `scripts/check_doc_numbers.py` (derive from `TASKS_V5`) +- Modify: `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json` (regenerated over 39 checks) +- Modify: `CLAUDE.md`, `README.md`, `benchmarks/README.md`, `CHANGELOG.md` +- Modify: `docs/superpowers/specs/2026-09-22-v1.6-mech-design.md` (the status line) +- Test: `tests/test_benchmark_v5.py` + +**Interfaces:** +- Consumes: `benchmarks/tasks_v4.py::TASKS_V4, TaskSpec, MATRICES`; `benchmarks/adapters/base.py::BenchmarkAdapter, TaskResult`; `engineering/scoring.py::combine(*severity_dicts) -> dict`; `engineering/validator.py::DrawingValidator().run(backend, expected=None) -> ValidationResult` with `.summary`; `engineering/critique.py::run_critique(backend, focus) -> list[Issue]`; `engineering/layers.py::apply_layer_set(backend, "mech")`; `engineering/fits.py::fit_lookup(code, nominal_mm) -> FitDeviation`; and, from the merged branches, the pinned `engineering/mech/{draw,part,stdparts,marks,dimension,views,primitives,xdata}` and `engineering/sheet/{frames,titleblock,bom}` signatures. +- Produces: `benchmarks/tasks_v5.py::NEW_TASKS_V5, TASKS_V5, MATRICES, DEFAULT_MATRIX = "v5", task_by_id(task_id, matrix=DEFAULT_MATRIX)`; `AutoCADMCPProAdapter._task_mech_assembly()`; the seven new `CHECKS` entries in `benchmarks/correctness_suite.py` (`mech_part_roundtrip`, `mech_section_hatch_area`, `mech_iso286_on_dimension`, `mech_thread_unrepresented_is_caught`, `std_part_iso4014_m12`, `sheet_frame_iso5457_a3`, `bom_balloon_link`); `scripts/smoke_mech_com.py::main(keep_document=False) -> int`. + +- [ ] **Step 1: Write the failing test for the v5 matrix and the six checks** + +Create `tests/test_benchmark_v5.py`: + +```python +# tests/test_benchmark_v5.py +"""The v5 task matrix: v4 plus what tracks B and G added. + +``mech_assembly`` is roadmap criterion 3 — a flange-coupling sheet built from +the tools, gated on two numbers that cannot be negotiated: +``drawing_critique(focus=None)`` returns zero issues and ``drawing_finalize`` +scores at least 90. The published competitor reports were never asked it and +the chart shows them ``not_run``, the rule v3 set for its five. +""" + +from __future__ import annotations + +import pytest + +from benchmarks.tasks_v4 import TASKS_V4 +from benchmarks.tasks_v5 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V5, TASKS_V5, task_by_id + + +def test_v5_extends_v4_without_touching_it(): + assert TASKS_V5[: len(TASKS_V4)] == TASKS_V4 + assert [t.task_id for t in NEW_TASKS_V5] == ["mech_assembly"] + assert len(TASKS_V5) == 18 + assert MATRICES["v4"] == TASKS_V4 and MATRICES["v5"] == TASKS_V5 + assert DEFAULT_MATRIX == "v5" + assert task_by_id("mech_assembly").category == "mech" + + +def test_the_adapter_implements_every_v5_task(): + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + for task in TASKS_V5: + assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id + + +def test_the_suite_carries_the_mechanical_and_sheet_checks(): + """Six from spec 14, plus mech_thread_unrepresented_is_caught: a quality + gate that never fires is not a gate, the way dim_overlap_critique_fires + already guards the dimension focus.""" + from benchmarks.correctness_suite import CHECKS + + for name in ( + "mech_part_roundtrip", + "mech_section_hatch_area", + "mech_iso286_on_dimension", + "mech_thread_unrepresented_is_caught", + "std_part_iso4014_m12", + ): + assert CHECKS[name][1] == "Mechanical" + assert CHECKS["sheet_frame_iso5457_a3"][1] == "Sheet" + assert CHECKS["bom_balloon_link"][1] == "Sheet" + assert len(CHECKS) == 39 + + +@pytest.mark.asyncio +async def test_mech_assembly_passes_headlessly(tmp_path): + """The benchmark task itself, run once here so a regression in the part + drawer, the sheet or the critique fails the suite and not only the matrix.""" + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + adapter = AutoCADMCPProAdapter(backend="ezdxf") + await adapter.setup(tmp_path) + try: + await adapter._reset() + passed, metrics, _artifacts = await adapter._task_mech_assembly() + finally: + await adapter.cleanup() + assert passed, metrics + assert metrics["critique_issues"] == 0, metrics["critique_focuses"] + assert metrics["score"] >= 90.0 + + +@pytest.mark.asyncio +async def test_mech_assembly_fails_loudly_when_the_critique_finds_anything(tmp_path, monkeypatch): + """The gate has to be able to fail. One un-marked circle is enough.""" + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + from engineering.mech import marks + + monkeypatch.setattr(marks, "centre_mark_prims", lambda *a, **k: ()) + adapter = AutoCADMCPProAdapter(backend="ezdxf") + await adapter.setup(tmp_path) + try: + await adapter._reset() + passed, metrics, _artifacts = await adapter._task_mech_assembly() + finally: + await adapter.cleanup() + assert passed is False + assert "mech_missing_centreline" in metrics["critique_focuses"] + + +@pytest.mark.asyncio +async def test_the_section_hatch_area_check_measures_the_drawing(): + """1200 mm^2 is computed by hand from the model, and read back through + analysis_measure_entity — a number the drawing code cannot fake.""" + from benchmarks import correctness_suite + + assert await correctness_suite.mech_section_hatch_area() is True + + +@pytest.mark.asyncio +async def test_the_iso5457_frame_check_passes(): + from benchmarks import correctness_suite + + assert await correctness_suite.sheet_frame_iso5457_a3() is True +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_benchmark_v5.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'benchmarks.tasks_v5'`. + +- [ ] **Step 3: Create `benchmarks/tasks_v5.py` and point the adapter at it** + +`benchmarks/tasks_v5.py`: + +```python +"""The v5 task matrix: v4 plus what tracks B and G added. + +``mech_assembly`` (tracks B + G) is roadmap criterion 3: a flange-coupling +sheet built from the tools — two parts with a section, a bolt circle of ISO +4014 bolts and ISO 4032 nuts, ISO 129 dimensions, an ISO 5457 A3 frame, an +ISO 7200 title block and an ISO 7573 parts list with balloons. The gate is two +numbers, not an impression: ``drawing_critique(focus=None)`` returns zero +issues and the finalize score is at least 90. Either one slipping fails the +task outright. + +The published competitor reports were never asked this question; the chart +shows ``not_run`` for them, not zero. +""" + +from __future__ import annotations + +from benchmarks.tasks_v2 import TASKS_V2 +from benchmarks.tasks_v3 import TASKS_V3 +from benchmarks.tasks_v4 import TASKS_V4, TaskSpec + +#: Added in v1.6.0 by tracks B + G. +NEW_TASKS_V5: tuple[TaskSpec, ...] = ( + TaskSpec( + "mech_assembly", + "mech", + "Draw a flange-coupling sheet from the part model and the sheet standard, " + "with a clean critique and a finalize score of at least 90", + 1.0, + ), +) + +TASKS_V5: tuple[TaskSpec, ...] = TASKS_V4 + NEW_TASKS_V5 + +#: Runner-selectable task sets. v2, v3 and v4 stay addressable so an earlier +#: report can be reproduced exactly rather than only described. +MATRICES: dict[str, tuple[TaskSpec, ...]] = { + "v2": TASKS_V2, + "v3": TASKS_V3, + "v4": TASKS_V4, + "v5": TASKS_V5, +} + +DEFAULT_MATRIX = "v5" + + +def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec: + for task in MATRICES[matrix]: + if task.task_id == task_id: + return task + raise KeyError(f"{task_id} is not in matrix {matrix}") +``` + +In `benchmarks/adapters/autocad_mcp_pro.py`, change the import line + +```python +from benchmarks.tasks_v4 import TaskSpec +``` + +to + +```python +from benchmarks.tasks_v5 import TaskSpec +``` + +and change the module docstring's first line to `"""Reference adapter for this repository, covering the v5 task matrix."""`. + +Then check nothing else still points at v4's matrix as the default: + +``` +uv run --frozen grep -rn "tasks_v4" --include=*.py . | grep -v tests/test_benchmark_v4 +``` + +Expected hits: `benchmarks/tasks_v5.py` (deliberate — v5 extends v4) and `benchmarks/run_competitors.py` / `benchmarks/compare_versions.py` if they import the matrix; repoint those to `benchmarks.tasks_v5` and its `DEFAULT_MATRIX`. + +- [ ] **Step 4: Run it to verify the next failure** + +Run: `uv run --frozen pytest tests/test_benchmark_v5.py -q` +Expected: `test_v5_extends_v4_without_touching_it` passes; `test_the_adapter_implements_every_v5_task` FAILS with `AssertionError: mech_assembly`. + +- [ ] **Step 5: Add `_task_mech_assembly` to the adapter** + +Insert into `AutoCADMCPProAdapter`, directly after `_task_page_setup_truth`: + +```python + async def _task_mech_assembly(self): + """A flange-coupling sheet built from the part model and the sheet standard. + + Roadmap criterion 3. Two parts (a hub with a bore and a flange), a full + section through the hub, four standard fasteners with their parts-list + rows and balloons, an ISO 5457 A3 frame and an ISO 7200 title block. + + The gate is two numbers, and both are asserted rather than reported: + ``run_critique(focus=None)`` must return zero issues, and the score the + finalize gate computes — ``combine(validator.summary, critique)`` — must + be at least 90. A regression in either fails the task; the focuses that + fired come back in the metrics so the failure names itself. + """ + from engineering import DrawingValidator + from engineering.critique import run_critique + from engineering.layers import apply_layer_set + from engineering.mech import marks + from engineering.mech.draw import add_view, dimension_part, draw_part, draw_prims + from engineering.mech.part import build_part + from engineering.mech.stdparts import insert_std_part + from engineering.mech.xdata import APP_ID, decode + from engineering.scoring import combine + from engineering.sheet.bom import balloon_prims, rows_from_records, table_prims + from engineering.sheet.frames import frame_prims + from engineering.sheet.titleblock import titleblock_prims + + await apply_layer_set(self.backend, "mech") + await draw_prims(self.backend, frame_prims("A3")) + await draw_prims( + self.backend, + titleblock_prims( + "A3", + { + "title": "FLANGE COUPLING", + "drawn_by": "autocad-mcp-pro", + "scale": "1:1", + "material": "S235JR", + }, + ), + ) + + hub = build_part( + { + "name": "HUB", + "material": "steel", + "segments": [ + {"length": 40.0, "d_outer": 40.0, "d_inner": 20.0}, + {"length": 15.0, "d_outer": 90.0, "d_inner": 20.0}, + ], + } + ) + flange = build_part( + { + "name": "FLANGE", + "material": "cast_iron", + "segments": [{"length": 15.0, "d_outer": 90.0, "d_inner": 40.0}], + } + ) + drawn_hub = await draw_part( + self.backend, hub, at=(90.0, 200.0), views=("front",), dimension=False + ) + await add_view(self.backend, drawn_hub["part_id"], "section", style="full") + await dimension_part(self.backend, drawn_hub["part_id"], style="chain") + await draw_part( + self.backend, flange, at=(280.0, 200.0), views=("front",), dimension=True + ) + + fasteners = [] + for index, x in enumerate((60.0, 110.0, 160.0, 210.0)): + designation = "ISO 4014 - M12x60" if index % 2 == 0 else "ISO 4032 - M12" + fasteners.append( + await insert_std_part(self.backend, designation, at=(x, 90.0), view="side") + ) + + records = [] + for result in fasteners: + handle = result.get("handle") or (result.get("handles") or [None])[0] + raw = await self.backend.entity_get_xdata(handle, APP_ID) + records.append(decode(raw["xdata"][APP_ID])) + rows = rows_from_records(records) + await draw_prims(self.backend, table_prims(rows, at=(290.0, 45.0))) + for index, row in enumerate(rows): + item = int(row.get("item", index + 1)) + await draw_prims( + self.backend, + balloon_prims(item, (60.0 + 50.0 * index, 135.0), (60.0 + 50.0 * index, 95.0)), + ) + + # ISO 128-23: every circular feature on the sheet carries its centre + # mark. Drawing them is part of a correct sheet, and it is what makes + # the zero-issue gate reachable rather than aspirational. + for ent in await self.backend.entity_list(type_filter="CIRCLE", limit=1000): + if str(ent.layer).upper() != "GEOMETRY": + continue + centre = (ent.properties or {}).get("center") + radius = (ent.properties or {}).get("radius") + if centre and radius: + await draw_prims( + self.backend, + marks.centre_mark_prims( + (float(centre[0]), float(centre[1])), float(radius) + ), + ) + + issues = await run_critique(self.backend, None) + validation = await DrawingValidator().run(self.backend) + summary = {"error": 0, "warning": 0, "info": 0} + for issue in issues: + summary[issue.severity] = summary.get(issue.severity, 0) + 1 + score = combine(validation.summary, summary) + passed = len(issues) == 0 and float(score["score"]) >= 90.0 + return ( + passed, + { + "critique_issues": len(issues), + "critique_focuses": sorted({issue.focus for issue in issues}), + "score": float(score["score"]), + "grade": score["grade"], + "invalidity_ratio": score["invalidity_ratio"], + "parts_list_rows": len(rows), + "fasteners": len(fasteners), + }, + [], + ) +``` + +- [ ] **Step 6: Run it to verify the matrix half passes** + +Run: `uv run --frozen pytest tests/test_benchmark_v5.py -q -k "matrix or adapter or assembly"` +Expected: `test_v5_extends_v4_without_touching_it`, `test_the_adapter_implements_every_v5_task`, `test_mech_assembly_passes_headlessly` and `test_mech_assembly_fails_loudly_when_the_critique_finds_anything` pass. If `test_mech_assembly_passes_headlessly` fails, the metrics name the focuses that fired — fix the *sheet*, not the gate: the two numbers are the criterion. + +- [ ] **Step 7: Add the six correctness checks** + +In `benchmarks/correctness_suite.py`, insert before the `CHECKS` dict: + +```python +# ── Mechanical parts and the sheet (tracks B + G) ─────────────────────────── + + +def _field(row: dict, *names): + """A row's value under whichever of these names it carries. + + The parts-list column spelling belongs to the sheet module; what this + suite pins is the numbers, so it reads by meaning rather than by name. + """ + for name in names: + if name in row: + return row[name] + return None + + +def _inserted_handle(result: dict): + return result.get("handle") or (result.get("handles") or [None])[0] + + +async def mech_part_roundtrip(): + """Draw a part, read it back from its own ACADMCP_MECH XDATA, compare. + + Segments and material only: a feature's parameter spelling belongs to + `engineering/mech/features.py` and its own tests, and a check that guessed + at it would fail for the wrong reason. + """ + from engineering.mech.draw import draw_part, read_part + from engineering.mech.part import build_part, part_to_dict + + b = await _b() + spec = { + "name": "SHAFT", + "material": "steel", + "segments": [ + {"length": 40.0, "d_outer": 30.0}, + {"length": 20.0, "d_outer": 50.0, "d_inner": 10.0}, + ], + } + part = build_part(spec) + drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False) + back = await read_part(b, drawn["part_id"]) + return part_to_dict(build_part(back["part"])) == part_to_dict(part) + + +async def mech_section_hatch_area(): + """The hatched cut area of a full section, measured against a hand number. + + A plain sleeve: length 60, outside diameter 40, bore 20. A full + longitudinal section cuts two faces, each 60 x 10, so the hatch fills + 60 * (40 - 20) = 1200 mm^2 exactly. The number is read out of the drawing + through entity_measure (the tool's analysis_measure_entity), never from the + code that drew it — which is why it cannot be faked. + """ + from engineering.mech.draw import add_view, draw_part + from engineering.mech.part import build_part + + b = await _b() + part = build_part( + { + "name": "SLEEVE", + "material": "steel", + "segments": [{"length": 60.0, "d_outer": 40.0, "d_inner": 20.0}], + } + ) + drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False) + await add_view(b, drawn["part_id"], "section", style="full") + total = 0.0 + for ent in await b.entity_list(type_filter="HATCH", limit=500): + total += float((await b.entity_measure(ent.handle))["area"]) + return abs(total - 1200.0) < 0.01 + + +async def mech_iso286_on_dimension(): + """A 40 H7 bore dimension carries ISO 286's +0.025 / 0 through the layout. + + ISO 286-1 table 1: the 30-50 mm step has IT7 = 25 um, and H fixes the lower + deviation at 0. The fit has to survive `layout_dimensions`, or the number + is right in the table and wrong on the sheet. + """ + from engineering.fits import fit_lookup + from engineering.mech.dimension import layout_dimensions + from engineering.mech.primitives import Circle, DimIntent + from engineering.mech.views import View + + deviation = fit_lookup("H7", 40.0) + if abs(deviation.upper_mm - 0.025) > 1e-9 or abs(deviation.lower_mm) > 1e-9: + return False + intent = DimIntent("diameter", (-20.0, 0.0), (20.0, 0.0), fit="H7", feature="bore") + view = View( + kind="front", + prims=(Circle((0.0, 0.0), 20.0),), + dims=(intent,), + omitted=(), + bbox=(-20.0, -20.0, 20.0, 20.0), + ) + placed = layout_dimensions(view, [intent]) + return len(placed) == 1 and placed[0].fit == "H7" and placed[0].feature == "bore" + + +async def mech_thread_unrepresented_is_caught(): + """The ISO 6410 focus fires on a thread drawn as a plain circle. + + A quality gate that never fires is not a gate; this is the mechanical twin + of `dim_overlap_critique_fires`. + """ + from engineering.mech.critique import build_index, issues_for + from engineering.mech.xdata import APP_ID, to_values + + b = await _b() + payload = { + "v": 1, + "kind": "part", + "id": "P1", + "view": "side", + "part": { + "name": "STUD", + "material": "steel", + "segments": [{"length": 60.0, "d_outer": 20.0}], + "features": [{"kind": "thread", "id": "t1", "designation": "M20x1.5"}], + }, + } + anchor = await b.entity_create_circle(0.0, 0.0, 0.5, layer="0") + await b.entity_set_xdata(anchor.handle, APP_ID, to_values(payload)) + await b.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY") + before = issues_for("mech_thread_unrepresented", await build_index(b)) + await b.entity_create_arc(0.0, 0.0, 8.0, 0.0, 270.0, layer="GEOMETRY") + after = issues_for("mech_thread_unrepresented", await build_index(b)) + return len(before) == 1 and after == [] + + +async def sheet_frame_iso5457_a3(): + """ISO 5457 A3: 420 x 297 trimmed, a 20 mm filing margin on the left and + 10 mm on the other three edges, so the frame runs (20, 10) to (410, 287) + and the drawing space is 390 x 277.""" + from engineering.mech.primitives import points_of + from engineering.sheet.frames import SHEETS, frame_prims + + if tuple(float(v) for v in SHEETS["A3"]) != (297.0, 420.0): + return False + prims = frame_prims("A3", orientation="landscape", zones=False, marks=False) + points = { + (round(float(x), 6), round(float(y), 6)) for prim in prims for x, y in points_of(prim) + } + corners = {(20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0)} + xs = [x for x, _y in points] + ys = [y for _x, y in points] + extent = (min(xs), min(ys), max(xs), max(ys)) + return corners <= points and extent == (20.0, 10.0, 410.0, 287.0) + + +async def bom_balloon_link(): + """Two identical bolts collapse into one row of quantity 2, the item number + is stable across a rerun, and the balloon drawn for that row carries it.""" + from engineering.mech.stdparts import insert_std_part + from engineering.mech.xdata import APP_ID, decode + from engineering.sheet.bom import balloon_prims, rows_from_records + + b = await _b() + records = [] + for x in (0.0, 60.0): + result = await insert_std_part(b, "ISO 4014 - M12x60", at=(x, 0.0), view="side") + raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID) + records.append(decode(raw["xdata"][APP_ID])) + rows = rows_from_records(records) + again = rows_from_records(records) + if len(rows) != 1 or len(again) != 1: + return False + item = _field(rows[0], "item", "no", "pos") + if item is None or item != _field(again[0], "item", "no", "pos"): + return False + if int(_field(rows[0], "qty", "quantity") or 0) != 2: + return False + texts = [p.text for p in balloon_prims(int(item), (0.0, 40.0), (0.0, 5.0)) if hasattr(p, "text")] + return texts == [str(int(item))] + + +async def std_part_iso4014_m12(): + """ISO 4014's M12 row and the block it becomes. + + ISO 4014:2011 table 1, M12: width across flats s = 18 mm, head height + k = 7.5 mm. The row is matched by value rather than by column name — the + spelling is the catalogue's business, the numbers are the standard's — and + the inserted block has to carry its designation back in ACADMCP_MECH. + """ + from engineering.mech.stdparts import catalogue, insert_std_part + from engineering.mech.xdata import APP_ID, decode + + rows = [ + row + for row in catalogue("M12") + if any("4014" in str(value) for value in row.values()) + ] + if not rows: + return False + numbers = { + round(float(value), 3) + for row in rows + for value in row.values() + if isinstance(value, (int, float)) and not isinstance(value, bool) + } + if not {18.0, 7.5} <= numbers: + return False + b = await _b() + result = await insert_std_part(b, "ISO 4014 - M12x60", at=(0.0, 0.0), view="side") + raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID) + payload = decode(raw["xdata"][APP_ID]) + return payload["kind"] == "std_part" and "M12" in str(payload["designation"]) +``` + +and register them in `CHECKS`, after the three `"Settings"` rows: + +```python + "mech_part_roundtrip": (mech_part_roundtrip, "Mechanical"), + "mech_section_hatch_area": (mech_section_hatch_area, "Mechanical"), + "mech_iso286_on_dimension": (mech_iso286_on_dimension, "Mechanical"), + "mech_thread_unrepresented_is_caught": (mech_thread_unrepresented_is_caught, "Mechanical"), + "std_part_iso4014_m12": (std_part_iso4014_m12, "Mechanical"), + "sheet_frame_iso5457_a3": (sheet_frame_iso5457_a3, "Sheet"), + "bom_balloon_link": (bom_balloon_link, "Sheet"), +``` + +That is seven entries for six spec rows: `mech_thread_unrepresented_is_caught` is the extra, and it is there because a critique focus that never fires is not a gate — the mechanical twin of the existing `dim_overlap_critique_fires`. The suite goes 32 → 39, not 32 → 38; note it in `decisions`. + +- [ ] **Step 8: Run the checks one at a time, then the file's tests** + +``` +uv run --frozen python benchmarks/correctness_suite.py --list +for c in mech_part_roundtrip mech_section_hatch_area mech_iso286_on_dimension mech_thread_unrepresented_is_caught std_part_iso4014_m12 sheet_frame_iso5457_a3 bom_balloon_link; do uv run --frozen python benchmarks/correctness_suite.py $c; done +uv run --frozen pytest tests/test_benchmark_v5.py -q +``` + +Expected: seven `PASS` lines and the whole test file green — 32 existing checks plus these seven is 39, which is what `tests/test_benchmark_v5.py` asserts. If a check prints `FAIL`, run it alone (`uv run --frozen python benchmarks/correctness_suite.py `) and fix the code it exercises; the one number that may never be edited to fit is a standards value. + +- [ ] **Step 9: Write `scripts/smoke_mech_com.py`** + +```python +"""Exercise tracks B and G once against the live AutoCAD on this machine. + +Creates a NEW document (never touches the operator's open drawing), then runs +the whole track through the same code the tools call: + + apply_layer_set("mech") -> frame_prims/titleblock_prims -> draw_prims + -> build_part -> draw_part -> add_view(section) -> dimension_part + -> insert_std_part (ISO 4014 block + ACADMCP_MECH xdata) + -> weld_symbol_prims -> rows_from_records -> table_prims + -> xref_attach / xref_manage("detach") -> drawing_export_dwg + +and prints what came back as JSON. Exit codes: + + 0 the sheet drew, the critique returned nothing and the .dwg exists + 1 it drew but the critique found something or the DWG is missing + 2 no live CAD application reachable (pywin32 missing, or the ProgID + cannot be created) -- the COM paths then remain fake-tested only + +The backend's ``connect()`` is lazy on purpose (the server must start before +AutoCAD is open), so reachability is read from ``system_status()`` rather than +from ``connect()``. + + AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_mech_com.py + uv run --frozen python scripts/smoke_mech_com.py --keep # leave it open +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +XDATA_APP = "ACADMCP_MECH" + + +async def main(keep_document: bool = False) -> int: + try: + from backends.com_backend import ComBackend + except ImportError as exc: + print(f"pywin32 not available: {exc}") + return 2 + backend = ComBackend() + try: + await backend.connect() + except Exception as exc: + print(f"no live AutoCAD reachable: {exc}") + return 2 + try: + status = await backend.system_status() + if not status.get("connected"): + print(f"no live AutoCAD reachable: {status.get('error', status)}") + return 2 + + from engineering.critique import run_critique + from engineering.layers import apply_layer_set + from engineering.mech.annotate import weld_symbol_prims + from engineering.mech.draw import add_view, dimension_part, draw_part, draw_prims + from engineering.mech.part import build_part + from engineering.mech.stdparts import insert_std_part + from engineering.sheet.bom import rows_from_records, table_prims + from engineering.sheet.frames import frame_prims + from engineering.sheet.titleblock import titleblock_prims + + workspace = Path(tempfile.mkdtemp(prefix="mech_smoke_")) + await backend.drawing_new() + await apply_layer_set(backend, "mech") + await draw_prims(backend, frame_prims("A3")) + await draw_prims( + backend, titleblock_prims("A3", {"title": "COM SMOKE", "scale": "1:1"}) + ) + + shaft = build_part( + { + "name": "SHAFT", + "material": "steel", + "segments": [ + {"length": 40.0, "d_outer": 30.0}, + {"length": 20.0, "d_outer": 50.0, "d_inner": 20.0}, + ], + } + ) + drawn = await draw_part( + backend, shaft, at=(90.0, 200.0), views=("front",), dimension=False + ) + section = await add_view(backend, drawn["part_id"], "section", style="full") + dims = await dimension_part(backend, drawn["part_id"], style="chain") + bolt = await insert_std_part( + backend, "ISO 4014 - M12x60", at=(250.0, 120.0), view="side" + ) + bolt_handle = bolt.get("handle") or (bolt.get("handles") or [None])[0] + got = await backend.entity_get_xdata(bolt_handle, XDATA_APP) + await draw_prims(backend, weld_symbol_prims(kind="fillet", size=5.0)) + rows = rows_from_records([{"v": 1, "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", "size": "M12x60", + "qty": 1, "material": "8.8"}]) + await draw_prims(backend, table_prims(rows, at=(290.0, 45.0))) + + xref_path = workspace / "xref_source.dxf" + await backend.drawing_export_dxf(str(xref_path)) + attached = await backend.xref_attach(str(xref_path), (0.0, 0.0), 1.0, 0.0, "attach") + listed = await backend.xref_manage(attached["name"], "list", None) + detached = await backend.xref_manage(attached["name"], "detach", None) + + dwg_path = workspace / "mech_smoke.dwg" + exported = await backend.drawing_export_dwg(str(dwg_path), "2018") + + issues = await run_critique(backend, None) + report = { + "autocad": status.get("autocad_version") or status.get("cad_progid"), + "part_id": drawn["part_id"], + "views": len(drawn.get("views") or []), + "section_omitted": section.get("omitted"), + "dimensions": len(dims.get("handles") or []), + "bolt_handle": bolt_handle, + "xdata_chunks": len(got["xdata"].get(XDATA_APP, [])), + "parts_list_rows": len(rows), + "xref_attached": attached.get("name"), + "xref_listed": len(listed.get("xrefs") or []), + "xref_detached": detached.get("ok", detached), + "dwg": {"path": exported.get("path", str(dwg_path)), + "exists": dwg_path.exists(), + "bytes": dwg_path.stat().st_size if dwg_path.exists() else 0}, + "critique_issues": len(issues), + "critique": [issue.to_dict() for issue in issues], + } + print(json.dumps(report, indent=2)) + if not keep_document: + await backend.drawing_close(save=False) + return 0 if (not issues and dwg_path.exists()) else 1 + finally: + await backend.disconnect() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main(keep_document="--keep" in sys.argv[1:]))) +``` + +- [ ] **Step 10: Run the smoke against the live seat and keep its output** + +Open AutoCAD 2026 first (a cold start can exceed `COM_CALL_TIMEOUT`), then: + +``` +AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_mech_com.py +``` + +Expected: exit 0 and a JSON report. Save the printed JSON — Step 15 quotes `autocad`, `dimensions`, `xdata_chunks`, `xref_attached`, `dwg.bytes` and `critique_issues` in the CHANGELOG, the way tracks A and E recorded theirs. If a member name is wrong, the error names it: fix the *backend* to the measured ActiveX member, never the smoke script's expectation, and record it in `deviations`. If AutoCAD is not reachable (exit 2), say so in `deviations` and do not write a smoke record the run did not produce. + +- [ ] **Step 11: Point the document-number checker at the v5 matrix** + +In `scripts/check_doc_numbers.py`: + +- change `from benchmarks.tasks_v4 import TASKS_V4` to `from benchmarks.tasks_v5 import TASKS_V5`; +- change `"v4_tasks": len(TASKS_V4),` to `"matrix_tasks": len(TASKS_V5),`; +- change the last `CHECKS` row to `("CLAUDE.md", "matrix task count", r"the current set \((\d+) tasks", "matrix_tasks")`. + +- [ ] **Step 12: Regenerate the published A/B report over the grown suite** + +`scripts/check_doc_numbers.py` fails while the suite has more checks than the published report was run over, and that is the point: the report has to be re-run, never re-typed. + +``` +uv run --frozen python benchmarks/compare_versions.py v1.5.1 --json benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json +uv run --frozen python -c "import json;print(json.load(open('benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json'))['summary'])" +``` + +Expected: `total` equals the suite size from Step 8, `regressed` is 0, and the seven new checks are `miss` on v1.5.1 (the modules do not exist there) and `pass` now. **A non-zero `regressed` stops the task**: find the regression and fix the code, never the report. + +- [ ] **Step 13: Add the `TOOL_PACKS=core,mech` idle row to the token suite and remeasure** + +In `benchmarks/token_suite.py::idle_lane`, after the `lean` row and before `config.settings.tool_profile = "full"`: + +```python + config.settings.tool_profile = "full" + previous_packs = config.settings.tool_packs + config.settings.tool_packs = "core,mech" + rows.append( + await _idle_row( + "packs_core_mech", + "TOOL_PACKS=core,mech - a mechanical client that never opens a " + "P&ID or a live seat.", + counter, + turns, + ) + ) + config.settings.tool_packs = previous_packs +``` + +and add `config.settings.tool_packs = "all"` to the function's `finally:` block so a failed measurement cannot leave the process on a narrowed surface. Then: + +``` +uv run --frozen python benchmarks/token_suite.py --tokenizer ratio +uv run --frozen pytest tests/test_token_suite.py -q +``` + +Expected: an extra idle row whose `advertised_tools` is 229 minus the nine `pid_*` tools minus the 22 environment tools. Keep the printed `advertised_tools` and `tokens` for every row — Step 15 writes them into the README table. + +- [ ] **Step 14: Update `CLAUDE.md`** + +Append to the numbered tool-section list, after item 20: + +``` +21. Mechanical parts (6 tools, pack `mech`): `mech_part_draw`, `mech_view_add`, `mech_dimension_part`, `mech_hole_pattern`, `mech_part_from_spec`, `mech_part_inspect`. One part model — a profile (`RevolvedPart` segments or a `PrismaticPart` outline) plus typed features — and one view engine that turns it into front / side / top / section / detail views with hidden lines, cut faces and ISO 129 dimensions. The model is written onto the drawing as `ACADMCP_MECH` XDATA, so `mech_view_add` can add a view months later without the caller re-describing the part. A feature that cannot be projected into a view is listed in `omitted: [{feature, reason}]` — never dropped. +22. Standard parts (3 tools, pack `mech`): `std_part_list`, `std_part_insert`, `std_feature_draw`. ISO 4014 / 4017 / 4032 / 7089 / 4762 fasteners, ISO 15 bearings with ISO 8826-1 representation, DIN 471/472 retaining-ring grooves, DIN 509 undercuts, DIN 332 centre holes and ISO 3601-2 O-ring grooves — authored tables, inserted as real blocks with attributes and `ACADMCP_MECH` XDATA, which is what makes the parts list a *read* of the drawing. A size outside a table's transcribed coverage is refused by name, never interpolated. +23. Mechanical annotation (5 tools, pack `mech`): `surface_texture` (ISO 21920-1, ISO 1302's older Ra form accepted), `weld_symbol` (ISO 2553), `centre_marks` (ISO 128-23), `section_line` (ISO 128-40 — it returns the plane `mech_view_add(kind="section")` consumes, so the label and the view cannot disagree), `hatch_material` (the ISO 128-50 map; an unknown material is refused with the list, never hatched as steel). +24. Sheet and delivery (11 tools, pack `core`): `sheet_frame` (ISO 5457 A4-A0 — 20 mm filing margin, 10 mm elsewhere on A3, zone grid, centring and trimming marks), `titleblock_apply` (ISO 7200 for every size; `titleblock_apply_iso_a3` is now a thin alias), `revision_add`, `bom_extract` (never modifies the drawing), `bom_table` (ISO 7573), `balloon_add` (ISO 6433, linked by XDATA so a rerun renumbers instead of duplicating), `data_extract` (CSV always; XLSX refused with `xlsx_write` when `openpyxl` is absent), `xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg` (COM `SaveAs` natively; headless only with the ODA File Converter, otherwise `dwg_write` refuses and names the install route). +``` + +Add to **Key Conventions**: + +``` +- **The part model, not primitives**: a machine part is `mech_part_draw` with a segment list or an outline plus typed features — never hand-drawn lines. The same rule as gears and keyways, for the same reason. +- **Standards tables are transcribed, never derived**: every row under `engineering/mech/standards/` carries a `SOURCE` naming standard, edition and table, and a size outside the coverage is refused by name. `engineering/mech/standards/materials.py` states which of its rows are ISO 128-50, which are ASME Y14.2M via `acadiso.pat`, and which (concrete, wood) have no mechanical standard at all. +- **Six `mech_*` critique focuses** (`mech_missing_centreline`, `mech_unhatched_section`, `mech_view_misaligned`, `mech_duplicate_dimension`, `mech_thread_unrepresented`, `mech_bom_balloon_mismatch`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing with no `ACADMCP_MECH` payload. `mech_view_misaligned` checks the projection axis and not the quadrant, and `mech_duplicate_dimension` catches two dimensions in the same place rather than a semantic restatement — both narrowings are in the module docstring, because the payload carries no projection angle and no engine exposes what a dimension measured. +``` + +Update the pack line from `**`TOOL_PACKS=all|core,pid`**` to `**`TOOL_PACKS=all|core,pid,settings,mech`**` (both places it appears), and update the two benchmark figures — `benchmarks/tasks_v5.py` is the current set (18 tasks; `--matrix v4` / `v3` / `v2` reproduce the earlier sets) and `benchmarks/correctness_suite.py` (the count from Step 8). + +- [ ] **Step 15: Update `README.md`, `benchmarks/README.md`, `CHANGELOG.md` and the spec status line** + +`README.md` — add two "What you get" rows after the Engineering generators row: + +``` +| Mechanical parts | one part model (segments or outline + typed features) and one view engine: front / side / top / section / detail with hidden lines, ISO 128-50 cut faces and ISO 129 dimensions; ISO 4014/4017/4032/7089/4762 fasteners, ISO 15 bearings, DIN 471/472, DIN 509, DIN 332 and ISO 3601-2 as real blocks with attributes; ISO 21920-1 surface texture, ISO 2553 welds, ISO 128-40 section lines | +| Sheet & delivery | ISO 5457 frames A4-A0 with zones and trim marks, ISO 7200 title blocks for every size, revision blocks with clouds, ISO 7573 parts lists with ISO 6433 balloons linked by XDATA, CSV/XLSX extraction, xrefs and images on both engines, real `.dwg` on a live seat | +``` + +Replace the token table rows with the numbers printed in Step 13, keeping the row order and adding the pack row: + +``` +| `TOOL_PROFILE=full` (default) | | tokens | +| `TOOL_PACKS=core,mech` (full profile) | | tokens | +| `TOOL_PACKS=core` (full profile) | | tokens | +| `TOOL_PROFILE=lean` | | tokens | +| `DISCOVERY_MODE=search` | 2 | ** tokens** | +``` + +and update the sentence above it ("The full catalog costs a client **N tokens**") from the same run. + +Update the A/B tables in `README.md` and `benchmarks/README.md` from the regenerated report's `summary` — the suite size, the baseline's `old_pass` and `old_pct`, the current `N / N`, `fixed` and `regressed` — and the prose beneath each table so it names the seven new rows as `miss -> pass` new capability. + +`CHANGELOG.md` — add to the `[Unreleased]` section, after the track E paragraph: + +``` +Tracks B and G of 1.6: the complete mechanical 2D part drawer and the sheet +standard. 25 tools in four new sections — mechanical parts (21), standard +parts (22), mechanical annotation (23) and sheet and delivery (24) — one part +model and one view engine instead of a zoo of per-part generators, a new +`mech` pack, six `mech_*` critique focuses and four new contract members +(`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`). Spec: +`docs/superpowers/specs/2026-09-22-v1.6-mech-design.md`. +``` + +and a bullet under `### Added` ending with the smoke record from Step 10, in the shape tracks A and E used: + +``` +- **Live COM smoke.** `scripts/smoke_mech_com.py` executed once on AutoCAD + : the A3 frame and ISO 7200 title block, a two-step + shaft with a full section and chain dimensions, an ISO 4014 + M12x60 block carrying ACADMCP_MECH chunks, an ISO 7573 parts + list, an xref attached and detached, and a real -byte `.dwg` written + by ActiveX `SaveAs` — `drawing_critique(focus=None)` returned + issues. +``` + +`docs/superpowers/specs/2026-09-22-v1.6-mech-design.md` — change the status line from `**Status:** design approved in conversation` to `**Status:** shipped (v1.6.0-dev, tracks B + G)`. + +- [ ] **Step 16: The full gate** + +``` +uv run --frozen ruff format benchmarks scripts tests +uv run --frozen ruff check . +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen python scripts/check_doc_numbers.py --list +uv run --frozen python scripts/check_doc_numbers.py +uv run --frozen pytest -q +uv run --frozen pytest tests/test_capability_contract.py tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_tool_packs.py tests/test_release_consistency.py -q +``` + +Expected: ruff clean, the inventory check reports no drift, `check_doc_numbers` exits 0 with every figure derived, and the whole suite is green including `tests/test_release_consistency.py::test_document_numbers_match_the_tree` — which is now correct on `main` because Step 15 retook every number it checks. If `check_doc_numbers` still reports the collected-tests figure, retake it from its own `--list` output; this task added test files, so the figure moved again after Task 15. + +- [ ] **Step 17: Commit** + +``` +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +bench: matrix v5 adds mech_assembly; the correctness suite grows with the mechanical and sheet checks + +The evidence for tracks B and G, in the shape tracks A and E used. + +- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay + addressable so an earlier report reproduces exactly rather than being only + described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet + built from the part model and the sheet standard, gated on two numbers that + cannot be negotiated: drawing_critique(focus=None) returns zero issues and + the finalize score is at least 90. The adapter returns the focuses that + fired, so a regression names itself instead of shading the score down, and + a test stubs out the centre marks to prove the gate can fail. +- Seven correctness checks. mech_section_hatch_area is the one that cannot be + faked: a sleeve of length 60, outside diameter 40 and bore 20 cuts two faces + totalling 1200 mm^2 exactly, and the number is read back out of the drawing + through analysis_measure_entity rather than from the code that drew it. + sheet_frame_iso5457_a3 pins the A3 frame against ISO 5457 (20 mm filing + margin, 10 mm elsewhere, so 20,10 to 410,287); mech_iso286_on_dimension + pins 40 H7 at +0.025/0 and proves the fit survives the layout; + std_part_iso4014_m12 pins s = 18 and k = 7.5 by value rather than by column + name. mech_thread_unrepresented_is_caught is the seventh: a quality gate + that never fires is not a gate. +- scripts/smoke_mech_com.py executes every new COM path once on a live seat - + the frame, the title block, a sectioned shaft, an ISO 4014 block with its + XDATA, a weld symbol, a parts list, an xref attach and detach, and a real + .dwg through ActiveX SaveAs - and the CHANGELOG records what it returned. +- The published A/B report is re-run over the grown suite rather than + re-typed, check_doc_numbers derives the task count from the v5 matrix, and + the token table is remeasured with a TOOL_PACKS=core,mech row. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` +