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
This commit is contained in:
Umut CAN
2026-09-23 14:50:07 +03:00
co-authored by Claude Opus 5.5
parent 83f192ac2f
commit 3248bfbfea
@@ -0,0 +1,241 @@
# 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)
```python
# 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.
---