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:
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.
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user