Files
autocad-mcp-pro/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md
T
Umut CANandClaude Opus 5 c97be8fc40 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
2026-09-22 14:56:37 +03:00

18 KiB
Raw Blame History

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.

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