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
18 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).