Files
autocad-mcp-pro/docs/superpowers/plans/2026-09-23-v1.6-arch-implementation.md
T
Umut CANandClaude Opus 5.5 3248bfbfea docs(plan): track F skeleton — 10 tasks in three groups, interfaces pinned
Header, global constraints, file structure, the interface block every task
builds against, the group/ownership table, the SECTION 25 banner protocol and
the merge notes. Task sections are drafted next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 14:50:07 +03:00

15 KiB
Raw Blame History

Track F — architectural 2D (the plan model): 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: A plan model (a wall network with hosted doors and windows) and one engine that draws it with clean junctions, cut openings and material poché, computes rooms as the faces the walls enclose, reads rooms from foreign plans, and delivers schedules, a furniture/sanitary catalogue, a grid, symbols and dimension chains — 12 tools on both engines.

Architecture: Pure modules under engineering/arch/ emit the typed primitives of engineering/mech/primitives.py with architectural roles; the async layer draws them through engineering.mech.draw.draw_prims (generalised in Task 1 to take a role → layer map), composing only generic contract members — no new contract member, so both engines work by construction.

Tech Stack: Python 3.11, FastMCP 3, ezdxf 1.4.x, pywin32 (COM), pytest + pytest-asyncio, ruff — everything through uv run --frozen.

Spec: docs/superpowers/specs/2026-09-23-v1.6-arch-design.md — read it before your task.

Global Constraints

  • Run everything through uv run --frozen …, never uv sync. If it fails with os error 32 on .venv/Scripts/autocad-mcp.exe, use uv run --frozen --no-sync … for the rest of the task and say so.
  • Commit with git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit, message ending with these two lines verbatim: Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> and Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd.
  • Known red: 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. Task 9 retakes the figure on main.
  • Units are millimetres, coordinates are WCS, angles in degrees CCW from +X. A wall's justification is the side of its axis the wall lies on, looking along the axis from its first point: left, right or center.
  • Validate before writing: every refusal fires before the first entity, names its path (walls[2].thickness), and leaves the drawing untouched.
  • Areas are measured, never typed. A room's area comes from the planar face its label point lies in; no tool accepts an area from its caller.
  • Language: lang is "en" (default) or "tr"; "tr" also switches the decimal separator to a comma. Unknown values are refused with the list.
  • Text heights follow the plot scale: scale is a denominator (50 → 1:50); a paper height h mm is drawn at h * scale drawing units. No annotative objects (recorded out of 1.6 in track E).
  • Layers come from engineering/arch/layers.py::ARCH_ROLE_LAYER; no tool hardcodes a layer name.
  • No standards value is invented. Catalogue sizes are nominal catalogue dimensions, stated as such in the data and the tool docstring. The Blondel range 600–650 mm is a design rule the stair tool reports, not a code.
  • Before committing: uv run --frozen ruff check <changed files> and uv run --frozen ruff format <changed files>.
  • Registration gates (when you register a tool): regenerate docs/tool-inventory.json (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.
  • 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 spec's behaviour, and record it. Never weaken a test.

File Structure

File Responsibility Task Group
engineering/mech/draw.py draw_prims gains role_layer= 1 main
engineering/mech/xdata.py codec gains app_id= / kinds= (mech defaults unchanged) 1 main
engineering/layers.py LAYER_SET_REGISTRY["arch"], LAYER_ROLES["arch"] 1 main
engineering/arch/__init__.py public re-exports 1 (+ each task appends) all
engineering/arch/layers.py ARCH_LAYERS, ARCH_ROLE_LAYER 1 main
engineering/arch/materials.py wall material → ISO 128-50 hatch 1 main
engineering/arch/lang.py EN/TR vocabulary, tag prefixes, number formatting 1 main
engineering/arch/model.py Wall/Opening/Stair/Room, validation, ACADMCP_ARCH payloads 1 main
engineering/arch/walls.py outlines, L/T/X junctions, opening gaps, poché 2 W
engineering/arch/openings.py door + window geometry 3 W
engineering/arch/stairs.py straight/L/U stairs, Blondel 3 W
engineering/arch/dimension.py exterior dimension chains 4 W
engineering/arch/draw.py async: walls, openings, stairs, chains 4 W
engineering/arch/faces.py planar-face finder (DCEL) 5 R
engineering/arch/rooms.py room labels (measured), foreign-plan reader 5 R
engineering/arch/schedule.py door/window/room schedules 6 R
engineering/arch/catalogue.py furniture + sanitary blocks 7 C
engineering/arch/symbols.py grid, north arrow, section/level/elevation marks 8 C
engineering/arch/critique.py three arch_* focuses 9 final
engineering/arch/spec.py arch_plan_from_spec 9 final
server.py SECTION 25 W opens the banner (Task 4); R and C append blocks 4–8 W/R/C

Interfaces (the contract between tasks)

# engineering/mech/draw.py (Task 1 — generalised, mechanical callers unchanged)
async def draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer_prefix="",
                     role_layer: Mapping[str, str] | None = None) -> dict
#   role_layer maps Prim.role -> layer; None keeps the mechanical ROLE_LAYER.
#   Every target layer is ensured before the first entity (live seats refuse
#   entity.Layer on a missing layer). A layer named in ARCH_LAYERS is created
#   with that row's colour, linetype and lineweight.

# engineering/mech/xdata.py (Task 1)
def encode(payload, *, app_id=APP_ID, kinds=KINDS) -> list[tuple[int, str]]
def to_values(payload, *, app_id=APP_ID, kinds=KINDS) -> list[str]
def decode(rows, *, app_id=APP_ID, kinds=KINDS) -> dict

# engineering/arch/layers.py (Task 1)
ARCH_LAYERS: list[tuple[str, int, str, float, str]]   # (name, aci, linetype, lineweight_mm, description)
ARCH_ROLE_LAYER: dict[str, str] = {
    "wall": "A-WALL-E-N", "wall_hatch": "A-WALL-H-N", "door": "A-DOOR-E-N",
    "window": "A-WIND-E-N", "stair": "A-STAIR-E-N", "furniture": "A-FURN-E-N",
    "sanitary": "A-SANR-E-N", "grid": "A-GRID-E-N", "room": "A-ROOM-T-N",
    "dim": "A-DIMEN-T-N", "symbol": "A-SYMB-T-N", "overhead": "A-OVHD-E-N",
}
# engineering/layers.py: LAYER_SET_REGISTRY["arch"] = ARCH_LAYERS;
#                        LAYER_ROLES["arch"] = {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"}

# engineering/arch/materials.py (Task 1)
WALL_MATERIALS = ("brick", "aac", "concrete", "reinforced_concrete", "gypsum_board", "stone", "timber", "generic")
def wall_hatch(material: str) -> dict        # {"pattern": str, "angle": float, "scale": float}; ValueError lists WALL_MATERIALS

# engineering/arch/lang.py (Task 1)
LANGS = ("en", "tr")
def vocab(lang: str) -> dict[str, str]       # keys: door_schedule, window_schedule, room_schedule, tag,
                                             # width, height, sill, swing, hand, wall, number, name, area,
                                             # total, up, down, north, level, in, out, left, right
def tag_prefix(kind: str, lang: str) -> str  # door → "D" | "K", window → "W" | "P"
def fmt_number(value: float, lang: str, decimals: int = 2) -> str   # "24.50" | "24,50"
def fmt_area_m2(area_mm2: float, lang: str) -> str                  # "24.50 m²" | "24,50 m²"

# engineering/arch/model.py (Task 1)
APP_ID = "ACADMCP_ARCH"
KINDS = ("wall", "opening", "stair", "room")
@dataclass(frozen=True)
class Wall:    id: str; axis: tuple[Pt, ...]; thickness: float; justification: str = "center"; material: str = "brick"; closed: bool = False
@dataclass(frozen=True)
class Opening: id: str; wall: str; kind: str; offset: float; width: float; swing: str = "in"; hand: str = "left"; sill: float | None = None; height: float | None = None; tag: str | None = None
@dataclass(frozen=True)
class Stair:   id: str; start: Pt; direction_deg: float; width: float; risers: int; riser_height: float; going: float; kind: str = "straight"; turn: str = "left"
@dataclass(frozen=True)
class Room:    id: str; name: str; number: str | None; at: Pt; area: float = 0.0
def wall_from_dict(d: dict, path: str = "wall") -> Wall          # ValueError("<path>.<field>: <reason>")
def opening_from_dict(d: dict, path: str = "opening") -> Opening
def stair_from_dict(d: dict, path: str = "stair") -> Stair
def room_from_dict(d: dict, path: str = "room") -> Room
def validate_openings(walls: Sequence[Wall], openings: Sequence[Opening]) -> None
    # an opening on an unknown wall, past its segment, straddling an axis vertex,
    # or overlapping another opening on the same wall -> ValueError naming both ids
def segment_of(wall: Wall, offset: float) -> tuple[int, float]   # (segment index, distance along it)
def to_payload(obj) -> dict       # {"v": 1, "kind": "wall"|"opening"|"stair"|"room", **fields}
def from_payload(payload: dict)   # -> Wall | Opening | Stair | Room
def encode(payload) / to_values(payload) / decode(rows)   # the mech codec with APP_ID/KINDS above

# engineering/arch/walls.py (Task 2)
@dataclass(frozen=True)
class WallGeometry:
    faces: tuple[Line, ...]          # role "wall": every face segment actually drawn
    jambs: tuple[Line, ...]          # role "wall": the short closing lines at opening edges and free ends
    regions: tuple[tuple[Pt, ...], ...]   # closed cut-face polygons (CCW), one per solid wall piece
    hatches: tuple[HatchArea, ...]   # one per region, material from wall_hatch (empty when poche=False)
    omitted: tuple[dict, ...]        # [{"element": id, "reason": str}]
def wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry
def room_segments(geometry: WallGeometry) -> tuple[tuple[Pt, Pt], ...]   # faces + jambs, for faces.py

# engineering/arch/openings.py (Task 3)
def opening_prims(wall: Wall, opening: Opening) -> tuple[Prim, ...]   # door: leaf + quarter swing arc (role "door"); window: frame + two glazing lines (role "window")
# engineering/arch/stairs.py (Task 3)
def blondel(riser_height: float, going: float) -> dict   # {"value": 2R+G, "ok": 600<=v<=650, "range": [600.0, 650.0]}
def stair_prims(stair: Stair, *, lang="en", scale=50) -> tuple[Prim, ...]   # treads, outline, walking line + arrow, "UP"/"ÇIKIŞ" label (role "stair")

# engineering/arch/dimension.py (Task 4)
def exterior_chains(walls, openings, *, sides=("bottom", "left"), first_offset=None, step=None, scale=50) -> tuple[DimIntent, ...]
# engineering/arch/draw.py (Task 4)
async def draw_walls(backend, walls, openings=(), *, poche=True) -> dict        # {"walls": [...], "openings": [...], "handles": [...], "omitted": [...]}
async def draw_stair(backend, stair, *, lang="en", scale=50) -> dict
async def draw_chains(backend, walls, openings, *, sides=("bottom","left"), scale=50) -> dict
async def read_plan(backend) -> dict                                            # {"walls": [Wall], "openings": [Opening], "stairs": [...], "rooms": [...]} from ACADMCP_ARCH

# engineering/arch/faces.py (Task 5)
@dataclass(frozen=True)
class Face: loop: tuple[Pt, ...]; area: float; centroid: Pt
def planar_faces(segments, *, tol=1.0) -> tuple[Face, ...]     # bounded faces only, CCW loops, area in mm²
def face_containing(faces, point) -> Face | None               # the smallest face containing the point
# engineering/arch/rooms.py (Task 5)
def room_label_prims(room: Room, *, lang="en", scale=50) -> tuple[Prim, ...]   # name, number, measured area (role "room")
def detect_rooms(segments, *, min_area=1.0e6, tol=1.0) -> tuple[dict, ...]     # {"loop", "area", "centroid", "confidence"}
async def label_room(backend, room_dict, *, lang="en", scale=50) -> dict       # reads wall faces from the drawing, measures, draws, writes the room record
async def rooms_detect(backend, *, layers=None, min_area=1.0e6, tol=1.0) -> dict   # never modifies the drawing

# engineering/arch/schedule.py (Task 6)
def schedule_rows(kind: str, plan: dict, *, lang="en") -> tuple[dict, ...]
async def draw_schedule(backend, kind, *, at, lang="en", scale=50) -> dict

# engineering/arch/catalogue.py (Task 7)
def catalogue(query: str | None = None, family: str | None = None, *, lang="en") -> tuple[dict, ...]
def block_spec(name: str) -> dict        # {"name", "entities", "attdefs"} for block_define
async def insert_item(backend, name, *, at, rotation=0.0) -> dict

# engineering/arch/symbols.py (Task 8)
def grid_prims(x_axes, y_axes, *, labels=None, extension=1500.0, scale=50) -> tuple[Prim, ...]
def symbol_prims(kind: str, at: Pt, *, lang="en", scale=50, **params) -> tuple[Prim, ...]
    # kind: "north_arrow" (rotation), "section_mark" (p1, p2, label), "level" (value), "elevation_mark" (label, directions)

Execution

Group Branch / worktree Tasks Owns
(main) main 1 engineering/arch/{__init__,layers,materials,lang,model}.py, the two mech generalisations, engineering/layers.py
W track-f/walls 2, 3, 4 walls.py, openings.py, stairs.py, dimension.py, draw.py; server SECTION 25 banner + wall block
R track-f/rooms 5, 6 faces.py, rooms.py, schedule.py; server SECTION 25 rooms block
C track-f/catalogue 7, 8 catalogue.py, symbols.py; server SECTION 25 catalogue block
final main (after merge) 9, 10 spec.py, critique.py, pack/lean/aliases/golden, benchmark, smoke, docs

Task 1 lands on main first; the three worktrees branch from that commit. Tasks inside a group are sequential.

SECTION 25 in server.py. Group W opens it in Task 4 with the banner # SECTION 25: Architecture (4 tools) followed by its four tools, then a line # ── arch: rooms (group R) ── and a line # ── arch: catalogue (group C) ── with nothing under them. Group R adds its three tools under the rooms marker and group C its four under the catalogue marker — on their own branches, where the markers do not exist yet, so each of R and C also opens the section: R writes the banner # SECTION 25: Architecture (3 tools) + its marker + its tools; C the banner (4 tools) + its marker + its tools. The merge keeps one banner (count 11 after the merge; Task 9 adds the twelfth) and the three blocks in the order walls → rooms → catalogue.

Merge notes

Order W → R → C. Expected conflicts, all anticipated: server.py SECTION 25 (one banner, three blocks, as above; the RESOURCES banner restored at the seam), engineering/arch/__init__.py (additive re-exports), discovery/aliases.py (additive), docs/tool-inventory.json (regenerate, never hand-merge), README release-snapshot tool count (recompute from the inventory; the collected-tests figure is Task 9's), tests/test_tool_registry.py group sizes (recompute from the live registry). Any other conflict: git merge --abort, record the branch, continue.