docs(plan): tracks B+G skeleton — 16 tasks in five groups, interfaces pinned

The header, global constraints, file structure, the interface block every task
builds against, the group/ownership table and the merge notes. Task sections
are drafted next.

Also corrects the spec's architecture section: the engineering tools compose
the generic contract members the way gear/keyway/titleblock already do, so only
four operations (xref_attach, xref_manage, image_attach, drawing_export_dwg)
need contract members and an implementation per engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 14:56:37 +03:00
co-authored by Claude Opus 5
parent 5ff66b641f
commit c97be8fc40
2 changed files with 321 additions and 2 deletions
@@ -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) <noreply@anthropic.com>` 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 <changed files>` and
`uv run --frozen ruff format <changed files>`.
- **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": "<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
```
```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("<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
```
```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).
---
@@ -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,