diff --git a/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md new file mode 100644 index 0000000..7094fe1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md @@ -0,0 +1,307 @@ +# Tracks B + G — mechanical 2D part drawer and sheet standard: implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One part model and one view engine that draw any revolved or prismatic +machine part — with hidden lines, sections, standard features and ISO +dimensions — plus the standard-parts catalogue, the ISO annotation symbols and +the sheet standard (frames, title blocks, revisions, parts list, balloons, +xrefs, DWG), as 25 tools on both engines. + +**Architecture:** Pure geometry modules (`engineering/mech/*`, +`engineering/sheet/*`) emit typed primitive *descriptions*; one async layer +(`draw.py` and the server tools) turns them into backend calls, exactly the way +`gear_draw_*` and `titleblock_apply_iso_a3` already work. Only four operations +differ per engine and become contract members (`xref_attach`, `xref_manage`, +`image_attach`, `drawing_export_dwg`). + +**Tech Stack:** Python 3.11, FastMCP 3, ezdxf 1.4.x, pywin32 (COM), pytest + +pytest-asyncio, ruff. Everything runs through `uv run --frozen`. + +**Spec:** `docs/superpowers/specs/2026-09-22-v1.6-mech-design.md` — read it +before your task; this plan argues from it and does not repeat its reasoning. + +## Global Constraints + +- **Run everything through `uv run --frozen …`.** Never `uv sync` (the shell's + bare `python` is 3.14 without dependencies). If `uv run --frozen` fails with + `os error 32` on `.venv/Scripts/autocad-mcp.exe`, a running MCP server holds + the launcher: use `uv run --frozen --no-sync …` for the rest of the task and + say so in `deviations`. +- **Commit** with + `git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit` + and end every message with these two lines verbatim: + `Co-Authored-By: Claude Opus 5 (1M context) ` and + `Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd`. +- **Known red that is not yours:** + `tests/test_release_consistency.py::test_document_numbers_match_the_tree` + drifts on the README "collected tests" figure whenever tests are added. + Deselect exactly that test in a worktree and say so; every other test in that + file must pass. Task 15 retakes the figure on main. +- **The table rule (spec §5).** Every row of every table under + `engineering/mech/standards/` is transcribed from the named standard's own + table, carries a `SOURCE` docstring naming standard + edition + table number, + and is pinned by a provenance test asserting named values. A size outside the + transcribed range is refused by name — never interpolated. **If you cannot + verify a row, do not ship the row**: narrow the coverage, say so in the + docstring and in `deviations`. +- **Validate before writing** (CLAUDE.md): every refusal fires before the first + entity reaches the drawing, names the offending path (`segments[2].d_outer`), + and leaves the drawing untouched. +- **WCS only.** Every coordinate in or out of a tool is WCS. Part-local + coordinates exist inside the pure modules; `draw.py` is the only place that + maps them, through the part's placement (`at`, `rotation`). +- **No silent wrong number.** A feature that cannot be projected into a view is + reported in `omitted: [{"feature": …, "reason": …}]`, never dropped. +- **Layers** come from the role map in `engineering/mech/primitives.py` + (`visible→GEOMETRY`, `hidden→HIDDEN`, `center→CENTER`, `phantom→PHANTOM`, + `hatch→HATCH`, `dim→DIM`, `text→TEXT`); no tool hardcodes a layer name. +- **Before committing:** `uv run --frozen ruff check ` and + `uv run --frozen ruff format `. +- **Registration gates** (only when you register a tool): regenerate + `docs/tool-inventory.json` with + `uv run --frozen python scripts/generate_tool_inventory.py`, bump the README + release-snapshot tool count, bump the frozen group-size snapshot in + `tests/test_tool_registry.py`, add an alias record per tool in + `discovery/aliases.py` (`tests/test_discovery_aliases.py` fails otherwise). +- **Deviations are data.** If a plan test asserts something the real API cannot + satisfy, verify the truth by running code, fix the *test* to that truth while + keeping the behaviour the spec demands, and record it with the evidence. + Never delete, skip or weaken a test to get green. + +--- + +## File Structure + +| File | Responsibility | Task | Group | +|---|---|---|---| +| `engineering/mech/primitives.py` | typed primitive descriptions + role→layer map | 1 | shared | +| `engineering/mech/xdata.py` | chunked JSON codec for the `ACADMCP_MECH` payload | 1 | shared | +| `engineering/mech/standards/__init__.py` | table registry + `lookup()` + coverage refusal | 1 | shared | +| `engineering/mech/standards/materials.py` | ISO 128-50 material → hatch pattern/angle/scale | 1 | shared | +| `engineering/mech/part.py` | `RevolvedPart`, `PrismaticPart`, `build_part`, validation | 2 | M | +| `engineering/mech/features.py` | every feature dataclass, its validation and geometry | 3 | M | +| `engineering/mech/standards/threads.py` | ISO 261/965 pitch + ISO 6410 representation rules | 3 | M | +| `engineering/mech/standards/undercuts.py` | DIN 509 forms E/F | 3 | M | +| `engineering/mech/standards/rings.py` | DIN 471 / DIN 472 groove dimensions | 3 | M | +| `engineering/mech/standards/centres.py` | DIN 332 forms A/B/R | 3 | M | +| `engineering/mech/standards/orings.py` | ISO 3601-2 groove dimensions | 3 | M | +| `engineering/mech/views.py` | the view engine: orthographic (4), sections + details (5) | 4, 5 | M | +| `engineering/mech/dimension.py` | dimension intents + overlap-free layout | 6 | M | +| `engineering/mech/draw.py` | the async layer: primitives → backend calls | 7 | M | +| `engineering/mech/standards/fasteners.py` | ISO 4014/4017/4032/7089/4762 tables | 8 | S | +| `engineering/mech/standards/bearings.py` | ISO 15 series + ISO 8826-1 representation | 8 | S | +| `engineering/mech/stdparts.py` | catalogue → block specs → insert + XDATA | 9 | S | +| `engineering/mech/annotate.py` | surface texture (ISO 21920-1), weld (ISO 2553) | 10 | A | +| `engineering/mech/marks.py` | centre marks, section line, material hatch | 11 | A | +| `engineering/sheet/frames.py` | ISO 5457 sizes, margins, zones, centring/trim marks | 12 | G | +| `engineering/sheet/titleblock.py` | ISO 7200 for A4–A0 (today's A3 module folds in) | 12 | G | +| `engineering/sheet/revision.py` | revision block rows, clouds, triangle tags | 13 | G | +| `engineering/sheet/bom.py` | extract → rows → ISO 7573 table; ISO 6433 balloons | 13 | G | +| `engineering/sheet/extract.py` | rows → CSV (always) / XLSX (optional dependency) | 14 | G | +| `backends/contracts/refs.py` | the four engine-specific members | 14 | G | +| `backends/ezdxf_backend.py` | their ezdxf implementations | 14 | G | +| `backends/com_backend.py` | their COM implementations | 14 | G | +| `engineering/mech/critique.py` | the six `mech_*` critique focuses | 15 | F | +| `server.py` | SECTION 21 (7) · 22 (9) · 23 (10, 11) · 24 (12, 13, 14) | per task | per group | +| `benchmarks/tasks_v5.py`, `benchmarks/correctness_suite.py` | evidence | 16 | F | +| `scripts/smoke_mech_com.py` | live COM execution, once | 16 | F | + +## Interfaces (the contract between tasks) + +Task 1 lands on `main` before the groups branch; every other task consumes it. + +```python +# engineering/mech/primitives.py +Role = Literal["visible", "hidden", "center", "phantom", "hatch", "dim", "text"] +ROLE_LAYER: dict[Role, str] # visible→GEOMETRY, hidden→HIDDEN, center→CENTER, + # phantom→PHANTOM, hatch→HATCH, dim→DIM, text→TEXT +Pt = tuple[float, float] + +class Line(NamedTuple): p1: Pt; p2: Pt; role: Role = "visible" +class Arc(NamedTuple): center: Pt; radius: float; start_deg: float; end_deg: float; role: Role = "visible" +class Circle(NamedTuple): center: Pt; radius: float; role: Role = "visible" +class Poly(NamedTuple): points: tuple[Pt, ...]; closed: bool = False; role: Role = "visible" +class HatchArea(NamedTuple): + loops: tuple[tuple[Pt, ...], ...] # [0] outer, [1:] islands + material: str = "steel" +class Text(NamedTuple): at: Pt; text: str; height: float = 3.5; rotation: float = 0.0; role: Role = "text" +class DimIntent(NamedTuple): + kind: Literal["linear", "aligned", "diameter", "radius", "angular"] + p1: Pt; p2: Pt + text_at: Pt | None = None + text_override: str | None = None + fit: str | None = None # e.g. "H7" → resolved by engineering/fits.py + tol: dict | None = None # {"mode": …, "upper": …, "lower": …} → engineering/tolerances.py + feature: str = "" # the feature id this dimension belongs to +Prim = Line | Arc | Circle | Poly | HatchArea | Text + +def bbox(prims: Iterable[Prim]) -> tuple[float, float, float, float] # (minx, miny, maxx, maxy) +def translate(prims: Iterable[Prim], dx: float, dy: float) -> tuple[Prim, ...] +def rotate(prims: Iterable[Prim], deg: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...] +def scale(prims: Iterable[Prim], factor: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...] + +# engineering/mech/xdata.py +APP_ID = "ACADMCP_MECH" +PAYLOAD_VERSION = 1 +def encode(payload: dict) -> list[tuple[int, str]] # 1001 app id + 1000 chunks ≤ 255 chars +def decode(rows: Sequence[tuple[int, str]]) -> dict # raises ValueError on a foreign/corrupt payload +# Payload shapes (pinned; both the writer and every reader test them literally): +# {"v": 1, "kind": "part", "id": "", "view": "front", "part": {...part_to_dict...}} +# {"v": 1, "kind": "std_part", "designation": "ISO 4014 - M12x60", "standard": "ISO 4014", +# "size": "M12x60", "qty": 1, "material": "8.8"} +# {"v": 1, "kind": "balloon", "item": 3, "targets": ["1A2B", …]} + +# engineering/mech/standards/__init__.py +class Coverage(NamedTuple): low: float; high: float; unit: str = "mm" +def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None +def lookup(standard: str, size, **kw) -> dict # ValueError naming standard + coverage when outside +def standards() -> tuple[str, ...] + +# engineering/mech/standards/materials.py +def hatch_for(material: str) -> dict # {"pattern": "ANSI31", "angle": 45.0, "scale": 1.0} +MATERIALS: tuple[str, ...] # ValueError lists these when the name is unknown +``` + +```python +# engineering/mech/part.py (Task 2) +class Segment(NamedTuple): + length: float; d_outer: float; d_inner: float = 0.0; taper_to: float | None = None +@dataclass(frozen=True) +class RevolvedPart: + name: str; segments: tuple[Segment, ...]; features: tuple[Feature, ...] = () + material: str = "steel" +@dataclass(frozen=True) +class PrismaticPart: + name: str; outline: tuple[Pt, ...]; thickness: float; features: tuple[Feature, ...] = () + material: str = "steel" +Part = RevolvedPart | PrismaticPart +def build_part(spec: dict) -> Part # validates; ValueError(": ") +def part_to_dict(part: Part) -> dict # round-trips through build_part +def part_length(part: Part) -> float +def part_max_diameter(part: RevolvedPart) -> float + +# engineering/mech/features.py (Task 3) +@runtime_checkable +class Feature(Protocol): + id: str + def validate(self, part) -> None # ValueError naming the path + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...] + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...] + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...] +def feature_from_dict(d: dict) -> Feature # the "kind" key selects the class +FEATURE_KINDS: tuple[str, ...] + +# engineering/mech/views.py (Tasks 4, 5) +@dataclass(frozen=True) +class View: + kind: Literal["front", "side", "top", "section", "detail"] + prims: tuple[Prim, ...] + dims: tuple[DimIntent, ...] + omitted: tuple[dict, ...] + bbox: tuple[float, float, float, float] + label: str | None = None + scale: float = 1.0 +def build_view(part, kind, *, side="right", plane=None, style="full", + scale=1.0, projection="first", detail=None) -> View +def layout_origin(parent: View, kind, *, side="right", projection="first", gap=20.0) -> Pt +SECTION_PLANE = {"p1": (0.0, 0.0), "p2": (0.0, 0.0), "label": "A", + "direction": (0.0, -1.0), "style": "full"} # the shape Task 11 produces + +# engineering/mech/dimension.py (Task 6) +def dimension_intents(part, view: View, *, style="chain", hole_table_threshold=8) -> tuple[DimIntent, ...] +def layout_dimensions(view: View, intents, *, offset=10.0, step=8.0) -> tuple[DimIntent, ...] +def hole_table_rows(part, view: View) -> tuple[dict, ...] # {"tag","x","y","d","qty","note"} + +# engineering/mech/draw.py (Task 7) +async def draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer_prefix="") -> dict +async def draw_part(backend, part, *, at=(0.0, 0.0), rotation=0.0, views=("front",), + projection="first", dimension=True) -> dict # {"part_id","views":[…],"handles":[…]} +async def add_view(backend, part_id, kind, **kw) -> dict +async def dimension_part(backend, part_id, *, style="chain") -> dict +async def read_part(backend, part_id=None) -> dict # {"part_id","part",…} from XDATA +``` + +```python +# engineering/mech/stdparts.py (Task 9) +def catalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...] +def block_spec(designation: str, view: str) -> dict # for block_define +async def insert_std_part(backend, designation, *, at, view="side", rotation=0.0, + layer="GEOMETRY", attributes=None) -> dict + +# engineering/mech/annotate.py (Task 10) +def surface_texture_prims(*, ra=None, rz=None, process=None, lay=None, + machining="any", all_around=False, height=3.5) -> tuple[Prim, ...] +def weld_symbol_prims(*, kind, size=None, length=None, pitch=None, side="arrow", + field_weld=False, all_around=False, process=None, height=3.5) -> tuple[Prim, ...] + +# engineering/mech/marks.py (Task 11) +def centre_mark_prims(center: Pt, radius: float, *, style="mark", extension=3.0) -> tuple[Prim, ...] +def section_line_prims(p1: Pt, p2: Pt, *, label="A", direction=(0.0, -1.0), height=5.0) -> tuple[Prim, ...] + +# engineering/sheet/frames.py (Task 12) +SHEETS: dict[str, tuple[float, float]] # "A4"…"A0" → (w, h) portrait, ISO 216 +def frame_prims(size: str, *, orientation="landscape", zones=True, marks=True) -> tuple[Prim, ...] +def frame_metrics(size: str, *, orientation="landscape") -> dict # margins, drawing area, zone grid + +# engineering/sheet/titleblock.py (Task 12) +def titleblock_prims(size: str, fields: dict, *, orientation="landscape", + projection="first") -> tuple[Prim, ...] +TITLEBLOCK_FIELDS: tuple[str, ...] # ISO 7200 data fields + +# engineering/sheet/bom.py (Task 13) +def rows_from_records(records: Sequence[dict], *, columns=None, group_by="designation") -> tuple[dict, ...] +def table_prims(rows, *, at, direction="up", standard="ISO 7573", height=7.0) -> tuple[Prim, ...] +def balloon_prims(item: int, at: Pt, leader_to: Pt, *, radius=4.0) -> tuple[Prim, ...] + +# backends/contracts/refs.py (Task 14) +async def xref_attach(self, path: str, at, scale: float, rotation: float, kind: str) -> dict +async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict +async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict +async def drawing_export_dwg(self, path: str, version: str) -> dict +``` + +## Execution + +| Group | Branch / worktree | Tasks | Owns | +|---|---|---|---| +| **(main)** | `main` | 1 | `engineering/mech/{primitives,xdata}.py`, `standards/{__init__,materials}.py` | +| **M** | `track-bg/mech` | 2, 3, 4, 5, 6, 7 | `engineering/mech/{part,features,views,dimension,draw}.py`, `standards/{threads,undercuts,rings,centres,orings}.py`, server SECTION 21 | +| **S** | `track-bg/std` | 8, 9 | `engineering/mech/stdparts.py`, `standards/{fasteners,bearings}.py`, server SECTION 22 | +| **A** | `track-bg/annot` | 10, 11 | `engineering/mech/{annotate,marks}.py`, server SECTION 23 | +| **G** | `track-bg/sheet` | 12, 13, 14 | `engineering/sheet/*`, `backends/contracts/refs.py`, both backends, server SECTION 24 | +| **F** | `main` (after merge) | 15, 16 | critique focuses, packs/lean/aliases/golden, benchmarks, smoke, docs | + +Task 1 lands on `main` first; the four group worktrees branch from that commit +and run in parallel. Tasks inside a group are sequential. + +### Merge notes (for the agent that merges the four branches) + +Merge order **M → S → A → G**. Expected conflicts, all anticipated: + +- `server.py`: each group appends its section before the `# ---` RESOURCES + banner. Keep all four, in order 21 → 22 → 23 → 24, and restore the banner at + the seam. +- `engineering/mech/__init__.py`: additive re-exports — keep every side's names, + alphabetical. +- `discovery/aliases.py`: additive — keep every group's alias records and merge + the `SHARED_ACAD_COMMANDS` additions alphabetically. +- `docs/tool-inventory.json`: regenerate after resolving the code + (`uv run --frozen python scripts/generate_tool_inventory.py`), never + hand-merge. +- `README.md`: the release-snapshot line — recompute tools/resources from the + regenerated inventory; leave the collected-tests figure to Task 15. Keep every + group's "What you get" row. +- `tests/test_tool_registry.py`: recompute the frozen group-size snapshot from + the live registry; concatenate the docstring paragraphs. +- `backends/base.py` and the two capability maps: only group G touches them + (`dwg_write`, `xref_live`, `xlsx_write`) — take G's side whole. +- Any conflict in a file **not** listed: `git merge --abort`, record the branch + as not merged, continue with the next branch. + +After the merges: `uv run --frozen ruff check .`, the full suite, and the gate +set (inventory `--check`, `tests/test_tool_registry.py`, +`tests/test_capability_contract.py`, `tests/test_discovery_aliases.py`, +`tests/test_release_consistency.py` — its collected-tests drift is Task 15's). + +--- diff --git a/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md b/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md index 55b0d07..27e6368 100644 --- a/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md +++ b/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md @@ -78,6 +78,8 @@ engineering/mech/ views.py the view engine: silhouette, hidden lines, section, detail dimension.py dimension intents per feature → laid-out DIMENSION calls draw.py async draw_*(backend, …) — the only module that touches a backend + primitives.py the typed primitive descriptions the pure modules emit + (Line, Arc, Circle, Poly, HatchArea, DimIntent, Role → layer) xdata.py ACADMCP_MECH payload: the part model written onto the drawing critique.py the six mech_* critique focuses standards/ @@ -99,12 +101,22 @@ engineering/sheet/ extract.py attributes/XDATA → CSV (always) / XLSX (optional dependency) refs.py xref attach/list/reload/bind/detach, image attach -backends/contracts/mech.py (new) part/view/dimension/standard-part/annotation members -backends/contracts/sheet.py (new) frame/titleblock/revision/bom/xref/image/dwg members +backends/contracts/refs.py (new) xref_attach, xref_manage, image_attach, drawing_export_dwg server.py SECTION 21 (6) · SECTION 22 (3) · SECTION 23 (5) · SECTION 24 (11) scripts/smoke_mech_com.py live COM execution of every new COM path, once ``` +**Only four new contract members.** Corrected while planning, 2026-09-22: the +existing engineering tools (`gear_draw_*`, `keyway_draw_*`, +`titleblock_apply_iso_a3`) are **not** contract members — they take the backend +and compose the generic ones (`entity_create_*`, `dimension_*`, `block_define`, +`entity_set_xdata`). Every mechanical, annotation, parts-list and frame tool +follows that pattern, so it is written **once** and works on both engines by +construction. Only the four operations that genuinely differ per engine become +contract members with an implementation each: `xref_attach`, `xref_manage`, +`image_attach`, `drawing_export_dwg`. This removes two contract files, halves +the backend work, and keeps the capability map to the three keys of §12. + The split that matters: **`part.py`, `features.py`, `views.py`, `dimension.py` and everything under `standards/` are pure** — no backend, no async, no DXF. They take numbers and return primitive *descriptions* (typed tuples: lines,