# Tracks B + G — mechanical 2D part drawer and sheet standard: implementation plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** One part model and one view engine that draw any revolved or prismatic machine part — with hidden lines, sections, standard features and ISO dimensions — plus the standard-parts catalogue, the ISO annotation symbols and the sheet standard (frames, title blocks, revisions, parts list, balloons, xrefs, DWG), as 25 tools on both engines. **Architecture:** Pure geometry modules (`engineering/mech/*`, `engineering/sheet/*`) emit typed primitive *descriptions*; one async layer (`draw.py` and the server tools) turns them into backend calls, exactly the way `gear_draw_*` and `titleblock_apply_iso_a3` already work. Only four operations differ per engine and become contract members (`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`). **Tech Stack:** Python 3.11, FastMCP 3, ezdxf 1.4.x, pywin32 (COM), pytest + pytest-asyncio, ruff. Everything runs through `uv run --frozen`. **Spec:** `docs/superpowers/specs/2026-09-22-v1.6-mech-design.md` — read it before your task; this plan argues from it and does not repeat its reasoning. ## Global Constraints - **Run everything through `uv run --frozen …`.** Never `uv sync` (the shell's bare `python` is 3.14 without dependencies). 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`. - **Commit** with `git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit` and end every message with these two lines verbatim: `Co-Authored-By: Claude Opus 5 (1M context) ` and `Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd`. - **Known red that is not yours:** `tests/test_release_consistency.py::test_document_numbers_match_the_tree` drifts on the README "collected tests" figure whenever tests are added. Deselect exactly that test in a worktree and say so; every other test in that file must pass. Task 15 retakes the figure on main. - **The table rule (spec §5).** Every row of every table under `engineering/mech/standards/` is transcribed from the named standard's own table, carries a `SOURCE` docstring naming standard + edition + table number, and is pinned by a provenance test asserting named values. A size outside the transcribed range is refused by name — never interpolated. **If you cannot verify a row, do not ship the row**: narrow the coverage, say so in the docstring and in `deviations`. - **Validate before writing** (CLAUDE.md): every refusal fires before the first entity reaches the drawing, names the offending path (`segments[2].d_outer`), and leaves the drawing untouched. - **WCS only.** Every coordinate in or out of a tool is WCS. Part-local coordinates exist inside the pure modules; `draw.py` is the only place that maps them, through the part's placement (`at`, `rotation`). - **No silent wrong number.** A feature that cannot be projected into a view is reported in `omitted: [{"feature": …, "reason": …}]`, never dropped. - **Layers** come from the role map in `engineering/mech/primitives.py` (`visible→GEOMETRY`, `hidden→HIDDEN`, `center→CENTER`, `phantom→PHANTOM`, `hatch→HATCH`, `dim→DIM`, `text→TEXT`); no tool hardcodes a layer name. - **Before committing:** `uv run --frozen ruff check ` and `uv run --frozen ruff format `. - **Registration gates** (only when you register a tool): regenerate `docs/tool-inventory.json` with `uv run --frozen python scripts/generate_tool_inventory.py`, bump the README release-snapshot tool count, bump the frozen group-size snapshot in `tests/test_tool_registry.py`, add an alias record per tool in `discovery/aliases.py` (`tests/test_discovery_aliases.py` fails otherwise). - **Deviations are data.** If a plan test asserts something the real API cannot satisfy, verify the truth by running code, fix the *test* to that truth while keeping the behaviour the spec demands, and record it with the evidence. Never delete, skip or weaken a test to get green. --- ## File Structure | File | Responsibility | Task | Group | |---|---|---|---| | `engineering/mech/primitives.py` | typed primitive descriptions + role→layer map | 1 | shared | | `engineering/mech/xdata.py` | chunked JSON codec for the `ACADMCP_MECH` payload | 1 | shared | | `engineering/mech/standards/__init__.py` | table registry + `lookup()` + coverage refusal | 1 | shared | | `engineering/mech/standards/materials.py` | ISO 128-50 material → hatch pattern/angle/scale | 1 | shared | | `engineering/mech/part.py` | `RevolvedPart`, `PrismaticPart`, `build_part`, validation | 2 | M | | `engineering/mech/features.py` | every feature dataclass, its validation and geometry | 3 | M | | `engineering/mech/standards/threads.py` | ISO 261/965 pitch + ISO 6410 representation rules | 3 | M | | `engineering/mech/standards/undercuts.py` | DIN 509 forms E/F | 3 | M | | `engineering/mech/standards/rings.py` | DIN 471 / DIN 472 groove dimensions | 3 | M | | `engineering/mech/standards/centres.py` | DIN 332 forms A/B/R | 3 | M | | `engineering/mech/standards/orings.py` | ISO 3601-2 groove dimensions | 3 | M | | `engineering/mech/views.py` | the view engine: orthographic (4), sections + details (5) | 4, 5 | M | | `engineering/mech/dimension.py` | dimension intents + overlap-free layout | 6 | M | | `engineering/mech/draw.py` | the async layer: primitives → backend calls | 7 | M | | `engineering/mech/standards/fasteners.py` | ISO 4014/4017/4032/7089/4762 tables | 8 | S | | `engineering/mech/standards/bearings.py` | ISO 15 series + ISO 8826-1 representation | 8 | S | | `engineering/mech/stdparts.py` | catalogue → block specs → insert + XDATA | 9 | S | | `engineering/mech/annotate.py` | surface texture (ISO 21920-1), weld (ISO 2553) | 10 | A | | `engineering/mech/marks.py` | centre marks, section line, material hatch | 11 | A | | `engineering/sheet/frames.py` | ISO 5457 sizes, margins, zones, centring/trim marks | 12 | G | | `engineering/sheet/titleblock.py` | ISO 7200 for A4–A0 (today's A3 module folds in) | 12 | G | | `engineering/sheet/revision.py` | revision block rows, clouds, triangle tags | 13 | G | | `engineering/sheet/bom.py` | extract → rows → ISO 7573 table; ISO 6433 balloons | 13 | G | | `engineering/sheet/extract.py` | rows → CSV (always) / XLSX (optional dependency) | 14 | G | | `backends/contracts/refs.py` | the four engine-specific members | 14 | G | | `backends/ezdxf_backend.py` | their ezdxf implementations | 14 | G | | `backends/com_backend.py` | their COM implementations | 14 | G | | `engineering/mech/critique.py` | the six `mech_*` critique focuses | 15 | F | | `server.py` | SECTION 21 (7) · 22 (9) · 23 (10, 11) · 24 (12, 13, 14) | per task | per group | | `benchmarks/tasks_v5.py`, `benchmarks/correctness_suite.py` | evidence | 16 | F | | `scripts/smoke_mech_com.py` | live COM execution, once | 16 | F | ## Interfaces (the contract between tasks) Task 1 lands on `main` before the groups branch; every other task consumes it. ```python # engineering/mech/primitives.py Role = Literal["visible", "hidden", "center", "phantom", "hatch", "dim", "text"] ROLE_LAYER: dict[Role, str] # visible→GEOMETRY, hidden→HIDDEN, center→CENTER, # phantom→PHANTOM, hatch→HATCH, dim→DIM, text→TEXT Pt = tuple[float, float] class Line(NamedTuple): p1: Pt; p2: Pt; role: Role = "visible" class Arc(NamedTuple): 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: tuple[tuple[Pt, ...], ...] # [0] outer, [1:] islands material: str = "steel" class Text(NamedTuple): at: Pt; text: str; height: float = 3.5; rotation: float = 0.0; role: Role = "text" class DimIntent(NamedTuple): 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 # e.g. "H7" → resolved by engineering/fits.py tol: dict | None = None # {"mode": …, "upper": …, "lower": …} → engineering/tolerances.py feature: str = "" # the feature id this dimension belongs to Prim = Line | Arc | Circle | Poly | HatchArea | Text def bbox(prims: Iterable[Prim]) -> tuple[float, float, float, float] # (minx, miny, maxx, maxy) def translate(prims: Iterable[Prim], dx: float, dy: float) -> tuple[Prim, ...] def rotate(prims: Iterable[Prim], deg: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...] def scale(prims: Iterable[Prim], factor: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...] # engineering/mech/xdata.py APP_ID = "ACADMCP_MECH" PAYLOAD_VERSION = 1 def encode(payload: dict) -> list[tuple[int, str]] # 1001 app id + 1000 chunks ≤ 255 chars def decode(rows: Sequence[tuple[int, str]]) -> dict # raises ValueError on a foreign/corrupt payload # Payload shapes (pinned; both the writer and every reader test them literally): # {"v": 1, "kind": "part", "id": "", "view": "front", "part": {...part_to_dict...}} # {"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", …]} # engineering/mech/standards/__init__.py class Coverage(NamedTuple): low: float; high: float; unit: str = "mm" def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None def lookup(standard: str, size, **kw) -> dict # ValueError naming standard + coverage when outside def standards() -> tuple[str, ...] # engineering/mech/standards/materials.py def hatch_for(material: str) -> dict # {"pattern": "ANSI31", "angle": 45.0, "scale": 1.0} MATERIALS: tuple[str, ...] # ValueError lists these when the name is unknown ``` ```python # engineering/mech/part.py (Task 2) 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[Feature, ...] = () material: str = "steel" @dataclass(frozen=True) class PrismaticPart: name: str; outline: tuple[Pt, ...]; thickness: float; features: tuple[Feature, ...] = () material: str = "steel" Part = RevolvedPart | PrismaticPart def build_part(spec: dict) -> Part # validates; 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 # engineering/mech/features.py (Task 3) @runtime_checkable class Feature(Protocol): id: str def validate(self, part) -> None # ValueError naming the path 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 # the "kind" key selects the class FEATURE_KINDS: tuple[str, ...] # engineering/mech/views.py (Tasks 4, 5) @dataclass(frozen=True) class View: kind: Literal["front", "side", "top", "section", "detail"] prims: tuple[Prim, ...] dims: tuple[DimIntent, ...] omitted: tuple[dict, ...] bbox: tuple[float, float, float, float] label: str | None = None scale: float = 1.0 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) -> Pt SECTION_PLANE = {"p1": (0.0, 0.0), "p2": (0.0, 0.0), "label": "A", "direction": (0.0, -1.0), "style": "full"} # the shape Task 11 produces # engineering/mech/dimension.py (Task 6) 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) -> tuple[DimIntent, ...] def hole_table_rows(part, view: View) -> tuple[dict, ...] # {"tag","x","y","d","qty","note"} # engineering/mech/draw.py (Task 7) async def draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer_prefix="") -> dict async def draw_part(backend, part, *, at=(0.0, 0.0), rotation=0.0, views=("front",), projection="first", dimension=True) -> dict # {"part_id","views":[…],"handles":[…]} async def add_view(backend, part_id, kind, **kw) -> dict async def dimension_part(backend, part_id, *, style="chain") -> dict async def read_part(backend, part_id=None) -> dict # {"part_id","part",…} from XDATA ``` ```python # engineering/mech/stdparts.py (Task 9) def catalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...] def block_spec(designation: str, view: str) -> dict # for block_define async def insert_std_part(backend, designation, *, at, view="side", rotation=0.0, layer="GEOMETRY", attributes=None) -> dict # engineering/mech/annotate.py (Task 10) def surface_texture_prims(*, ra=None, rz=None, process=None, lay=None, machining="any", all_around=False, height=3.5) -> 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, ...] # engineering/mech/marks.py (Task 11) def centre_mark_prims(center: Pt, radius: float, *, style="mark", extension=3.0) -> tuple[Prim, ...] def section_line_prims(p1: Pt, p2: Pt, *, label="A", direction=(0.0, -1.0), height=5.0) -> tuple[Prim, ...] # engineering/sheet/frames.py (Task 12) SHEETS: dict[str, tuple[float, float]] # "A4"…"A0" → (w, h) portrait, ISO 216 def frame_prims(size: str, *, orientation="landscape", zones=True, marks=True) -> tuple[Prim, ...] def frame_metrics(size: str, *, orientation="landscape") -> dict # margins, drawing area, zone grid # engineering/sheet/titleblock.py (Task 12) def titleblock_prims(size: str, fields: dict, *, orientation="landscape", projection="first") -> tuple[Prim, ...] TITLEBLOCK_FIELDS: tuple[str, ...] # ISO 7200 data fields # engineering/sheet/bom.py (Task 13) def rows_from_records(records: Sequence[dict], *, columns=None, group_by="designation") -> tuple[dict, ...] def table_prims(rows, *, at, direction="up", standard="ISO 7573", height=7.0) -> tuple[Prim, ...] def balloon_prims(item: int, at: Pt, leader_to: Pt, *, radius=4.0) -> tuple[Prim, ...] # backends/contracts/refs.py (Task 14) async def xref_attach(self, path: str, at, scale: float, rotation: float, kind: str) -> dict async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict async def drawing_export_dwg(self, path: str, version: str) -> dict ``` ## Execution | Group | Branch / worktree | Tasks | Owns | |---|---|---|---| | **(main)** | `main` | 1 | `engineering/mech/{primitives,xdata}.py`, `standards/{__init__,materials}.py` | | **M** | `track-bg/mech` | 2, 3, 4, 5, 6, 7 | `engineering/mech/{part,features,views,dimension,draw}.py`, `standards/{threads,undercuts,rings,centres,orings}.py`, server SECTION 21 | | **S** | `track-bg/std` | 8, 9 | `engineering/mech/stdparts.py`, `standards/{fasteners,bearings}.py`, server SECTION 22 | | **A** | `track-bg/annot` | 10, 11 | `engineering/mech/{annotate,marks}.py`, server SECTION 23 | | **G** | `track-bg/sheet` | 12, 13, 14 | `engineering/sheet/*`, `backends/contracts/refs.py`, both backends, server SECTION 24 | | **F** | `main` (after merge) | 15, 16 | critique focuses, packs/lean/aliases/golden, benchmarks, smoke, docs | Task 1 lands on `main` first; the four group worktrees branch from that commit and run in parallel. Tasks inside a group are sequential. ### Merge notes (for the agent that merges the four branches) Merge order **M → S → A → G**. Expected conflicts, all anticipated: - `server.py`: each group appends its section before the `# ---` RESOURCES banner. Keep all four, in order 21 → 22 → 23 → 24, and restore the banner at the seam. - `engineering/mech/__init__.py`: additive re-exports — keep every side's names, alphabetical. - `discovery/aliases.py`: additive — keep every group's alias records and merge the `SHARED_ACAD_COMMANDS` additions alphabetically. - `docs/tool-inventory.json`: regenerate after resolving the code (`uv run --frozen python scripts/generate_tool_inventory.py`), never hand-merge. - `README.md`: the release-snapshot line — recompute tools/resources from the regenerated inventory; leave the collected-tests figure to Task 15. Keep every group's "What you get" row. - `tests/test_tool_registry.py`: recompute the frozen group-size snapshot from the live registry; concatenate the docstring paragraphs. - `backends/base.py` and the two capability maps: only group G touches them (`dwg_write`, `xref_live`, `xlsx_write`) — take G's side whole. - Any conflict in a file **not** listed: `git merge --abort`, record the branch as not merged, continue with the next branch. After the merges: `uv run --frozen ruff check .`, the full suite, and the gate set (inventory `--check`, `tests/test_tool_registry.py`, `tests/test_capability_contract.py`, `tests/test_discovery_aliases.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." ) # The caller's tab comes from the listing that was just read -- both engines # report it there (ezdxf from $TILEMODE / the active layout, COM from # ``doc.ActiveLayout.Name``). A private attribute would not: only # EzdxfBackend has ``_current_space``, so reading it left a live caller # standing on Model after every border. previous = str(listing.get("current") or "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 #: The ISO 5456-2 / ISO 128-30 projection symbol, transcribed from two #: independent reproductions of the standard's figure (ISO's own is paywalled #: and was not read). Each entry is that source's OWN coordinates, measured off #: the file, and the landmark order left-to-right is what the symbol means. #: #: 1. FreeCAD's ISO 5457 sheet template, first-angle symbol -- #: src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg, #: ids `first_angle_trapezoid`, `first_angle_inner circle`, #: `first_angle_outer circle`. #: 2. Wikimedia Commons `Convention placement vues dessin technique.svg`, which #: draws both symbols side by side, labelled FR (first) and US (third). #: #: A test that read these numbers out of `projection_symbol_prims` instead #: would pass whichever way round the symbol was drawn, which is exactly how #: the mirrored third-angle cone shipped in the first place. REFERENCE_FIGURES = { "first": ( # source, short-side x, long-side x, end-view (circles) x ("FreeCAD A3_Landscape_ISO5457_advanced.svg", 379.0, 389.0, 396.0), ("Commons Convention placement vues dessin technique.svg (FR)", 13.343, 81.869, 148.005), ), "third": ( ("Commons Convention placement vues dessin technique.svg (US)", 380.275, 448.801, 314.140), ), } #: The three landmarks, in the order `_landmarks` and REFERENCE_FIGURES list them. LANDMARKS = ("short", "long", "circles") def _landmarks(angle: str) -> tuple[float, float, float]: """(short-side x, long-side x, end-view x) of the symbol this module draws.""" prims = projection_symbol_prims((0.0, 0.0), angle) cone = next(p for p in prims if isinstance(p, Poly)) circles = [p for p in prims if isinstance(p, Circle)] left_x = min(x for x, _ in cone.points) right_x = max(x for x, _ in cone.points) left_h = max(y for x, y in cone.points if x == left_x) * 2 right_h = max(y for x, y in cone.points if x == right_x) * 2 short_x, long_x = (left_x, right_x) if left_h < right_h else (right_x, left_x) assert len({c.center[0] for c in circles}) == 1 return short_x, long_x, circles[0].center[0] @pytest.mark.parametrize("angle", ["first", "third"]) def test_the_symbol_lands_the_landmarks_in_the_order_the_reference_figures_do(angle): """The meaning of the symbol is the left-to-right order of three landmarks: the cone's short side, its long side and the end view. Assert that order against the measured figures, not against what the module computes.""" mine = _landmarks(angle) for source, *reference in REFERENCE_FIGURES[angle]: expected = [name for _x, name in sorted(zip(reference, LANDMARKS, strict=True))] got = [name for _x, name in sorted(zip(mine, LANDMARKS, strict=True))] assert got == expected, f"{angle}-angle symbol disagrees with {source}" def test_the_short_side_points_away_from_the_circles_in_first_and_towards_in_third(): """The mirror-invariant half of the rule, and the one every source states in words: Wikipedia's Multiview orthographic projection, Symbol -- "the first-angle symbol shows the trapezoid with its shortest side away from the circles", "the third-angle symbol shows the trapezoid with its shortest side towards the circles".""" short_x, long_x, circles_x = _landmarks("first") assert abs(short_x - circles_x) > abs(long_x - circles_x) short_x, long_x, circles_x = _landmarks("third") assert abs(short_x - circles_x) < abs(long_x - circles_x) def test_the_symbol_keeps_the_reference_figures_proportions_and_its_axis(): """Both reference figures draw the end view as two circles of ratio 1:2 (FreeCAD r 2.5 / r 5) on a centre line; the absolute size is this module's.""" first = projection_symbol_prims((0.0, 0.0), "first") circles = [p for p in first if isinstance(p, Circle)] assert {round(c.radius, 6) for c in circles} == {4.0, 2.0} assert any(isinstance(p, Line) and p.role == "center" for p in first) def test_the_two_symbols_are_not_the_same_picture(): assert _landmarks("first") != _landmarks("third") 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 5456-2 / ISO 128-30 projection-angle symbol, centred on `at`. ARRANGEMENT -- transcribed, not derived. The symbol is a truncated cone drawn in two views: the cone in elevation (a trapezoid) and the end view (two concentric circles). Which side the end view sits on is what makes the symbol mean *first* or *third* angle, and it is a convention that has to be read off the standard's figure: the projection rule alone cannot pick it, because the same picture is a legal drawing of the mirrored object seen from the other side. ISO 5456-2's own figure is behind ISO's paywall and was not read. Two independent reproductions of it were downloaded and measured instead, and they agree on every landmark used here: * FreeCAD's ISO 5457 sheet template, first-angle symbol (`src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg`, ids `first_angle_trapezoid` / `first_angle_*_circle`): the trapezoid is `m 389,222 -10,2.5 v 5 l 10,2.5 z` -- a 5-unit side at x = 379 and a 10-unit side at x = 389 -- and the circles (r 2.5 and r 5) are at cx = 396. Short side LEFT; end view to the RIGHT of the cone. * Wikimedia Commons `Convention placement vues dessin technique.svg`, which draws both symbols side by side, labelled FR and US. FR (first angle): trapezoid 35.856 units tall at x = 13.343 and 66.135 at x = 81.869, circles at x = 148.005. US (third angle): trapezoid 35.856 at x = 380.275 and 66.135 at x = 448.801, circles at x = 314.140. So, in both symbols alike, the trapezoid's SHORT side is on the left; only the end view changes side: first angle cone, then the circles to its RIGHT third angle the circles, then the cone to their RIGHT which is the same thing as the rule the standard is usually quoted by -- the short side points AWAY from the circles in first angle and TOWARDS them in third. `tests/test_sheet_titleblock.py` pins the ordering against the measured figures above, so this cannot be "fixed" back by eye. 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. They keep the 1:2 small-to-large ratio both reference figures' circles have. """ 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 # The end view sits right of the cone in first angle, left of it in third. sign = 1.0 if angle == "first" else -1.0 circles_x = (half - PROJECTION_R) * sign # The trapezoid itself is the same picture in both symbols: short side left. cone_left = -half if angle == "first" else half - PROJECTION_CONE_LENGTH cone_right = cone_left + PROJECTION_CONE_LENGTH cone = Poly( points=( (cx + cone_left, cy - PROJECTION_SMALL_R), (cx + cone_right, cy - PROJECTION_R), (cx + cone_right, cy + PROJECTION_R), (cx + cone_left, 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 )" ```