The plan embeds Task 12's module and tests verbatim, so it still carried the `_current_space` read and the derivation that put the third-angle cone's short side away from the circles. Both replaced with what shipped, so a re-run of the task cannot transcribe the defects back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
901 KiB
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 …. Neveruv sync(the shell's barepythonis 3.14 without dependencies). Ifuv run --frozenfails withos error 32on.venv/Scripts/autocad-mcp.exe, a running MCP server holds the launcher: useuv run --frozen --no-sync …for the rest of the task and say so indeviations. - Commit with
git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commitand end every message with these two lines verbatim:Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>andClaude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd. - Known red that is not yours:
tests/test_release_consistency.py::test_document_numbers_match_the_treedrifts 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 aSOURCEdocstring 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 indeviations. - 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.pyis 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 <changed files>anduv run --frozen ruff format <changed files>. - Registration gates (only when you register a tool): regenerate
docs/tool-inventory.jsonwithuv run --frozen python scripts/generate_tool_inventory.py, bump the README release-snapshot tool count, bump the frozen group-size snapshot intests/test_tool_registry.py, add an alias record per tool indiscovery/aliases.py(tests/test_discovery_aliases.pyfails 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.
# 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": "<hex>", "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
# 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("<path>: <reason>")
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
# 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 theSHARED_ACAD_COMMANDSadditions 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.pyand 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_BYTESandapp_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) -> dictreturning exactly{"pattern", "angle", "scale"},MATERIALS: tuple[str, ...],MATERIAL_TABLE: dict[str, dict](the full rows,sourceincluded),ALIASES: dict[str, str].
-
Step 1: Write the failing test for the primitive descriptions
Create tests/test_mech_primitives.py:
"""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.pyand the package__init__
engineering/mech/primitives.py:
"""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:
"""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:
"""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
"""``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": "<hex>", "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:
"""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
"""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:
"""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
"""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) <noreply@anthropic.com>
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) -> dictandMATERIALS: 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):
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("<path>: <reason>") 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) -> boolfeaturesis annotatedtuple[Any, ...]rather than the skeleton'stuple[Feature, ...]:Featureis a Protocol inengineering/mech/features.py(Task 3), which imports this module, so naming it here would be a circular import. The surface is duck-typed andvalidate_partchecks it at runtime, so every caller written against the pinned annotation is unaffected. The duck-typed feature surfacebuild_partrelies on (Task 3 implements it):feature.id: str,feature.validate(part) -> None,feature.to_dict() -> dict, optionalfeature.removal_box(part) -> tuple[float, float, float, float] | None, optionalfeature.expand() -> tuple[Feature, ...]. -
Step 1: Write the failing test
Create tests/test_mech_part.py:
"""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
"""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:
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) <noreply@anthropic.com>
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) -> dictwith keyswidth,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:
# 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:
"""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
"""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:
"""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:
"""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:
"""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:
"""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:
"""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:
"""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
"""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:<index>'.") 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:<index>'.")
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
# -- 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:<index>'); 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
# -- 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):
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) <noreply@anthropic.com>
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'sprims / dims / removal_box / radial_edit / corner_edit / outline_edit / omission / axisymmetric. -
Produces (Tasks 5, 6, 7 consume exactly these):
@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) -> Ptlayout_origincarries 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:
"""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
"""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):
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) <noreply@anthropic.com>
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_KINDSgrows to five andbuild_viewroutes 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):
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:
"""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:
VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail")
#: 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:
# -- 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:
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:
from engineering.mech.primitives import Line, Poly, Prim, Pt, bbox
Then append:
# -- 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
del plane, style, detail # the orthographic kinds take no plane or detail
with
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
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) <noreply@anthropic.com>
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) -> FitDeviationwith.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 asserver.py::_fit_to_tolerancesdoes). -
Produces (Task 7 consumes exactly these):
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 thresholdTwo 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, andhole_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:
"""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
"""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=<ISO 286 code> 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):
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) <noreply@anthropic.com>
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(mechadded 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.toolregistrations, immediately above the# ── RESOURCES ──banner) - Modify:
discovery/aliases.py(oneToolAliasesrecord per new tool) - Modify:
docs/tool-inventory.json(regenerated, never hand-edited) - Modify:
README.md(release snapshot 204 → 210, advertised 199 → 205, the<sub>204 tools in 23 groupscaption →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 gainsmech; 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_valuesis the chunk-strings helper Task 1 ships for exactly this call, so this module does not re-derive it fromencode;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
partXDATA payload is the skeleton's pinned shape plus two additive keys, both written by this task and read back byread_part,mech_part_inspectand Task 15's critique index (every reader uses.get, so a payload without them still decodes):{"v": 1, "kind": "part", "id": "<anchor handle>", "view": "front", "part": {...part_to_dict...}, "placement": {"at": [x, y], "rotation": deg, "projection": "first"}, "views": [<view record>, ...]}.placementis what makes the WCS mapping re-readable months later, andviewsis what letsmech_view_addlay a second view out against the first without the caller re-describing either.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) -> dictThree 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, anddimension_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:
"""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
"""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
mechas a group and a pack inserver.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:
TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
(d) In PACK_TOOL_NAMES, add after the "settings" entry:
# 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:
# ---------------------------------------------------------------------------
# ── 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.
# ── 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
<sub>204 tools in 23 groups; to <sub>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.pyfor the fourth pack
Three edits, all mechanical:
(a) Replace the ALL_PACKS line with:
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):
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) <noreply@anthropic.com>
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_HEADSOURCE_ISO_4014 / SOURCE_ISO_4017 / SOURCE_ISO_4032 / SOURCE_ISO_7089 / SOURCE_ISO_4762 / SOURCE_ISO_261: strnominal_diameter(size) -> float,thread_pitch(size) -> floathex_head(size) -> dict,hex_nut(size) -> dict,washer(size) -> dict,socket_head(size) -> dictthread_length(size, length: float, standard: str = "ISO 4014") -> floatacross_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: floatSOURCE_ISO_15 / SOURCE_ISO_8826_1 / SOURCE_ISO_8826_2: strbearing(designation) -> dictreturning{"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:
"""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
"""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:
"""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
"""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) <noreply@anthropic.com>
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.toolregistrations,mechadded 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 helperentity_set_xdatatakes, so this module does not re-derive it fromencode— andengineering.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 fromengineering.mech.features.FEATURE_KINDS(Task 3), which is a different and longer tuple: this one is the five standard featuresstd_feature_drawcan add to geometry that already exists.parse_designation(designation: str) -> dictcatalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...]block_spec(designation: str, view: str) -> dictasync 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-onlymaterial, because the pinnedstd_partXDATA payload carries amaterialfield andattributes={"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:
"""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
"""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 "
"'<standard> - <size>', 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:
@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_parttoengineering/mech/stdparts.py
Append to the module:
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:
"""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:
# ── 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:
TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
(d) In PACK_TOOL_NAMES, add after the "settings" entry:
# 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):
# ---------------------------------------------------------------------------
# ── 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.
# ── 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
<sub>204 tools in 23 groups; to <sub>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) <noreply@anthropic.com>
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 thesurface_texture/weld_symbolregistrations, immediately above the# ── RESOURCES ──banner) - Modify:
discovery/aliases.py(oneToolAliasesrecord per new tool) - Modify:
docs/tool-inventory.json(regenerated, never hand-edited) - Modify:
README.md(release-snapshot tool count 204 → 206, and the<sub>204 tools in 23 groupsline) - Modify:
tests/test_tool_registry.py(frozen group snapshot:engineering10 → 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):
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:
"""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.pywith the transcribed tables and the surface-texture geometry
"""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:
# ── 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):
# ── 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:
# ── 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:
# ── 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:
# ── 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:
# ---------------------------------------------------------------------------
# ── 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
<sub>204 tools in 23 groups; line to <sub>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) <noreply@anthropic.com>
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); appendcentre_marks,section_line,hatch_materialinside SECTION 23) - Modify:
discovery/aliases.py(threeToolAliasesrecords) - Modify:
docs/tool-inventory.json(regenerated) - Modify:
README.md(release-snapshot tool count 206 → 209, and the<sub>206 tools in 23 groupsline) - Modify:
tests/test_tool_registry.py(frozen group snapshot:engineering12 → 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"}) andMATERIALS: 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 carryproperties["center"]andproperties["radius"];radiusis 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:
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:
"""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.pywith the centre-mark geometry
"""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:
# ── 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:
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:
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:
# ── 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:
# ── 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:
"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
# ── SECTION 23: Mechanical Annotation (2 tools) ─────────────────────────────
to
# ── SECTION 23: Mechanical Annotation (5 tools) ─────────────────────────────
and append these three registrations at the end of SECTION 23 (still above the
# ── RESOURCES ── banner):
@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
<sub>206 tools in 23 groups; line to <sub>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) <noreply@anthropic.com>
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 intoengineering/sheet/titleblock.py; what is left is the historical entry point —apply_iso_a3_titleblockforwards toapply_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-exportTitleBlockMetadata,apply_iso_a3_titleblockunchanged; addapply_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) -> dictengineering/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:
"""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:
"""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):
"""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:
# ── 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:
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:
# ── 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:
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.
"""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:
"""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:
"""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
from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
with
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__:
"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:
# ---------------------------------------------------------------------------
# ── 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:
"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) <noreply@anthropic.com>
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;REVCLOUDandTABLEjoinSHARED_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 stringsentity_set_xdata(handle, app_name, values)takes —encodereturns(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_balloonshere, themech_bom_balloon_mismatchfocus in Task 15) uses.get, so a payload without it still decodes. The revision block is not anACADMCP_MECHpayload:KINDSis pinned to three and a fourth kind would be refused byencode, soengineering/sheet/revision.pycarries 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) -> dictengineering/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, andengineering.mech.xdata.KINDSis pinned to("part", "std_part", "balloon"), so it gets its own XDATA application instead of a fourth kind.entity_set_xdatatakes 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:
"""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:
"""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:
# ── 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:
#: 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:
"""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:
"""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:
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:
@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:
"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) <noreply@anthropic.com>
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(composeRefsContractintoAutoCADBackend) - Modify:
backends/ezdxf_backend.py(the four implementations +dwg_write/xref_live/xlsx_writeincapabilities()) - 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)withasync 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:
"""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:
"""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):
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:
# ── 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:
# 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):
# ── 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:
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():
# 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:
"""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:
"""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:
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:
@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:
"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) <noreply@anthropic.com>
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 theCritiqueFocusLiteral and intoALL_CRITIQUE_FOCUSES) - Modify:
engineering/critique.py(registerMECH_DISPATCHthe wayPID_DISPATCHis registered) - Modify:
server.py(TOOL_PACK_NAMESgains"mech";PACK_TOOL_NAMES["mech"]; five names intoLEAN_TOOL_NAMES;_GROUP_TAG_PRIORITY/_GROUP_TAG_LABELSgainmechandsheet; the two focus lists indrawing_critiqueanddrawing_finalize) - Modify:
discovery/aliases.py(SHARED_ACAD_COMMANDSgains 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<sub>pack line) - Test:
tests/test_mech_critique.py
Interfaces:
-
Consumes:
engineering/mech/primitives.py::ROLE_LAYERandengineering/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.propertieskeys measured on both engines —bounding_box: {"min": [x, y], "max": [x, y]}on every entity,center/radiuson CIRCLE and ARC,start/endon LINE,insertion/block_nameon INSERT,defpointon 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 areasync check(backend, shared) -> list[Issue]withcheck.needs_shared = True;server.PACK_TOOL_NAMES["mech"](14 names) andserver.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:
"""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
"""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:
"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:
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
mechpack and the five lean names inserver.py
Replace TOOL_PACK_NAMES = ("core", "pid", "settings") with:
TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
and add to PACK_TOOL_NAMES, after the "settings" entry:
# 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:
# 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):
_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:
"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:
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:
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"]toALL_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:
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, addassert MECH_TOOLS <= disabledandassert not (SHEET_TOOLS & disabled); - in
test_all_is_the_default_and_enables_every_pack, change the last assertion toassert info["tool_packs"]["enabled"] == sorted(ALL_PACKS)and addassert not (MECH_TOOLS & disabled); - in
test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack, change the docstring'slean = 55tolean = 60, changelen(server.LEAN_TOOL_NAMES) == 55to== 60, change the final assertion toassert info["enabled_count"] == 60 - 6, "lean minus the three pid and three mech tools", and addassert LEAN_MECH_ESSENTIALS <= server.LEAN_TOOL_NAMES; - in
test_the_three_track_e_sections_file_under_their_own_groups, changeassert len(groups) == 23toassert len(groups) == 25and add:
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 ):
# 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 <sub> line under "What you get" — 204 tools in 23 groups becomes 229 tools in 25 groups, and the pack sentence becomes:
<sub>229 tools in 25 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> 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) <noreply@anthropic.com>
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 fromtasks_v5, add_task_mech_assembly) - Modify:
benchmarks/correctness_suite.py(32 → 39 checks: the spec's six plusmech_thread_unrepresented_is_caught, argued in Step 6) - Modify:
benchmarks/token_suite.py(aTOOL_PACKS=core,mechidle row) - Modify:
scripts/check_doc_numbers.py(derive fromTASKS_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) -> ValidationResultwith.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 pinnedengineering/mech/{draw,part,stdparts,marks,dimension,views,primitives,xdata}andengineering/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 newCHECKSentries inbenchmarks/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:
# 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.pyand point the adapter at it
benchmarks/tasks_v5.py:
"""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
from benchmarks.tasks_v4 import TaskSpec
to
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_assemblyto the adapter
Insert into AutoCADMCPProAdapter, directly after _task_page_setup_truth:
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:
# ── 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:
"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 <name>) 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
"""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_V4tofrom benchmarks.tasks_v5 import TASKS_V5; -
change
"v4_tasks": len(TASKS_V4),to"matrix_tasks": len(TASKS_V5),; -
change the last
CHECKSrow 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,mechidle 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":
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.mdand 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) | <advertised_tools> | <tokens> tokens |
| `TOOL_PACKS=core,mech` (full profile) | <advertised_tools> | <tokens> tokens |
| `TOOL_PACKS=core` (full profile) | <advertised_tools> | <tokens> tokens |
| `TOOL_PROFILE=lean` | <advertised_tools> | <tokens> tokens |
| `DISCOVERY_MODE=search` | 2 | **<tokens> 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
<version from the report>: the A3 frame and ISO 7200 title block, a two-step
shaft with a full section and <dimensions> chain dimensions, an ISO 4014
M12x60 block carrying <xdata_chunks> ACADMCP_MECH chunks, an ISO 7573 parts
list, an xref attached and detached, and a real <bytes>-byte `.dwg` written
by ActiveX `SaveAs` — `drawing_critique(focus=None)` returned
<critique_issues> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
MSG
)"