From b012fdf1be9f1365f97efee334bf69f6e254239a Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 16:04:06 +0300 Subject: [PATCH] =?UTF-8?q?docs(plan):=20track=20F=20=E2=80=94=2010=20TDD?= =?UTF-8?q?=20tasks=20in=20three=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 (main) lays the foundation every group branches from: the arch layer set and role map, wall materials, EN/TR vocabulary, the plan model with its ACADMCP_ARCH payload (opening_kind / stair_kind beside the record kind), draw_prims(role_layer=) and the codec's app_id= / kinds=. - Group W (Tasks 2-4): the wall engine (L/T/X junctions, opening cuts, poche), doors, windows and stairs, the async layer with read_plan and the exterior chains; SECTION 25 opens with four tools. - Group R (Tasks 5-6): the planar-face finder, measured room labels, the foreign-plan reader and the door/window/room schedules; three tools. - Group C (Tasks 7-8): the furniture and sanitary catalogue, the grid and the plan symbols; four tools. - Final (Tasks 9-10, main after the W -> R -> C merge): arch_plan_from_spec, the three arch_* critique focuses, the arch pack and lean 64, then the v6 matrix with arch_roundtrip, four correctness checks, the live smoke and the docs. Consistency pass: group W's poche now goes through draw_prims with the arch role map (Task 1 resolves wall materials there), and the hedges on Task 1's payload spelling and Task 5's rooms_detect shape are replaced by the shapes those tasks pin. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- .../2026-09-23-v1.6-arch-implementation.md | 13691 ++++++++++++++++ 1 file changed, 13691 insertions(+) diff --git a/docs/superpowers/plans/2026-09-23-v1.6-arch-implementation.md b/docs/superpowers/plans/2026-09-23-v1.6-arch-implementation.md index 53817d9..ca7512c 100644 --- a/docs/superpowers/plans/2026-09-23-v1.6-arch-implementation.md +++ b/docs/superpowers/plans/2026-09-23-v1.6-arch-implementation.md @@ -239,3 +239,13694 @@ group sizes (recompute from the live registry). Any other conflict: `git merge --abort`, record the branch, continue. --- + +### Task 1: Shared foundation — layers, materials, language, the plan model and two generalisations + +**Files:** +- Create: `engineering/arch/__init__.py` +- Create: `engineering/arch/layers.py` +- Create: `engineering/arch/materials.py` +- Create: `engineering/arch/lang.py` +- Create: `engineering/arch/model.py` +- Create: `tests/test_arch_foundation.py` +- Create: `tests/test_arch_model.py` +- Modify: `engineering/mech/xdata.py` (`encode` / `to_values` / `decode` gain keyword-only `app_id=` / `kinds=`; the defaults stay `ACADMCP_MECH` and the three mechanical kinds) +- Modify: `engineering/mech/draw.py` (`draw_prims(..., role_layer=None)`; `_ensure_layers` creates an `arch` layer from its `ARCH_LAYERS` row) +- Modify: `engineering/layers.py` (`LAYER_SET_REGISTRY["arch"]`, `LAYER_ROLES["arch"]`, registered at the end of the module) +- Modify: `engineering/plan_spec.py` (`LayerSetId` gains `"arch"` — a type annotation only, so `drawing_plan(..., layer_set_id="arch")` type-checks) +- Modify: `server.py` (only `drawing_apply_iso_layers`: the `standard` description names `arch`, the docstring names the refusal) +- Test: `tests/test_arch_foundation.py`, `tests/test_arch_model.py` + +**Interfaces:** +- Consumes: `engineering/mech/primitives.py::Line, HatchArea, Text, layer_for, ROLE_LAYER, Prim` (a `HatchArea` has no `role`; every other primitive carries `role`); `engineering/mech/standards/materials.py::hatch_for(material) -> {"pattern", "angle", "scale"}`; `engineering/mech/annotate.py::ensure_annotation_layers(backend, names) -> tuple[str, ...]`; `backends/xdata_specs.py::MAX_BYTES, app_size`; `engineering/layers.py::apply_layer_set(backend, standard) -> dict[str, str]`, `resolve_role_layer(layer_set_id, role) -> str`; `backend.layer_create(name=, color=, linetype=, lineweight=)`, `backend.layer_list() -> list[LayerInfo]` (`.name`, `.color`, `.linetype`). +- Produces (exactly the skeleton's pins, plus the extras listed last): + - `engineering/mech/draw.py::draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer_prefix="", role_layer: Mapping[str, str] | None = None) -> dict` — `{"handles": [...], "counts": {...}}`. With a role map, a `HatchArea` lands on the map's `hatch` key or, failing that, its `wall_hatch` key; a map that carries `wall_hatch` resolves hatch materials through `wall_hatch(material)`, every other map (and `None`) through the mechanical `hatch_for`. An unknown role or material is refused before the first entity. + - `engineering/mech/xdata.py::encode(payload, *, app_id=APP_ID, kinds=KINDS) -> list[tuple[int, str]]`, `to_values(payload, *, app_id=APP_ID, kinds=KINDS) -> list[str]`, `decode(rows, *, app_id=APP_ID, kinds=KINDS) -> dict`. + - `engineering/arch/layers.py::ARCH_LAYERS: list[tuple[str, int, str, float, str]]`, `ARCH_ROLE_LAYER: dict[str, str]` (the twelve pinned roles); extras `ARCH_LAYER_ROLES = {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"}`, `ARCH_LAYER_DEFS: dict[str, tuple[int, str, float]]`. + - `engineering/layers.py::LAYER_SET_REGISTRY["arch"] is ARCH_LAYERS`, `LAYER_ROLES["arch"] == {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"}`. + - `engineering/arch/materials.py::WALL_MATERIALS` (the eight pinned names, in the pinned order), `wall_hatch(material: str) -> dict` (`{"pattern", "angle", "scale"}`; `ValueError` lists `WALL_MATERIALS`); extra `WALL_HATCH_TABLE: dict[str, dict]` (each row also carries `source`). + - `engineering/arch/lang.py::LANGS = ("en", "tr")`, `vocab(lang) -> dict[str, str]` (the 22 pinned keys), `tag_prefix(kind, lang) -> str`, `fmt_number(value, lang, decimals=2) -> str`, `fmt_area_m2(area_mm2, lang) -> str`. + - `engineering/arch/model.py::APP_ID = "ACADMCP_ARCH"`, `KINDS = ("wall", "opening", "stair", "room")`, the frozen dataclasses `Wall`, `Opening`, `Stair`, `Room` with exactly the pinned fields and defaults, `wall_from_dict(d, path="wall") -> Wall`, `opening_from_dict(d, path="opening") -> Opening`, `stair_from_dict(d, path="stair") -> Stair`, `room_from_dict(d, path="room") -> Room`, `validate_openings(walls, openings) -> None`, `segment_of(wall, offset) -> tuple[int, float]`, `to_payload(obj) -> dict`, `from_payload(payload) -> Wall | Opening | Stair | Room`, `encode(payload)`, `to_values(payload)`, `decode(rows)`; extras `axis_segments(wall) -> list[tuple[Pt, Pt]]` (the closing segment last when `closed`), `wall_length(wall) -> float`, `PAYLOAD_VERSION`, `JUSTIFICATIONS`, `OPENING_KINDS`, `SWINGS`, `HANDS`, `STAIR_KINDS`, `TURNS`, `EPS = 1e-9`. + - Payload rule every later reader relies on: `to_payload(Opening(...))["kind"] == "opening"` and its door/window kind travels as `"opening_kind"`; a stair's straight/l/u kind travels as `"stair_kind"`. `from_payload` puts both back. (`{"v": 1, "kind": ..., **fields}` cannot hold the payload kind and the element kind under one key.) + +- [ ] **Step 1: Write the failing test for the layer set, the materials, the vocabulary and the two generalisations** + +Create `tests/test_arch_foundation.py`: + +```python +"""Track F's shared foundation: the arch layer set, the wall-material hatches, +EN/TR vocabulary, and the two generalisations of the mechanical layer +(`draw_prims(role_layer=)` and the XDATA codec's `app_id=` / `kinds=`). + +The mechanical callers are the regression gate for the two generalisations: +`tests/test_mech_draw.py`, `tests/test_mech_xdata.py` and the rest of the +mechanical suite run unchanged against the new signatures. +""" + +from __future__ import annotations + +import math + +import pytest +from ezdxf.tools import pattern as ezpattern + +from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.arch.materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch +from engineering.layers import LAYER_ROLES, LAYER_SET_REGISTRY, apply_layer_set, resolve_role_layer +from engineering.mech import xdata as codec +from engineering.mech.draw import draw_prims +from engineering.mech.primitives import HatchArea, Line, Text +from engineering.plan_spec import ISO_128_LINEWEIGHTS_MM + +# -- the layer set --------------------------------------------------------------- + +EXPECTED_LAYERS = { + "A-WALL-E-N": ("Continuous", 0.50), + "A-WALL-H-N": ("Continuous", 0.13), + "A-DOOR-E-N": ("Continuous", 0.25), + "A-WIND-E-N": ("Continuous", 0.25), + "A-STAIR-E-N": ("Continuous", 0.25), + "A-FURN-E-N": ("Continuous", 0.18), + "A-SANR-E-N": ("Continuous", 0.18), + "A-GRID-E-N": ("CENTER", 0.18), + "A-ROOM-T-N": ("Continuous", 0.25), + "A-DIMEN-T-N": ("Continuous", 0.18), + "A-SYMB-T-N": ("Continuous", 0.25), + "A-OVHD-E-N": ("HIDDEN", 0.18), + "A-CONST-E-N": ("Continuous", 0.05), +} + + +def test_the_arch_set_is_the_thirteen_pinned_layers_plus_zero(): + rows = {name: (linetype, lw) for name, _aci, linetype, lw, _d in ARCH_LAYERS} + assert rows.pop("0") == ("Continuous", 0.25) + assert rows == EXPECTED_LAYERS + + +def test_every_lineweight_but_the_construction_scratch_is_iso_128(): + for name, _aci, _lt, lineweight, _d in ARCH_LAYERS: + if "CONST" in name: + assert lineweight == 0.05 # skipped by the iso128 critique, cleared before finalize + continue + assert lineweight in ISO_128_LINEWEIGHTS_MM, name + + +def test_the_construction_layer_is_colour_250_like_every_other_set(): + colours = {name: aci for name, aci, _lt, _lw, _d in ARCH_LAYERS} + assert colours["A-CONST-E-N"] == 250 + + +def test_every_role_maps_onto_a_layer_of_the_set(): + names = {row[0] for row in ARCH_LAYERS} + assert set(ARCH_ROLE_LAYER.values()) <= names + assert ARCH_ROLE_LAYER == { + "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", + } + + +def test_the_set_is_registered_with_its_dimension_and_construction_roles(): + assert LAYER_SET_REGISTRY["arch"] is ARCH_LAYERS + assert LAYER_ROLES["arch"] == {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"} + assert resolve_role_layer("arch", "dim") == "A-DIMEN-T-N" + assert resolve_role_layer("arch", "construction") == "A-CONST-E-N" + + +@pytest.mark.asyncio +async def test_apply_layer_set_arch_creates_every_layer_with_its_row(backend): + result = await apply_layer_set(backend, "arch") + assert all(result[name] == "created" for name in EXPECTED_LAYERS) + layers = {lyr.name: lyr for lyr in await backend.layer_list()} + assert layers["A-GRID-E-N"].linetype.upper() == "CENTER" + assert layers["A-OVHD-E-N"].linetype.upper() == "HIDDEN" + assert layers["A-CONST-E-N"].color == 250 + + +# -- wall materials -------------------------------------------------------------- + + +def _period(name: str) -> float: + """The finest perpendicular line period of a pattern at scale 1 (ezdxf ISO set).""" + periods = [] + for angle, _base, offset, _dashes in ezpattern.load(measurement=1)[name]: + rad = math.radians(angle) + period = abs(-offset[0] * math.sin(rad) + offset[1] * math.cos(rad)) + if period > 1e-9: + periods.append(period) + return min(periods) + + +def test_the_eight_pinned_materials_each_have_one_row(): + assert WALL_MATERIALS == ( + "brick", + "aac", + "concrete", + "reinforced_concrete", + "gypsum_board", + "stone", + "timber", + "generic", + ) + assert set(WALL_HATCH_TABLE) == set(WALL_MATERIALS) + + +def test_wall_hatch_returns_exactly_pattern_angle_scale(): + assert wall_hatch("brick") == {"pattern": "ANSI31", "angle": 0.0, "scale": 20.0} + assert wall_hatch("Reinforced Concrete")["pattern"] == "JIS_RC_15" + + +def test_an_unknown_material_is_refused_with_the_list(): + with pytest.raises( + ValueError, match=r"unknown wall material 'adobe'; wall materials are brick, aac" + ): + wall_hatch("adobe") + + +def test_every_pattern_exists_in_both_ezdxf_measurement_sets(): + """ezdxf silently falls back to ANSI31 for a pattern it does not know.""" + iso = ezpattern.load(measurement=1) + imperial = ezpattern.load(measurement=0) + for material in WALL_MATERIALS: + name = wall_hatch(material)["pattern"] + assert name in iso and name in imperial, material + + +def test_the_periods_on_a_1_to_50_sheet_are_the_documented_ones(): + """Recomputed from ezdxf's own definitions: period x scale / 50 = paper mm.""" + expected_paper_mm = { + "brick": 1.27, + "aac": 2.54, + "reinforced_concrete": 3.0, + "stone": 1.5, + "timber": 1.273, + "generic": 1.27, + } + for material, paper in expected_paper_mm.items(): + row = wall_hatch(material) + assert round(_period(row["pattern"]) * row["scale"] / 50.0, 3) == paper, material + + +def test_aac_is_the_lighter_diagonal_of_brick(): + assert wall_hatch("aac")["pattern"] == wall_hatch("brick")["pattern"] + assert wall_hatch("aac")["scale"] == 2 * wall_hatch("brick")["scale"] + + +def test_every_row_states_where_its_symbol_comes_from(): + for material in WALL_MATERIALS: + source = WALL_HATCH_TABLE[material]["source"] + assert any( + token in source for token in ("ISO 128-50", "JIS A 0150", "acadiso.pat", "convention") + ) + + +# -- language -------------------------------------------------------------------- + + +def test_both_languages_carry_the_same_keys(): + assert LANGS == ("en", "tr") + assert ( + set(vocab("en")) + == set(vocab("tr")) + == { + "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", + } + ) + assert vocab("tr")["up"] == "ÇIKIŞ" + assert vocab("en")["up"] == "UP" + + +def test_tag_prefixes(): + assert (tag_prefix("door", "en"), tag_prefix("window", "en")) == ("D", "W") + assert (tag_prefix("door", "tr"), tag_prefix("window", "tr")) == ("K", "P") + + +def test_turkish_switches_the_decimal_separator_to_a_comma(): + assert fmt_number(24.5, "en") == "24.50" + assert fmt_number(24.5, "tr") == "24,50" + assert fmt_number(-0.001, "en") == "0.00" + assert fmt_area_m2(24_500_000.0, "en") == "24.50 m²" + assert fmt_area_m2(24_500_000.0, "tr") == "24,50 m²" + assert fmt_area_m2(20_000_000.0, "en") == "20.00 m²" + + +def test_an_unknown_language_is_refused_with_the_list(): + for call in ( + lambda: vocab("de"), + lambda: fmt_number(1.0, "de"), + lambda: tag_prefix("door", "fr"), + ): + with pytest.raises( + ValueError, match=r"lang: unknown language '(de|fr)'; languages are en, tr" + ): + call() + + +def test_a_negative_area_is_refused(): + with pytest.raises(ValueError, match=r"area: must be finite and not negative"): + fmt_area_m2(-1.0, "en") + + +# -- draw_prims(role_layer=) --------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_role_map_puts_each_role_on_its_architectural_layer(backend): + square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0)) + result = await draw_prims( + backend, + [ + Line((0.0, 0.0), (200.0, 0.0), "wall"), + Line((0.0, 0.0), (0.0, 900.0), "door"), + Text((500.0, 500.0), "LIVING", 125.0, 0.0, "room"), + HatchArea((square,), "brick"), + ], + role_layer=ARCH_ROLE_LAYER, + ) + assert len(result["handles"]) == 4 + placed = {(e.type, e.layer) for e in await backend.entity_list(limit=100)} + assert placed == { + ("LINE", "A-WALL-E-N"), + ("LINE", "A-DOOR-E-N"), + ("TEXT", "A-ROOM-T-N"), + ("HATCH", "A-WALL-H-N"), + } + + +@pytest.mark.asyncio +async def test_a_wall_hatch_takes_the_wall_material_pattern(backend): + square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0)) + await draw_prims( + backend, [HatchArea((square,), "reinforced_concrete")], role_layer=ARCH_ROLE_LAYER + ) + (hatch,) = await backend.entity_list(type_filter="HATCH", limit=10) + entity = backend._doc.entitydb[hatch.handle] + assert entity.dxf.pattern_name == "JIS_RC_15" + assert math.isclose(entity.dxf.pattern_scale, 10.0) + + +@pytest.mark.asyncio +async def test_missing_architectural_layers_are_created_with_their_row(backend): + await draw_prims(backend, [Line((0.0, 0.0), (1000.0, 0.0), "grid")], role_layer=ARCH_ROLE_LAYER) + layers = {lyr.name: lyr for lyr in await backend.layer_list()} + grid = layers["A-GRID-E-N"] + assert grid.linetype.upper() == "CENTER" + assert grid.color == 1 + + +@pytest.mark.asyncio +async def test_an_unknown_role_is_refused_before_anything_is_drawn(backend): + with pytest.raises( + ValueError, match=r"draw_prims: unknown role 'visible'; this role map has wall" + ): + await draw_prims( + backend, + [Line((0.0, 0.0), (1.0, 0.0), "wall"), Line((0.0, 0.0), (1.0, 0.0), "visible")], + role_layer=ARCH_ROLE_LAYER, + ) + assert await backend.entity_list(limit=10) == [] + + +@pytest.mark.asyncio +async def test_an_unknown_wall_material_is_refused_before_anything_is_drawn(backend): + square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0)) + with pytest.raises(ValueError, match=r"unknown wall material 'steel'"): + await draw_prims( + backend, + [Line((0.0, 0.0), (1.0, 0.0), "wall"), HatchArea((square,), "steel")], + role_layer=ARCH_ROLE_LAYER, + ) + assert await backend.entity_list(limit=10) == [] + + +@pytest.mark.asyncio +async def test_without_a_role_map_the_mechanical_layers_and_materials_are_unchanged(backend): + square = ((0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)) + await draw_prims(backend, [Line((0.0, 0.0), (1.0, 0.0)), HatchArea((square,), "steel")]) + layers = {e.layer for e in await backend.entity_list(limit=10)} + assert layers == {"GEOMETRY", "HATCH"} + (hatch,) = await backend.entity_list(type_filter="HATCH", limit=10) + assert backend._doc.entitydb[hatch.handle].dxf.pattern_name == "ANSI31" + + +# -- the XDATA codec's app_id / kinds ---------------------------------------------- + + +def test_the_codec_defaults_are_still_the_mechanical_ones(): + part = {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": {}} + assert codec.encode(part)[0] == (1001, "ACADMCP_MECH") + assert codec.decode(codec.to_values(part)) == part + + +def test_the_codec_writes_and_reads_another_application(): + payload = {"v": 1, "kind": "thing", "n": 3} + rows = codec.encode(payload, app_id="ACADMCP_TEST", kinds=("thing",)) + assert rows[0] == (1001, "ACADMCP_TEST") + assert codec.decode(rows, app_id="ACADMCP_TEST", kinds=("thing",)) == payload + with pytest.raises( + ValueError, match=r"ACADMCP_TEST: unknown payload kind 'part'; kinds are thing" + ): + codec.encode({"v": 1, "kind": "part"}, app_id="ACADMCP_TEST", kinds=("thing",)) + with pytest.raises( + ValueError, match=r"ACADMCP_MECH: this XDATA belongs to application 'ACADMCP_TEST'" + ): + codec.decode(rows) + + +# -- the tool ------------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_drawing_apply_iso_layers_takes_arch(): + from fastmcp import Client + + import server + + async with Client(server.mcp) as client: + await client.call_tool("drawing_new", {}) + result = ( + await client.call_tool("drawing_apply_iso_layers", {"standard": "arch"}) + ).structured_content + assert result["standard"] == "arch" + assert result["layers"]["A-WALL-E-N"] == "created" + assert result["layers"]["A-GRID-E-N"] == "created" +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_foundation.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.arch'`. + +- [ ] **Step 3: Create `engineering/arch/layers.py`** + +```python +"""The ``arch`` layer set and the role -> layer map every architectural module uses. + +Names follow the ISO 13567 field order the repository's ``iso13567`` set +already uses — agent (``A`` = architect), element, presentation (``E`` element +graphics, ``H`` hatching, ``T`` text/annotation), status (``N`` new) — with the +element field in its common short spelling (``WALL``, ``DOOR``, ``WIND``); the +fields are not padded to ISO 13567-2's fixed widths, exactly as the existing +``M-GEOMET-E-N`` rows are not. + +Lineweights are ISO 128 values only (0.13 / 0.18 / 0.25 / 0.50). The one +exception is ``A-CONST-E-N`` at 0.05 mm, the scratch weight every construction +layer in this repository carries: the ``iso128`` critique skips a layer whose +name contains ``CONST``, and ``construction_left`` requires it to be empty +before finalize. Colours are a repository convention — ISO 13567 fixes names, +not colours. + +``ARCH_ROLE_LAYER`` is the one place a role becomes a layer name, so no tool +hardcodes one. +""" + +from __future__ import annotations + +#: (name, ACI colour, linetype, lineweight mm, description) — the row shape of +#: every set in ``engineering/layers.py``. +ARCH_LAYERS: list[tuple[str, int, str, float, str]] = [ + ("0", 7, "Continuous", 0.25, "default"), + ("A-WALL-E-N", 7, "Continuous", 0.50, "Architecture / walls, cut outline / new"), + ("A-WALL-H-N", 8, "Continuous", 0.13, "Architecture / walls, poche hatch / new"), + ("A-DOOR-E-N", 3, "Continuous", 0.25, "Architecture / doors, leaf and swing / new"), + ("A-WIND-E-N", 4, "Continuous", 0.25, "Architecture / windows, frame and glazing / new"), + ("A-STAIR-E-N", 6, "Continuous", 0.25, "Architecture / stairs / new"), + ("A-FURN-E-N", 5, "Continuous", 0.18, "Architecture / furniture / new"), + ("A-SANR-E-N", 5, "Continuous", 0.18, "Architecture / sanitary fixtures / new"), + ("A-GRID-E-N", 1, "CENTER", 0.18, "Architecture / structural grid / new"), + ("A-ROOM-T-N", 7, "Continuous", 0.25, "Architecture / room labels / new"), + ("A-DIMEN-T-N", 2, "Continuous", 0.18, "Architecture / dimensions / new"), + ("A-SYMB-T-N", 7, "Continuous", 0.25, "Architecture / symbols and marks / new"), + ("A-OVHD-E-N", 3, "HIDDEN", 0.18, "Architecture / overhead, above the cut plane / new"), + ("A-CONST-E-N", 250, "Continuous", 0.05, "Architecture / construction (scratch)"), +] + +#: Architectural role -> layer. ``wall_hatch`` is where a ``HatchArea`` lands +#: when ``draw_prims`` is given this map (a HatchArea carries no role of its own). +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", +} + +#: The two roles ``engineering/layers.py::resolve_role_layer`` answers for. +ARCH_LAYER_ROLES: dict[str, str] = {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"} + +#: name -> (colour, linetype, lineweight): what ``draw_prims`` creates a missing +#: architectural layer with. +ARCH_LAYER_DEFS: dict[str, tuple[int, str, float]] = { + name: (color, linetype, lineweight) for name, color, linetype, lineweight, _d in ARCH_LAYERS +} +``` + +- [ ] **Step 4: Create `engineering/arch/materials.py`** + +Every pattern name below was read off the installed `acadiso.pat` of AutoCAD 2026 (`R25.1/enu/Support/acadiso.pat`: `*ANSI31,ANSI Iron, Brick, Stone masonry`, `*AR-CONC, random dot and stone pattern`, `*AR-SAND, random dot pattern`, `*JIS_RC_15, RC JIS A 0150(@15)`, `*JIS_STN_2.5, STONE JIS A 0150(@2.5)`, `*JIS_WOOD, WOOD JIS A 0150`) and exists in both of ezdxf 1.4.4's pattern sets (`ezdxf.tools.pattern.load(measurement=1)` and `measurement=0`). The perpendicular periods at scale 1, measured from ezdxf's definitions: ANSI31 3.175, JIS_RC_15 15.0, JIS_STN_2.5 2.5, JIS_WOOD 0.7071 mm. + +```python +"""Wall material -> the area hatch (poche) of its cut face. + +SOURCE, stated row by row because it is not one document: + +* ISO 128-50 (representation of areas on cuts and sections) standardises only + the *general* hatching — narrow continuous lines at 45 degrees — and leaves + distinguishing materials to other conventions. ``ANSI31`` in AutoCAD 2026's + ``acadiso.pat`` is that line set, and its own description in the installed + file (``R25.1/enu/Support/acadiso.pat``) reads ``*ANSI31,ANSI Iron, Brick, + Stone masonry``. ``brick`` and ``generic`` take it. +* ``reinforced_concrete``, ``stone`` and ``timber`` take the JIS A 0150 + (Japanese architectural drawing conventions) symbols AutoCAD ships in the + same file: ``*JIS_RC_15, RC JIS A 0150(@15)``, ``*JIS_STN_2.5, STONE JIS A + 0150(@2.5)`` and ``*JIS_WOOD, WOOD JIS A 0150``. A reinforced-concrete + section cannot be ANSI31 *and* AR-CONC in one HATCH entity, so it takes the + one symbol that names reinforced concrete. +* ``concrete`` and ``gypsum_board`` take AutoCAD's own ``*AR-CONC, random dot + and stone pattern`` and ``*AR-SAND, random dot pattern`` — declared as + AutoCAD patterns, not standard symbols. +* ``aac`` (autoclaved aerated concrete) has no symbol in any of these. It is + ANSI31 at twice the brick spacing — a lighter diagonal — which is a + repository convention and says so. +* ``timber`` adds 90 degrees so its single line family runs on the opposite + diagonal to brick: JIS_WOOD is one 45-degree family, and unrotated it would + be indistinguishable from brick on the sheet. + +``scale`` is chosen for a 1:50 plan, the default plot scale of every ``arch`` +tool (``wall_hatch`` takes no scale, as pinned): brick lines 3.175 x 20 = +63.5 mm apart, 1.27 mm on a 1:50 sheet; aac 2.54 mm; reinforced concrete one +triple-line group every 150 mm (3.0 mm on the sheet); stone 75 mm (1.5 mm); +timber 0.7071 x 90 = 63.6 mm (1.27 mm). AR-CONC and AR-SAND are defined in +building millimetres and ship at 1.0. The periods are recomputed from ezdxf's +own pattern definitions by ``tests/test_arch_foundation.py``, not trusted here. +""" + +from __future__ import annotations + +WALL_MATERIALS: tuple[str, ...] = ( + "brick", + "aac", + "concrete", + "reinforced_concrete", + "gypsum_board", + "stone", + "timber", + "generic", +) + +WALL_HATCH_TABLE: dict[str, dict] = { + "brick": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 20.0, + "source": "ISO 128-50 general hatching = acadiso.pat *ANSI31 (Iron, Brick, Stone masonry)", + }, + "aac": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 40.0, + "source": "repository convention: ANSI31 at twice the brick spacing; no standard AAC symbol", + }, + "concrete": { + "pattern": "AR-CONC", + "angle": 0.0, + "scale": 1.0, + "source": "acadiso.pat *AR-CONC, random dot and stone pattern (AutoCAD, not a standard)", + }, + "reinforced_concrete": { + "pattern": "JIS_RC_15", + "angle": 0.0, + "scale": 10.0, + "source": "acadiso.pat *JIS_RC_15, RC JIS A 0150(@15): the JIS A 0150 RC symbol", + }, + "gypsum_board": { + "pattern": "AR-SAND", + "angle": 0.0, + "scale": 1.0, + "source": "acadiso.pat *AR-SAND, random dot pattern (AutoCAD, not a standard)", + }, + "stone": { + "pattern": "JIS_STN_2.5", + "angle": 0.0, + "scale": 30.0, + "source": "acadiso.pat *JIS_STN_2.5, STONE JIS A 0150(@2.5): the JIS A 0150 stone symbol", + }, + "timber": { + "pattern": "JIS_WOOD", + "angle": 90.0, + "scale": 90.0, + "source": "acadiso.pat *JIS_WOOD, WOOD JIS A 0150; turned 90 deg so it cannot read as brick", + }, + "generic": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 20.0, + "source": "ISO 128-50 general hatching = acadiso.pat *ANSI31", + }, +} + + +def wall_hatch(material: str) -> dict: + """``{"pattern", "angle", "scale"}`` for a wall material. Unknown names are refused.""" + if not isinstance(material, str): + raise ValueError( + f"material: expected a name, got {type(material).__name__}; " + f"wall materials are {', '.join(WALL_MATERIALS)}" + ) + name = material.strip().lower().replace("-", "_").replace(" ", "_") + row = WALL_HATCH_TABLE.get(name) + if row is None: + raise ValueError( + f"material: unknown wall material {material!r}; " + f"wall materials are {', '.join(WALL_MATERIALS)}" + ) + return {"pattern": row["pattern"], "angle": row["angle"], "scale": row["scale"]} +``` + +- [ ] **Step 5: Create `engineering/arch/lang.py`** + +```python +"""English / Turkish vocabulary for labels and schedules, and the number format. + +``lang="en"`` is the default; ``lang="tr"`` switches the words *and* the +decimal separator to a comma (``24,50 m²``), the user's decision of +2026-09-23. Any other value is refused with the list, never silently +rendered in English. + +The Turkish words are the ones a Turkish drawing office writes on a plan +(``MAHAL LİSTESİ`` for a room schedule, ``KOT`` for a level, ``ÇIKIŞ`` for +the up-arrow of a stair); they are vocabulary, not a standard. +""" + +from __future__ import annotations + +import math + +LANGS: tuple[str, ...] = ("en", "tr") + +_VOCAB: dict[str, dict[str, str]] = { + "en": { + "door_schedule": "DOOR SCHEDULE", + "window_schedule": "WINDOW SCHEDULE", + "room_schedule": "ROOM SCHEDULE", + "tag": "TAG", + "width": "WIDTH", + "height": "HEIGHT", + "sill": "SILL", + "swing": "SWING", + "hand": "HAND", + "wall": "WALL", + "number": "NO.", + "name": "NAME", + "area": "AREA", + "total": "TOTAL", + "up": "UP", + "down": "DN", + "north": "N", + "level": "LEVEL", + "in": "IN", + "out": "OUT", + "left": "LEFT", + "right": "RIGHT", + }, + "tr": { + "door_schedule": "KAPI LİSTESİ", + "window_schedule": "PENCERE LİSTESİ", + "room_schedule": "MAHAL LİSTESİ", + "tag": "KOD", + "width": "GENİŞLİK", + "height": "YÜKSEKLİK", + "sill": "PARAPET", + "swing": "AÇILIŞ", + "hand": "YÖN", + "wall": "DUVAR", + "number": "NO", + "name": "MAHAL ADI", + "area": "ALAN", + "total": "TOPLAM", + "up": "ÇIKIŞ", + "down": "İNİŞ", + "north": "K", + "level": "KOT", + "in": "İÇE", + "out": "DIŞA", + "left": "SOL", + "right": "SAĞ", + }, +} + +_TAG_PREFIX: dict[str, dict[str, str]] = { + "en": {"door": "D", "window": "W"}, + "tr": {"door": "K", "window": "P"}, +} + + +def _lang(lang: str) -> str: + if not isinstance(lang, str) or lang.strip().lower() not in LANGS: + raise ValueError(f"lang: unknown language {lang!r}; languages are {', '.join(LANGS)}") + return lang.strip().lower() + + +def vocab(lang: str) -> dict[str, str]: + """Every word a label or schedule writes, in ``lang``. A copy: callers may not edit it.""" + return dict(_VOCAB[_lang(lang)]) + + +def tag_prefix(kind: str, lang: str) -> str: + """``door`` -> ``D`` / ``K`` (kapı), ``window`` -> ``W`` / ``P`` (pencere).""" + table = _TAG_PREFIX[_lang(lang)] + if kind not in table: + raise ValueError(f"kind: unknown opening kind {kind!r}; kinds are door, window") + return table[kind] + + +def fmt_number(value: float, lang: str, decimals: int = 2) -> str: + """``24.5`` -> ``"24.50"`` (en) / ``"24,50"`` (tr). No thousands separator.""" + code = _lang(lang) + if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0: + raise ValueError(f"decimals: expected a whole number >= 0, got {decimals!r}") + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"value: expected a number, got {value!r}") from None + if not math.isfinite(number): + raise ValueError(f"value: must be finite, got {value!r}") + text = f"{number:.{decimals}f}" + if text.startswith("-") and float(text) == 0.0: + text = text[1:] # never print "-0.00" + return text.replace(".", ",") if code == "tr" else text + + +def fmt_area_m2(area_mm2: float, lang: str) -> str: + """An area measured in mm² written in m² with two decimals: ``"24.50 m²"`` / ``"24,50 m²"``.""" + try: + area = float(area_mm2) + except (TypeError, ValueError): + raise ValueError(f"area: expected a number, got {area_mm2!r}") from None + if not math.isfinite(area) or area < 0.0: + raise ValueError(f"area: must be finite and not negative, got {area_mm2!r}") + return f"{fmt_number(area / 1.0e6, lang, 2)} m²" +``` + +- [ ] **Step 6: Create `engineering/arch/__init__.py` (the foundation re-exports; Step 16 adds the model)** + +```python +"""Architectural 2D: the plan model and the engine that draws it (track F). + +Merge rule for the three track-F branches: the re-exports below are additive +and alphabetical - keep every side's names. +""" + +from __future__ import annotations + +from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab +from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER +from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch + +__all__ = [ + "ARCH_LAYERS", + "ARCH_LAYER_DEFS", + "ARCH_LAYER_ROLES", + "ARCH_ROLE_LAYER", + "LANGS", + "WALL_HATCH_TABLE", + "WALL_MATERIALS", + "fmt_area_m2", + "fmt_number", + "tag_prefix", + "vocab", + "wall_hatch", +] +``` + +- [ ] **Step 7: Run it to verify the pure half passes and the integration half fails for the right reasons** + +Run: `uv run --frozen pytest tests/test_arch_foundation.py -q` +Expected: `9 failed, 18 passed`. The nine, and why: +- `test_the_set_is_registered_with_its_dimension_and_construction_roles` — `KeyError: 'arch'`; +- `test_apply_layer_set_arch_creates_every_layer_with_its_row` — `RuntimeError: Unknown layer set 'arch'. Choose one of: ['iso13567', 'mech', 'pid']`; +- the five `draw_prims` tests (`test_a_role_map_puts_each_role_on_its_architectural_layer`, `test_a_wall_hatch_takes_the_wall_material_pattern`, `test_missing_architectural_layers_are_created_with_their_row`, `test_an_unknown_role_is_refused_before_anything_is_drawn`, `test_an_unknown_wall_material_is_refused_before_anything_is_drawn`) — `TypeError: draw_prims() got an unexpected keyword argument 'role_layer'`; +- `test_the_codec_writes_and_reads_another_application` — `TypeError: encode() got an unexpected keyword argument 'app_id'`; +- `test_drawing_apply_iso_layers_takes_arch` — `ToolError: ... Unknown layer set 'arch'`. + +- [ ] **Step 8: Generalise the XDATA codec** + +Replace the whole of `engineering/mech/xdata.py` with the version below. The only behavioural change is that `app_id` and `kinds` are parameters; with their defaults every mechanical caller (`draw.py`, `stdparts.py`, `critique.py`, `sheet/bom.py`) encodes and decodes exactly as before. + +```python +"""``ACADMCP_MECH`` XDATA: the part model written onto the drawing. + +The Track A codec (`engineering/pid/xdata.py`) with its limits made explicit. +XDATA strings cap at 255 characters and an entity carries at most 16 KB across +every application, so a payload is JSON-serialised ASCII-only and split into +1000 chunks under one 1001 application marker. `json.dumps(ensure_ascii=True)` +escapes every newline, so no chunk can carry a line separator into the DXF. + +A payload that is not ours is *refused*, never decoded into something +plausible: a foreign application marker, a chunk stream that is not JSON, a +version this build does not read, or a kind outside `KINDS`. Three shapes are +written, and both the writer and every reader test them literally: + + {"v": 1, "kind": "part", "id": "", "view": "front", "part": {...}} + {"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", ...]} + +The codec is shared: ``app_id=`` / ``kinds=`` let another application write +its own payloads through the same chunking and the same refusals (track F +writes ``ACADMCP_ARCH`` with the kinds wall / opening / stair / room through +`engineering/arch/model.py`). The defaults are the mechanical ones, so every +mechanical caller is unchanged. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence + +from backends.xdata_specs import MAX_BYTES, app_size + +APP_ID = "ACADMCP_MECH" +PAYLOAD_VERSION = 1 +CHUNK = 255 +KINDS: tuple[str, ...] = ("part", "std_part", "balloon") + + +def _validate(payload: dict, app_id: str, kinds: Sequence[str]) -> dict: + if not isinstance(payload, dict): + raise TypeError(f"{app_id}: a payload must be a dict, got {type(payload).__name__}") + version = payload.get("v", PAYLOAD_VERSION) + if version != PAYLOAD_VERSION: + raise ValueError( + f"{app_id}: payload version {version!r}; this build writes and reads " + f"version {PAYLOAD_VERSION}" + ) + kind = payload.get("kind") + if kind not in kinds: + raise ValueError(f"{app_id}: unknown payload kind {kind!r}; kinds are {', '.join(kinds)}") + return {**payload, "v": PAYLOAD_VERSION} + + +def encode( + payload: dict, *, app_id: str = APP_ID, kinds: Sequence[str] = KINDS +) -> list[tuple[int, str]]: + """``[(1001, app_id), (1000, chunk), ...]`` — refused before anything is written.""" + checked = _validate(payload, app_id, kinds) + text = json.dumps(checked, separators=(",", ":"), sort_keys=True, ensure_ascii=True) + rows: list[tuple[int, str]] = [(1001, app_id)] + rows.extend((1000, text[i : i + CHUNK]) for i in range(0, len(text), CHUNK)) + size = app_size(app_id, [(code, value) for code, value in rows if code != 1001]) + if size > MAX_BYTES: + raise ValueError( + f"{app_id}: payload encodes to {size} bytes; the limit is 16 KB per entity " + "across every application" + ) + return rows + + +def to_values(payload: dict, *, app_id: str = APP_ID, kinds: Sequence[str] = KINDS) -> list[str]: + """Just the chunk strings — what ``entity_set_xdata(handle, app_id, values)`` takes.""" + return [value for code, value in encode(payload, app_id=app_id, kinds=kinds) if code == 1000] + + +def decode(rows: Sequence, *, app_id: str = APP_ID, kinds: Sequence[str] = KINDS) -> dict: + """``rows`` may be ``(code, value)`` pairs or the bare value list a backend + returns from ``entity_get_xdata``. Raises ValueError on anything foreign.""" + if not rows: + raise ValueError(f"{app_id}: empty XDATA") + parts: list[str] = [] + for row in rows: + if isinstance(row, str): + parts.append(row) + continue + try: + code, value = row + except (TypeError, ValueError): + raise ValueError(f"{app_id}: {row!r} is not an XDATA (code, value) row") from None + if code == 1001: + if str(value) != app_id: + raise ValueError( + f"{app_id}: this XDATA belongs to application {value!r}, not to us" + ) + continue + if code != 1000: + raise ValueError(f"{app_id}: group code {code} is not a payload chunk") + parts.append(str(value)) + try: + payload = json.loads("".join(parts)) + except json.JSONDecodeError as exc: + raise ValueError(f"{app_id}: the chunk stream is not JSON ({exc.msg})") from exc + if not isinstance(payload, dict): + raise ValueError(f"{app_id}: the payload decoded to {type(payload).__name__}, not a dict") + return _validate(payload, app_id, kinds) +``` + +- [ ] **Step 9: Generalise `draw_prims` to take a role map** + +In `engineering/mech/draw.py`, four edits. + +(a) The import block — replace + +```python +import logging +import math +from typing import Any +``` + +with + +```python +import logging +import math +from collections.abc import Mapping +from typing import Any +``` + +(b) Replace the head of `draw_prims`, from its signature down to and including its `_ensure_layers` call — + +```python +async def draw_prims( + backend, + prims, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + layer_prefix: str = "", +) -> dict: + """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``.""" + placed = tuple(prims) + _refuse_islands_without_edge_paths(backend, placed) + await _ensure_layers(backend, sorted({f"{layer_prefix}{layer_for(p)}" for p in placed})) +``` + +— with + +```python +#: The keys a role map may carry for a HatchArea, which has no role of its own: +#: ``hatch`` (a general map) or ``wall_hatch`` (the architectural map, whose +#: only hatch is wall poche). The first one the map carries wins. +_HATCH_ROLES: tuple[str, ...] = ("hatch", "wall_hatch") + + +def _layer_of(prim, role_layer: Mapping[str, str] | None) -> str: + """The layer a primitive lands on: the mechanical ROLE_LAYER, or ``role_layer``.""" + if role_layer is None: + return layer_for(prim) + if isinstance(prim, HatchArea): + for key in _HATCH_ROLES: + if key in role_layer: + return role_layer[key] + raise ValueError( + "draw_prims: this role map has no hatch layer (a 'hatch' or 'wall_hatch' key); " + f"its roles are {', '.join(role_layer)}." + ) + role = getattr(prim, "role", "visible") + try: + return role_layer[role] + except KeyError: + raise ValueError( + f"draw_prims: unknown role {role!r}; this role map has {', '.join(role_layer)}." + ) from None + + +def _hatch_style(material: str, role_layer: Mapping[str, str] | None) -> dict: + """The pattern a HatchArea is filled with. + + The architectural map (the one that carries ``wall_hatch``) resolves wall + materials through ``engineering/arch/materials.py::wall_hatch`` - brick, + aac, reinforced concrete ... at plan scale. Every other map, and no map, + keeps the mechanical ISO 128-50 section map, so a mechanical caller is + unchanged. + """ + if role_layer is not None and "wall_hatch" in role_layer: + from engineering.arch.materials import wall_hatch + + return wall_hatch(material) + return hatch_for(material) + + +async def draw_prims( + backend, + prims, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + layer_prefix: str = "", + role_layer: Mapping[str, str] | None = None, +) -> dict: + """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``. + + ``role_layer`` maps ``Prim.role`` to a layer; ``None`` keeps the mechanical + ``ROLE_LAYER``. Every role and every hatch material is resolved - and every + target layer ensured - before the first entity, so an unknown role or + material refuses with nothing drawn. + """ + placed = tuple(prims) + _refuse_islands_without_edge_paths(backend, placed) + targets = [f"{layer_prefix}{_layer_of(p, role_layer)}" for p in placed] + for prim in placed: + if isinstance(prim, HatchArea): + _hatch_style(prim.material, role_layer) + await _ensure_layers(backend, sorted(set(targets))) +``` + +(c) In the drawing loop of `draw_prims`, replace + +```python + for prim in placed: + layer = f"{layer_prefix}{layer_for(prim)}" +``` + +with + +```python + for prim, layer in zip(placed, targets, strict=True): +``` + +and in its `HatchArea` branch replace + +```python + style = hatch_for(prim.material) +``` + +with + +```python + style = _hatch_style(prim.material, role_layer) +``` + +(d) Replace the tail of `_ensure_layers` — from the last two lines of its docstring to the end of the function — + +```python + cure (``ensure_annotation_layers``) at the places every mechanical drawing + path passes through. + """ + from engineering.mech.annotate import ensure_annotation_layers + + return await ensure_annotation_layers(backend, [str(name) for name in names if name]) +``` + +with + +```python + cure (``ensure_annotation_layers``) at the places every mechanical drawing + path passes through. + + A layer of the ``arch`` set is created with its own ``ARCH_LAYERS`` row - + a grid on CENTER at 0.18 mm, a wall outline at 0.50 mm - rather than as a + bare white Continuous layer; every other name goes through + ``ensure_annotation_layers`` exactly as before. + """ + from engineering.arch.layers import ARCH_LAYER_DEFS + from engineering.mech.annotate import ensure_annotation_layers + + wanted = [str(name) for name in names if name] + architectural = [name for name in wanted if name in ARCH_LAYER_DEFS] + created: list[str] = [] + if architectural: + existing = {lyr.name.lower() for lyr in await backend.layer_list()} + for name in architectural: + if name.lower() in existing: + continue + color, linetype, lineweight = ARCH_LAYER_DEFS[name] + await backend.layer_create( + name=name, color=color, linetype=linetype, lineweight=lineweight + ) + existing.add(name.lower()) + created.append(name) + rest = [name for name in wanted if name not in ARCH_LAYER_DEFS] + created.extend(await ensure_annotation_layers(backend, rest)) + return tuple(created) +``` + +The `engineering.arch` imports stay inside the two functions: `engineering/arch/model.py` imports the mechanical codec, so a module-level import here would load the architectural package from inside the mechanical one. + +- [ ] **Step 10: Register the `arch` layer set, the `LayerSetId` literal and the tool description** + +In `engineering/layers.py`, change the `apply_layer_set` docstring line + +```python + """Idempotently apply a named layer set ('mech', 'pid', 'iso13567').""" +``` + +to + +```python + """Idempotently apply a named layer set ('mech', 'pid', 'iso13567', 'arch').""" +``` + +and append at the very end of the module (after `apply_layer_set`): + +```python + + +# ── the architectural set (track F) ───────────────────────────────────────── +# The rows live with the plan model (engineering/arch/layers.py), where +# ARCH_ROLE_LAYER maps each role onto them. Registered last, after every name +# above exists: importing engineering.arch runs its package __init__, which +# pulls in the mechanical codec - and, once track F is merged, the wall engine +# - and those import from this module. A partially initialised +# engineering.layers must already hold every name they ask for. +from engineering.arch.layers import ARCH_LAYER_ROLES, ARCH_LAYERS # noqa: E402 + +LAYER_SET_REGISTRY["arch"] = ARCH_LAYERS +LAYER_ROLES["arch"] = dict(ARCH_LAYER_ROLES) +``` + +In `engineering/plan_spec.py`, change + +```python +LayerSetId = Literal["iso13567", "mech", "pid"] +``` + +to + +```python +LayerSetId = Literal["iso13567", "mech", "pid", "arch"] +``` + +In `server.py`, in `drawing_apply_iso_layers`, replace the `standard` description + +```python + description="Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming).", +``` + +with + +```python + description=( + "Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming), " + "arch (ISO 13567-style architectural: walls, poche, doors, windows, stairs, " + "furniture, sanitary, grid, rooms, dimensions, symbols, overhead)." + ), +``` + +and its docstring + +```python + """Bootstrap a full ISO-conformant layer set with correct colors and lineweights. + Idempotent — existing layers are not modified. + """ +``` + +with + +```python + """Bootstrap a full ISO-conformant layer set with correct colors and lineweights. + Idempotent — existing layers are not modified. + + Refused by name: a standard that is not one of mech, pid, iso13567, arch. + """ +``` + +No tool is added, so the inventory, the banner counts and the registry snapshot do not move. + +- [ ] **Step 11: Run the foundation tests to verify they pass** + +Run: `uv run --frozen pytest tests/test_arch_foundation.py -q` +Expected: `27 passed`. + +- [ ] **Step 12: Run the mechanical regression gate — the callers of the two generalisations, unchanged** + +Run: `uv run --frozen pytest tests/test_mech_draw.py tests/test_mech_sections.py tests/test_mech_xdata.py tests/test_mech_critique.py tests/test_mech_stdparts.py tests/test_sheet_bom.py tests/test_xdata.py tests/test_meta_tools.py tests/test_layer_block.py -q` +Expected: all pass, and not one of these files was edited. Then the whole mechanical, sheet and P&ID surface: + +Run: `uv run --frozen pytest tests/test_mech_*.py tests/test_sheet_*.py tests/test_pid_*.py tests/test_xdata.py tests/test_meta_tools.py tests/test_layer_block.py -q` +Expected: all pass (1707 on the tree this plan was written against). A failure here is a regression in `draw_prims` or the codec — fix the generalisation, never the mechanical test. + +- [ ] **Step 13: Write the failing test for the plan model** + +Create `tests/test_arch_model.py`: + +```python +"""The plan model: every refusal of spec §4, by path, and the ACADMCP_ARCH round trip. + +Pure: no backend. A refusal is a ``ValueError`` whose message starts with the +path of the offending field, so a caller of ``arch_plan_from_spec`` is told +``walls[2].thickness`` and not "invalid wall". +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.model import ( + APP_ID, + KINDS, + Opening, + Room, + Stair, + Wall, + decode, + encode, + from_payload, + opening_from_dict, + room_from_dict, + segment_of, + stair_from_dict, + to_payload, + to_values, + validate_openings, + wall_from_dict, + wall_length, +) + +L_WALL = {"id": "W1", "axis": [[0, 0], [5000, 0], [5000, 4000]], "thickness": 200} + + +def _wall(**over) -> Wall: + return wall_from_dict({**L_WALL, **over}, "walls[0]") + + +def _door(**over) -> Opening: + base = {"id": "D1", "wall": "W1", "kind": "door", "offset": 1000, "width": 900} + return opening_from_dict({**base, **over}, "openings[0]") + + +# -- walls ----------------------------------------------------------------------- + + +def test_a_wall_reads_its_defaults_and_normalises_its_axis(): + wall = _wall() + assert wall == Wall( + id="W1", + axis=((0.0, 0.0), (5000.0, 0.0), (5000.0, 4000.0)), + thickness=200.0, + justification="center", + material="brick", + closed=False, + ) + assert wall_length(wall) == 9000.0 + + +@pytest.mark.parametrize( + ("value", "reason"), + [ + (-200, "must be greater than zero"), + (0, "must be greater than zero"), + (float("nan"), "must be finite"), + (float("inf"), "must be finite"), + ("thick", "expected a number"), + (True, "expected a number"), + ], +) +def test_a_thickness_that_is_not_a_finite_positive_number_is_refused_by_path(value, reason): + with pytest.raises(ValueError, match=rf"^walls\[2\]\.thickness: {reason}"): + wall_from_dict({**L_WALL, "thickness": value}, "walls[2]") + + +def test_an_axis_needs_two_points(): + with pytest.raises( + ValueError, match=r"^walls\[0\]\.axis: a wall needs at least 2 points, got 1" + ): + _wall(axis=[[0, 0]]) + + +def test_a_closed_axis_needs_three_points(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis: a closed wall needs at least 3"): + _wall(axis=[[0, 0], [10, 0]], closed=True) + + +def test_a_zero_length_segment_is_refused_at_the_repeated_point(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[2\]: repeats axis\[1\]"): + _wall(axis=[[0, 0], [5000, 0], [5000, 0], [5000, 4000]]) + + +def test_a_closed_axis_that_repeats_its_first_point_is_refused(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[3\]: repeats axis\[0\]"): + _wall(axis=[[0, 0], [10, 0], [10, 10], [0, 0]], closed=True) + + +def test_an_arc_segment_is_refused_by_name_not_approximated(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[1\]: .*arc segment is refused"): + _wall(axis=[[0, 0], [5000, 0, 0.4142], [5000, 4000]]) + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[1\]: an arc segment is refused"): + _wall(axis=[[0, 0], {"center": [2500, 0], "radius": 2500}, [5000, 4000]]) + + +def test_an_unknown_material_is_refused_with_the_list(): + with pytest.raises( + ValueError, match=r"^walls\[0\]\.material: unknown wall material 'adobe'" + ) as err: + _wall(material="adobe") + assert "brick, aac, concrete, reinforced_concrete" in str(err.value) + + +def test_an_unknown_justification_and_an_unknown_field_are_refused(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.justification: 'middle' is not one of"): + _wall(justification="middle") + with pytest.raises(ValueError, match=r"^walls\[0\]: unknown field\(s\) thicknes"): + wall_from_dict({**L_WALL, "thicknes": 200}, "walls[0]") + + +# -- openings -------------------------------------------------------------------- + + +def test_an_opening_reads_its_defaults(): + assert _door() == Opening( + id="D1", wall="W1", kind="door", offset=1000.0, width=900.0, swing="in", hand="left" + ) + + +def test_an_opening_needs_a_positive_width_and_a_non_negative_offset(): + with pytest.raises(ValueError, match=r"^openings\[0\]\.width: must be greater than zero"): + _door(width=0) + with pytest.raises(ValueError, match=r"^openings\[0\]\.offset: must not be negative"): + _door(offset=-1) + assert _door(offset=0).offset == 0.0 + + +def test_an_opening_on_a_wall_that_is_not_in_the_plan_is_refused(): + with pytest.raises(ValueError, match=r"opening D1: wall 'W9' is not in the plan; walls are W1"): + validate_openings([_wall()], [_door(wall="W9")]) + + +def test_an_opening_that_runs_past_its_wall_is_refused(): + with pytest.raises( + ValueError, match=r"opening D1: runs past the end of wall W1 .* 9500 > axis length 9000" + ): + validate_openings([_wall()], [_door(offset=8600)]) + + +def test_an_opening_that_straddles_an_axis_vertex_is_refused(): + # The axis turns at 5000 mm; 4500-5400 has a jamb on each side of the corner. + with pytest.raises( + ValueError, match=r"opening D1: straddles the axis vertex of wall W1 at 5000" + ): + validate_openings([_wall()], [_door(offset=4500)]) + + +def test_an_opening_may_end_exactly_at_a_vertex(): + validate_openings([_wall()], [_door(offset=4100, width=900)]) + + +def test_two_overlapping_openings_are_refused_naming_both_ids(): + door = _door() + window = opening_from_dict( + {"id": "W2", "wall": "W1", "kind": "window", "offset": 1500, "width": 1200}, "openings[1]" + ) + with pytest.raises( + ValueError, match=r"openings D1 and W2 overlap on wall W1: 1000-1900 and 1500-2700" + ): + validate_openings([_wall()], [door, window]) + + +def test_two_openings_that_only_touch_are_accepted(): + window = opening_from_dict( + {"id": "W2", "wall": "W1", "kind": "window", "offset": 1900, "width": 1200}, "openings[1]" + ) + validate_openings([_wall()], [_door(), window]) + + +def test_duplicate_ids_are_refused(): + with pytest.raises(ValueError, match=r"walls: two walls share the id 'W1'"): + validate_openings([_wall(), _wall()], []) + with pytest.raises(ValueError, match=r"openings: two openings share the id 'D1'"): + validate_openings([_wall()], [_door(), _door(offset=3000)]) + + +def test_segment_of_finds_the_segment_and_the_distance_along_it(): + wall = _wall() + assert segment_of(wall, 0.0) == (0, 0.0) + assert segment_of(wall, 1000.0) == (0, 1000.0) + assert segment_of(wall, 5000.0) == (1, 0.0) # a vertex belongs to the segment it starts + assert segment_of(wall, 6500.0) == (1, 1500.0) + assert segment_of(wall, 9000.0) == (1, 4000.0) # the very end belongs to the last one + with pytest.raises(ValueError, match=r"offset 9001.0 is outside wall W1 \(axis length 9000\)"): + segment_of(wall, 9001.0) + + +def test_a_closed_wall_counts_its_closing_segment(): + ring = wall_from_dict( + { + "id": "R", + "axis": [[0, 0], [4000, 0], [4000, 3000], [0, 3000]], + "thickness": 200, + "closed": True, + }, + "walls[0]", + ) + assert wall_length(ring) == 14000.0 + assert segment_of(ring, 12000.0) == (3, 1000.0) + + +# -- stairs and rooms ------------------------------------------------------------ + + +STAIR = { + "id": "S1", + "start": [1000, 500], + "direction_deg": 90, + "width": 1000, + "risers": 16, + "riser_height": 175, + "going": 280, +} + + +def test_a_stair_reads_its_defaults(): + assert stair_from_dict(STAIR, "stairs[0]") == Stair( + id="S1", + start=(1000.0, 500.0), + direction_deg=90.0, + width=1000.0, + risers=16, + riser_height=175.0, + going=280.0, + kind="straight", + turn="left", + ) + + +@pytest.mark.parametrize( + ("over", "message"), + [ + ({"risers": 1}, r"^stairs\[0\]\.risers: a stair needs at least 2 risers, got 1"), + ({"risers": 15.5}, r"^stairs\[0\]\.risers: expected a whole number of risers"), + ({"going": -280}, r"^stairs\[0\]\.going: must be greater than zero"), + ({"riser_height": float("nan")}, r"^stairs\[0\]\.riser_height: must be finite"), + ({"kind": "spiral"}, r"^stairs\[0\]\.kind: 'spiral' is not one of straight, l, u"), + ], +) +def test_a_stair_refuses_by_path(over, message): + with pytest.raises(ValueError, match=message): + stair_from_dict({**STAIR, **over}, "stairs[0]") + + +def test_a_room_refuses_a_typed_area(): + with pytest.raises(ValueError, match=r"^rooms\[0\]\.area: an area is measured .* never typed"): + room_from_dict( + {"id": "R1", "name": "LIVING", "at": [2000, 2000], "area": 20.0e6}, "rooms[0]" + ) + + +def test_a_room_reads_a_number_given_as_an_integer(): + room = room_from_dict({"id": "R1", "name": "LIVING", "number": 101, "at": [2000, 2000]}) + assert room == Room(id="R1", name="LIVING", number="101", at=(2000.0, 2000.0), area=0.0) + + +# -- the ACADMCP_ARCH payload ---------------------------------------------------- + + +def test_the_codec_is_the_mechanical_one_under_our_own_application_id(): + assert APP_ID == "ACADMCP_ARCH" + assert KINDS == ("wall", "opening", "stair", "room") + rows = encode(to_payload(_wall())) + assert rows[0] == (1001, "ACADMCP_ARCH") + assert all(code == 1000 and len(value) <= 255 for code, value in rows[1:]) + + +@pytest.mark.parametrize( + "obj", + [ + _wall(material="aac", justification="left"), + _door(tag="D1", height=2100), + stair_from_dict(STAIR), + Room(id="R1", name="MUTFAK", number="2", at=(1.0, 2.0), area=12.5e6), + ], +) +def test_every_plan_object_survives_the_drawing(obj): + payload = decode(to_values(to_payload(obj))) + assert payload["kind"] in KINDS and payload["v"] == 1 + assert from_payload(payload) == obj + + +def test_an_element_kind_travels_beside_the_payload_kind(): + payload = to_payload(_door()) + assert payload["kind"] == "opening" and payload["opening_kind"] == "door" + assert to_payload(stair_from_dict(STAIR))["stair_kind"] == "straight" + + +def test_a_mechanical_payload_is_not_ours(): + from engineering.mech.xdata import to_values as mech_values + + part = {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": {}} + with pytest.raises(ValueError, match=r"ACADMCP_ARCH: unknown payload kind 'part'"): + decode(mech_values(part)) + with pytest.raises(ValueError, match=r"belongs to application 'ACADMCP_MECH'"): + decode([(1001, "ACADMCP_MECH"), (1000, "{}")]) +``` + +- [ ] **Step 14: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_model.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.arch.model'`. + +- [ ] **Step 15: Create `engineering/arch/model.py`** + +```python +"""The plan model: walls with hosted openings, stairs and rooms. + +A wall is an axis polyline of straight segments, a thickness, a justification +(the side of the axis the wall lies on, *looking along the axis from its first +point*) and a material. An opening is hosted in a wall by distance along that +axis: ``offset`` runs from the axis start to the opening's near jamb, so +``offset + width`` is its far jamb. A room is a name at a point; its area is +measured from the face the walls enclose and is never accepted from a caller. + +Validation happens before anything is written and names its path +(``walls[2].thickness: must be greater than zero, got -200``), the repository +rule. The plan travels on the drawing as ``ACADMCP_ARCH`` XDATA through the +mechanical codec generalised in track F (``engineering/mech/xdata.py`` with +``app_id=`` / ``kinds=``), so a schedule is a *read* of the drawing. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import asdict, dataclass + +from engineering.mech import xdata as _codec + +from .materials import WALL_HATCH_TABLE, WALL_MATERIALS + +Pt = tuple[float, float] + +APP_ID = "ACADMCP_ARCH" +KINDS: tuple[str, ...] = ("wall", "opening", "stair", "room") +PAYLOAD_VERSION = _codec.PAYLOAD_VERSION + +JUSTIFICATIONS: tuple[str, ...] = ("center", "left", "right") +OPENING_KINDS: tuple[str, ...] = ("door", "window") +SWINGS: tuple[str, ...] = ("in", "out") +HANDS: tuple[str, ...] = ("left", "right") +STAIR_KINDS: tuple[str, ...] = ("straight", "l", "u") +TURNS: tuple[str, ...] = ("left", "right") + +#: Below this, two lengths along a wall are the same length (mm). +EPS = 1e-9 + + +@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 + + +# -- field readers ------------------------------------------------------------- + + +def _number(value, where: str, *, positive: bool = True, allow_zero: bool = False) -> float: + if isinstance(value, bool): + raise ValueError(f"{where}: expected a number, got {value!r}") + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}") + if positive and not allow_zero and number <= 0.0: + raise ValueError(f"{where}: must be greater than zero, got {number:g}") + if positive and allow_zero and number < 0.0: + raise ValueError(f"{where}: must not be negative, got {number:g}") + return number + + +def _text(value, where: str, *, optional: bool = False) -> str | None: + if value is None and optional: + return None + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise ValueError(f"{where}: expected a name, got {value!r}") + text = str(value).strip() + if not text: + raise ValueError(f"{where}: must not be empty") + return text + + +def _choice(value, where: str, allowed: Sequence[str]) -> str: + if not isinstance(value, str) or value.strip().lower() not in allowed: + raise ValueError(f"{where}: {value!r} is not one of {', '.join(allowed)}") + return value.strip().lower() + + +def _point(value, where: str) -> Pt: + if isinstance(value, dict): + if {"bulge", "arc", "center", "radius"} & set(value): + raise ValueError( + f"{where}: an arc segment is refused - a wall axis is straight segments in " + "this build (curved walls are out of 1.6); split the curve into straight walls" + ) + raise ValueError(f"{where}: expected [x, y], got {value!r}") + if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)): + raise ValueError(f"{where}: expected [x, y], got {value!r}") + if len(value) == 3: + raise ValueError( + f"{where}: a third value is a bulge, and an arc segment is refused - a wall axis " + "is straight segments in this build (curved walls are out of 1.6)" + ) + if len(value) != 2: + raise ValueError(f"{where}: expected [x, y], got {value!r}") + return ( + _number(value[0], f"{where}[0]", positive=False), + _number(value[1], f"{where}[1]", positive=False), + ) + + +def _known(d, path: str, allowed: Sequence[str]) -> dict: + if not isinstance(d, dict): + raise ValueError(f"{path}: expected a dict, got {type(d).__name__}") + unknown = sorted(set(d) - set(allowed)) + if unknown: + raise ValueError( + f"{path}: unknown field(s) {', '.join(unknown)}; fields are {', '.join(allowed)}" + ) + return d + + +def _required(d: dict, key: str, path: str): + if key not in d or d[key] is None: + raise ValueError(f"{path}.{key}: required") + return d[key] + + +# -- builders ----------------------------------------------------------------- + +_WALL_FIELDS = ("id", "axis", "thickness", "justification", "material", "closed") +_OPENING_FIELDS = ( + "id", + "wall", + "kind", + "offset", + "width", + "swing", + "hand", + "sill", + "height", + "tag", +) +_STAIR_FIELDS = ( + "id", + "start", + "direction_deg", + "width", + "risers", + "riser_height", + "going", + "kind", + "turn", +) +_ROOM_FIELDS = ("id", "name", "number", "at") + + +def wall_from_dict(d: dict, path: str = "wall") -> Wall: + """A validated Wall. Refusals are ``ValueError(".: ")``.""" + _known(d, path, _WALL_FIELDS) + wall_id = _text(_required(d, "id", path), f"{path}.id") + raw_axis = _required(d, "axis", path) + if isinstance(raw_axis, (str, bytes, dict)) or not isinstance(raw_axis, (list, tuple)): + raise ValueError(f"{path}.axis: expected a list of [x, y] points, got {raw_axis!r}") + axis = tuple(_point(p, f"{path}.axis[{i}]") for i, p in enumerate(raw_axis)) + closed = d.get("closed", False) + if not isinstance(closed, bool): + raise ValueError(f"{path}.closed: expected true or false, got {closed!r}") + need = 3 if closed else 2 + if len(axis) < need: + raise ValueError( + f"{path}.axis: a {'closed ' if closed else ''}wall needs at least {need} points, " + f"got {len(axis)}" + ) + for i in range(1, len(axis)): + if math.dist(axis[i - 1], axis[i]) <= EPS: + raise ValueError( + f"{path}.axis[{i}]: repeats axis[{i - 1}] - a zero-length segment has no " + "direction to offset" + ) + if closed and math.dist(axis[-1], axis[0]) <= EPS: + raise ValueError( + f"{path}.axis[{len(axis) - 1}]: repeats axis[0] - closed=true already closes " + "the loop; drop the repeated point" + ) + thickness = _number(_required(d, "thickness", path), f"{path}.thickness") + justification = _choice( + d.get("justification", "center"), f"{path}.justification", JUSTIFICATIONS + ) + material = d.get("material", "brick") + if not isinstance(material, str) or material.strip().lower() not in WALL_HATCH_TABLE: + raise ValueError( + f"{path}.material: unknown wall material {material!r}; " + f"wall materials are {', '.join(WALL_MATERIALS)}" + ) + return Wall( + id=wall_id, + axis=axis, + thickness=thickness, + justification=justification, + material=material.strip().lower(), + closed=closed, + ) + + +def opening_from_dict(d: dict, path: str = "opening") -> Opening: + _known(d, path, _OPENING_FIELDS) + sill = d.get("sill") + height = d.get("height") + tag = d.get("tag") + return Opening( + id=_text(_required(d, "id", path), f"{path}.id"), + wall=_text(_required(d, "wall", path), f"{path}.wall"), + kind=_choice(_required(d, "kind", path), f"{path}.kind", OPENING_KINDS), + offset=_number(_required(d, "offset", path), f"{path}.offset", allow_zero=True), + width=_number(_required(d, "width", path), f"{path}.width"), + swing=_choice(d.get("swing", "in"), f"{path}.swing", SWINGS), + hand=_choice(d.get("hand", "left"), f"{path}.hand", HANDS), + sill=None if sill is None else _number(sill, f"{path}.sill", allow_zero=True), + height=None if height is None else _number(height, f"{path}.height"), + tag=_text(tag, f"{path}.tag", optional=True), + ) + + +def stair_from_dict(d: dict, path: str = "stair") -> Stair: + _known(d, path, _STAIR_FIELDS) + risers = _required(d, "risers", path) + if isinstance(risers, bool) or not isinstance(risers, int): + if isinstance(risers, float) and risers.is_integer(): + risers = int(risers) + else: + raise ValueError(f"{path}.risers: expected a whole number of risers, got {risers!r}") + if risers < 2: + raise ValueError(f"{path}.risers: a stair needs at least 2 risers, got {risers}") + return Stair( + id=_text(_required(d, "id", path), f"{path}.id"), + start=_point(_required(d, "start", path), f"{path}.start"), + direction_deg=_number( + _required(d, "direction_deg", path), f"{path}.direction_deg", positive=False + ), + width=_number(_required(d, "width", path), f"{path}.width"), + risers=risers, + riser_height=_number(_required(d, "riser_height", path), f"{path}.riser_height"), + going=_number(_required(d, "going", path), f"{path}.going"), + kind=_choice(d.get("kind", "straight"), f"{path}.kind", STAIR_KINDS), + turn=_choice(d.get("turn", "left"), f"{path}.turn", TURNS), + ) + + +def room_from_dict(d: dict, path: str = "room") -> Room: + """A validated Room with ``area`` 0.0: the area is measured, never accepted.""" + if isinstance(d, dict) and "area" in d: + raise ValueError( + f"{path}.area: an area is measured from the face the walls enclose, never typed; " + "remove it" + ) + _known(d, path, _ROOM_FIELDS) + return Room( + id=_text(_required(d, "id", path), f"{path}.id"), + name=_text(_required(d, "name", path), f"{path}.name"), + number=_text(d.get("number"), f"{path}.number", optional=True), + at=_point(_required(d, "at", path), f"{path}.at"), + ) + + +# -- walls and hosted openings ------------------------------------------------- + + +def axis_segments(wall: Wall) -> list[tuple[Pt, Pt]]: + """The straight axis segments, the closing one included when the wall is closed.""" + pts = list(wall.axis) + segs = [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)] + if wall.closed: + segs.append((pts[-1], pts[0])) + return segs + + +def wall_length(wall: Wall) -> float: + """The length of the axis, closing segment included when the wall is closed.""" + return sum(math.dist(a, b) for a, b in axis_segments(wall)) + + +def segment_of(wall: Wall, offset: float) -> tuple[int, float]: + """``(segment index, distance along it)`` for a distance along the axis. + + A distance that lands exactly on an axis vertex belongs to the segment that + *starts* there; the very end of the axis belongs to the last segment. + """ + where = float(offset) + total = wall_length(wall) + if not math.isfinite(where) or where < -EPS or where > total + EPS: + raise ValueError(f"offset {offset!r} is outside wall {wall.id} (axis length {total:g})") + run = 0.0 + segs = axis_segments(wall) + for index, (a, b) in enumerate(segs): + length = math.dist(a, b) + if where < run + length - EPS: + return index, max(0.0, where - run) + run += length + last = len(segs) - 1 + return last, math.dist(*segs[last]) + + +def _vertex_runs(wall: Wall) -> list[float]: + """Axis distances of the interior vertices (where one segment turns into the next).""" + runs: list[float] = [] + run = 0.0 + segs = axis_segments(wall) + for a, b in segs[:-1]: + run += math.dist(a, b) + runs.append(run) + return runs + + +def validate_openings(walls: Sequence[Wall], openings: Sequence[Opening]) -> None: + """Refuse an opening that its wall cannot host - before anything is drawn. + + Refused: two walls or two openings with one id; an opening on a wall that + is not in the plan; one that runs past the end of its wall's axis; one + that straddles an axis vertex (a jamb on each side of a corner); two + openings on one wall whose spans overlap - naming both ids. + """ + by_id: dict[str, Wall] = {} + for wall in walls: + if wall.id in by_id: + raise ValueError(f"walls: two walls share the id {wall.id!r}") + by_id[wall.id] = wall + seen: set[str] = set() + spans: dict[str, list[tuple[float, float, str]]] = {} + for opening in openings: + if opening.id in seen: + raise ValueError(f"openings: two openings share the id {opening.id!r}") + seen.add(opening.id) + wall = by_id.get(opening.wall) + if wall is None: + known = ", ".join(sorted(by_id)) or "none" + raise ValueError( + f"opening {opening.id}: wall {opening.wall!r} is not in the plan; walls are {known}" + ) + start = float(opening.offset) + end = start + float(opening.width) + total = wall_length(wall) + if end > total + EPS: + raise ValueError( + f"opening {opening.id}: runs past the end of wall {wall.id} - " + f"{start:g} + {float(opening.width):g} = {end:g} > axis length {total:g}" + ) + for run in _vertex_runs(wall): + if start + EPS < run < end - EPS: + raise ValueError( + f"opening {opening.id}: straddles the axis vertex of wall {wall.id} at " + f"{run:g} ({start:g}-{end:g}); an opening sits inside one straight segment" + ) + for other_start, other_end, other_id in spans.get(wall.id, ()): + if start < other_end - EPS and other_start < end - EPS: + raise ValueError( + f"openings {other_id} and {opening.id} overlap on wall {wall.id}: " + f"{other_start:g}-{other_end:g} and {start:g}-{end:g}" + ) + spans.setdefault(wall.id, []).append((start, end, opening.id)) + + +# -- the ACADMCP_ARCH payload -------------------------------------------------- + +_BY_KIND = {Wall: "wall", Opening: "opening", Stair: "stair", Room: "room"} + + +def _jsonable(value): + if isinstance(value, tuple): + return [_jsonable(v) for v in value] + return value + + +def to_payload(obj) -> dict: + """``{"v": 1, "kind": "wall"|"opening"|"stair"|"room", **fields}`` - JSON-ready. + + ``Opening.kind`` (door / window) and ``Stair.kind`` (straight / l / u) would + overwrite the payload's own ``kind``, so they travel as ``opening_kind`` and + ``stair_kind``; ``from_payload`` puts them back. + """ + kind = _BY_KIND.get(type(obj)) + if kind is None: + raise TypeError(f"to_payload: not a plan object: {type(obj).__name__}") + fields = {key: _jsonable(value) for key, value in asdict(obj).items()} + if "kind" in fields: + fields[f"{kind}_kind"] = fields.pop("kind") + return {"v": PAYLOAD_VERSION, "kind": kind, **fields} + + +def from_payload(payload: dict): + """The Wall / Opening / Stair / Room a payload describes, validated again.""" + if not isinstance(payload, dict): + raise ValueError(f"{APP_ID}: a payload must be a dict, got {type(payload).__name__}") + version = payload.get("v", PAYLOAD_VERSION) + if version != PAYLOAD_VERSION: + raise ValueError( + f"{APP_ID}: payload version {version!r}; this build reads version {PAYLOAD_VERSION}" + ) + kind = payload.get("kind") + fields = {k: v for k, v in payload.items() if k not in ("v", "kind")} + if f"{kind}_kind" in fields: + fields["kind"] = fields.pop(f"{kind}_kind") + where = f"{APP_ID} {kind}" + if kind == "wall": + return wall_from_dict(fields, where) + if kind == "opening": + return opening_from_dict(fields, where) + if kind == "stair": + return stair_from_dict(fields, where) + if kind == "room": + area = _number(fields.pop("area", 0.0), f"{where}.area", allow_zero=True) + room = room_from_dict(fields, where) + return Room(id=room.id, name=room.name, number=room.number, at=room.at, area=area) + raise ValueError(f"{APP_ID}: unknown payload kind {kind!r}; kinds are {', '.join(KINDS)}") + + +def encode(payload: dict) -> list[tuple[int, str]]: + """The mechanical codec with ``app_id=ACADMCP_ARCH`` and the four plan kinds.""" + return _codec.encode(payload, app_id=APP_ID, kinds=KINDS) + + +def to_values(payload: dict) -> list[str]: + """Just the chunk strings - what ``entity_set_xdata(handle, APP_ID, values)`` takes.""" + return _codec.to_values(payload, app_id=APP_ID, kinds=KINDS) + + +def decode(rows: Sequence) -> dict: + """A payload read back from ``entity_get_xdata``; anything foreign is refused.""" + return _codec.decode(rows, app_id=APP_ID, kinds=KINDS) +``` + +- [ ] **Step 16: Re-export the model from the package** + +Replace `engineering/arch/__init__.py` with: + +```python +"""Architectural 2D: the plan model and the engine that draws it (track F). + +Merge rule for the three track-F branches: the re-exports below are additive +and alphabetical - keep every side's names. +""" + +from __future__ import annotations + +from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab +from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER +from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch +from .model import ( + APP_ID, + KINDS, + Opening, + Room, + Stair, + Wall, + axis_segments, + decode, + encode, + from_payload, + opening_from_dict, + room_from_dict, + segment_of, + stair_from_dict, + to_payload, + to_values, + validate_openings, + wall_from_dict, + wall_length, +) + +__all__ = [ + "APP_ID", + "ARCH_LAYERS", + "ARCH_LAYER_DEFS", + "ARCH_LAYER_ROLES", + "ARCH_ROLE_LAYER", + "KINDS", + "LANGS", + "Opening", + "Room", + "Stair", + "WALL_HATCH_TABLE", + "WALL_MATERIALS", + "Wall", + "axis_segments", + "decode", + "encode", + "fmt_area_m2", + "fmt_number", + "from_payload", + "opening_from_dict", + "room_from_dict", + "segment_of", + "stair_from_dict", + "tag_prefix", + "to_payload", + "to_values", + "validate_openings", + "vocab", + "wall_from_dict", + "wall_hatch", + "wall_length", +] +``` + +- [ ] **Step 17: Run both test files to verify they pass** + +Run: `uv run --frozen pytest tests/test_arch_model.py tests/test_arch_foundation.py -q` +Expected: `67 passed`. + +- [ ] **Step 18: Gates** + +``` +uv run --frozen ruff format engineering/arch engineering/mech/draw.py engineering/mech/xdata.py engineering/layers.py engineering/plan_spec.py server.py tests/test_arch_foundation.py tests/test_arch_model.py +uv run --frozen ruff check engineering/arch engineering/mech/draw.py engineering/mech/xdata.py engineering/layers.py engineering/plan_spec.py server.py tests/test_arch_foundation.py tests/test_arch_model.py +uv run --frozen python -c "import engineering, engineering.arch, engineering.layers, engineering.critique, server; print('imports ok')" +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +Expected: ruff clean; `imports ok` (the four entry points load in any order — the `engineering.layers` ↔ `engineering.arch` cycle is closed by registering the set last); the inventory check reports no drift (no tool was added); the whole suite green with the one deselected test, which is the known red of the Global Constraints — this task adds 67 tests, so the README collected-tests figure drifts until Task 9 retakes it. + +- [ ] **Step 19: Commit** + +``` +git add engineering/arch engineering/mech/draw.py engineering/mech/xdata.py engineering/layers.py engineering/plan_spec.py server.py tests/test_arch_foundation.py tests/test_arch_model.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(arch): the plan model and its foundation - layers, materials, EN/TR, two generalisations + +Track F starts here; the three group branches are cut from this commit. + +- engineering/arch/model.py: Wall, Opening, Stair and Room as frozen + dataclasses, and every refusal of spec 4 before anything is drawn, by path + (walls[2].thickness): finite positive numbers, an axis of at least two + distinct points with no zero-length segment, an arc segment refused by name + rather than approximated, an opening on an unknown wall, past its wall, + across an axis vertex or overlapping another (both ids named), an unknown + material, and a typed room area - areas are measured, never typed. The + plan travels as ACADMCP_ARCH XDATA; an opening's door/window kind and a + stair's straight/l/u kind ride beside the payload kind as opening_kind / + stair_kind, because one key cannot hold both. +- engineering/arch/layers.py: the arch set - thirteen ISO 13567-style layers + with ISO 128 lineweights only (0.05 mm on the construction scratch layer, + as every set in this repository) - and ARCH_ROLE_LAYER, the one place a + role becomes a layer. Registered in engineering/layers.py last, which is + what keeps the engineering.layers <-> engineering.arch import cycle closed. +- engineering/arch/materials.py: eight wall materials, one pattern each, + every row citing its source: ANSI31 for brick (ISO 128-50 general hatching, + acadiso.pat's own "Iron, Brick, Stone masonry"), AAC as ANSI31 at twice the + spacing (a repository convention, and it says so), AR-CONC / AR-SAND as + AutoCAD patterns, and the JIS A 0150 RC / stone / wood symbols. Reinforced + concrete cannot be ANSI31 and AR-CONC in one HATCH, so it takes the one + symbol that names it. Periods on a 1:50 sheet are recomputed from ezdxf's + own pattern definitions by the tests. +- engineering/arch/lang.py: EN / TR vocabulary, D/W and K/P tag prefixes, and + the decimal comma for lang="tr" (24,50 m2). +- draw_prims(role_layer=) and the XDATA codec's app_id= / kinds=: both + generalised with the mechanical defaults, and the mechanical suite passes + unchanged against them - that is the regression gate. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 2: The wall engine — outlines, L/T/X junctions, opening gaps and material poché + +**Files:** +- Create: `engineering/arch/walls.py` +- Modify: `engineering/arch/__init__.py` (re-export the wall engine's public names) +- Test: `tests/test_arch_walls.py` + +**Interfaces:** +- Consumes: `engineering.arch.model` (Task 1) — `Wall`, `Opening`, `segment_of(wall: Wall, offset: float) -> tuple[int, float]`, `validate_openings(walls: Sequence[Wall], openings: Sequence[Opening]) -> None`; `engineering.arch.materials.wall_hatch(material: str) -> dict` (ValueError on an unknown material); `engineering.mech.primitives` — `HatchArea`, `Line`, `Pt`; `engineering.measure` — `polygon_area_perimeter(vertices, closed) -> tuple[float, float]`, `is_self_intersecting(vertices) -> bool | None`. +- Produces (Tasks 3, 4, 5 and 9 rely on these): `WallGeometry(faces: tuple[Line, ...], jambs: tuple[Line, ...], regions: tuple[tuple[Pt, ...], ...], hatches: tuple[HatchArea, ...], omitted: tuple[dict, ...], thresholds: tuple[Line, ...] = ())` — the skeleton's five fields in their pinned order plus one trailing defaulted field; `wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry`; `room_segments(geometry: WallGeometry) -> tuple[tuple[Pt, Pt], ...]` (faces + jambs + thresholds); and, beyond the skeleton, `wall_geometry_by_wall(walls, openings=(), *, poche=True, join_tol=1.0) -> dict[str, WallGeometry]`, `face_offsets(wall: Wall) -> tuple[float, float]` (left, right offsets along the axis's left normal), `wall_segments(wall: Wall) -> tuple[tuple[Pt, Pt], ...]` (closing segment last when `closed`), `MIN_JOIN_ANGLE_DEG = 5.0`. Every `Line` has role `"wall"`; every region is CCW; `omitted` rows are `{"element": opening_id, "reason": str}`. + +The engine in one paragraph, so the test below reads as a specification: every axis segment is offset into two faces by its justification. At every node (segment ends within `join_tol`) the ends are taken as outgoing directions, sorted by angle, and the left face of each end meets the right face of the next one counter-clockwise — that one rule gives the L mitre, stops a T's stem on the crossing wall's near face, and cuts all four faces of an X. A T is found as an axis vertex lying inside another segment's body: the vertex is carried along its own axis onto the crossing axis and the crossing segment is split there. An X is two axes crossing in their interiors: both are split. A node of three or more ends leaves a hub polygon no segment covers; it is a region of its own. Openings cut both faces and are closed by jambs; free ends are closed by jambs; collinear faces of one wall that continue each other are merged into one line. + +- [ ] **Step 1: Write the failing test** + +`tests/test_arch_walls.py`: + +```python +"""The wall engine: outlines, L/T/X junctions, opening gaps and poche. + +Every corner below is hand-computed and written out: two 200 mm walls centred +on their axes put their faces 100 mm either side, so an L at the origin has +its inner corner at (100, 100) and its outer one at (-100, -100), and a T or +an X leaves the faces of the crossing wall cut exactly 100 mm either side of +the stem's axis. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.model import Opening, Wall +from engineering.arch.walls import ( + MIN_JOIN_ANGLE_DEG, + WallGeometry, + face_offsets, + room_segments, + wall_geometry, + wall_geometry_by_wall, + wall_segments, +) +from engineering.mech.primitives import HatchArea, Line + + +def seg(line) -> tuple: + """An undirected, rounded segment: comparable as a set member.""" + a = (round(line.p1[0], 9) + 0.0, round(line.p1[1], 9) + 0.0) + b = (round(line.p2[0], 9) + 0.0, round(line.p2[1], 9) + 0.0) + return tuple(sorted((a, b))) + + +def segs(lines) -> set: + return {seg(line) for line in lines} + + +def S(x1, y1, x2, y2) -> tuple: + return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2))))) + + +def ring(points) -> tuple: + """A polygon, rounded and rotated to start at its smallest vertex (order kept).""" + pts = [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in points] + k = pts.index(min(pts)) + return tuple(pts[k:] + pts[:k]) + + +def signed_area(points) -> float: + return ( + sum( + x1 * y2 - x2 * y1 + for (x1, y1), (x2, y2) in zip(points, list(points[1:]) + [points[0]], strict=True) + ) + / 2.0 + ) + + +def wall(id, *axis, thickness=200.0, **kw) -> Wall: + return Wall(id=id, axis=tuple(axis), thickness=thickness, **kw) + + +# -- the wall's own numbers ------------------------------------------------------ + + +def test_face_offsets_follow_the_justification(): + assert face_offsets(wall("c", (0, 0), (1, 0))) == (100.0, -100.0) + assert face_offsets(wall("l", (0, 0), (1, 0), justification="left")) == (200.0, 0.0) + assert face_offsets(wall("r", (0, 0), (1, 0), justification="right")) == (0.0, -200.0) + + +def test_wall_segments_close_a_closed_axis(): + square = wall("s", (0, 0), (4000, 0), (4000, 3000), (0, 3000), closed=True) + assert wall_segments(square) == ( + ((0.0, 0.0), (4000.0, 0.0)), + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ((0.0, 3000.0), (0.0, 0.0)), + ) + + +# -- a free end ------------------------------------------------------------------ + + +def test_a_single_wall_is_two_faces_closed_by_a_jamb_at_each_free_end(): + geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="left")]) + assert isinstance(geo, WallGeometry) + assert segs(geo.faces) == {S(0, 100, 3000, 100), S(0, 0, 3000, 0)} + assert segs(geo.jambs) == {S(0, 0, 0, 100), S(3000, 0, 3000, 100)} + assert [ring(r) for r in geo.regions] == [ring([(0, 0), (3000, 0), (3000, 100), (0, 100)])] + assert all(line.role == "wall" for line in (*geo.faces, *geo.jambs)) + + +def test_right_justification_puts_the_wall_on_the_other_side_of_the_axis(): + geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="right")]) + assert segs(geo.faces) == {S(0, 0, 3000, 0), S(0, -100, 3000, -100)} + + +# -- L ----------------------------------------------------------------------------- + + +L_FACES = { + S(100, 100, 4000, 100), # wall a, inner face + S(-100, -100, 4000, -100), # wall a, outer face + S(100, 100, 100, 3000), # wall b, inner face + S(-100, -100, -100, 3000), # wall b, outer face +} +L_JAMBS = {S(4000, -100, 4000, 100), S(-100, 3000, 100, 3000)} +L_REGIONS = { + ring([(-100, -100), (4000, -100), (4000, 100), (100, 100)]), + ring([(100, 100), (100, 3000), (-100, 3000), (-100, -100)]), +} + + +def test_an_l_between_two_walls_mitres_both_faces_to_their_intersections(): + geo = wall_geometry([wall("a", (0, 0), (4000, 0)), wall("b", (0, 0), (0, 3000))]) + assert segs(geo.faces) == L_FACES + assert segs(geo.jambs) == L_JAMBS + assert {ring(r) for r in geo.regions} == L_REGIONS + assert len(geo.hatches) == 2 + + +def test_an_l_inside_one_wall_is_the_same_corner(): + geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))]) + assert segs(geo.faces) == L_FACES + assert segs(geo.jambs) == L_JAMBS + assert {ring(r) for r in geo.regions} == L_REGIONS + + +def test_every_region_is_counter_clockwise(): + geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))]) + assert all(signed_area(r) > 0 for r in geo.regions) + + +# -- T ----------------------------------------------------------------------------- + + +def test_a_t_stops_the_stem_on_the_near_face_and_keeps_the_far_face_whole(): + geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))]) + assert segs(geo.faces) == { + S(-3000, -100, 3000, -100), # the far face: one line past the stem + S(-3000, 100, -100, 100), # the near face, cut where the stem meets it + S(100, 100, 3000, 100), + S(-100, 100, -100, 2500), # the stem, stopped on the near face + S(100, 100, 100, 2500), + } + assert segs(geo.jambs) == { + S(-3000, -100, -3000, 100), + S(3000, -100, 3000, 100), + S(-100, 2500, 100, 2500), + } + assert {ring(r) for r in geo.regions} == { + ring([(-3000, -100), (0, -100), (-100, 100), (-3000, 100)]), + ring([(0, -100), (3000, -100), (3000, 100), (100, 100)]), + ring([(100, 100), (100, 2500), (-100, 2500), (-100, 100)]), + ring([(100, 100), (-100, 100), (0, -100)]), # the hub the three pieces leave + } + + +def test_a_stem_drawn_to_the_face_is_carried_to_the_axis(): + touching = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 100), (0, 2500))]) + on_axis = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))]) + assert segs(touching.faces) == segs(on_axis.faces) + + +# -- X ----------------------------------------------------------------------------- + + +def test_an_x_cuts_all_four_faces(): + geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, -2500), (0, 2500))]) + assert segs(geo.faces) == { + S(-3000, 100, -100, 100), + S(100, 100, 3000, 100), + S(-3000, -100, -100, -100), + S(100, -100, 3000, -100), + S(-100, -2500, -100, -100), + S(-100, 100, -100, 2500), + S(100, -2500, 100, -100), + S(100, 100, 100, 2500), + } + assert len(geo.jambs) == 4 + assert ring([(100, 100), (-100, 100), (-100, -100), (100, -100)]) in { + ring(r) for r in geo.regions + } + assert len(geo.regions) == 5 # four arms and the hub + + +# -- openings ------------------------------------------------------------------------ + + +def test_a_door_removes_its_width_from_both_faces_and_is_closed_by_two_jambs(): + geo = wall_geometry( + [wall("a", (0, 0), (5000, 0))], + [Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)], + ) + assert segs(geo.faces) == { + S(0, 100, 1000, 100), + S(1900, 100, 5000, 100), + S(0, -100, 1000, -100), + S(1900, -100, 5000, -100), + } + assert segs(geo.jambs) == { + S(0, -100, 0, 100), + S(5000, -100, 5000, 100), + S(1000, -100, 1000, 100), + S(1900, -100, 1900, 100), + } + assert segs(geo.thresholds) == {S(1000, 100, 1900, 100), S(1000, -100, 1900, -100)} + assert {ring(r) for r in geo.regions} == { + ring([(0, -100), (1000, -100), (1000, 100), (0, 100)]), + ring([(1900, -100), (5000, -100), (5000, 100), (1900, 100)]), + } + assert [h.material for h in geo.hatches] == ["brick", "brick"] + assert geo.omitted == () + + +def test_an_opening_is_placed_along_the_whole_axis_not_its_first_segment(): + geo = wall_geometry( + [wall("a", (0, 0), (4000, 0), (4000, 3000))], + [Opening(id="w1", wall="a", kind="window", offset=4500.0, width=1200.0)], + ) + assert {S(3900, 100, 3900, 500), S(3900, 1700, 3900, 3000)} <= segs(geo.faces) + assert {S(3900, 500, 4100, 500), S(3900, 1700, 4100, 1700)} <= segs(geo.jambs) + + +def test_an_opening_that_runs_into_a_junction_is_omitted_by_name(): + geo = wall_geometry( + [wall("a", (0, 0), (4000, 0), (4000, 3000))], + [Opening(id="d9", wall="a", kind="door", offset=3500.0, width=450.0)], + ) + ((entry,),) = [geo.omitted] + assert entry["element"] == "d9" + assert "junction" in entry["reason"] + assert segs(geo.faces) == segs( + wall_geometry([wall("a", (0, 0), (4000, 0), (4000, 3000))]).faces + ) + + +# -- poche ----------------------------------------------------------------------------- + + +def test_one_hatch_per_region_in_the_wall_material_and_none_without_poche(): + walls = [wall("a", (-3000, 0), (3000, 0), material="concrete"), wall("b", (0, 0), (0, 2500))] + geo = wall_geometry(walls) + assert len(geo.hatches) == len(geo.regions) == 4 + assert all(isinstance(h, HatchArea) and len(h.loops) == 1 for h in geo.hatches) + assert sorted(h.material for h in geo.hatches) == ["brick", "concrete", "concrete", "concrete"] + bare = wall_geometry(walls, poche=False) + assert bare.hatches == () + assert len(bare.regions) == 4 + + +def test_by_wall_splits_every_element_to_its_owner(): + walls = [wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))] + split = wall_geometry_by_wall(walls) + whole = wall_geometry(walls) + assert set(split) == {"a", "b"} + assert segs(split["a"].faces) | segs(split["b"].faces) == segs(whole.faces) + assert segs(split["b"].faces) == {S(-100, 100, -100, 2500), S(100, 100, 100, 2500)} + assert len(split["a"].regions) == 3 # two arms and the hub (a is first) + assert len(split["b"].regions) == 1 + + +def test_room_segments_are_faces_jambs_and_thresholds(): + geo = wall_geometry( + [wall("a", (0, 0), (5000, 0))], + [Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)], + ) + segments = room_segments(geo) + assert len(segments) == len(geo.faces) + len(geo.jambs) + len(geo.thresholds) == 10 + assert {S(*p1, *p2) for p1, p2 in segments} == segs(geo.faces) | segs(geo.jambs) | segs( + geo.thresholds + ) + + +# -- refusals ------------------------------------------------------------------------- + + +def test_a_stem_meeting_a_wall_below_five_degrees_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (-2000, -150), (2000, 0))]) + message = str(excinfo.value) + assert "'a'" in message and "'b'" in message + assert f"{MIN_JOIN_ANGLE_DEG:g}°" in message + + +def test_two_ends_meeting_below_five_degrees_are_refused(): + with pytest.raises(ValueError, match="apart"): + wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (0, 0), (5000, 200))]) + + +def test_duplicate_wall_ids_are_refused_with_both_paths(): + with pytest.raises(ValueError, match=r"walls\[1\]\.id: 'a' is already used by walls\[0\]"): + wall_geometry([wall("a", (0, 0), (1000, 0)), wall("a", (0, 500), (1000, 500))]) + + +def test_an_unknown_material_is_refused_by_path(): + with pytest.raises(ValueError, match=r"walls\[0\]\.material"): + wall_geometry([wall("a", (0, 0), (1000, 0), material="cheese")]) + + +def test_a_corner_inside_another_wall_is_refused(): + with pytest.raises(ValueError, match="corner"): + wall_geometry( + [ + wall("a", (-3000, 0), (3000, 0)), + wall("b", (-500, 2000), (0, 50), (500, 2000)), + ] + ) + + +def test_an_end_whose_axis_misses_the_other_wall_is_refused(): + with pytest.raises(ValueError, match="beyond its end"): + wall_geometry([wall("a", (0, 0), (3000, 0)), wall("b", (2990, 80), (2000, 2000))]) + + +def test_a_stub_lying_inside_the_wall_it_joins_is_refused(): + with pytest.raises(ValueError, match="lies inside the wall it joins"): + wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 80))]) + + +def test_a_segment_too_short_for_its_corner_is_refused(): + # a 30 degree corner puts the inner mitre 100 / tan(15 deg) = 373.2 along the + # second segment, which is only 300 long: its outline would cross itself + with pytest.raises(ValueError, match="too short"): + wall_geometry([wall("a", (1000, 0), (0, 0), (259.8, 150.0))]) + + +def test_a_stub_ending_inside_its_own_corner_is_refused(): + # 60 long past a 200 mm L: its end lies inside the first segment's body + with pytest.raises(ValueError, match="lies inside the wall it joins"): + wall_geometry([wall("a", (0, 0), (1000, 0), (1000, 60))]) + + +def test_a_bad_join_tolerance_is_refused(): + with pytest.raises(ValueError, match="join_tol"): + wall_geometry([wall("a", (0, 0), (1000, 0))], join_tol=0.0) + + +def test_line_primitives_are_the_mech_line_type(): + geo = wall_geometry([wall("a", (0, 0), (1000, 0))]) + assert all(isinstance(line, Line) for line in (*geo.faces, *geo.jambs)) + + +def test_a_closed_axis_is_a_ring_with_no_jamb_and_a_4_by_5_m_inner_face(): + geo = wall_geometry([wall("r", (0, 0), (4200, 0), (4200, 5200), (0, 5200), closed=True)]) + assert geo.jambs == () + assert segs(geo.faces) == { + S(100, 100, 4100, 100), + S(4100, 100, 4100, 5100), + S(4100, 5100, 100, 5100), + S(100, 5100, 100, 100), + S(-100, -100, 4300, -100), + S(4300, -100, 4300, 5300), + S(4300, 5300, -100, 5300), + S(-100, 5300, -100, -100), + } + assert len(geo.regions) == 4 +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_walls.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.walls'`. + +- [ ] **Step 3: Write `engineering/arch/walls.py`** + +```python +"""The wall engine: axis polylines in, clean wall outlines out. + +A wall is an axis polyline with a thickness and a justification - the side of +the axis the wall lies on, looking along it from its first point. This module +offsets every axis segment into its two faces and resolves every place where +segments meet: + +* **L and straight joins** - two segment ends at one point. Each face is met by + the face of its neighbour and both are trimmed or extended to their + intersection, so the corner is a clean mitre. +* **T** - a segment ends *inside* another wall. Its axis is carried to the + crossing axis, the crossing segment is split there, and the same rule stops + the stem's faces on the crossing wall's near face. That face is cut where + the stem meets it; the far face stays one line. +* **X** - two axes cross in their interiors. Both are split and all four + faces are cut. + +All three are one algorithm: at every node the segment ends are taken as +outgoing directions and sorted by angle, and the left face of each end meets +the right face of the next one counter-clockwise. A node of three or more ends +leaves a *hub* - the square an X crosses in, the triangle a centred T leaves - +that no single segment covers; it is a region of its own, so the poche has no +hole in it. + +An opening removes its width from both faces and each edge is closed by a jamb; +a free wall end is closed by a jamb too. Every solid piece - one axis segment's +stretch between junctions, openings and free ends, plus each hub - is one CCW +region and, with ``poche=True``, one ``HatchArea`` in the wall's material. + +Refused by name before anything is computed: two walls with one id, an unknown +material or justification, a zero-length segment, two segments that meet at +less than ``MIN_JOIN_ANGLE_DEG`` (the mitre would run away), a wall corner that +lies inside another wall off its axis, an end inside a wall whose axis would +have to run past that wall's end to meet it, a segment that lies wholly +inside the wall it joins, and one too short for its junctions (its outline +would cross itself - `engineering.measure.is_self_intersecting`). An opening that cannot be cut because it runs into a +junction is returned in ``omitted``, never dropped. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass, field + +from engineering.arch.materials import wall_hatch +from engineering.arch.model import Opening, Wall, segment_of, validate_openings +from engineering.measure import is_self_intersecting, polygon_area_perimeter +from engineering.mech.primitives import HatchArea, Line, Pt + +#: The shallowest angle two wall axes may meet at. Below it the mitre point of +#: the two faces runs more than eleven wall thicknesses away from the node, so +#: the junction is refused rather than drawn as a spike. A drafting limit of +#: this engine, not a building rule. +MIN_JOIN_ANGLE_DEG = 5.0 + +_EPS = 1e-9 +#: Decimals a point is rounded to when face lines are matched end to end. +_KEY_DECIMALS = 6 + + +@dataclass(frozen=True) +class WallGeometry: + faces: tuple[Line, ...] + jambs: tuple[Line, ...] + regions: tuple[tuple[Pt, ...], ...] + hatches: tuple[HatchArea, ...] + omitted: tuple[dict, ...] + #: Each opening's two face lines across its gap. Never drawn: they are what + #: `room_segments` adds so a doorway does not merge two rooms into one face. + thresholds: tuple[Line, ...] = () + + +# -- vectors ------------------------------------------------------------------ + + +def _sub(p: Pt, q: Pt) -> Pt: + return (p[0] - q[0], p[1] - q[1]) + + +def _add(p: Pt, q: Pt) -> Pt: + return (p[0] + q[0], p[1] + q[1]) + + +def _mul(v: Pt, k: float) -> Pt: + return (v[0] * k, v[1] * k) + + +def _dot(u: Pt, v: Pt) -> float: + return u[0] * v[0] + u[1] * v[1] + + +def _cross(u: Pt, v: Pt) -> float: + return u[0] * v[1] - u[1] * v[0] + + +def _left(u: Pt) -> Pt: + return (-u[1], u[0]) + + +def _unit(p: Pt, q: Pt) -> Pt: + length = math.dist(p, q) + return ((q[0] - p[0]) / length, (q[1] - p[1]) / length) + + +def _meet(p: Pt, u: Pt, q: Pt, v: Pt) -> Pt: + """Where the line through ``p`` along ``u`` meets the line through ``q`` along ``v``.""" + t = _cross(_sub(q, p), v) / _cross(u, v) + return _add(p, _mul(u, t)) + + +def _heading(u: Pt) -> float: + return math.degrees(math.atan2(u[1], u[0])) % 360.0 + + +def _line_angle(u: Pt, v: Pt) -> float: + """The angle between two lines (not directions), 0-90 degrees.""" + return math.degrees(math.asin(min(1.0, abs(_cross(u, v))))) + + +def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None: + """Consecutive duplicates removed; None if nothing is enclosed. + + Every caller builds its ring counter-clockwise - a piece as right face out, + left face back; a hub in the angular order of its corners - so the ring is + not turned here. The area is `engineering.measure`'s, not a second shoelace. + """ + kept: list[Pt] = [] + for p in points: + if not kept or math.dist(kept[-1], p) > _EPS: + kept.append(p) + if len(kept) > 1 and math.dist(kept[0], kept[-1]) <= _EPS: + kept.pop() + if len(kept) < 3: + return None + area, _perimeter = polygon_area_perimeter([(x, y, 0.0) for x, y in kept], True) + return tuple(kept) if area > _EPS else None + + +def _key(p: Pt) -> tuple[float, float]: + return (round(p[0], _KEY_DECIMALS) + 0.0, round(p[1], _KEY_DECIMALS) + 0.0) + + +# -- the wall's own numbers ----------------------------------------------------- + + +def face_offsets(wall: Wall) -> tuple[float, float]: + """``(left, right)``: the signed offsets of a wall's two faces along the axis's left normal.""" + thickness = float(wall.thickness) + if wall.justification == "center": + return (thickness / 2.0, -thickness / 2.0) + if wall.justification == "left": + return (thickness, 0.0) + if wall.justification == "right": + return (0.0, -thickness) + raise ValueError( + f"wall {wall.id!r}: justification {wall.justification!r}; " + "justifications are center, left, right" + ) + + +def wall_segments(wall: Wall) -> tuple[tuple[Pt, Pt], ...]: + """The wall's axis segments in order, the closing one last when ``closed``.""" + points = [(float(x), float(y)) for x, y in wall.axis] + pairs = list(zip(points, points[1:], strict=False)) + if wall.closed: + pairs.append((points[-1], points[0])) + return tuple(pairs) + + +# -- the network ---------------------------------------------------------------- + + +@dataclass +class _Seg: + wall: int + index: int + va: tuple[int, int] + vb: tuple[int, int] + left: float + right: float + cuts: list[float] = field(default_factory=list) + + +@dataclass +class _Piece: + seg: _Seg + a: Pt + b: Pt + s0: float + corners: dict = field(default_factory=dict) # (end 0|1, "left"|"right") -> Pt + + +def _validate(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> None: + if not isinstance(join_tol, (int, float)) or not math.isfinite(join_tol) or join_tol <= 0: + raise ValueError(f"join_tol: must be a finite number above zero, got {join_tol!r}") + seen: dict[str, int] = {} + for i, wall in enumerate(walls): + if not isinstance(wall, Wall): + raise ValueError(f"walls[{i}]: expected a Wall, got {type(wall).__name__}") + if wall.id in seen: + raise ValueError( + f"walls[{i}].id: {wall.id!r} is already used by walls[{seen[wall.id]}]" + ) + seen[wall.id] = i + try: + wall_hatch(wall.material) + except ValueError as exc: + raise ValueError(f"walls[{i}].material: {exc}") from None + try: + face_offsets(wall) + except ValueError as exc: + raise ValueError(f"walls[{i}].justification: {exc}") from None + segments = wall_segments(wall) + if not segments: + raise ValueError(f"walls[{i}].axis: needs at least two points") + for k, (a, b) in enumerate(segments): + if math.dist(a, b) <= _EPS: + raise ValueError(f"walls[{i}].axis[{k}]: zero-length segment at {a}") + validate_openings(list(walls), list(openings)) + + +def _segments(walls: Sequence[Wall]) -> tuple[dict, list[_Seg]]: + pos: dict[tuple[int, int], Pt] = {} + segs: list[_Seg] = [] + for wi, wall in enumerate(walls): + left, right = face_offsets(wall) + count = len(wall.axis) + for k, (x, y) in enumerate(wall.axis): + pos[(wi, k)] = (float(x), float(y)) + for i in range(count if wall.closed else count - 1): + segs.append(_Seg(wi, i, (wi, i), (wi, (i + 1) % count), left, right)) + return pos, segs + + +def _name(walls: Sequence[Wall], index: int) -> str: + return f"walls[{index}] ({walls[index].id!r})" + + +def _refuse_shallow(walls, one: _Seg, other: _Seg, u: Pt, v: Pt) -> None: + angle = _line_angle(u, v) + if angle < MIN_JOIN_ANGLE_DEG: + raise ValueError( + f"{_name(walls, one.wall)} meets {_name(walls, other.wall)} at {angle:.1f}°; " + f"a junction below {MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean " + "outline - end the wall at a steeper angle." + ) + + +def _resolve_tees(walls, pos, segs, tol: float) -> None: + """Carry every axis end that stops inside another wall onto that wall's axis, and split it.""" + touching: dict[tuple[int, int], list[_Seg]] = {} + for seg in segs: + touching.setdefault(seg.va, []).append(seg) + touching.setdefault(seg.vb, []).append(seg) + for vertex, mine in touching.items(): + for other in segs: + if any(other is seg for seg in mine): + continue + p = pos[vertex] + a, b = pos[other.va], pos[other.vb] + if math.dist(p, a) <= tol or math.dist(p, b) <= tol: + continue + length = math.dist(a, b) + u = _unit(a, b) + s = _dot(_sub(p, a), u) + d = _dot(_sub(p, a), _left(u)) + if s < -tol or s > length + tol or d < other.right - tol or d > other.left + tol: + continue + for seg in mine: + _refuse_shallow(walls, seg, other, _unit(pos[seg.va], pos[seg.vb]), u) + if abs(d) <= tol: + x = _add(a, _mul(u, s)) + elif len(mine) == 1: + seg = mine[0] + far = pos[seg.vb] if seg.va == vertex else pos[seg.va] + x = _meet(far, _unit(far, p), a, u) + else: + raise ValueError( + f"the corner of {_name(walls, vertex[0])} at ({p[0]:g}, {p[1]:g}) lies inside " + f"{_name(walls, other.wall)} off its axis; end the wall there or move the corner " + "onto the other wall's axis." + ) + sx = _dot(_sub(x, a), u) + if sx < -tol or sx > length + tol: + raise ValueError( + f"{_name(walls, vertex[0])} ends inside {_name(walls, other.wall)} at " + f"({p[0]:g}, {p[1]:g}), but its axis meets that wall's axis beyond its end; " + "join the two axes at a common point." + ) + if sx <= tol: + pos[vertex] = a + elif sx >= length - tol: + pos[vertex] = b + else: + pos[vertex] = x + other.cuts.append(sx) + break + + +def _resolve_crossings(walls, pos, segs, tol: float) -> None: + """Split both segments wherever two axes cross in their interiors (an X).""" + for i, one in enumerate(segs): + for other in segs[i + 1 :]: + if {one.va, one.vb} & {other.va, other.vb}: + continue + a1, b1 = pos[one.va], pos[one.vb] + a2, b2 = pos[other.va], pos[other.vb] + r = _sub(b1, a1) + q = _sub(b2, a2) + denom = _cross(r, q) + if abs(denom) <= _EPS: + continue + t = _cross(_sub(a2, a1), q) / denom + w = _cross(_sub(a2, a1), r) / denom + l1 = math.hypot(*r) + l2 = math.hypot(*q) + if tol < t * l1 < l1 - tol and tol < w * l2 < l2 - tol: + _refuse_shallow(walls, one, other, _unit(a1, b1), _unit(a2, b2)) + one.cuts.append(t * l1) + other.cuts.append(w * l2) + + +def _pieces(walls, pos, segs, tol: float) -> list[_Piece]: + pieces: list[_Piece] = [] + for seg in segs: + a, b = pos[seg.va], pos[seg.vb] + length = math.dist(a, b) + if length <= tol: + raise ValueError( + f"{_name(walls, seg.wall)}: segment {seg.index} lies inside the wall it joins - " + "nothing of it stands clear of that wall; lengthen it or leave it out." + ) + u = _unit(a, b) + stops: list[float] = [] + for cut in sorted(seg.cuts): + if tol < cut < length - tol and (not stops or cut - stops[-1] > tol): + stops.append(cut) + bounds = [0.0, *stops, length] + for s0, s1 in zip(bounds, bounds[1:], strict=False): + start = a if s0 == 0.0 else _add(a, _mul(u, s0)) + end = b if s1 == length else _add(a, _mul(u, s1)) + pieces.append(_Piece(seg, start, end, s0)) + return pieces + + +def _outgoing(piece: _Piece, end: int): + """(origin, direction, left normal, left offset, right offset) leaving the node at ``end``.""" + u = _unit(piece.a, piece.b) + if end == 0: + return piece.a, u, _left(u), piece.seg.left, piece.seg.right + back = (-u[0], -u[1]) + return piece.b, back, _left(back), -piece.seg.right, -piece.seg.left + + +def _canonical(end: int, side: str) -> str: + """Leaving through its end, a piece's left face is its canonical right one.""" + if end == 0: + return side + return "right" if side == "left" else "left" + + +def _foot(node: Pt, p: Pt, u: Pt) -> Pt: + return _add(p, _mul(u, _dot(_sub(node, p), u))) + + +def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> dict: + walls = list(walls) + openings = list(openings) + _validate(walls, openings, join_tol) + pos, segs = _segments(walls) + _resolve_tees(walls, pos, segs, join_tol) + _resolve_crossings(walls, pos, segs, join_tol) + pieces = _pieces(walls, pos, segs, join_tol) + + faces: list[tuple[Line, str]] = [] + jambs: list[tuple[Line, str]] = [] + thresholds: list[tuple[Line, str]] = [] + regions: list[tuple[tuple[Pt, ...], str]] = [] + omitted: list[tuple[dict, str]] = [] + + # -- the nodes ------------------------------------------------------------ + nodes: list[Pt] = [] + ends: list[list[tuple[_Piece, int]]] = [] + for piece in pieces: + for end, point in ((0, piece.a), (1, piece.b)): + for k, node in enumerate(nodes): + if math.dist(point, node) <= join_tol: + ends[k].append((piece, end)) + break + else: + nodes.append(point) + ends.append([(piece, end)]) + + for node, members in zip(nodes, ends, strict=True): + if len(members) == 1: + piece, end = members[0] + origin, _u, n, left, right = _outgoing(piece, end) + pl = _add(origin, _mul(n, left)) + pr = _add(origin, _mul(n, right)) + piece.corners[(end, _canonical(end, "left"))] = pl + piece.corners[(end, _canonical(end, "right"))] = pr + jambs.append((Line(pl, pr, "wall"), walls[piece.seg.wall].id)) + continue + ordered = sorted(members, key=lambda m: _heading(_outgoing(*m)[1])) + hub: list[Pt] = [] + for idx, (pi, ei) in enumerate(ordered): + pj, ej = ordered[(idx + 1) % len(ordered)] + oi, ui, ni, li, _ri = _outgoing(pi, ei) + oj, uj, nj, _lj, rj = _outgoing(pj, ej) + gap = (_heading(uj) - _heading(ui)) % 360.0 + if gap < MIN_JOIN_ANGLE_DEG: + raise ValueError( + f"{_name(walls, pi.seg.wall)} and {_name(walls, pj.seg.wall)} meet at " + f"({node[0]:g}, {node[1]:g}) {gap:.1f}° apart; a junction below " + f"{MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean outline." + ) + face_i = _add(oi, _mul(ni, li)) + face_j = _add(oj, _mul(nj, rj)) + if abs(_cross(ui, uj)) <= 1e-12: + ci = _foot(node, face_i, ui) + cj = _foot(node, face_j, uj) + if math.dist(ci, cj) <= _EPS: + cj = ci + else: + faces.append((Line(ci, cj, "wall"), walls[pi.seg.wall].id)) + else: + ci = cj = _meet(face_i, ui, face_j, uj) + pi.corners[(ei, _canonical(ei, "left"))] = ci + pj.corners[(ej, _canonical(ej, "right"))] = cj + hub.append(ci) + if cj is not ci: + hub.append(cj) + if len(ordered) >= 3: + polygon = _polygon(hub) + if polygon: + owner = min(piece.seg.wall for piece, _end in ordered) + regions.append((polygon, walls[owner].id)) + + # -- the openings ----------------------------------------------------------- + by_id = {wall.id: i for i, wall in enumerate(walls)} + by_seg = {(seg.wall, seg.index): seg for seg in segs} + cut_in: dict[int, list[tuple[float, float]]] = {} + for opening in openings: + wi = by_id[opening.wall] + wall = walls[wi] + si, along = segment_of(wall, float(opening.offset)) + a0, b0 = wall_segments(wall)[si] + start = _add(a0, _mul(_unit(a0, b0), along)) + seg = by_seg[(wi, si)] + u = _unit(pos[seg.va], pos[seg.vb]) + s0 = _dot(_sub(start, pos[seg.va]), u) + s1 = s0 + float(opening.width) + host = None + for piece in pieces: + length = math.dist(piece.a, piece.b) + if piece.seg is seg and piece.s0 - _EPS <= s0 and s1 <= piece.s0 + length + _EPS: + host = piece + break + if host is None: + omitted.append( + ( + { + "element": opening.id, + "reason": f"it crosses a junction on wall {wall.id!r} segment {si}; " + "an opening must lie inside one stretch of wall", + }, + wall.id, + ) + ) + continue + d0 = s0 - host.s0 + d1 = s1 - host.s0 + clear_from = max( + _dot(_sub(host.corners[(0, side)], host.a), u) for side in ("left", "right") + ) + clear_to = min(_dot(_sub(host.corners[(1, side)], host.a), u) for side in ("left", "right")) + if d0 < clear_from - _EPS or d1 > clear_to + _EPS: + omitted.append( + ( + { + "element": opening.id, + "reason": f"it runs into a junction of wall {wall.id!r}: on segment {si} " + f"the solid wall runs from {host.s0 + clear_from:g} to " + f"{host.s0 + clear_to:g} mm along the axis, the opening from " + f"{s0:g} to {s1:g}", + }, + wall.id, + ) + ) + continue + cut_in.setdefault(id(host), []).append((d0, d1)) + + # -- faces, jambs and regions, piece by piece --------------------------------- + for piece in pieces: + seg = piece.seg + owner = walls[seg.wall].id + u = _unit(piece.a, piece.b) + n = _left(u) + + def at(s: float, offset: float, piece=piece, u=u, n=n) -> Pt: + return _add(_add(piece.a, _mul(u, s)), _mul(n, offset)) + + cur_l = piece.corners[(0, "left")] + cur_r = piece.corners[(0, "right")] + runs: list[tuple[Pt, Pt, Pt, Pt]] = [] + for d0, d1 in sorted(cut_in.get(id(piece), [])): + la, ra = at(d0, seg.left), at(d0, seg.right) + lb, rb = at(d1, seg.left), at(d1, seg.right) + runs.append((cur_l, cur_r, la, ra)) + jambs.append((Line(la, ra, "wall"), owner)) + jambs.append((Line(lb, rb, "wall"), owner)) + thresholds.append((Line(la, lb, "wall"), owner)) + thresholds.append((Line(ra, rb, "wall"), owner)) + cur_l, cur_r = lb, rb + runs.append((cur_l, cur_r, piece.corners[(1, "left")], piece.corners[(1, "right")])) + for sl, sr, el, er in runs: + if math.dist(sl, el) > _EPS: + faces.append((Line(sl, el, "wall"), owner)) + if math.dist(sr, er) > _EPS: + faces.append((Line(sr, er, "wall"), owner)) + polygon = _polygon((sr, er, el, sl)) + if polygon and is_self_intersecting(polygon): + raise ValueError( + f"{_name(walls, seg.wall)}: segment {seg.index} is too short for the " + "junctions at its ends - its outline would cross itself; lengthen it " + "or join it elsewhere." + ) + if polygon: + regions.append((polygon, owner)) + + return { + "walls": walls, + "faces": _merge_collinear(faces, jambs), + "jambs": jambs, + "thresholds": thresholds, + "regions": regions, + "omitted": omitted, + } + + +def _merge_collinear(faces, jambs): + """Join two faces of one wall that continue each other in a straight line. + + That is what keeps a crossing wall's far face one line past a T, and a + straight axis with an intermediate vertex one face. A point touched by a + third line (a jamb, another face) is a real corner and is left alone. + """ + items = list(faces) + while True: + touching: dict[tuple[float, float], list[int]] = {} + for idx, (line, _owner) in enumerate(items): + for p in (line.p1, line.p2): + touching.setdefault(_key(p), []).append(idx) + for line, _owner in jambs: + for p in (line.p1, line.p2): + touching.setdefault(_key(p), []).append(-1) + for key, idxs in touching.items(): + if len(idxs) != 2 or -1 in idxs or idxs[0] == idxs[1]: + continue + i, j = sorted(idxs) + (li, wi), (lj, wj) = items[i], items[j] + if wi != wj: + continue + near_i, far_i = (li.p2, li.p1) if _key(li.p2) == key else (li.p1, li.p2) + far_j = lj.p2 if _key(lj.p1) == key else lj.p1 + d1 = _unit(far_i, near_i) + d2 = _unit(near_i, far_j) + if abs(_cross(d1, d2)) > 1e-9 or _dot(d1, d2) <= 0.0: + continue + items[i] = (Line(far_i, far_j, "wall"), wi) + del items[j] + break + else: + return items + + +def _assemble(parts: dict, poche: bool, owner: str | None) -> WallGeometry: + def mine(rows): + return [item for item, who in rows if owner is None or who == owner] + + material = {wall.id: wall.material for wall in parts["walls"]} + regions = [(polygon, who) for polygon, who in parts["regions"] if owner in (None, who)] + hatches = ( + tuple(HatchArea((polygon,), material[who]) for polygon, who in regions) if poche else () + ) + return WallGeometry( + faces=tuple(mine(parts["faces"])), + jambs=tuple(mine(parts["jambs"])), + regions=tuple(polygon for polygon, _who in regions), + hatches=hatches, + omitted=tuple(mine(parts["omitted"])), + thresholds=tuple(mine(parts["thresholds"])), + ) + + +def wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry: + """The whole network's outlines, jambs, regions and poche - see the module docstring.""" + return _assemble(_build(walls, openings, join_tol), poche, None) + + +def wall_geometry_by_wall( + walls, openings=(), *, poche=True, join_tol=1.0 +) -> dict[str, WallGeometry]: + """The same geometry, split by the wall each element belongs to. + + A hub (the middle of a T or an X) belongs to the first of its walls in the + order given. `engineering.arch.draw` uses the split to redraw only the walls + whose outline a new wall or opening changes. + """ + parts = _build(walls, openings, join_tol) + return {wall.id: _assemble(parts, poche, wall.id) for wall in parts["walls"]} + + +def room_segments(geometry: WallGeometry) -> tuple[tuple[Pt, Pt], ...]: + """Faces, jambs and thresholds as bare segments - the input of the planar-face finder.""" + return tuple( + (line.p1, line.p2) for line in (*geometry.faces, *geometry.jambs, *geometry.thresholds) + ) +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_walls.py -q` +Expected: PASS — 28 passed. + +If a refusal test fails because Task 1's `Wall` or `Opening` already refuses the value in its own constructor (an unknown material, say), the refusal still fires before anything is drawn — keep the behaviour, move the construction into the `pytest.raises` block or assert Task 1's message, and record it (Global Constraints: deviations are data). + +- [ ] **Step 5: Re-export from the package surface** + +In `engineering/arch/__init__.py` (Task 1 created it; keep every name it already has), add this import block and the same seven names to `__all__`, both alphabetical: + +```python +from engineering.arch.walls import ( + MIN_JOIN_ANGLE_DEG, + WallGeometry, + face_offsets, + room_segments, + wall_geometry, + wall_geometry_by_wall, + wall_segments, +) +``` + +- [ ] **Step 6: Gates** + +``` +uv run --frozen ruff check engineering/arch/walls.py engineering/arch/__init__.py tests/test_arch_walls.py +uv run --frozen ruff format engineering/arch/walls.py engineering/arch/__init__.py tests/test_arch_walls.py +uv run --frozen pytest tests/test_arch_walls.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +Expected: ruff clean; 28 passed; the full suite green apart from the deselected known-red collected-tests figure (Global Constraints — record the deselection). No tool is registered in this task, so the inventory, README count, registry snapshot and alias gates do not move. + +- [ ] **Step 7: Commit** + +``` +git add engineering/arch/walls.py engineering/arch/__init__.py tests/test_arch_walls.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(arch): the wall engine - L/T/X junctions, opening gaps and material poche + +engineering/arch/walls.py turns a wall network - axis polylines with a +thickness, a justification and a material - into clean outlines. One rule +resolves every junction: at each node the segment ends are sorted by angle and +the left face of each meets the right face of the next one counter-clockwise. +That mitres an L, stops a T's stem on the crossing wall's near face (cut there, +while the far face stays one line) and cuts all four faces of an X. A T is an +axis end lying inside another wall: it is carried onto the crossing axis and +the crossing segment is split. The hub a three- or four-way node leaves is a +region of its own, so the poche has no hole. + +Openings remove their width from both faces and are closed by jambs; a free +end is closed by a jamb. Each solid piece is one CCW region and one HatchArea +in the wall's material. The two face lines across each opening are kept as +thresholds - never drawn - so room_segments hands the face finder closed rooms +instead of rooms merged through their doorways. + +Refused by name before any geometry: a duplicate id, an unknown material or +justification, a zero-length segment, a junction below 5 degrees, a corner +inside another wall off its axis, an end whose axis meets the other wall past +its end, a segment lying wholly inside the wall it joins, and one too short +for its junctions (engineering.measure.is_self_intersecting on its outline). +An opening that runs into a junction is returned in omitted, never dropped. + +Every corner in the tests is hand-computed: two 200 mm walls at the origin +meet at (100, 100) inside and (-100, -100) outside. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 3: Doors, windows and stairs + +**Files:** +- Create: `engineering/arch/openings.py` +- Create: `engineering/arch/stairs.py` +- Modify: `engineering/arch/__init__.py` (re-export the door/window and stair names) +- Test: `tests/test_arch_openings.py` +- Test: `tests/test_arch_stairs.py` + +**Interfaces:** +- Consumes: `engineering.arch.model` (Task 1) — `Wall`, `Opening`, `Stair`, `segment_of(wall, offset) -> tuple[int, float]`; `engineering.arch.lang.vocab(lang: str) -> dict[str, str]` (key `"up"`; ValueError on an unknown language); `engineering.arch.walls` (Task 2) — `face_offsets(wall) -> tuple[float, float]`, `wall_segments(wall) -> tuple[tuple[Pt, Pt], ...]`; `engineering.mech.primitives` — `Arc`, `Line`, `Poly`, `Text`, `Prim`, `Pt`, `rotate(prims, deg, about)`, `translate(prims, dx, dy)`. +- Produces: `opening_prims(wall: Wall, opening: Opening) -> tuple[Prim, ...]` — a door is `(leaf: Line, swing: Arc)` with role `"door"`, a window is four `Line`s with role `"window"` (left-face frame, right-face frame, two glazing lines); the first primitive is the one Task 4 writes the opening record on. `blondel(riser_height: float, going: float) -> dict` — `{"value": 2R+G, "ok": 600 <= value <= 650, "range": [600.0, 650.0]}`. `stair_prims(stair: Stair, *, lang="en", scale=50) -> tuple[Prim, ...]` — every riser line, the outline lines, the walking line as an open `Poly`, two arrow `Line`s and one `Text`, all role `"stair"`, in WCS. Constants `BLONDEL_RANGE = (600.0, 650.0)`, `STAIR_KINDS = ("straight", "l", "u")`, `TURNS = ("left", "right")`, `TEXT_PAPER_MM = 2.5`, `ARROW_PAPER_MM = 3.0`. + +Conventions this task fixes (each is stated in the module docstring as this repository's convention, not a standard): `swing="in"` opens to the left of the wall axis (the inside of a counter-clockwise perimeter); `hand` names the hinge jamb as seen by a person on the swing side facing the wall; a door is drawn open at 90°; window glazing lines sit at the thirds of the wall thickness; a stair's `start` is the midpoint of its bottom riser; an L or U puts `(risers + 1) // 2` risers in its first flight and turns on a square landing `width` deep; a U has no stairwell. The Blondel band 600–650 mm is reported as the step-length design rule attributed to François Blondel (Cours d'architecture, 1675) — never as a building code. + +- [ ] **Step 1: Write the failing test for doors and windows** + +`tests/test_arch_openings.py`: + +```python +"""Door and window symbols: hinge, leaf, swing arc, frame and glazing. + +The wall runs along +X from the origin, 200 mm thick and centred, so its left +face is y = +100 and its right face y = -100. The door is 900 wide at offset +1000: its jambs are x = 1000 and x = 1900. For each swing/hand pair the hinge, +the open leaf and the arc angles are written out below by hand. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.model import Opening, Wall +from engineering.arch.openings import opening_prims +from engineering.mech.primitives import Arc, Line + +WALL = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0) + + +def door(swing: str, hand: str) -> Opening: + return Opening( + id="d1", wall="a", kind="door", offset=1000.0, width=900.0, swing=swing, hand=hand + ) + + +def pt(p) -> tuple[float, float]: + return (round(p[0], 9) + 0.0, round(p[1], 9) + 0.0) + + +@pytest.mark.parametrize( + ("swing", "hand", "hinge", "leaf_end", "start_deg", "end_deg"), + [ + # in = the axis's left (+Y). Facing the wall from +Y, the viewer's left is +X. + ("in", "left", (1900.0, 100.0), (1900.0, 1000.0), 90.0, 180.0), + ("in", "right", (1000.0, 100.0), (1000.0, 1000.0), 0.0, 90.0), + # out = the axis's right (-Y). Facing the wall from -Y, the viewer's left is -X. + ("out", "left", (1000.0, -100.0), (1000.0, -1000.0), 270.0, 0.0), + ("out", "right", (1900.0, -100.0), (1900.0, -1000.0), 180.0, 270.0), + ], +) +def test_a_door_hinges_on_its_hand_and_swings_a_quarter_arc_to_its_side( + swing, hand, hinge, leaf_end, start_deg, end_deg +): + leaf, arc = opening_prims(WALL, door(swing, hand)) + assert isinstance(leaf, Line) and leaf.role == "door" + assert (pt(leaf.p1), pt(leaf.p2)) == (hinge, leaf_end) + assert isinstance(arc, Arc) and arc.role == "door" + assert pt(arc.center) == hinge + assert arc.radius == 900.0 + assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (start_deg, end_deg) + + +def test_the_leaf_is_as_long_as_the_opening_is_wide_and_square_to_the_wall(): + leaf, _arc = opening_prims(WALL, door("in", "left")) + dx = leaf.p2[0] - leaf.p1[0] + dy = leaf.p2[1] - leaf.p1[1] + assert (dx, dy) == (0.0, 900.0) + + +def test_a_door_on_a_second_segment_is_placed_along_the_whole_axis(): + bent = Wall(id="b", axis=((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0)), thickness=200.0) + op = Opening( + id="d2", wall="b", kind="door", offset=4500.0, width=800.0, swing="in", hand="right" + ) + leaf, arc = opening_prims(bent, op) + # segment 1 runs +Y from (4000, 0); its left normal is -X, so "in" is x < 4000 + # and its left face is x = 3900. Facing +X from the left side, the viewer's + # left is +Y, so a right hand hinges at the near jamb, y = 500. + assert pt(leaf.p1) == (3900.0, 500.0) + assert pt(leaf.p2) == (3100.0, 500.0) + assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (90.0, 180.0) + + +def test_a_window_is_two_frame_lines_and_two_glazing_lines(): + window = Opening(id="w1", wall="a", kind="window", offset=2500.0, width=1200.0) + prims = opening_prims(WALL, window) + assert len(prims) == 4 + assert all(isinstance(p, Line) and p.role == "window" for p in prims) + assert [(pt(p.p1), pt(p.p2)) for p in prims] == [ + ((2500.0, 100.0), (3700.0, 100.0)), # frame, left face + ((2500.0, -100.0), (3700.0, -100.0)), # frame, right face + ((2500.0, round(-100.0 + 200.0 / 3.0, 9)), (3700.0, round(-100.0 + 200.0 / 3.0, 9))), + ((2500.0, round(-100.0 + 400.0 / 3.0, 9)), (3700.0, round(-100.0 + 400.0 / 3.0, 9))), + ] + + +def test_an_opening_of_another_wall_is_refused(): + with pytest.raises(ValueError, match="hosted in wall 'z'"): + opening_prims(WALL, Opening(id="d1", wall="z", kind="door", offset=0.0, width=900.0)) + + +def test_an_unknown_swing_is_refused_with_the_list(): + with pytest.raises(ValueError, match="swings are in, out"): + opening_prims(WALL, door("sideways", "left")) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_openings.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.openings'`. + +- [ ] **Step 3: Write `engineering/arch/openings.py`** + +```python +"""Door and window symbols in plan, hosted in their wall. + +The wall engine (`walls.py`) cuts the gap; this module draws what stands in it. +Both read the opening's place from the same numbers - the wall's axis, its face +offsets and ``model.segment_of`` - so the symbol cannot drift off its gap. + +Conventions (this repository's, stated because plan symbols vary by office): + +* ``swing="in"`` opens to the **left** of the wall's axis, looking along it + from its first point; ``"out"`` to the right. A perimeter drawn + counter-clockwise has its inside on the left, so "in" means into the building. +* ``hand`` names the jamb that carries the hinges, seen by a person standing on + the side the door swings towards and facing the wall: ``"left"`` hinges on + their left-hand jamb. +* A door is drawn **open at 90°**: the leaf is a line as long as the opening is + wide, square to the wall from the hinge, on the swing-side face; the swing is + a quarter arc about the hinge from the closed position to the open one. +* A window is a frame line on each wall face across the opening and two + glazing lines between them at the thirds of the wall thickness (a drafting + convention, not a standard). + +The first primitive is the one `draw.py` writes the opening record on: the +door leaf, or the window's frame line on the left face. +""" + +from __future__ import annotations + +import math + +from engineering.arch.model import Opening, Wall, segment_of +from engineering.arch.walls import face_offsets, wall_segments +from engineering.mech.primitives import Arc, Line, Prim, Pt + + +def _deg(v: Pt) -> float: + return math.degrees(math.atan2(v[1], v[0])) % 360.0 + + +def opening_prims(wall: Wall, opening: Opening) -> tuple[Prim, ...]: + """The door leaf and swing, or the window frame and glazing, in WCS.""" + if opening.wall != wall.id: + raise ValueError( + f"opening {opening.id!r} is hosted in wall {opening.wall!r}, not in {wall.id!r}" + ) + width = float(opening.width) + index, along = segment_of(wall, float(opening.offset)) + (ax, ay), (bx, by) = wall_segments(wall)[index] + length = math.hypot(bx - ax, by - ay) + u = ((bx - ax) / length, (by - ay) / length) + n = (-u[1], u[0]) + left, right = face_offsets(wall) + + def at(s: float, offset: float) -> Pt: + return (ax + u[0] * s + n[0] * offset, ay + u[1] * s + n[1] * offset) + + s0, s1 = along, along + width + if opening.kind == "window": + third = (left - right) / 3.0 + return ( + Line(at(s0, left), at(s1, left), "window"), + Line(at(s0, right), at(s1, right), "window"), + Line(at(s0, right + third), at(s1, right + third), "window"), + Line(at(s0, right + 2.0 * third), at(s1, right + 2.0 * third), "window"), + ) + if opening.kind != "door": + raise ValueError(f"opening {opening.id!r}: kind {opening.kind!r}; kinds are door, window") + if opening.swing not in ("in", "out"): + raise ValueError(f"opening {opening.id!r}: swing {opening.swing!r}; swings are in, out") + if opening.hand not in ("left", "right"): + raise ValueError(f"opening {opening.id!r}: hand {opening.hand!r}; hands are left, right") + + side = 1.0 if opening.swing == "in" else -1.0 + face = left if side > 0 else right + # Facing the wall from the swing side, the viewer's left hand points along + # +u when they stand on the axis's left, and along -u when on its right. + toward = side if opening.hand == "left" else -side + hinge_s, free_s = (s1, s0) if toward > 0 else (s0, s1) + hinge = at(hinge_s, face) + closed = at(free_s, face) + opened = (hinge[0] + n[0] * side * width, hinge[1] + n[1] * side * width) + a_closed = _deg((closed[0] - hinge[0], closed[1] - hinge[1])) + a_open = _deg((opened[0] - hinge[0], opened[1] - hinge[1])) + if abs(((a_open - a_closed) % 360.0) - 90.0) < 1e-6: + swing = Arc(hinge, width, a_closed, a_open, "door") + else: + swing = Arc(hinge, width, a_open, a_closed, "door") + return (Line(hinge, opened, "door"), swing) +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_openings.py -q` +Expected: PASS — 9 passed. + +- [ ] **Step 5: Write the failing test for stairs and the Blondel rule** + +`tests/test_arch_stairs.py`: + +```python +"""Stairs in plan: tread lines, outline, walking line, label, and the Blondel rule. + +Every expected coordinate is hand-computed from the stair's numbers: a flight +of m risers at going g runs (m - 1) * g, a riser line spans the full width, and +the first flight of an L or U carries (risers + 1) // 2 risers. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.lang import vocab +from engineering.arch.model import Stair +from engineering.arch.stairs import BLONDEL_RANGE, blondel, stair_prims +from engineering.mech.primitives import Line, Poly, Text + + +def S(x1, y1, x2, y2) -> tuple: + return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2))))) + + +def segs(prims) -> list: + out = [] + for p in prims: + if isinstance(p, Line): + a = (round(p.p1[0], 9) + 0.0, round(p.p1[1], 9) + 0.0) + b = (round(p.p2[0], 9) + 0.0, round(p.p2[1], 9) + 0.0) + out.append(tuple(sorted((a, b)))) + return out + + +def path(prims) -> list: + (walk,) = [p for p in prims if isinstance(p, Poly)] + return [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in walk.points] + + +def stair(**kw) -> Stair: + base = { + "id": "s1", + "start": (1000.0, 2000.0), + "direction_deg": 0.0, + "width": 1000.0, + "risers": 17, + "riser_height": 170.0, + "going": 290.0, + } + return Stair(**{**base, **kw}) + + +# -- Blondel ----------------------------------------------------------------------- + + +def test_blondel_reports_two_risers_plus_a_going_against_600_to_650(): + assert BLONDEL_RANGE == (600.0, 650.0) + assert blondel(170.0, 290.0) == {"value": 630.0, "ok": True, "range": [600.0, 650.0]} + assert blondel(200.0, 300.0) == {"value": 700.0, "ok": False, "range": [600.0, 650.0]} + assert blondel(150.0, 280.0)["ok"] is False # 580 + assert blondel(175.0, 250.0)["ok"] is True # 600, the lower edge is inside + + +def test_blondel_refuses_a_non_positive_dimension_by_name(): + with pytest.raises(ValueError, match="riser_height"): + blondel(0.0, 290.0) + with pytest.raises(ValueError, match="going"): + blondel(170.0, -1.0) + + +# -- straight ----------------------------------------------------------------------- + + +def test_a_straight_stair_draws_every_riser_once_and_both_stringers(): + prims = stair_prims(stair()) + lines = segs(prims) + risers = [S(1000 + 290 * k, 1500, 1000 + 290 * k, 2500) for k in range(17)] + assert set(risers) <= set(lines) + assert S(1000, 1500, 5640, 1500) in lines # (17 - 1) * 290 = 4640 + assert S(1000, 2500, 5640, 2500) in lines + assert len(lines) == 17 + 2 + 2 # risers, stringers, the two arrow strokes + assert all(p.role == "stair" for p in prims) + + +def test_the_walking_line_runs_up_the_middle_with_an_arrow_at_the_top(): + prims = stair_prims(stair(), scale=50) + assert path(prims) == [(1000.0, 2000.0), (5640.0, 2000.0)] + lines = segs(prims) + # arrow 3 mm x 50 = 150 long, its strokes 50 either side of the line + assert S(5490, 2050, 5640, 2000) in lines + assert S(5490, 1950, 5640, 2000) in lines + + +def test_the_up_label_sits_on_the_first_tread_at_the_plot_scale_height(): + (text,) = [p for p in stair_prims(stair(), scale=50) if isinstance(p, Text)] + assert text.text == vocab("en")["up"] + assert text.height == 125.0 # 2.5 mm x 50 + # (0.15 * 290, -0.25 * 1000 - 0.5 * 125) from the start + assert (round(text.at[0], 9), round(text.at[1], 9)) == (1043.5, 1687.5) + (tr,) = [p for p in stair_prims(stair(), lang="tr") if isinstance(p, Text)] + assert tr.text == vocab("tr")["up"] + + +def test_direction_turns_the_whole_stair_about_its_start(): + prims = stair_prims(stair(direction_deg=90.0)) + lines = segs(prims) + risers = [S(500, 2000 + 290 * k, 1500, 2000 + 290 * k) for k in range(17)] + assert set(risers) <= set(lines) + assert path(prims) == [(1000.0, 2000.0), (1000.0, 6640.0)] + (text,) = [p for p in prims if isinstance(p, Text)] + assert text.rotation == 90.0 + + +# -- L and U ------------------------------------------------------------------------ + + +def test_an_l_stair_turns_left_on_a_square_landing(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="l", risers=16, going=280.0)) + lines = segs(prims) + # 8 + 8 risers; the landing starts at 7 * 280 = 1960, the top riser at 500 + 7 * 280 + first = [S(280 * k, -500, 280 * k, 500) for k in range(8)] + second = [S(1960, 500 + 280 * k, 2960, 500 + 280 * k) for k in range(8)] + assert set(first + second) <= set(lines) + assert { + S(0, -500, 2960, -500), + S(2960, -500, 2960, 2460), + S(0, 500, 1960, 500), + S(1960, 500, 1960, 2460), + } <= set(lines) + assert len(lines) == 16 + 4 + 2 + assert path(prims) == [(0.0, 0.0), (2460.0, 0.0), (2460.0, 2460.0)] + + +def test_an_l_stair_turning_right_is_the_mirror_image(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="l", turn="right", risers=16, going=280.0)) + lines = segs(prims) + assert S(1960, -500, 2960, -500) in lines + assert S(2960, 500, 2960, -2460) in lines + assert path(prims)[-1] == (2460.0, -2460.0) + + +def test_a_u_stair_returns_beside_the_first_flight(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="u", risers=18, going=270.0, width=1100.0)) + lines = segs(prims) + # 9 + 9 risers; landing at 8 * 270 = 2160; the second flight comes back to x = 0 + first = [S(270 * k, -550, 270 * k, 550) for k in range(9)] + second = [S(2160 - 270 * k, 550, 2160 - 270 * k, 1650) for k in range(9)] + assert set(first + second) <= set(lines) + assert { + S(0, -550, 3260, -550), + S(3260, -550, 3260, 1650), + S(3260, 1650, 0, 1650), + S(0, 550, 2160, 550), + } <= set(lines) + assert len(lines) == 18 + 4 + 2 + assert path(prims) == [(0.0, 0.0), (2710.0, 0.0), (2710.0, 1100.0), (0.0, 1100.0)] + + +# -- refusals --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kw", "match"), + [ + ({"risers": 1}, "at least 2"), + ({"kind": "l", "risers": 3}, "at least 4"), + ({"risers": True}, "whole number"), + ({"kind": "spiral"}, "kinds are straight, l, u"), + ({"turn": "up"}, "turns are left, right"), + ({"width": 0.0}, "width"), + ({"going": float("nan")}, "going"), + ], +) +def test_a_bad_stair_is_refused_before_any_geometry(kw, match): + with pytest.raises(ValueError, match=match): + stair_prims(stair(**kw)) + + +def test_an_unknown_language_is_refused(): + with pytest.raises(ValueError): + stair_prims(stair(), lang="de") +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_stairs.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.stairs'`. + +- [ ] **Step 7: Write `engineering/arch/stairs.py`** + +```python +"""Stairs in plan: straight, L and U flights, the walking line and the Blondel check. + +Geometry, in the stair's own frame (x up the first flight, y to its left): + +* ``start`` is the midpoint of the bottom riser; ``direction_deg`` turns the + frame into WCS. +* ``risers`` counts every rise from the lower floor to the upper one. A flight + of ``m`` risers has ``m - 1`` treads of depth ``going``; every riser is drawn + once as a line across the flight, so a straight stair of 17 risers shows 17 + lines, the first at the start and the last at the top. +* An L or a U splits the risers into two flights, ``(risers + 1) // 2`` in the + first, and turns ``turn`` on a square landing ``width`` deep. The U's two + flights lie side by side with no stairwell between them. +* The walking line runs up the middle of every flight and across the landing, + with an open arrow at the top; the "up" word of the chosen language sits on + the first tread. + +`blondel` reports 2R + G against 600-650 mm. That band is the step-length rule +attributed to François Blondel (Cours d'architecture, 1675), reported as a +design rule: it is not a building code, and no national stair regulation is +checked here. +""" + +from __future__ import annotations + +import math + +from engineering.arch.lang import vocab +from engineering.arch.model import Stair +from engineering.mech.primitives import Line, Poly, Prim, Pt, Text, rotate, translate + +BLONDEL_RANGE: tuple[float, float] = (600.0, 650.0) +STAIR_KINDS: tuple[str, ...] = ("straight", "l", "u") +TURNS: tuple[str, ...] = ("left", "right") +#: Paper sizes, multiplied by the plot-scale denominator. Drafting choices of +#: this repository, not standard values. +TEXT_PAPER_MM = 2.5 +ARROW_PAPER_MM = 3.0 + + +def _positive(value, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where}: expected a number, got {value!r}") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{where}: must be a finite number above zero, got {value!r}") + return number + + +def blondel(riser_height: float, going: float) -> dict: + """``{"value": 2R + G, "ok": 600 <= value <= 650, "range": [600.0, 650.0]}`` in mm.""" + rise = _positive(riser_height, "riser_height") + tread = _positive(going, "going") + value = 2.0 * rise + tread + low, high = BLONDEL_RANGE + return {"value": value, "ok": low <= value <= high, "range": [low, high]} + + +def _validate(stair: Stair, scale) -> tuple[float, float, int, float]: + width = _positive(stair.width, f"stair {stair.id!r}: width") + going = _positive(stair.going, f"stair {stair.id!r}: going") + _positive(stair.riser_height, f"stair {stair.id!r}: riser_height") + factor = _positive(scale, "scale") + if stair.kind not in STAIR_KINDS: + raise ValueError( + f"stair {stair.id!r}: kind {stair.kind!r}; kinds are {', '.join(STAIR_KINDS)}" + ) + if stair.turn not in TURNS: + raise ValueError(f"stair {stair.id!r}: turn {stair.turn!r}; turns are {', '.join(TURNS)}") + risers = stair.risers + if isinstance(risers, bool) or not isinstance(risers, int): + raise ValueError(f"stair {stair.id!r}: risers must be a whole number, got {risers!r}") + least = 2 if stair.kind == "straight" else 4 + if risers < least: + raise ValueError( + f"stair {stair.id!r}: risers {risers}; a {stair.kind} stair needs at least {least} " + "(two per flight)" + ) + for where, value in ( + ("direction_deg", stair.direction_deg), + ("start[0]", stair.start[0]), + ("start[1]", stair.start[1]), + ): + if isinstance(value, bool) or not math.isfinite(float(value)): + raise ValueError(f"stair {stair.id!r}: {where} must be finite, got {value!r}") + return width, going, risers, factor + + +def _flights(kind: str, width: float, going: float, risers: int): + """Riser lines, outline lines and the walking-line path, turning left.""" + half = width / 2.0 + if kind == "straight": + run = (risers - 1) * going + treads = [Line((k * going, -half), (k * going, half), "stair") for k in range(risers)] + outline = [ + Line((0.0, -half), (run, -half), "stair"), + Line((0.0, half), (run, half), "stair"), + ] + return treads, outline, [(0.0, 0.0), (run, 0.0)] + first = (risers + 1) // 2 + second = risers - first + landing = (first - 1) * going + treads = [Line((k * going, -half), (k * going, half), "stair") for k in range(first)] + if kind == "l": + top = half + (second - 1) * going + treads += [ + Line((landing, half + k * going), (landing + width, half + k * going), "stair") + for k in range(second) + ] + outline = [ + Line((0.0, -half), (landing + width, -half), "stair"), + Line((landing + width, -half), (landing + width, top), "stair"), + Line((0.0, half), (landing, half), "stair"), + Line((landing, half), (landing, top), "stair"), + ] + return treads, outline, [(0.0, 0.0), (landing + half, 0.0), (landing + half, top)] + back = landing - (second - 1) * going + treads += [ + Line((landing - k * going, half), (landing - k * going, 3.0 * half), "stair") + for k in range(second) + ] + outline = [ + Line((0.0, -half), (landing + width, -half), "stair"), + Line((landing + width, -half), (landing + width, 3.0 * half), "stair"), + Line((landing + width, 3.0 * half), (back, 3.0 * half), "stair"), + Line((min(0.0, back), half), (landing, half), "stair"), + ] + path = [(0.0, 0.0), (landing + half, 0.0), (landing + half, width), (back, width)] + return treads, outline, path + + +def _mirror(p: Pt) -> Pt: + return (p[0], -p[1]) + + +def stair_prims(stair: Stair, *, lang: str = "en", scale=50) -> tuple[Prim, ...]: + """Treads, outline, walking line with its arrow, and the "up" label - in WCS, role "stair".""" + width, going, risers, factor = _validate(stair, scale) + label = vocab(lang)["up"] + treads, outline, path = _flights(stair.kind, width, going, risers) + if stair.turn == "right": + treads = [line._replace(p1=_mirror(line.p1), p2=_mirror(line.p2)) for line in treads] + outline = [line._replace(p1=_mirror(line.p1), p2=_mirror(line.p2)) for line in outline] + path = [_mirror(p) for p in path] + + (px, py), (ex, ey) = path[-2], path[-1] + length = math.hypot(ex - px, ey - py) + d = ((ex - px) / length, (ey - py) / length) + p = (-d[1], d[0]) + arrow = ARROW_PAPER_MM * factor + tail = (ex - d[0] * arrow, ey - d[1] * arrow) + head = [ + Line((tail[0] + p[0] * arrow / 3.0, tail[1] + p[1] * arrow / 3.0), (ex, ey), "stair"), + Line((tail[0] - p[0] * arrow / 3.0, tail[1] - p[1] * arrow / 3.0), (ex, ey), "stair"), + ] + height = TEXT_PAPER_MM * factor + text = Text((0.15 * going, -0.25 * width - 0.5 * height), label, height, 0.0, "stair") + local = (*treads, *outline, Poly(tuple(path), False, "stair"), *head, text) + turned = rotate(local, float(stair.direction_deg), (0.0, 0.0)) + return translate(turned, float(stair.start[0]), float(stair.start[1])) +``` + +- [ ] **Step 8: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_stairs.py tests/test_arch_openings.py -q` +Expected: PASS — 26 passed (17 + 9). + +- [ ] **Step 9: Re-export from the package surface** + +In `engineering/arch/__init__.py` keep every name already there and add these two import blocks and their names to `__all__`, alphabetical: + +```python +from engineering.arch.openings import opening_prims +from engineering.arch.stairs import ( + ARROW_PAPER_MM, + BLONDEL_RANGE, + STAIR_KINDS, + TEXT_PAPER_MM, + TURNS, + blondel, + stair_prims, +) +``` + +- [ ] **Step 10: Gates** + +``` +uv run --frozen ruff check engineering/arch/openings.py engineering/arch/stairs.py engineering/arch/__init__.py tests/test_arch_openings.py tests/test_arch_stairs.py +uv run --frozen ruff format engineering/arch/openings.py engineering/arch/stairs.py engineering/arch/__init__.py tests/test_arch_openings.py tests/test_arch_stairs.py +uv run --frozen pytest tests/test_arch_walls.py tests/test_arch_openings.py tests/test_arch_stairs.py -q +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +Expected: ruff clean; 54 passed (28 + 9 + 17); the full suite green apart from the deselected known red. No tool is registered, so no registration gate moves. + +- [ ] **Step 11: Commit** + +``` +git add engineering/arch/openings.py engineering/arch/stairs.py engineering/arch/__init__.py tests/test_arch_openings.py tests/test_arch_stairs.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(arch): door and window symbols, straight/L/U stairs and the Blondel check + +openings.py draws what stands in the gap the wall engine cuts, from the same +numbers - the wall axis, its face offsets and model.segment_of - so the symbol +cannot drift off its gap. A door is drawn open at 90 degrees: its leaf as long +as the opening is wide, square to the wall from the hinge on the swing-side +face, and a quarter arc from the closed position to the open one. swing=in is +the left of the axis (the inside of a CCW perimeter); hand names the hinge jamb +seen from the swing side facing the wall. All four swing/hand pairs are tested +against hand-computed hinges and arc angles. A window is a frame line on each +face and two glazing lines at the thirds of the thickness. + +stairs.py draws every riser once as a line across its flight, the outline, a +walking line up the middle with an open arrow at the top, and the language's +'up' word on the first tread at 2.5 mm times the plot scale. An L or a U turns +on a square landing; its first flight carries (risers + 1) // 2 risers. The +riser count and every dimension are refused by name before any geometry. + +blondel() reports 2R + G against 600-650 mm - the step-length rule attributed +to Francois Blondel (1675), reported as a design rule and never as a building +code: no national stair regulation is checked. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 4: The async layer, exterior dimension chains and the first four tools + +**Files:** +- Create: `engineering/arch/dimension.py` +- Create: `engineering/arch/draw.py` +- Modify: `server.py` (`_GROUP_TAG_PRIORITY` / `_GROUP_TAG_LABELS` gain `arch` → `architecture`; SECTION 25 opens with `arch_wall`, `arch_opening`, `arch_stair`, `arch_dimension_chains` and the two empty group markers) +- Modify: `discovery/aliases.py` (four alias records) +- Modify: `engineering/arch/__init__.py` (re-export the dimension chains) +- Modify: `tests/test_tool_registry.py` (group-size snapshot: `architecture: 4`, 233 tools) +- Modify: `tests/test_tool_packs.py` (26 groups) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release-snapshot tool count 229 → 233, advertised 224 → 228, 26 groups) +- Test: `tests/test_arch_dimension.py` +- Test: `tests/test_arch_draw.py` +- Test: `tests/test_arch_tools.py` + +**Interfaces:** +- Consumes: `engineering.mech.draw.draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer_prefix="", role_layer: Mapping[str, str] | None = None) -> dict` (Task 1); `engineering.arch.layers` — `ARCH_LAYERS: list[tuple[str, int, str, float, str]]`, `ARCH_ROLE_LAYER` (Task 1; with this map `draw_prims` puts a `HatchArea` on `A-WALL-H-N` in the pattern `engineering.arch.materials.wall_hatch(material)` gives); `engineering.arch.lang.tag_prefix(kind, lang) -> str`; `engineering.arch.model` — `APP_ID`, `Wall`, `Opening`, `Stair`, `Room`, `decode(rows) -> dict`, `from_payload(payload)`, `to_payload(obj) -> dict`, `to_values(payload) -> list[str]`, `wall_from_dict(d, path)`, `opening_from_dict(d, path)`, `stair_from_dict(d, path)`, `segment_of`; Task 2's `wall_geometry`, `wall_geometry_by_wall`, `WallGeometry`, `face_offsets`, `wall_segments`; Task 3's `opening_prims`, `stair_prims`, `blondel`; `engineering.mech.primitives.DimIntent`. +- Produces: `exterior_chains(walls, openings=(), *, sides=("bottom", "left"), first_offset=None, step=None, scale=50) -> tuple[DimIntent, ...]` (linear intents; `feature` = `":"`; `text_at` = the point the dimension line passes through). In `engineering/arch/draw.py`: `async draw_walls(backend, walls, openings=(), *, poche=True) -> dict` (`{"walls", "openings", "handles", "omitted", "backend"}`), `async draw_stair(backend, stair, *, lang="en", scale=50) -> dict`, `async draw_chains(backend, walls, openings, *, sides=("bottom", "left"), scale=50, first_offset=None, step=None) -> dict`, `async read_plan(backend) -> dict` (`{"walls": [Wall], "openings": [Opening], "stairs": [Stair], "rooms": [Room], "carriers": {":": handle}}`, never modifies the drawing), and for the tools `async add_walls(backend, walls: list[dict], *, poche=True) -> dict`, `async add_opening(backend, opening: dict, *, lang="en", poche=True) -> dict`, `async add_stair(backend, stair: dict, *, lang="en", scale=50) -> dict`, `assign_tags(openings, existing=(), *, lang="en") -> tuple[Opening, ...]`, `async ensure_arch_layers(backend, names) -> tuple[str, ...]`, `HANDLES_APP = "ACADMCP_ARCH_H"`, `RECORD_ROLES = ("wall", "door", "window", "stair", "room")`. Group R's `label_room` writes its room record on the room layer, which `read_plan` already scans. + +Two storage facts later tasks rely on: every element's `ACADMCP_ARCH` record sits on one of its own entities (a wall's first face line, an opening's door leaf or left frame line, a stair's first riser line), and beside it, under the application `ACADMCP_ARCH_H`, the handles of every entity that element owns — which is how `add_walls` / `add_opening` take exactly a wall's faces, jambs, poche and hosted symbols back out before redrawing it. `read_plan` classifies a record by the type `from_payload` returns, never by the payload's `kind` key: in the pinned payload shape `{"v": 1, "kind": ..., **fields}` the `kind` key is the record kind (`"wall"`, `"opening"`, `"stair"`, `"room"`), and Task 1 carries `Opening.kind` (door / window) as `"opening_kind"` and `Stair.kind` (straight / l / u) as `"stair_kind"`; `from_payload` puts both back, so the model object is the one thing a reader needs. + +- [ ] **Step 1: Write the failing test for the exterior chains** + +`tests/test_arch_dimension.py`: + +```python +"""Exterior dimension chains: three rows per side, from the wall engine's own faces. + +The plan: a closed 8200 x 6200 axis rectangle of 200 mm walls (outer faces +-100..8300 x -100..6300), a 100 mm partition on x = 4100 teed into the bottom +and top walls (faces x = 4050 / 4150), a 1200 window at 1500 and a 1000 door +at 5500 in the bottom wall. Every point below is hand-computed from those +numbers; at 1:50 the rows sit 500, 900 and 1300 mm outside the building. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.dimension import ( + FIRST_OFFSET_PAPER_MM, + ROWS, + SIDES, + STEP_PAPER_MM, + exterior_chains, +) +from engineering.arch.model import Opening, Wall +from engineering.mech.primitives import DimIntent + +SHELL = Wall( + id="shell", + axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)), + thickness=200.0, + closed=True, +) +PARTITION = Wall(id="p", axis=((4100.0, 0.0), (4100.0, 6200.0)), thickness=100.0) +WINDOW = Opening(id="w1", wall="shell", kind="window", offset=1500.0, width=1200.0) +DOOR = Opening(id="d1", wall="shell", kind="door", offset=5500.0, width=1000.0) + + +def row(intents, feature) -> list[tuple]: + out = [] + for intent in intents: + if intent.feature == feature: + out.append( + ( + tuple(round(v, 9) + 0.0 for v in intent.p1), + tuple(round(v, 9) + 0.0 for v in intent.p2), + tuple(round(v, 9) + 0.0 for v in intent.text_at), + ) + ) + return out + + +def test_the_rows_and_the_offsets_are_declared(): + assert SIDES == ("bottom", "top", "left", "right") + assert ROWS == ("openings", "walls", "overall") + assert (FIRST_OFFSET_PAPER_MM, STEP_PAPER_MM) == (10.0, 8.0) + + +def test_the_bottom_side_carries_openings_walls_and_overall(): + intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("bottom",), scale=50) + assert all(isinstance(i, DimIntent) and i.kind == "linear" for i in intents) + assert row(intents, "bottom:openings") == [ + ((-100.0, -100.0), (1500.0, -100.0), (700.0, -600.0)), + ((1500.0, -100.0), (2700.0, -100.0), (2100.0, -600.0)), + ((2700.0, -100.0), (5500.0, -100.0), (4100.0, -600.0)), + ((5500.0, -100.0), (6500.0, -100.0), (6000.0, -600.0)), + ((6500.0, -100.0), (8300.0, -100.0), (7400.0, -600.0)), + ] + assert row(intents, "bottom:walls") == [ + ((-100.0, -100.0), (100.0, -100.0), (0.0, -1000.0)), + ((100.0, -100.0), (4050.0, -100.0), (2075.0, -1000.0)), + ((4050.0, -100.0), (4150.0, -100.0), (4100.0, -1000.0)), + ((4150.0, -100.0), (8100.0, -100.0), (6125.0, -1000.0)), + ((8100.0, -100.0), (8300.0, -100.0), (8200.0, -1000.0)), + ] + assert row(intents, "bottom:overall") == [ + ((-100.0, -100.0), (8300.0, -100.0), (4100.0, -1400.0)), + ] + assert len(intents) == 11 + + +def test_a_row_that_repeats_a_farther_one_is_not_emitted(): + intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("left",), scale=50) + # no opening in the left wall: that row would only restate the overall one + assert row(intents, "left:openings") == [] + assert row(intents, "left:walls") == [ + ((-100.0, -100.0), (-100.0, 100.0), (-1000.0, 0.0)), + ((-100.0, 100.0), (-100.0, 6100.0), (-1000.0, 3100.0)), + ((-100.0, 6100.0), (-100.0, 6300.0), (-1000.0, 6200.0)), + ] + assert row(intents, "left:overall") == [((-100.0, -100.0), (-100.0, 6300.0), (-1400.0, 3100.0))] + + +def test_explicit_offsets_override_the_plot_scale_defaults(): + intents = exterior_chains( + [SHELL], [], sides=("top",), first_offset=300.0, step=250.0, scale=100 + ) + # the top: the extremes only, so the walls row is the corner walls' faces + assert row(intents, "top:overall") == [((-100.0, 6300.0), (8300.0, 6300.0), (4100.0, 7100.0))] + assert {i.text_at[1] for i in intents if i.feature == "top:walls"} == {6850.0} + + +def test_bad_sides_and_scales_are_refused_by_name(): + with pytest.raises(ValueError, match=r"sides\[0\]: 'north'"): + exterior_chains([SHELL], [], sides=("north",)) + with pytest.raises(ValueError, match="named twice"): + exterior_chains([SHELL], [], sides=("left", "left")) + with pytest.raises(ValueError, match="scale"): + exterior_chains([SHELL], [], scale=0) + with pytest.raises(ValueError, match="sides"): + exterior_chains([SHELL], [], sides="bottom") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_dimension.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.dimension'`. + +- [ ] **Step 3: Write `engineering/arch/dimension.py`** + +```python +"""Exterior dimension chains: openings, walls, overall - three rows per side. + +An architectural plan carries its dimensions outside the building, in rows +parallel to each facade, nearest first: + +1. **openings** - the facade's extremes and both jambs of every opening in the + exterior wall on that side; +2. **walls** - the extremes and both faces of every wall that meets that + exterior wall, so the chain reads wall thickness, clear room width, wall + thickness, ...; +3. **overall** - the building's extreme faces. + +The "exterior wall on a side" is every axis segment parallel to that side +whose outer face no other parallel segment overlapping it lies beyond. Points +come from the wall engine's own faces, so a chain can never disagree with the +outline it measures. + +ISO 129-1: each measurement appears once. A row whose points are exactly those +of a farther row (a facade with no opening, a side no wall meets) is not +emitted; the rows that are keep their places, so the overall row is always at +the same distance. + +Offsets are paper millimetres times the plot-scale denominator: the first row +10 mm from the building and 8 mm between rows. Those two numbers are this +repository's drafting defaults, not standard values; pass ``first_offset`` / +``step`` in drawing units to override them. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +from engineering.arch.model import Wall, segment_of +from engineering.arch.walls import face_offsets, wall_geometry, wall_segments +from engineering.mech.primitives import DimIntent + +SIDES: tuple[str, ...] = ("bottom", "top", "left", "right") +ROWS: tuple[str, ...] = ("openings", "walls", "overall") +FIRST_OFFSET_PAPER_MM = 10.0 +STEP_PAPER_MM = 8.0 +_TOL = 1.0 + + +def _positive(value, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where}: expected a number, got {value!r}") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{where}: must be a finite number above zero, got {value!r}") + return number + + +def _sides(sides) -> tuple[str, ...]: + if isinstance(sides, str) or not isinstance(sides, (list, tuple)) or not sides: + raise ValueError(f"sides: name at least one of {', '.join(SIDES)}, got {sides!r}") + chosen: list[str] = [] + for i, side in enumerate(sides): + if side not in SIDES: + raise ValueError(f"sides[{i}]: {side!r}; sides are {', '.join(SIDES)}") + if side in chosen: + raise ValueError(f"sides[{i}]: {side!r} is named twice") + chosen.append(side) + return tuple(chosen) + + +def _unit(a, b) -> tuple[float, float]: + length = math.dist(a, b) + return ((b[0] - a[0]) / length, (b[1] - a[1]) / length) + + +def _exterior(walls: Sequence[Wall], ai: int, sign: float) -> list[dict]: + """The segments parallel to a side whose outer face nothing parallel lies beyond.""" + ci = 1 - ai + parallel: list[dict] = [] + for wall in walls: + left, right = face_offsets(wall) + for index, (a, b) in enumerate(wall_segments(wall)): + u = _unit(a, b) + if abs(u[ci]) > 1e-9: + continue + n = (-u[1], u[0]) + faces = (a[ci] + n[ci] * left, a[ci] + n[ci] * right) + parallel.append( + { + "wall": wall, + "index": index, + "a": a, + "b": b, + "span": (min(a[ai], b[ai]), max(a[ai], b[ai])), + "outer": min(faces) if sign < 0 else max(faces), + } + ) + exterior = [] + for item in parallel: + lo, hi = item["span"] + covered = any( + other is not item + and min(hi, other["span"][1]) - max(lo, other["span"][0]) > _TOL + and ( + other["outer"] < item["outer"] - _TOL + if sign < 0 + else other["outer"] > item["outer"] + _TOL + ) + for other in parallel + ) + if not covered: + exterior.append(item) + return exterior + + +def _inside(point, item: dict) -> bool: + a, b = item["a"], item["b"] + left, right = face_offsets(item["wall"]) + u = _unit(a, b) + s = (point[0] - a[0]) * u[0] + (point[1] - a[1]) * u[1] + d = (point[0] - a[0]) * -u[1] + (point[1] - a[1]) * u[0] + return -_TOL <= s <= math.dist(a, b) + _TOL and right - _TOL <= d <= left + _TOL + + +def _points(values: set[float]) -> list[float]: + out: list[float] = [] + for value in sorted(values): + if not out or value - out[-1] > 1e-6: + out.append(value) + return out + + +def exterior_chains( + walls, + openings=(), + *, + sides=("bottom", "left"), + first_offset=None, + step=None, + scale=50, +) -> tuple[DimIntent, ...]: + """Linear ``DimIntent`` rows outside each named side, nearest first. + + ``feature`` names the row as ``":"`` (``"bottom:openings"``); + ``text_at`` is the point the dimension line passes through. + """ + walls = list(walls) + openings = list(openings) + chosen = _sides(sides) + factor = _positive(scale, "scale") + first = ( + FIRST_OFFSET_PAPER_MM * factor + if first_offset is None + else _positive(first_offset, "first_offset") + ) + gap = STEP_PAPER_MM * factor if step is None else _positive(step, "step") + geometry = wall_geometry(walls, openings, poche=False) + if not geometry.faces: + raise ValueError("walls: there is no wall to dimension") + xs = [p[0] for line in geometry.faces for p in (line.p1, line.p2)] + ys = [p[1] for line in geometry.faces for p in (line.p1, line.p2)] + box = {"bottom": min(ys), "top": max(ys), "left": min(xs), "right": max(xs)} + skipped = {entry["element"] for entry in geometry.omitted} + by_id = {wall.id: wall for wall in walls} + + intents: list[DimIntent] = [] + for side in chosen: + ai = 0 if side in ("bottom", "top") else 1 + sign = -1.0 if side in ("bottom", "left") else 1.0 + edge = box[side] + lo, hi = (box["left"], box["right"]) if ai == 0 else (box["bottom"], box["top"]) + exterior = _exterior(walls, ai, sign) + hosts = {(item["wall"].id, item["index"]) for item in exterior} + + jambs = {lo, hi} + for opening in openings: + if opening.id in skipped: + continue + wall = by_id[opening.wall] + index, along = segment_of(wall, float(opening.offset)) + if (wall.id, index) not in hosts: + continue + a, b = wall_segments(wall)[index] + u = _unit(a, b) + jambs.add(a[ai] + u[ai] * along) + jambs.add(a[ai] + u[ai] * (along + float(opening.width))) + + faces = {lo, hi} + for wall in walls: + left, right = face_offsets(wall) + for a, b in wall_segments(wall): + u = _unit(a, b) + if abs(u[1 - ai]) <= 1e-9: + continue + n = (-u[1], u[0]) + for end in (a, b): + if any(_inside(end, item) for item in exterior): + faces.add(end[ai] + n[ai] * left) + faces.add(end[ai] + n[ai] * right) + + rows = [_points(jambs), _points(faces), _points({lo, hi})] + for r, points in enumerate(rows): + if any(points == later for later in rows[r + 1 :]): + continue + offset = edge + sign * (first + r * gap) + for p, q in zip(points, points[1:], strict=False): + if ai == 0: + p1, p2, at = (p, edge), (q, edge), ((p + q) / 2.0, offset) + else: + p1, p2, at = (edge, p), (edge, q), (offset, (p + q) / 2.0) + intents.append(DimIntent("linear", p1, p2, at, feature=f"{side}:{ROWS[r]}")) + return tuple(intents) +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_dimension.py -q` +Expected: PASS — 5 passed. + +- [ ] **Step 5: Write the failing test for the async layer** + +`tests/test_arch_draw.py` (the `backend` fixture is `tests/conftest.py`'s fresh headless document): + +```python +"""The async layer: walls, openings, stairs and chains drawn through the ezdxf backend. + +The same code runs on the live engine: `engineering/arch/draw.py` composes only +generic contract members (entity_create_*, dimension_linear, entity_set_xdata, +entity_list, entity_delete), like the mechanical track's draw layer. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.draw import ( + HANDLES_APP, + add_opening, + add_stair, + add_walls, + assign_tags, + draw_chains, + draw_stair, + draw_walls, + read_plan, +) +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import APP_ID, Opening, Stair, Wall + +pytestmark = pytest.mark.asyncio + +WALL_A = {"id": "a", "axis": [[0.0, 0.0], [5000.0, 0.0]], "thickness": 200.0} +WALL_B = {"id": "b", "axis": [[2500.0, 0.0], [2500.0, 3000.0]], "thickness": 100.0} + + +async def on(backend, role, type_filter=None): + return await backend.entity_list( + type_filter=type_filter, layer_filter=ARCH_ROLE_LAYER[role], limit=10000 + ) + + +def ends(info) -> tuple: + a = tuple(round(v, 6) + 0.0 for v in info.properties["start"][:2]) + b = tuple(round(v, 6) + 0.0 for v in info.properties["end"][:2]) + return tuple(sorted((a, b))) + + +async def test_draw_walls_puts_faces_jambs_and_poche_on_their_layers(backend): + wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0) + door = Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0, tag="D1") + result = await draw_walls(backend, [wall], [door]) + lines = await on(backend, "wall", "LINE") + assert {ends(e) for e in lines} == { + ((0.0, 100.0), (1000.0, 100.0)), + ((1900.0, 100.0), (5000.0, 100.0)), + ((0.0, -100.0), (1000.0, -100.0)), + ((1900.0, -100.0), (5000.0, -100.0)), + ((0.0, -100.0), (0.0, 100.0)), + ((5000.0, -100.0), (5000.0, 100.0)), + ((1000.0, -100.0), (1000.0, 100.0)), + ((1900.0, -100.0), (1900.0, 100.0)), + } + assert len(await on(backend, "wall_hatch", "HATCH")) == 2 + assert len(await on(backend, "door", "LINE")) == 1 + assert len(await on(backend, "door", "ARC")) == 1 + assert [w["id"] for w in result["walls"]] == ["a"] + assert [o["id"] for o in result["openings"]] == ["d1"] + assert result["omitted"] == [] + + +async def test_the_records_round_trip_through_xdata(backend): + wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0, material="aac") + window = Opening(id="w1", wall="a", kind="window", offset=2000.0, width=1200.0, tag="W1") + drawn = await draw_walls(backend, [wall], [window]) + plan = await read_plan(backend) + assert plan["walls"] == [wall] + assert plan["openings"] == [window] + assert plan["carriers"]["wall:a"] == drawn["walls"][0]["record"] + carrier = drawn["openings"][0]["record"] + assert plan["carriers"]["opening:w1"] == carrier + # the window's record sits on its left-face frame line, with its four lines owned + raw = await backend.entity_get_xdata(carrier, HANDLES_APP) + assert len(raw["xdata"][HANDLES_APP]) == 4 + assert (await backend.entity_get_xdata(carrier, APP_ID))["xdata"][APP_ID] + + +async def test_add_walls_redraws_only_the_wall_the_new_one_joins(backend): + await add_walls( + backend, + [WALL_A, {"id": "c", "axis": [[0.0, 2000.0], [5000.0, 2000.0]], "thickness": 200.0}], + ) + result = await add_walls( + backend, [{"id": "b", "axis": [[2500.0, 0.0], [2500.0, 1000.0]], "thickness": 100.0}] + ) + assert result["added"] == ["b"] + assert result["redrawn"] == ["a"] # c is not touched by b + faces = {ends(e) for e in await on(backend, "wall", "LINE")} + # a's near face is now cut where b meets it; its far face is still one line + assert ((0.0, 100.0), (2450.0, 100.0)) in faces + assert ((2550.0, 100.0), (5000.0, 100.0)) in faces + assert ((0.0, -100.0), (5000.0, -100.0)) in faces + assert ((0.0, 100.0), (5000.0, 100.0)) not in faces # the old, uncut face was erased + plan = await read_plan(backend) + assert sorted(w.id for w in plan["walls"]) == ["a", "b", "c"] + + +async def test_an_opening_added_later_cuts_its_wall(backend): + await add_walls(backend, [WALL_A]) + before = {ends(e) for e in await on(backend, "wall", "LINE")} + assert ((0.0, 100.0), (5000.0, 100.0)) in before + result = await add_opening( + backend, {"wall": "a", "kind": "door", "offset": 1000.0, "width": 900.0} + ) + assert result["opening"] == "D1" and result["tag"] == "D1" + assert result["redrawn"] == ["a"] + after = {ends(e) for e in await on(backend, "wall", "LINE")} + assert ((0.0, 100.0), (5000.0, 100.0)) not in after + assert {((0.0, 100.0), (1000.0, 100.0)), ((1900.0, 100.0), (5000.0, 100.0))} <= after + assert len(await on(backend, "door", "ARC")) == 1 + second = await add_opening( + backend, {"wall": "a", "kind": "door", "offset": 3000.0, "width": 800.0} + ) + assert second["tag"] == "D2" + assert len(await on(backend, "door", "ARC")) == 2 # the first door was redrawn, not lost + plan = await read_plan(backend) + assert sorted(op.tag for op in plan["openings"]) == ["D1", "D2"] + + +async def test_an_opening_in_an_unknown_wall_or_overlapping_is_refused_untouched(backend): + await add_walls(backend, [WALL_A]) + count = len(await backend.entity_list(limit=10000)) + with pytest.raises(ValueError, match="'zz' is not a wall"): + await add_opening(backend, {"wall": "zz", "kind": "door", "offset": 0.0, "width": 900.0}) + await add_opening(backend, {"wall": "a", "kind": "window", "offset": 1000.0, "width": 1200.0}) + count = len(await backend.entity_list(limit=10000)) + with pytest.raises(ValueError): + await add_opening(backend, {"wall": "a", "kind": "door", "offset": 1500.0, "width": 900.0}) + assert len(await backend.entity_list(limit=10000)) == count + + +async def test_a_wall_id_already_drawn_is_refused(backend): + await add_walls(backend, [WALL_A]) + with pytest.raises(ValueError, match=r"walls\[0\]\.id: 'a' is already drawn"): + await add_walls(backend, [WALL_A]) + + +async def test_a_new_wall_that_would_swallow_an_opening_is_refused(backend): + await add_walls(backend, [WALL_A]) + await add_opening(backend, {"wall": "a", "kind": "door", "offset": 2200.0, "width": 900.0}) + count = len(await backend.entity_list(limit=10000)) + with pytest.raises(ValueError, match="uncuttable"): + await add_walls(backend, [WALL_B]) + assert len(await backend.entity_list(limit=10000)) == count + + +async def test_draw_stair_writes_its_record_and_reports_blondel(backend): + stair = Stair( + id="s1", + start=(0.0, 0.0), + direction_deg=0.0, + width=1000.0, + risers=17, + riser_height=170.0, + going=290.0, + ) + result = await draw_stair(backend, stair) + assert result["blondel"] == {"value": 630.0, "ok": True, "range": [600.0, 650.0]} + assert result["treads"] == 16 + assert len(await on(backend, "stair", "LINE")) == 17 + 2 + 2 + assert (await read_plan(backend))["stairs"] == [stair] + with pytest.raises(ValueError, match="'s1' is already drawn"): + await add_stair( + backend, + { + "id": "s1", + "start": [0, 0], + "width": 1000.0, + "risers": 17, + "riser_height": 170.0, + "going": 290.0, + }, + ) + + +async def test_draw_chains_puts_real_dimensions_on_the_dimension_layer(backend): + shell = Wall( + id="shell", + axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)), + thickness=200.0, + closed=True, + ) + result = await draw_chains(backend, [shell], [], sides=("bottom", "left"), scale=50) + dims = await on(backend, "dim", "DIMENSION") + assert len(dims) == result["count"] == 8 # per side: walls (3) + overall (1) + assert {row["row"] for row in result["dimensions"]} == { + "bottom:walls", + "bottom:overall", + "left:walls", + "left:overall", + } + + +async def test_assign_tags_numbers_each_kind_in_the_chosen_language(): + ops = [ + Opening(id="1", wall="a", kind="door", offset=0.0, width=900.0), + Opening(id="2", wall="a", kind="window", offset=2000.0, width=900.0), + Opening(id="3", wall="a", kind="door", offset=4000.0, width=900.0, tag="D1"), + ] + assert [op.tag for op in assign_tags(ops)] == ["D2", "W1", "D1"] + assert [op.tag for op in assign_tags(ops[:2], lang="tr")] == ["K1", "P1"] +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_draw.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.draw'`. + +- [ ] **Step 7: Write `engineering/arch/draw.py`** + +Every primitive — the faces, the jambs, the poché and the opening and stair symbols — goes through `draw_prims(..., role_layer=ARCH_ROLE_LAYER)`. Task 1 made that map resolve a `HatchArea` onto `ARCH_ROLE_LAYER["wall_hatch"]` and its material through `wall_hatch(material)` (the map carries the `wall_hatch` key), refusing an unknown material before the first entity, so no hatch is drawn outside `draw_prims` here. + +```python +"""The async layer of the architectural track - the only module here that touches a backend. + +Everything else under `engineering/arch/` is pure; this module turns its +primitives into entities through the generic contract members (so it runs on +both engines as written) and writes the plan onto the drawing: + +* each wall's ``ACADMCP_ARCH`` record on its first face line, each opening's + on its door leaf or its window frame, each stair's on its first riser line; +* next to each record, under ``ACADMCP_ARCH_H``, the handles of every entity + that element owns - faces, jambs, poche, the symbol - so a later call can + take exactly those entities back out and redraw them. + +`read_plan` reads the records back. `add_walls` and `add_opening` build on +it: they compute the whole network before and after the change, redraw only +the walls whose outline the change touches, and say which. + +Every refusal fires before the first entity is created or erased. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import replace + +from engineering.arch.dimension import exterior_chains +from engineering.arch.lang import tag_prefix +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.arch.model import ( + APP_ID, + Opening, + Room, + Stair, + Wall, + decode, + from_payload, + opening_from_dict, + stair_from_dict, + to_payload, + to_values, + wall_from_dict, +) +from engineering.arch.openings import opening_prims +from engineering.arch.stairs import blondel, stair_prims +from engineering.arch.walls import WallGeometry, wall_geometry_by_wall +from engineering.mech.draw import draw_prims + +log = logging.getLogger(__name__) + +#: The application that carries an element's owned handles beside its record. +HANDLES_APP = "ACADMCP_ARCH_H" + +#: The layers `read_plan` scans for records - never the poche or the dimensions. +RECORD_ROLES: tuple[str, ...] = ("wall", "door", "window", "stair", "room") + +#: Model class -> the record kind, read off the object rather than the payload, +#: so the reader does not depend on how a payload spells its own kind. +_KIND = {Wall: "wall", Opening: "opening", Stair: "stair", Room: "room"} + + +# -- layers ----------------------------------------------------------------------- + + +async def ensure_arch_layers(backend, names) -> tuple[str, ...]: + """Create the named layers the drawing lacks, with their ``ARCH_LAYERS`` row. + + A live seat refuses ``entity.Layer = name`` on a missing layer (measured + by the mechanical track), so every draw path ensures its layers first. + """ + rows = {row[0]: row for row in ARCH_LAYERS} + existing = {layer.name.lower() for layer in await backend.layer_list()} + created: list[str] = [] + for name in dict.fromkeys(names): + if name.lower() in existing: + continue + _name, color, linetype, lineweight, _description = rows.get( + name, (name, 7, "Continuous", 0.25, "") + ) + await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight) + created.append(name) + return tuple(created) + + +# -- records -------------------------------------------------------------------- + + +async def _write_record(backend, handle: str, element, owned: list[str]) -> None: + await backend.entity_set_xdata(handle, APP_ID, to_values(to_payload(element))) + await backend.entity_set_xdata(handle, HANDLES_APP, list(owned)) + + +async def read_plan(backend) -> dict: + """Every ``ACADMCP_ARCH`` record on the drawing, rebuilt into model objects. + + ``carriers`` maps ``":"`` to the entity that carries the record. + Reads only; the drawing is never modified. + """ + plan: dict = {"walls": [], "openings": [], "stairs": [], "rooms": [], "carriers": {}} + for role in RECORD_ROLES: + for info in await backend.entity_list(layer_filter=ARCH_ROLE_LAYER[role], limit=100000): + raw = await backend.entity_get_xdata(info.handle, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + continue + element = from_payload(decode(values)) + kind = _KIND[type(element)] + plan[f"{kind}s"].append(element) + plan["carriers"][f"{kind}:{element.id}"] = info.handle + return plan + + +async def _owned(backend, carrier: str) -> list[str]: + raw = await backend.entity_get_xdata(carrier, HANDLES_APP) + return [str(h) for h in ((raw.get("xdata") or {}).get(HANDLES_APP) or [])] + + +async def _erase(backend, plan: dict, wall_id: str) -> list[str]: + """Take a wall, its openings and their poche back out. Returns handles already gone.""" + carriers = [plan["carriers"][f"wall:{wall_id}"]] + carriers += [ + plan["carriers"][f"opening:{op.id}"] for op in plan["openings"] if op.wall == wall_id + ] + missing: list[str] = [] + for carrier in carriers: + for handle in await _owned(backend, carrier) or [carrier]: + try: + await backend.entity_delete(handle) + except Exception as exc: # erased by hand since it was drawn: nothing to take back + log.info("arch: %s was already gone (%s)", handle, exc) + missing.append(handle) + return missing + + +# -- walls ------------------------------------------------------------------------ + + +def _signature(geometry: WallGeometry) -> tuple: + """What a wall looks like, rounded - equal signatures mean nothing to redraw.""" + + def seg(line): + a = (round(line.p1[0], 6), round(line.p1[1], 6)) + b = (round(line.p2[0], 6), round(line.p2[1], 6)) + return tuple(sorted((a, b))) + + def ring(points): + pts = [(round(x, 6), round(y, 6)) for x, y in points] + k = pts.index(min(pts)) + return tuple(pts[k:] + pts[:k]) + + return ( + frozenset(seg(line) for line in geometry.faces), + frozenset(seg(line) for line in geometry.jambs), + frozenset(ring(region) for region in geometry.regions), + frozenset(entry["element"] for entry in geometry.omitted), + ) + + +async def _draw_wall(backend, wall, geometry: WallGeometry, openings) -> dict: + """One wall's faces, jambs, poche and hosted symbols, with their records.""" + skipped = {entry["element"] for entry in geometry.omitted} + symbols = [(op, opening_prims(wall, op)) for op in openings if op.id not in skipped] + # Faces first, so owned[0] - the record carrier - is the wall's first face line; + # the poche follows on the wall-hatch layer, in each material's pattern. + drawn = await draw_prims( + backend, + [*geometry.faces, *geometry.jambs, *geometry.hatches], + role_layer=ARCH_ROLE_LAYER, + ) + owned = list(drawn["handles"]) + done: list[dict] = [] + for op, prims in symbols: + placed = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + await _write_record(backend, placed["handles"][0], op, placed["handles"]) + done.append( + { + "id": op.id, + "tag": op.tag, + "record": placed["handles"][0], + "handles": placed["handles"], + } + ) + await _write_record(backend, owned[0], wall, owned) + return { + "wall": {"id": wall.id, "record": owned[0], "handles": owned}, + "openings": done, + "omitted": list(geometry.omitted), + } + + +def _collect(results: list[dict]) -> dict: + return { + "walls": [r["wall"] for r in results], + "openings": [op for r in results for op in r["openings"]], + "handles": [ + h + for r in results + for h in (*r["wall"]["handles"], *(x for op in r["openings"] for x in op["handles"])) + ], + "omitted": [entry for r in results for entry in r["omitted"]], + } + + +async def draw_walls(backend, walls, openings=(), *, poche=True) -> dict: + """Draw a wall network and its openings, writing every record. Refusals first.""" + walls = list(walls) + openings = list(openings) + geometry = wall_geometry_by_wall(walls, openings, poche=poche) + for wall in walls: # every symbol is validated before the first entity + for op in openings: + if op.wall == wall.id: + opening_prims(wall, op) + results = [ + await _draw_wall( + backend, wall, geometry[wall.id], [op for op in openings if op.wall == wall.id] + ) + for wall in walls + ] + return {**_collect(results), "backend": backend.name} + + +async def add_walls(backend, walls, *, poche=True) -> dict: + """Add walls to the plan on the drawing, redrawing the drawn walls they join.""" + if isinstance(walls, dict) or not isinstance(walls, (list, tuple)) or not walls: + raise ValueError("walls: give a list of at least one wall {id, axis, thickness, ...}") + new = [wall_from_dict(item, f"walls[{i}]") for i, item in enumerate(walls)] + plan = await read_plan(backend) + drawn = {wall.id for wall in plan["walls"]} + for i, wall in enumerate(new): + if wall.id in drawn: + raise ValueError( + f"walls[{i}].id: {wall.id!r} is already drawn; choose a new id " + f"(drawn: {', '.join(sorted(drawn))})" + ) + before = ( + wall_geometry_by_wall(plan["walls"], plan["openings"], poche=poche) if plan["walls"] else {} + ) + network = [*plan["walls"], *new] + after = wall_geometry_by_wall(network, plan["openings"], poche=poche) + changed = [ + wall.id + for wall in plan["walls"] + if _signature(before[wall.id]) != _signature(after[wall.id]) + ] + for wall_id in changed: + was = {entry["element"] for entry in before[wall_id].omitted} + for entry in after[wall_id].omitted: + if entry["element"] not in was: + raise ValueError( + f"the new walls would leave opening {entry['element']!r} of wall " + f"{wall_id!r} uncuttable ({entry['reason']}); move the opening or the wall" + ) + by_id = {wall.id: wall for wall in network} + targets = [*changed, *(wall.id for wall in new)] + for wall_id in targets: + for op in plan["openings"]: + if op.wall == wall_id: + opening_prims(by_id[wall_id], op) + + missing: list[str] = [] + for wall_id in changed: + missing += await _erase(backend, plan, wall_id) + results = [ + await _draw_wall( + backend, + by_id[wall_id], + after[wall_id], + [op for op in plan["openings"] if op.wall == wall_id], + ) + for wall_id in targets + ] + return { + **_collect(results), + "added": [wall.id for wall in new], + "redrawn": changed, + "already_erased": missing, + "backend": backend.name, + } + + +# -- openings --------------------------------------------------------------------- + + +def assign_tags(openings, existing=(), *, lang: str = "en") -> tuple[Opening, ...]: + """Give every untagged opening the next free tag of its kind: D1, W2 (EN) / K1, P2 (TR).""" + used = {op.tag for op in (*existing, *openings) if op.tag} + out: list[Opening] = [] + for op in openings: + if op.tag: + out.append(op) + continue + prefix = tag_prefix(op.kind, lang) + number = 1 + while f"{prefix}{number}" in used: + number += 1 + used.add(f"{prefix}{number}") + out.append(replace(op, tag=f"{prefix}{number}")) + return tuple(out) + + +async def add_opening(backend, opening: dict, *, lang: str = "en", poche: bool = True) -> dict: + """Host a door or a window in a drawn wall, found by id, and redraw that wall cut.""" + if not isinstance(opening, dict): + raise ValueError(f"opening: expected a dict, got {type(opening).__name__}") + raw = dict(opening) + auto_id = not raw.get("id") + if auto_id: + raw["id"] = "__new__" + op = opening_from_dict(raw, "opening") + plan = await read_plan(backend) + walls = {wall.id: wall for wall in plan["walls"]} + if op.wall not in walls: + names = ", ".join(sorted(walls)) or "none - draw one with arch_wall" + raise ValueError(f"opening.wall: {op.wall!r} is not a wall on this drawing; walls: {names}") + (op,) = assign_tags([op], plan["openings"], lang=lang) + if auto_id: + op = replace(op, id=op.tag) + if op.id in {other.id for other in plan["openings"]}: + raise ValueError(f"opening.id: {op.id!r} is already drawn; choose a new id") + hosted = [other for other in plan["openings"] if other.wall == op.wall] + [op] + geometry = wall_geometry_by_wall(plan["walls"], [*plan["openings"], op], poche=poche) + for entry in geometry[op.wall].omitted: + if entry["element"] == op.id: + raise ValueError(f"opening {op.id!r} cannot be cut: {entry['reason']}") + wall = walls[op.wall] + for other in hosted: + opening_prims(wall, other) + missing = await _erase(backend, plan, op.wall) + result = await _draw_wall(backend, wall, geometry[op.wall], hosted) + return { + **_collect([result]), + "opening": op.id, + "tag": op.tag, + "redrawn": [op.wall], + "already_erased": missing, + "backend": backend.name, + } + + +# -- stairs ----------------------------------------------------------------------- + + +async def draw_stair(backend, stair: Stair, *, lang: str = "en", scale=50) -> dict: + """A stair in plan with its record; the Blondel value is reported, never enforced.""" + prims = stair_prims(stair, lang=lang, scale=scale) + check = blondel(stair.riser_height, stair.going) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + await _write_record(backend, drawn["handles"][0], stair, drawn["handles"]) + flights = 1 if stair.kind == "straight" else 2 + return { + "stair": stair.id, + "record": drawn["handles"][0], + "handles": drawn["handles"], + "risers": stair.risers, + "treads": stair.risers - flights, + "blondel": check, + "backend": backend.name, + } + + +async def add_stair(backend, stair: dict, *, lang: str = "en", scale=50) -> dict: + """`draw_stair` for a stair given as a dict, refusing an id already on the drawing.""" + if not isinstance(stair, dict): + raise ValueError(f"stair: expected a dict, got {type(stair).__name__}") + model = stair_from_dict(stair, "stair") + stair_prims(model, lang=lang, scale=scale) + plan = await read_plan(backend) + if model.id in {other.id for other in plan["stairs"]}: + raise ValueError(f"stair.id: {model.id!r} is already drawn; choose a new id") + return await draw_stair(backend, model, lang=lang, scale=scale) + + +# -- dimensions --------------------------------------------------------------------- + + +async def draw_chains( + backend, walls, openings, *, sides=("bottom", "left"), scale=50, first_offset=None, step=None +) -> dict: + """The exterior chains as real linear DIMENSIONs on the dimension layer.""" + intents = exterior_chains( + walls, openings, sides=sides, first_offset=first_offset, step=step, scale=scale + ) + layer = ARCH_ROLE_LAYER["dim"] + await ensure_arch_layers(backend, [layer]) + handles: list[str] = [] + rows: list[dict] = [] + for intent in intents: + horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0]) + info = await backend.dimension_linear( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + intent.text_at[0], + intent.text_at[1], + 0.0 if horizontal else 90.0, + layer, + ) + handles.append(info.handle) + rows.append( + { + "row": intent.feature, + "from": list(intent.p1), + "to": list(intent.p2), + "value": math.dist(intent.p1, intent.p2), + } + ) + return { + "count": len(handles), + "handles": handles, + "dimensions": rows, + "text_height": ( + "set by the current dimension style; at 1:{0:g} set DIMSCALE={0:g} with " + "dimstyle_modify so the text plots at its paper height".format(float(scale)) + ), + "backend": backend.name, + } +``` + +- [ ] **Step 8: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_draw.py tests/test_arch_dimension.py -q` +Expected: PASS — 15 passed (10 + 5). + +`test_the_records_round_trip_through_xdata` compares `read_plan`'s `Wall` and `Opening` with the originals by dataclass equality. That holds because Task 1's `from_payload` rebuilds every object through `wall_from_dict` / `opening_from_dict`, which normalise `axis` to a tuple of float tuples and every number to a float — Task 1's own `test_every_plan_object_survives_the_drawing` pins it. + +- [ ] **Step 9: Write the failing test for the four tools** + +`tests/test_arch_tools.py`: + +```python +"""SECTION 25's first four tools, called the way a client calls them. + +In-memory client on a fresh headless document; the env var pins ezdxf so no +test here can reach an operator's live drawing (tests/conftest.py). +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server + +pytestmark = pytest.mark.asyncio + +ARCH_TOOLS = {"arch_wall", "arch_opening", "arch_stair", "arch_dimension_chains"} + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +async def call(client, name, args) -> dict: + return (await client.call_tool(name, args)).structured_content + + +async def test_the_four_tools_are_registered_under_the_architecture_group(): + tools = {tool.name: tool for tool in await server._registered_tools()} + assert ARCH_TOOLS <= set(tools) + groups = await server._tool_groups() + assert set(groups["architecture"]) == ARCH_TOOLS + for name in ARCH_TOOLS: + card = (tools[name].meta or {})["cad"] + assert card["cost"] == "mutate" + assert card["synonyms"], f"{name} needs an alias record" + + +async def test_a_plan_built_tool_by_tool(client): + walls = await call( + client, + "arch_wall", + { + "walls": [ + { + "id": "shell", + "axis": [[0, 0], [8200, 0], [8200, 6200], [0, 6200]], + "thickness": 200, + "closed": True, + } + ] + }, + ) + assert walls["added"] == ["shell"] and walls["redrawn"] == [] + partition = await call( + client, + "arch_wall", + {"walls": [{"id": "p", "axis": [[4100, 0], [4100, 6200]], "thickness": 100}]}, + ) + assert partition["redrawn"] == ["shell"] + door = await call( + client, "arch_opening", {"wall": "shell", "offset": 5500, "width": 1000, "swing": "in"} + ) + assert door["tag"] == "D1" + window = await call( + client, "arch_opening", {"wall": "shell", "kind": "window", "offset": 1500, "width": 1200} + ) + assert window["tag"] == "W1" + stair = await call( + client, + "arch_stair", + { + "stair_id": "s1", + "start_x": 1000, + "start_y": 1500, + "width": 1000, + "risers": 17, + "riser_height": 170, + "going": 290, + "direction_deg": 90, + }, + ) + assert stair["blondel"]["ok"] is True + chains = await call(client, "arch_dimension_chains", {"sides": ["bottom"]}) + # 5 openings + 5 walls + 1 overall, hand-counted in tests/test_arch_dimension.py + assert chains["count"] == 11 + + +async def test_refusals_come_back_as_tool_errors_that_name_the_cause(client): + with pytest.raises(ToolError, match="no architectural walls"): + await call(client, "arch_dimension_chains", {}) + with pytest.raises(ToolError, match="'nowhere' is not a wall"): + await call(client, "arch_opening", {"wall": "nowhere", "offset": 0, "width": 900}) + await call( + client, "arch_wall", {"walls": [{"id": "a", "axis": [[0, 0], [5000, 0]], "thickness": 200}]} + ) + with pytest.raises(ToolError, match="5°"): + await call( + client, + "arch_wall", + {"walls": [{"id": "b", "axis": [[-2000, -150], [2000, 0]], "thickness": 200}]}, + ) + with pytest.raises(ToolError, match="at least 4"): + await call( + client, + "arch_stair", + { + "stair_id": "s", + "start_x": 0, + "start_y": 0, + "width": 900, + "risers": 3, + "riser_height": 170, + "going": 290, + "kind": "u", + }, + ) +``` + +- [ ] **Step 10: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_tools.py -q` +Expected: FAIL — 3 failed: the registration test with `AssertionError` on `ARCH_TOOLS <= set(tools)`, the other two with `fastmcp.exceptions.ToolError: Not found: Unknown tool: 'arch_wall'` (the refusal test first reports `Regex pattern did not match` because `Unknown tool: 'arch_dimension_chains'` is not the refusal it expects). + +- [ ] **Step 11: File the `arch` tag under its own group** + +In `server.py`, `_GROUP_TAG_PRIORITY` — add `"arch"` directly after `"mech"`: + +```python +_GROUP_TAG_PRIORITY = ( + "pid", + "mech", + "arch", + "sheet", +``` + +and in `_GROUP_TAG_LABELS`, directly after `"mech": "mechanical",`: + +```python + "arch": "architecture", +``` + +This is the SECTION 21 precedent (`mech` was ranked when its section opened), so `arch_wall` files under `architecture` rather than under its secondary `create` tag. Groups R and C tag their tools `arch` too; the merge keeps this one line. + +- [ ] **Step 12: Open SECTION 25 in `server.py`** + +Insert the block below between the end of `drawing_export_dwg` (the last tool of SECTION 24, ending `return await _backend(ctx).drawing_export_dwg(str(validated), version)`) and the three-line `# ── RESOURCES ──` banner, leaving two blank lines on each side. The two marker lines stay empty in this branch: group R's rooms block goes under the first, group C's catalogue block under the second (skeleton, Execution). + +```python +# --------------------------------------------------------------------------- +# ── SECTION 25: Architecture (4 tools) ────────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Add walls to the plan: clean L/T/X junctions, cut openings, material poché.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Wall", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_wall( + walls: Annotated[ + list[dict], + Field( + description=( + "Walls to add: [{id, axis:[[x,y],...] (2+ points, straight segments, WCS mm), " + "thickness, justification:'center'|'left'|'right' (side of the axis looking " + "along it), material:'brick'|'aac'|'concrete'|'reinforced_concrete'|" + "'gypsum_board'|'stone'|'timber'|'generic', closed:bool}]" + ) + ), + ], + poche: Annotated[ + bool, + Field(default=True, description="Hatch the cut wall faces in their material (ISO 128-50)"), + ] = True, + ctx: Context = None, +) -> dict: + """Add walls to the plan network already on the drawing and resolve every junction. + + Two ends at one point mitre into a clean L; an end inside another wall is a + T (the stem stops on the crossing wall's near face, which is cut there); two + axes crossing are an X (all four faces cut). A drawn wall whose outline the + new walls change is taken back out and redrawn with its openings - the + result lists it under `redrawn` - and a wall they do not touch is left alone. + Every wall carries an `ACADMCP_ARCH` record, so later calls find it by id. + + Refused before anything is drawn or erased, by path: a wall id already on + the drawing or used twice, an axis of fewer than two points or with a + zero-length segment, a non-positive thickness, an unknown justification or + material (the list is named), two walls meeting at less than 5° (the mitre + would run away), a wall corner lying inside another wall off its axis, an + end inside a wall whose axis only meets it past that wall's end, and a new + wall that would leave a drawn opening uncuttable. A curved wall is not + supported: an axis is straight segments only. + """ + from engineering.arch.draw import add_walls + + await ctx.info(f"arch_wall: {len(walls)} wall(s)") + try: + return await add_walls(_backend(ctx), walls, poche=poche) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Host a door or a window in a drawn wall by distance along it; the wall is cut.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Door / Window", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_opening( + wall: Annotated[str, Field(description="Id of the drawn wall that hosts the opening")], + offset: Annotated[ + float, + Field( + description="Distance along the wall's axis from its first point to the near jamb (mm)" + ), + ], + width: Annotated[float, Field(description="Clear width of the opening (mm)")], + kind: Annotated[str, Field(default="door", description="door | window")] = "door", + swing: Annotated[ + str, + Field( + default="in", + description="Doors: in = to the left of the axis (inside a CCW perimeter) | out", + ), + ] = "in", + hand: Annotated[ + str, + Field( + default="left", + description="Doors: the hinge jamb, seen from the swing side facing the wall: left | right", + ), + ] = "left", + sill: Annotated[ + float | None, Field(default=None, description="Windows: sill height (schedule only, mm)") + ] = None, + height: Annotated[ + float | None, Field(default=None, description="Opening height (schedule only, mm)") + ] = None, + tag: Annotated[ + str | None, + Field(default=None, description="Schedule tag; default the next D1/W1 (en) or K1/P1 (tr)"), + ] = None, + opening_id: Annotated[ + str | None, Field(default=None, description="Opening id; default its tag") + ] = None, + lang: Annotated[str, Field(default="en", description="Tag language: en | tr")] = "en", + poche: Annotated[ + bool, Field(default=True, description="Hatch the redrawn wall's cut faces") + ] = True, + ctx: Context = None, +) -> dict: + """A door or a window in a wall already drawn, found by its id; the wall is redrawn cut. + + The opening removes its width from both wall faces and each edge is closed + by a jamb. A door is drawn open at 90° - its leaf as long as the opening is + wide, hinged on `hand`, with a quarter-arc swing on the `swing` side; a + window is a frame line on each face and two glazing lines between them. The + opening's `ACADMCP_ARCH` record sits on the leaf or the frame, so a schedule + reads it back. + + Refused before anything is drawn or erased: a wall id not on the drawing + (the drawn ids are named), an opening id already drawn, an opening that + runs past its wall segment or straddles an axis vertex, one overlapping + another opening on the same wall (both ids named), one that runs into a + junction (the solid stretch it must fit in is named), an unknown kind, + swing, hand or language. + """ + from engineering.arch.draw import add_opening + + spec = { + "id": opening_id, + "wall": wall, + "kind": kind, + "offset": offset, + "width": width, + "swing": swing, + "hand": hand, + "sill": sill, + "height": height, + "tag": tag, + } + await ctx.info(f"arch_opening: {kind} in wall {wall}") + try: + return await add_opening( + _backend(ctx), {k: v for k, v in spec.items() if v is not None}, lang=lang, poche=poche + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a straight, L or U stair in plan and report its Blondel step rule.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Stair", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_stair( + stair_id: Annotated[str, Field(description="Stair id, unique on the drawing")], + start_x: Annotated[float, Field(description="WCS X of the midpoint of the bottom riser")], + start_y: Annotated[float, Field(description="WCS Y of the midpoint of the bottom riser")], + width: Annotated[float, Field(description="Flight width (mm)")], + risers: Annotated[int, Field(description="Number of risers from floor to floor")], + riser_height: Annotated[float, Field(description="Riser height R (mm)")], + going: Annotated[float, Field(description="Tread going G (mm)")], + direction_deg: Annotated[ + float, Field(default=0.0, description="Direction up the first flight, degrees CCW from +X") + ] = 0.0, + kind: Annotated[str, Field(default="straight", description="straight | l | u")] = "straight", + turn: Annotated[ + str, Field(default="left", description="L / U stairs: which way the second flight turns") + ] = "left", + lang: Annotated[str, Field(default="en", description="Label language: en | tr")] = "en", + scale: Annotated[ + float, + Field( + default=50.0, description="Plot-scale denominator for the label and arrow (50 = 1:50)" + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """A stair in plan: every riser as a line, the outline, the walking line with its arrow. + + The label ("UP" / "ÇIKIŞ") sits on the first tread at 2.5 mm × `scale`. The + result reports `blondel` - 2R + G against 600-650 mm, the step-length rule + attributed to François Blondel (1675). It is a design rule reported for the + drafter, not a building code: no national stair regulation is checked and + a value outside the band is reported (`ok: false`), not refused. + + Refused before anything is drawn: a stair id already on the drawing, a + non-positive width, riser height or going, fewer than 2 risers (4 for an L + or a U - two per flight), a non-integer riser count, an unknown kind, turn + or language. + """ + from engineering.arch.draw import add_stair + + spec = { + "id": stair_id, + "start": [start_x, start_y], + "direction_deg": direction_deg, + "width": width, + "risers": risers, + "riser_height": riser_height, + "going": going, + "kind": kind, + "turn": turn, + } + await ctx.info(f"arch_stair: {kind} stair {stair_id}") + try: + return await add_stair(_backend(ctx), spec, lang=lang, scale=scale) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Dimension the plan's facades: openings, walls and overall chains outside each side.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Dimension Chains", "destructiveHint": False}, + tags={"arch", "dimension"}, +) +async def arch_dimension_chains( + sides: Annotated[ + list[str] | None, + Field( + default=None, + description="Sides to dimension: bottom | top | left | right (default bottom, left)", + ), + ] = None, + scale: Annotated[ + float, + Field( + default=50.0, + description="Plot-scale denominator: rows sit 10 mm and 8 mm apart on paper", + ), + ] = 50.0, + first_offset: Annotated[ + float | None, + Field( + default=None, description="Distance of the first row from the building (drawing units)" + ), + ] = None, + step: Annotated[ + float | None, Field(default=None, description="Distance between rows (drawing units)") + ] = None, + ctx: Context = None, +) -> dict: + """The three exterior chains an architectural plan carries, as real DIMENSIONs. + + Nearest first: the openings row (every jamb in that facade), the walls row + (both faces of every wall meeting it - wall thickness, clear width, wall + thickness, ...) and the overall row. The points come from the wall engine's + own faces, read back from the plan on the drawing. A row that would only + restate a farther one is not drawn (ISO 129-1: each measurement once). + The row spacing defaults (10 mm, then 8 mm on paper) are this server's + drafting choices, not standard values. Text height follows the current + dimension style: at 1:50 set DIMSCALE=50 with `dimstyle_modify`. + + Refused by name: a drawing with no walls, an unknown or repeated side, a + non-positive scale, offset or step. + """ + from engineering.arch.draw import draw_chains, read_plan + + backend = _backend(ctx) + try: + plan = await read_plan(backend) + if not plan["walls"]: + raise ValueError( + "this drawing carries no architectural walls; draw them with arch_wall" + ) + await ctx.info(f"arch_dimension_chains: {len(plan['walls'])} wall(s)") + return await draw_chains( + backend, + plan["walls"], + plan["openings"], + sides=tuple(sides or ("bottom", "left")), + scale=scale, + first_offset=first_offset, + step=step, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +# ── arch: rooms (group R) ── + +# ── arch: catalogue (group C) ── +``` + +- [ ] **Step 13: Add the alias records** + +In `discovery/aliases.py`, inside `TOOL_ALIASES`, after the `std_feature_draw` record (the last entry, ending `"punta deligi",` / `),` / `),`) and before the closing `}`: + +```python + # ── Architecture (SECTION 25, track F group W) ────────────────────────── + "arch_wall": ToolAliases( + acad=("WALLADD", "MLINE"), + synonyms=( + "draw a wall", + "wall with a thickness", + "exterior wall", + "partition wall", + "floor plan walls", + "clean wall corners", + "t junction of walls", + "wall poche", + "duvar", + "duvar çiz", + "duvar ciz", + "bölme duvar", + "bolme duvar", + "kat planı", + "kat plani", + ), + ), + "arch_opening": ToolAliases( + acad=("DOORADD", "WINDOWADD"), + synonyms=( + "add a door", + "door in a wall", + "door swing", + "add a window", + "window in a wall", + "cut an opening in the wall", + "kapı", + "kapi", + "kapı ekle", + "pencere", + "pencere ekle", + "doğrama", + "dograma", + ), + ), + "arch_stair": ToolAliases( + acad=("STAIRADD",), + synonyms=( + "draw a stair", + "staircase", + "flight of stairs", + "l-shaped stair", + "u-shaped stair", + "walking line", + "blondel rule", + "risers and treads", + "merdiven", + "merdiven çiz", + "merdiven ciz", + "basamak", + "rıht", + "riht", + ), + ), + "arch_dimension_chains": ToolAliases( + acad=(), + synonyms=( + "dimension the facade", + "exterior dimensions", + "dimension chains", + "overall building dimensions", + "opening dimensions on the plan", + "ölçülendir", + "olculendir plan", + "dış ölçü", + "dis olcu", + "aks ölçüsü", + "aks olcusu", + ), + ), +``` + +`WALLADD`, `DOORADD`, `WINDOWADD` and `STAIRADD` are AutoCAD Architecture's own command names and `MLINE` is plain AutoCAD's multiline, the command a drafter without AutoCAD Architecture draws walls with; none of the five is claimed by another record, so `SHARED_ACAD_COMMANDS` does not change. `arch_dimension_chains` carries no command: `QDIM` / `DIMCONTINUE` / `DIMBASELINE` already route to `dimension_auto`, and a wrong alias misroutes a search. + +- [ ] **Step 14: Run the tool test to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_tools.py tests/test_discovery_aliases.py -q` +Expected: PASS — the 3 tool tests and every alias gate pass. + +- [ ] **Step 15: Move the registration snapshots** + +In `tests/test_tool_registry.py`, `test_tool_group_sizes_are_unchanged`: + +- the docstring's first line becomes `"""Frozen snapshot of the surface (233 tools, 26 groups).`; +- directly before the docstring's closing `"""` (after the line ending `every one of them its pre-SECTION-24 value.`), add the paragraph: + +```python + + `architecture` appeared with v1.6's track F: SECTION 25 opened with the + plan model's wall engine (`arch_wall` / `arch_opening` / `arch_stair` / + `arch_dimension_chains`); the `arch` tag is ranked directly after `mech` in + `_GROUP_TAG_PRIORITY`, so those tools file under `architecture` rather + than under their secondary `create` / `dimension` tags. +``` + +- the snapshot dict gains `"architecture": 4,` directly after `"analysis": 12,`; +- `assert sum(sizes.values()) == 229` becomes `assert sum(sizes.values()) == 233`. + +In `tests/test_tool_packs.py`, `test_the_three_track_e_sections_file_under_their_own_groups`: `assert len(groups) == 25` becomes + +```python + assert len(groups) == 26 # + architecture (SECTION 25, track F) +``` + +- [ ] **Step 16: Regenerate the inventory and move the README count** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` +Expected: `wrote docs/tool-inventory.json: 233 tools, 8 resources, 5 prompts, 26 groups`. + +Then in `README.md`: + +- `> **v1.5 release snapshot:** 229 tools · 8 resources` → `> **v1.5 release snapshot:** 233 tools · 8 resources` (leave the collected-tests figure: it is the known red, and Task 9 retakes it on main); +- `> 229 is the **registered** count; a default install advertises 224 over `tools/list`,` → `> 233 is the **registered** count; a default install advertises 228 over `tools/list`,`; +- `229 tools in 25 groups;` → `233 tools in 26 groups;`. + +The measured figures elsewhere in the README (the 72,163-token catalog cost, the 224-tool discovery ranking row, the alias-corpus totals) are measurements, not counts; Task 10 retakes them after the merge. + +- [ ] **Step 17: Re-export the chains from the package surface** + +In `engineering/arch/__init__.py` keep every name and add, alphabetical, with the names in `__all__`: + +```python +from engineering.arch.dimension import ( + FIRST_OFFSET_PAPER_MM, + ROWS, + SIDES, + STEP_PAPER_MM, + exterior_chains, +) +``` + +`engineering/arch/draw.py` is deliberately not re-exported: it is the async layer, imported by its full path like `engineering/pid/spec.py`, and keeping it out of the package import keeps `engineering/layers.py` → `engineering.arch.layers` free of a path back into the backend layer. + +- [ ] **Step 18: Gates** + +``` +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen ruff check engineering/arch/dimension.py engineering/arch/draw.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_dimension.py tests/test_arch_draw.py tests/test_arch_tools.py tests/test_tool_registry.py tests/test_tool_packs.py +uv run --frozen ruff format engineering/arch/dimension.py engineering/arch/draw.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_dimension.py tests/test_arch_draw.py tests/test_arch_tools.py tests/test_tool_registry.py tests/test_tool_packs.py +uv run --frozen pytest tests/test_arch_walls.py tests/test_arch_openings.py tests/test_arch_stairs.py tests/test_arch_dimension.py tests/test_arch_draw.py tests/test_arch_tools.py tests/test_tool_registry.py tests/test_tool_packs.py tests/test_tool_inventory.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +Expected: `ok: docs/tool-inventory.json matches the live surface`; ruff clean; every listed test passes — `test_section_header_counts_match_decorators` sees SECTION 25 declare 4 and hold 4, `test_readme_snapshot_counts_match_registrations` sees 233/8/5 on both sides; the full suite green apart from the deselected known red (record it). + +- [ ] **Step 19: Commit** + +``` +git add engineering/arch/dimension.py engineering/arch/draw.py engineering/arch/__init__.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_arch_dimension.py tests/test_arch_draw.py tests/test_arch_tools.py tests/test_tool_registry.py tests/test_tool_packs.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(arch): SECTION 25 opens - walls, openings, stairs and exterior chains as tools + +engineering/arch/draw.py is the only module of the track that touches a +backend. It draws through draw_prims(role_layer=ARCH_ROLE_LAYER) and the +generic contract members, so it runs on both engines as written, and it writes +the plan onto the drawing: each element's ACADMCP_ARCH record on one of its own +entities (a wall's first face line, a door's leaf, a window's frame, a stair's +first riser) and, beside it under ACADMCP_ARCH_H, the handles that element +owns. read_plan reads the records back and never modifies the drawing. + +arch_wall computes the whole network before and after the new walls and +redraws only the drawn walls whose outline changes - erasing exactly the +entities they own - and says which under redrawn. arch_opening hosts a door or +a window in a drawn wall found by id and redraws that wall cut; its tag +defaults to the next D1/W1 (K1/P1 in Turkish). A new wall that would leave a +drawn opening uncuttable, and an opening that runs into a junction, are refused +before anything is erased. arch_stair reports the Blondel value; it does not +enforce it. + +exterior_chains gives each side up to three rows, nearest first: every jamb in +that facade, both faces of every wall meeting it, and the overall - from the +wall engine's own faces. A row that would only restate a farther one is not +emitted (ISO 129-1: each measurement once). The 10 mm / 8 mm row spacing is a +declared drafting default times the plot scale, not a standard value. + +The arch tag is ranked after mech so the four tools file under a new +architecture group; alias records carry WALLADD/MLINE, DOORADD/WINDOWADD and +STAIRADD with English and Turkish shop words. Inventory regenerated; README +snapshot 233 tools (228 advertised) in 26 groups; registry snapshot +architecture: 4. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 5: The planar-face finder, measured room labels and the foreign-plan reader + +**Files:** +- Create: `engineering/arch/faces.py` +- Create: `engineering/arch/rooms.py` +- Modify: `engineering/arch/__init__.py` (re-export the faces and rooms names; additive) +- Modify: `server.py` (the SECTION 25 banner `(2 tools)`, the `# ── arch: rooms (group R) ──` marker and the `arch_room` / `arch_rooms_detect` registrations, inserted directly above the RESOURCES banner) +- Modify: `discovery/aliases.py` (two alias records under a new `# ── Architecture: rooms (SECTION 25, track F group R) ──` block) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release-snapshot tool count 229 → 231, advertised 224 → 226; the caption `229 tools in 25 groups` → `231 tools in 25 groups`) +- Modify: `tests/test_tool_registry.py` (frozen group-size snapshot: `entity_creation` 18 → 19, `entity_query` 9 → 10, total 229 → 231) +- Test: `tests/test_arch_faces.py` +- Test: `tests/test_arch_rooms.py` +- Test: `tests/test_arch_rooms_tools.py` + +**Interfaces:** +- Consumes (Task 1, on `main`, exactly as the skeleton pins them): + - `engineering.arch.model.{APP_ID, Wall, Opening, Stair, Room, room_from_dict, segment_of, to_payload, from_payload, to_values, decode}` — `room_from_dict(d: dict, path: str = "room") -> Room`, `segment_of(wall: Wall, offset: float) -> tuple[int, float]`, `to_payload(obj) -> dict`, `from_payload(payload: dict)`, `to_values(payload) -> list[str]`, `decode(rows) -> dict`. + - `engineering.arch.layers.ARCH_ROLE_LAYER: dict[str, str]` (`"wall"` → `A-WALL-E-N`, `"room"` → `A-ROOM-T-N`). + - `engineering.arch.lang.{LANGS, fmt_area_m2}` — `fmt_area_m2(area_mm2: float, lang: str) -> str` (`"20.00 m²"` / `"20,00 m²"`). + - `engineering.mech.draw.draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer_prefix="", role_layer: Mapping[str, str] | None = None) -> dict` — `role_layer=ARCH_ROLE_LAYER` puts a `Text(..., role="room")` on `A-ROOM-T-N` and ensures the layer before the first entity. + - Repository, unchanged: `engineering.measure.polygon_area_perimeter(vertices, closed) -> (area, perimeter)`; `engineering.mech.primitives.{Pt, Text}`; `engineering.sheet.bom.all_entities(backend, *, type_filter=None, layer=None)` (the paged `entity_list` reader); backend `entity_list`, `entity_get_xdata`, `entity_set_xdata`, `layer_list`. +- Produces (`engineering/arch/faces.py`): + - `DEFAULT_TOL = 1.0` + - `@dataclass(frozen=True) class Face: loop: tuple[Pt, ...]; area: float; centroid: Pt; holes: tuple[tuple[Pt, ...], ...] = ()` — the skeleton's three fields in their pinned order plus one defaulted field (additive: `Face(loop, area, centroid)` still constructs). + - `planar_faces(segments, *, tol=1.0) -> tuple[Face, ...]` — bounded faces only, CCW loops starting at the lowest-then-leftmost vertex, area in mm² net of nested pieces. + - `face_containing(faces, point) -> Face | None` — the smallest face whose loop holds the point and none of whose holes do. + - `tagged_faces(segments, *, tol=1.0) -> tuple[tuple[Face, tuple[frozenset, ...]], ...]` — the same faces plus, per face, the tag set of each edge (a segment may be `(p1, p2)` or `(p1, p2, tag)`). +- Produces (`engineering/arch/rooms.py`): + - `room_label_prims(room: Room, *, lang="en", scale=50) -> tuple[Prim, ...]` (every prim is a `Text` with role `"room"`) + - `detect_rooms(segments, *, min_area=1.0e6, tol=1.0, min_width=600.0) -> tuple[dict, ...]` — dicts with the pinned keys `loop`, `area`, `centroid`, `confidence` plus `holes`; `min_width` is additive and keyword-only. + - `async label_room(backend, room_dict, *, lang="en", scale=50) -> dict` + - `async rooms_detect(backend, *, layers=None, min_area=1.0e6, tol=1.0) -> dict` + - `opening_closures(walls, openings) -> tuple[tuple[tuple[Pt, Pt], ...], tuple[dict, ...]]` + - `async read_records(backend) -> dict` — `{"walls": [Wall], "openings": [Opening], "stairs": [Stair], "rooms": [Room], "room_handles": {id: handle}, "unreadable": int}` — the four model lists of Task 4's `read_plan` (which group W builds on its own branch; this branch cannot import `engineering/arch/draw.py`), with `room_handles` and `unreadable` where `read_plan` has `carriers`; Task 6 consumes it, and after the merge both readers stay (Task 9 and Task 10 read through `read_plan`, the schedules through `read_records`). + - `async read_segments(backend, layers) -> tuple[list, list[dict]]`, `mean_width(face: Face) -> float` + - Constants `NAME_HEIGHT = 3.5`, `TEXT_HEIGHT = 2.5`, `LINE_PITCH = 1.6`, `MIN_ROOM_WIDTH = 600.0`, `DEFAULT_MIN_AREA = 1.0e6`, `CONFIDENCE_OURS = 1.0`, `CONFIDENCE_FOREIGN = 0.6`, `WALL_LAYER_KEYWORDS = ("WALL", "DUVAR")`. +- Produces (`server.py`): tools `arch_room(name, x, y, number=None, room_id=None, lang="en", scale=50.0)` and `arch_rooms_detect(layers=None, min_area=1.0e6, tol=1.0)`. + +Why `rooms.py` imports `draw_prims` and `all_entities` inside the functions: Task 1 makes `engineering/mech/draw.py` read `ARCH_LAYERS` (it creates an arch layer with its row's colour, linetype and lineweight), so a module-level `from engineering.mech.draw import draw_prims` in a module that `engineering/arch/__init__.py` imports would close an import cycle. The server tools import lazily for the same reason, as every SECTION 21-24 tool already does. + +- [ ] **Step 1: Write the failing test for the face finder** + +Create `tests/test_arch_faces.py`: + +```python +"""The planar-face finder: segments in, closed faces out, areas measured. + +Every expected coordinate and area below is computed by hand in the comment +beside it; nothing is read back from the implementation. +""" + +from __future__ import annotations + +import re + +import pytest + +from engineering.arch.faces import Face, face_containing, planar_faces, tagged_faces + +EPS = 1e-9 + + +def rect(x0, y0, x1, y1): + """The four sides of an axis-aligned rectangle as segments.""" + return [ + ((x0, y0), (x1, y0)), + ((x1, y0), (x1, y1)), + ((x1, y1), (x0, y1)), + ((x0, y1), (x0, y0)), + ] + + +def two_rooms(): + """Two 4000 x 5000 rooms between 200 mm walls, as clean outlines. + + Outer outline (0,0)-(8600,5400). Inner face of the exterior walls + (200,200)-(8400,5200), with the shared wall's two faces x=4200 and x=4400 + stopping on it at T-junctions - exactly what a clean wall engine draws. + Room A is (200,200)-(4200,5200), room B (4400,200)-(8400,5200). + """ + return [ + *rect(0.0, 0.0, 8600.0, 5400.0), + *rect(200.0, 200.0, 8400.0, 5200.0), + ((4200.0, 200.0), (4200.0, 5200.0)), + ((4400.0, 200.0), (4400.0, 5200.0)), + ] + + +def test_one_rectangle_is_one_ccw_face_starting_low_left(): + (face,) = planar_faces(rect(0.0, 0.0, 4000.0, 3000.0)) + assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0)) + assert abs(face.area - 12_000_000.0) < EPS # 4000 x 3000 + assert face.centroid == pytest.approx((2000.0, 1500.0), abs=EPS) + assert face.holes == () + + +def test_the_same_rectangle_drawn_clockwise_is_still_a_ccw_face(): + clockwise = [ + ((0.0, 0.0), (0.0, 3000.0)), + ((0.0, 3000.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (4000.0, 0.0)), + ((4000.0, 0.0), (0.0, 0.0)), + ] + (face,) = planar_faces(clockwise) + assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0)) + + +def test_two_adjacent_rooms_are_two_room_faces_plus_the_wall_bodies(): + faces = planar_faces(two_rooms()) + # room A, room B, the shared wall body, and the exterior ring + assert len(faces) == 4 + room_a = face_containing(faces, (2200.0, 2700.0)) + room_b = face_containing(faces, (6400.0, 2700.0)) + assert abs(room_a.area - 20_000_000.0) < EPS # 4000 x 5000 + assert abs(room_b.area - 20_000_000.0) < EPS + assert room_a.loop == ((200.0, 200.0), (4200.0, 200.0), (4200.0, 5200.0), (200.0, 5200.0)) + assert room_b.loop == ((4400.0, 200.0), (8400.0, 200.0), (8400.0, 5200.0), (4400.0, 5200.0)) + assert room_a.centroid == pytest.approx((2200.0, 2700.0), abs=EPS) + shared = face_containing(faces, (4300.0, 2700.0)) + assert abs(shared.area - 1_000_000.0) < EPS # 200 x 5000 + ring = face_containing(faces, (100.0, 2700.0)) + # 8600 x 5400 = 46 440 000 minus the inner outline 8200 x 5000 = 41 000 000 + assert abs(ring.area - 5_440_000.0) < EPS + # the hole is the inner outline, clockwise, split where the shared wall meets it + assert ring.holes == ( + ( + (200.0, 200.0), + (200.0, 5200.0), + (4200.0, 5200.0), + (4400.0, 5200.0), + (8400.0, 5200.0), + (8400.0, 200.0), + (4400.0, 200.0), + (4200.0, 200.0), + ), + ) + # the ring's net region is symmetric about the outline's centre + assert ring.centroid == pytest.approx((4300.0, 2700.0), abs=1e-6) + + +def test_a_4000_by_5000_room_between_200_mm_walls_measures_20_m2_not_the_axis_area(): + # walls on axes (-100,-100)-(4100,5100); the axis rectangle is 4200 x 5200 + faces = planar_faces([*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)]) + room = face_containing(faces, (2000.0, 2500.0)) + assert abs(room.area - 20_000_000.0) < EPS # 20.00 m2, not 21.84 m2 + wall = face_containing(faces, (-100.0, 2500.0)) + # 4400 x 5400 = 23 760 000 minus 20 000 000 + assert abs(wall.area - 3_760_000.0) < EPS + + +def test_a_t_junction_splits_the_line_it_touches(): + # a partition from (2000,0) to (2000,3000) stops on both long sides + faces = planar_faces([*rect(0.0, 0.0, 4000.0, 3000.0), ((2000.0, 0.0), (2000.0, 3000.0))]) + assert len(faces) == 2 + left, right = faces + assert left.loop == ((0.0, 0.0), (2000.0, 0.0), (2000.0, 3000.0), (0.0, 3000.0)) + assert right.loop == ((2000.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (2000.0, 3000.0)) + assert abs(left.area - 6_000_000.0) < EPS # 2000 x 3000 + assert abs(right.area - 6_000_000.0) < EPS + + +def test_a_t_touch_within_tol_still_splits(): + # the partition stops 0.5 mm short of the bottom side: within tol=1.0 + faces = planar_faces( + [*rect(0.0, 0.0, 4000.0, 3000.0), ((2000.0, 0.5), (2000.0, 3000.0))], tol=1.0 + ) + assert len(faces) == 2 + # the split vertex is the partition's own end (2000, 0.5), which bends the + # bottom side by 0.5 mm; each half is 2000 x 3000 minus a 0.5 mm-high sliver + # triangle of base 2000: 6 000 000 - 0.5 * 2000 * 0.5 = 5 999 500 + assert [round(f.area, 6) for f in faces] == [5_999_500.0, 5_999_500.0] + + +def test_crossing_lines_split_each_other_and_their_overshoots_are_pruned(): + segments = [ + *rect(0.0, 0.0, 4000.0, 3000.0), + ((-500.0, 1500.0), (4500.0, 1500.0)), # overshoots 500 at both ends + ((2000.0, -500.0), (2000.0, 3500.0)), + ] + faces = planar_faces(segments) + assert len(faces) == 4 + assert all(abs(f.area - 3_000_000.0) < EPS for f in faces) # 2000 x 1500 each + assert faces[0].loop == ((0.0, 0.0), (2000.0, 0.0), (2000.0, 1500.0), (0.0, 1500.0)) + + +def test_a_dangling_segment_is_ignored(): + segments = [ + *rect(0.0, 0.0, 4000.0, 3000.0), + ((4000.0, 3000.0), (5000.0, 4000.0)), # a spur off a corner + ((1000.0, 1000.0), (1500.0, 1200.0)), # a stray line inside the room + ] + (face,) = planar_faces(segments) + assert abs(face.area - 12_000_000.0) < EPS + + +def test_endpoints_within_tol_merge_and_a_wider_gap_leaves_the_loop_open(): + closing = ((0.0, 3000.4), (0.0, 0.0)) # starts 0.4 mm off the corner (0, 3000) + sides = [ + ((0.0, 0.0), (4000.0, 0.0)), + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ] + (face,) = planar_faces([*sides, closing], tol=1.0) + # (0, 3000) was seen first, so it is the vertex: the area is exact + assert abs(face.area - 12_000_000.0) < EPS + open_gap = ((0.0, 2995.0), (0.0, 0.0)) # stops 5 mm short of (0, 3000) + assert planar_faces([*sides, open_gap], tol=1.0) == () + + +def test_an_overshoot_through_a_corner_still_closes_the_loop(): + sides = [ + ((0.0, 0.0), (4000.0, 0.0)), + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ] + # the closing side runs 5 mm past the corner (0, 3000): the corner splits it + # and the 5 mm stub is a dangling edge + (face,) = planar_faces([*sides, ((0.0, 3005.0), (0.0, 0.0))]) + assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0)) + assert abs(face.area - 12_000_000.0) < EPS + + +def test_collinear_overlaps_become_one_edge(): + segments = [ + ((0.0, 0.0), (3000.0, 0.0)), + ((1000.0, 0.0), (4000.0, 0.0)), # overlaps the first from 1000 to 3000 + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ((0.0, 3000.0), (0.0, 0.0)), + ] + (face,) = planar_faces(segments) + assert face.loop == ( + (0.0, 0.0), + (1000.0, 0.0), + (3000.0, 0.0), + (4000.0, 0.0), + (4000.0, 3000.0), + (0.0, 3000.0), + ) + assert abs(face.area - 12_000_000.0) < EPS + + +def test_a_column_inside_a_room_is_a_hole_in_the_room(): + segments = [*rect(0.0, 0.0, 4000.0, 3000.0), *rect(1000.0, 1000.0, 1400.0, 1400.0)] + faces = planar_faces(segments) + room = face_containing(faces, (3000.0, 2000.0)) + column = face_containing(faces, (1200.0, 1200.0)) + assert abs(room.area - 11_840_000.0) < EPS # 12 000 000 - 400 x 400 + assert abs(column.area - 160_000.0) < EPS + assert len(room.holes) == 1 + # net centroid: (12e6 * (2000, 1500) - 160 000 * (1200, 1200)) / 11 840 000 + assert room.centroid == pytest.approx( + ( + (12e6 * 2000.0 - 160_000.0 * 1200.0) / 11_840_000.0, + (12e6 * 1500.0 - 160_000.0 * 1200.0) / 11_840_000.0, + ), + abs=1e-6, + ) + + +def test_a_point_in_no_face_answers_none(): + faces = planar_faces(rect(0.0, 0.0, 4000.0, 3000.0)) + assert face_containing(faces, (5000.0, 5000.0)) is None + + +def test_edge_tags_ride_along_to_every_face_edge(): + tagged = [(p1, p2, "WALL") for p1, p2 in rect(0.0, 0.0, 4000.0, 3000.0)] + tagged[0] = (tagged[0][0], tagged[0][1], "0") + ((face, tags),) = tagged_faces(tagged) + assert tags == (frozenset({"0"}), frozenset({"WALL"}), frozenset({"WALL"}), frozenset({"WALL"})) + assert isinstance(face, Face) + + +@pytest.mark.parametrize( + ("segments", "tol", "message"), + [ + ([((0.0, 0.0),)], 1.0, "segments[0]: expected (p1, p2) or (p1, p2, tag), got 1 items"), + ([((0.0, "x"), (1.0, 1.0))], 1.0, "segments[0][0][1]: expected a number, got 'x'"), + ([((0.0, float("nan")), (1.0, 1.0))], 1.0, "segments[0][0][1]: must be finite"), + (rect(0.0, 0.0, 1.0, 1.0), 0.0, "tol: must be greater than zero, got 0"), + ], +) +def test_bad_input_is_refused_by_path(segments, tol, message): + with pytest.raises(ValueError, match=re.escape(message)): + planar_faces(segments, tol=tol) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_faces.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.faces'`. + +- [ ] **Step 3: Implement the planar-face finder** + +Create `engineering/arch/faces.py`: + +```python +"""The planar-face finder: straight segments in, the closed faces they bound out. + +A room is the floor the walls enclose, so its area is a *face* of the planar +graph the wall outlines draw - never the axis rectangle and never a number the +caller types. This module finds those faces from plain segments, which is what +lets `arch_room` measure a room and `arch_rooms_detect` read a foreign plan made +of LINEs on both engines: the live engine has no `boundary_trace`, and it does +not need one. + +The algorithm, in the order it runs: + +1. **Merge.** Endpoints closer than ``tol`` become one vertex (the first one + seen keeps its coordinates), so a corner drawn 0.4 mm short still closes. +2. **Split.** Every segment is split at every point where another one crosses + it and at every vertex that lies on it within ``tol`` - the T-touch, where a + partition wall stops on the face of the wall it meets. Collinear overlaps + fall out of the same rule: each overlapping end splits the other segment and + the coincident pieces become one edge. +3. **Prune.** An edge that ends in a vertex of degree one bounds nothing - a + dangling line, an overshoot past a crossing - and is removed, repeatedly, + until no such edge is left. +4. **Walk.** Each undirected edge is two half-edges. From the half-edge + ``u -> v`` the walk continues at ``v`` along the next edge *clockwise* from + ``v -> u``, which keeps the face on the left. A bounded face comes out + counter-clockwise (positive signed area); the outer boundary of each + connected piece comes out clockwise. +5. **Nest.** A clockwise boundary that lies inside a bounded face of *another* + piece is a hole in the smallest such face: the island a column makes in a + room, or the whole inner outline of a ring of walls inside its outer outline. + A face's ``area`` is its loop's area minus its holes'. + +Areas come from `engineering.measure.polygon_area_perimeter`, the same +arithmetic `analysis_measure_entity` reports; this module computes the *sign* of +the shoelace itself only to tell a face from a boundary, because the shared +function returns the magnitude by design. + +Pairs of segments are tested against each other, which is quadratic: a floor +plan of a few thousand wall segments is well inside it, a city block is not. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +from engineering.measure import polygon_area_perimeter + +Pt = tuple[float, float] + +__all__ = ["DEFAULT_TOL", "Face", "face_containing", "planar_faces", "tagged_faces"] + +#: Endpoints closer than this (drawing units, mm) are one vertex. +DEFAULT_TOL = 1.0 + +_EPS = 1e-12 + + +@dataclass(frozen=True) +class Face: + """One bounded face. + + ``loop`` is counter-clockwise and starts at its lowest, then leftmost, + vertex. ``holes`` are the clockwise outer boundaries of the pieces nested + inside it. ``area`` (mm^2) is the loop's area minus the holes'; ``centroid`` + is the centroid of that net region. + """ + + loop: tuple[Pt, ...] + area: float + centroid: Pt + holes: tuple[tuple[Pt, ...], ...] = () + + +class _Vertices: + """Snap points to vertices: a point within ``tol`` of a vertex *is* that vertex.""" + + def __init__(self, tol: float) -> None: + self.tol = tol + self.points: list[Pt] = [] + self._grid: dict[tuple[int, int], list[int]] = {} + + def _cell(self, p: Pt) -> tuple[int, int]: + return (math.floor(p[0] / self.tol), math.floor(p[1] / self.tol)) + + def index(self, p: Pt) -> int: + cx, cy = self._cell(p) + best: int | None = None + best_d = self.tol + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for i in self._grid.get((cx + dx, cy + dy), ()): + d = math.dist(self.points[i], p) + if d <= best_d: + best, best_d = i, d + if best is not None: + return best + i = len(self.points) + self.points.append((float(p[0]), float(p[1]))) + self._grid.setdefault((cx, cy), []).append(i) + return i + + +def _number(value, where: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}") + return number + + +def _point(value, where: str) -> Pt: + try: + x, y = value[0], value[1] + except (TypeError, IndexError, KeyError): + raise ValueError(f"{where}: expected [x, y], got {value!r}") from None + return (_number(x, f"{where}[0]"), _number(y, f"{where}[1]")) + + +def _segments(segments: Iterable) -> list[tuple[Pt, Pt, object]]: + """``(p1, p2)`` or ``(p1, p2, tag)``; the tag rides along to the edges.""" + out: list[tuple[Pt, Pt, object]] = [] + for i, seg in enumerate(segments): + try: + count = len(seg) + except TypeError: + raise ValueError(f"segments[{i}]: expected (p1, p2) or (p1, p2, tag)") from None + if count not in (2, 3): + raise ValueError( + f"segments[{i}]: expected (p1, p2) or (p1, p2, tag), got {count} items" + ) + tag = seg[2] if count == 3 else None + out.append((_point(seg[0], f"segments[{i}][0]"), _point(seg[1], f"segments[{i}][1]"), tag)) + return out + + +def _cross(ax: float, ay: float, bx: float, by: float) -> float: + return ax * by - ay * bx + + +def _crossing(p1: Pt, p2: Pt, q1: Pt, q2: Pt) -> Pt | None: + """Where two segments meet, ends included; None for parallel or apart.""" + rx, ry = p2[0] - p1[0], p2[1] - p1[1] + sx, sy = q2[0] - q1[0], q2[1] - q1[1] + denom = _cross(rx, ry, sx, sy) + if abs(denom) <= _EPS * math.hypot(rx, ry) * math.hypot(sx, sy): + return None + qx, qy = q1[0] - p1[0], q1[1] - p1[1] + t = _cross(qx, qy, sx, sy) / denom + u = _cross(qx, qy, rx, ry) / denom + if -_EPS <= t <= 1.0 + _EPS and -_EPS <= u <= 1.0 + _EPS: + return (p1[0] + t * rx, p1[1] + t * ry) + return None + + +def _along(p: Pt, a: Pt, b: Pt) -> tuple[float, float]: + """(distance along a->b of p's foot, distance of p from the line).""" + dx, dy = b[0] - a[0], b[1] - a[1] + length = math.hypot(dx, dy) + ux, uy = dx / length, dy / length + px, py = p[0] - a[0], p[1] - a[1] + return (px * ux + py * uy, abs(_cross(ux, uy, px, py))) + + +def _signed_area(points: Sequence[Pt]) -> float: + total = 0.0 + for i in range(len(points)): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % len(points)] + total += x1 * y2 - x2 * y1 + return total / 2.0 + + +def _area(points: Sequence[Pt]) -> float: + return polygon_area_perimeter([(x, y, 0.0) for x, y in points], True)[0] + + +def _centroid(points: Sequence[Pt]) -> tuple[Pt, float]: + """(centroid, signed area) of a simple loop; a zero-area loop answers its vertex mean.""" + signed = _signed_area(points) + if abs(signed) < _EPS: + n = len(points) + return (sum(p[0] for p in points) / n, sum(p[1] for p in points) / n), 0.0 + cx = cy = 0.0 + for i in range(len(points)): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % len(points)] + w = x1 * y2 - x2 * y1 + cx += (x1 + x2) * w + cy += (y1 + y2) * w + return (cx / (6.0 * signed), cy / (6.0 * signed)), signed + + +def _inside(point: Pt, loop: Sequence[Pt]) -> bool: + """Even-odd ray cast; a point exactly on an edge may answer either way.""" + x, y = point + inside = False + n = len(loop) + for i in range(n): + x1, y1 = loop[i] + x2, y2 = loop[(i + 1) % n] + if (y1 > y) != (y2 > y): + xs = x1 + (y - y1) * (x2 - x1) / (y2 - y1) + if xs > x: + inside = not inside + return inside + + +def _start_low_left(loop: list[int], points: list[Pt]) -> list[int]: + k = min(range(len(loop)), key=lambda i: (points[loop[i]][1], points[loop[i]][0])) + return loop[k:] + loop[:k] + + +def tagged_faces( + segments, *, tol: float = DEFAULT_TOL +) -> tuple[tuple[Face, tuple[frozenset, ...]], ...]: + """`planar_faces`, plus, per face, the tag set of every edge it is bounded by. + + An edge carries the tags of every input segment that covers it, so a reader + can ask whether a face is enclosed *only* by segments of a kind - which is + what `detect_rooms` turns into a confidence. + """ + tol = _number(tol, "tol") + if tol <= 0.0: + raise ValueError(f"tol: must be greater than zero, got {tol:g}") + raw = _segments(segments) + verts = _Vertices(tol) + + # 1. merge endpoints + snapped: list[tuple[int, int, object]] = [] + for p1, p2, tag in raw: + a, b = verts.index(p1), verts.index(p2) + if a != b: + snapped.append((a, b, tag)) + + # 2. split at crossings and at vertices lying on a segment + splits: list[set[int]] = [set() for _ in snapped] + boxes = [] + for a, b, _tag in snapped: + (ax, ay), (bx, by) = verts.points[a], verts.points[b] + boxes.append((min(ax, bx) - tol, min(ay, by) - tol, max(ax, bx) + tol, max(ay, by) + tol)) + for i in range(len(snapped)): + ai, bi, _ = snapped[i] + for j in range(i + 1, len(snapped)): + bx0, by0, bx1, by1 = boxes[j] + if boxes[i][0] > bx1 or bx0 > boxes[i][2] or boxes[i][1] > by1 or by0 > boxes[i][3]: + continue + aj, bj, _ = snapped[j] + hit = _crossing(verts.points[ai], verts.points[bi], verts.points[aj], verts.points[bj]) + if hit is not None: + v = verts.index(hit) + splits[i].add(v) + splits[j].add(v) + for i, (a, b, _tag) in enumerate(snapped): + pa, pb = verts.points[a], verts.points[b] + length = math.dist(pa, pb) + x0, y0, x1, y1 = boxes[i] + for v, p in enumerate(verts.points): + if v in (a, b) or not (x0 <= p[0] <= x1 and y0 <= p[1] <= y1): + continue + s, off = _along(p, pa, pb) + if off <= tol and tol < s < length - tol: + splits[i].add(v) + + edges: dict[tuple[int, int], set] = {} + for i, (a, b, tag) in enumerate(snapped): + pa, pb = verts.points[a], verts.points[b] + chain = ( + [a] + + sorted( + (v for v in splits[i] if v not in (a, b)), + key=lambda v: _along(verts.points[v], pa, pb)[0], + ) + + [b] + ) + for u, v in zip(chain, chain[1:], strict=False): + if u != v: + edges.setdefault((min(u, v), max(u, v)), set()).add(tag) + + # 3. prune dangling edges + adjacent: dict[int, set[int]] = {} + for u, v in edges: + adjacent.setdefault(u, set()).add(v) + adjacent.setdefault(v, set()).add(u) + loose = [v for v, ns in adjacent.items() if len(ns) == 1] + while loose: + v = loose.pop() + for w in list(adjacent.get(v, ())): + adjacent[w].discard(v) + adjacent[v].discard(w) + edges.pop((min(v, w), max(v, w)), None) + if len(adjacent[w]) == 1: + loose.append(w) + adjacent = {v: ns for v, ns in adjacent.items() if ns} + + # 4. walk: the next half-edge is the next one clockwise at the far vertex + pts = verts.points + order: dict[int, list[int]] = { + v: sorted(ns, key=lambda w, v=v: math.atan2(pts[w][1] - pts[v][1], pts[w][0] - pts[v][0])) + for v, ns in adjacent.items() + } + seen: set[tuple[int, int]] = set() + cycles: list[list[int]] = [] + for u, ns in adjacent.items(): + for v in ns: + if (u, v) in seen: + continue + cycle: list[int] = [] + a, b = u, v + while (a, b) not in seen: + seen.add((a, b)) + cycle.append(a) + around = order[b] + a, b = b, around[around.index(a) - 1] + cycles.append(cycle) + + component: dict[int, int] = {} + for start in adjacent: + if start in component: + continue + stack = [start] + component[start] = start + while stack: + v = stack.pop() + for w in adjacent[v]: + if w not in component: + component[w] = start + stack.append(w) + + bounded: list[list[int]] = [] + boundaries: list[list[int]] = [] + for cycle in cycles: + signed = _signed_area([pts[v] for v in cycle]) + if signed > _EPS: + bounded.append(_start_low_left(cycle, pts)) + elif signed < -_EPS: + boundaries.append(cycle) + + # 5. nest each piece's outer boundary in the smallest face of another piece + gross = [_area([pts[v] for v in loop]) for loop in bounded] + holes: list[list[list[int]]] = [[] for _ in bounded] + for boundary in boundaries: + probe = pts[boundary[0]] + home = None + for k, loop in enumerate(bounded): + if component[loop[0]] == component[boundary[0]]: + continue + if _inside(probe, [pts[v] for v in loop]) and (home is None or gross[k] < gross[home]): + home = k + if home is not None: + holes[home].append(_start_low_left(boundary, pts)) + + out: list[tuple[Face, tuple[frozenset, ...]]] = [] + for k, loop in enumerate(bounded): + outer = [pts[v] for v in loop] + (gx, gy), g_signed = _centroid(outer) + net = gross[k] + mx, my, weight = gx * g_signed, gy * g_signed, g_signed + hole_loops = [] + for hole in holes[k]: + ring = [pts[v] for v in hole] + (hx, hy), h_signed = _centroid(ring) # clockwise, so negative: it subtracts + net -= _area(ring) + mx, my, weight = mx + hx * h_signed, my + hy * h_signed, weight + h_signed + hole_loops.append(tuple(ring)) + centroid = (mx / weight, my / weight) if abs(weight) > _EPS else (gx, gy) + tags = [] + for ring in [loop, *holes[k]]: + for i in range(len(ring)): + u, v = ring[i], ring[(i + 1) % len(ring)] + tags.append(frozenset(edges[(min(u, v), max(u, v))])) + face = Face(loop=tuple(outer), area=net, centroid=centroid, holes=tuple(hole_loops)) + out.append((face, tuple(tags))) + out.sort(key=lambda item: (item[0].loop[0][1], item[0].loop[0][0], item[0].area)) + return tuple(out) + + +def planar_faces(segments, *, tol: float = DEFAULT_TOL) -> tuple[Face, ...]: + """Every bounded face the segments enclose, CCW, net of nested pieces, in mm^2. + + Refused by path, before anything is computed: a segment that is not + ``(p1, p2)`` or ``(p1, p2, tag)``, a coordinate that is not a finite + number, a ``tol`` that is not positive. + """ + return tuple(face for face, _tags in tagged_faces(segments, tol=tol)) + + +def face_containing(faces: Iterable[Face], point) -> Face | None: + """The smallest face whose loop holds ``point`` and none of whose holes do.""" + p = _point(point, "point") + found = [ + face + for face in faces + if _inside(p, face.loop) and not any(_inside(p, hole) for hole in face.holes) + ] + return min(found, key=lambda face: face.area) if found else None +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_faces.py -q` +Expected: `18 passed`. + +- [ ] **Step 5: Write the failing test for room labels and the reader** + +Create `tests/test_arch_rooms.py`: + +```python +"""Room labels with a measured area, and the foreign-plan reader. + +The pure half asserts emitted primitives and hand-computed coordinates; the +async half runs against the headless backend (the `backend` fixture in +tests/conftest.py). The wall and opening records a drawn plan carries are +written with `model.to_values` directly - the wall engine that writes them in +production is Task 4's, on another branch, and the record shape is pinned by +the skeleton. +""" + +from __future__ import annotations + +import re + +import pytest + +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import APP_ID, Opening, Room, Wall, decode, to_payload, to_values +from engineering.arch.rooms import ( + CONFIDENCE_FOREIGN, + CONFIDENCE_OURS, + detect_rooms, + label_room, + opening_closures, + room_label_prims, + rooms_detect, +) +from engineering.mech.primitives import Text + +WALL = ARCH_ROLE_LAYER["wall"] +ROOM = ARCH_ROLE_LAYER["room"] +EPS = 1e-9 + + +def rect(x0, y0, x1, y1): + return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] + + +def two_rooms(tag=None): + """Two 4000 x 5000 rooms between 200 mm walls (see tests/test_arch_faces.py).""" + segments = [ + *rect(0.0, 0.0, 8600.0, 5400.0), + *rect(200.0, 200.0, 8400.0, 5200.0), + ((4200.0, 200.0), (4200.0, 5200.0)), + ((4400.0, 200.0), (4400.0, 5200.0)), + ] + return segments if tag is None else [(p1, p2, tag) for p1, p2 in segments] + + +async def draw_lines(backend, segments, layer): + for (x1, y1), (x2, y2) in segments: + await backend.entity_create_line(x1, y1, x2, y2, layer=layer) + + +async def write_record(backend, obj, layer): + """A record the way a drawn plan carries it: XDATA on an entity of its layer.""" + carrier = await backend.entity_create_line(0.0, -1000.0, 1.0, -1000.0, layer=layer) + await backend.entity_set_xdata(carrier.handle, APP_ID, to_values(to_payload(obj))) + return carrier.handle + + +async def texts(backend, layer): + return { + e.properties.get("text"): e + for e in await backend.entity_list(type_filter="TEXT", layer_filter=layer, limit=1000) + } + + +# ── pure: the label ───────────────────────────────────────────────────────── + + +def test_label_prims_stack_name_number_area_down_from_the_point_at_plot_scale(): + room = Room(id="R1", name="LIVING", number="01", at=(2000.0, 2500.0), area=20_000_000.0) + prims = room_label_prims(room, lang="en", scale=50) + # 1:50 -> name 3.5 x 50 = 175, number and area 2.5 x 50 = 125; + # baselines 2500, 2500 - 1.6 x 125 = 2300, 2300 - 1.6 x 125 = 2100 + assert prims == ( + Text(at=(2000.0, 2500.0), text="LIVING", height=175.0, rotation=0.0, role="room"), + Text(at=(2000.0, 2300.0), text="01", height=125.0, rotation=0.0, role="room"), + Text(at=(2000.0, 2100.0), text="20.00 m²", height=125.0, rotation=0.0, role="room"), + ) + + +def test_label_without_a_number_puts_the_area_on_the_second_line_and_tr_uses_a_comma(): + room = Room(id="R2", name="SALON", number=None, at=(0.0, 0.0), area=24_500_000.0) + prims = room_label_prims(room, lang="tr", scale=100) + # 1:100 -> name 350, area 250 at 0 - 1.6 x 250 = -400 + assert [(p.at, p.text, p.height) for p in prims] == [ + ((0.0, 0.0), "SALON", 350.0), + ((0.0, -400.0), "24,50 m²", 250.0), + ] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"lang": "de"}, "lang: 'de' is not a label language; languages are en, tr"), + ({"scale": 0}, "scale: must be a finite number greater than zero, got 0"), + ], +) +def test_label_prims_refuse_an_unknown_language_or_scale(kwargs, message): + room = Room(id="R1", name="A", number=None, at=(0.0, 0.0), area=1.0) + with pytest.raises(ValueError, match=re.escape(message)): + room_label_prims(room, **kwargs) + + +# ── pure: openings close their gap ────────────────────────────────────────── + + +def test_an_opening_is_closed_along_both_wall_faces(): + # the shared wall's axis runs up x=4300 from y=100 to y=5300, 200 thick, centred; + # a 900 door 1100 from the axis start spans y=1200..2100. + wall = Wall(id="w5", axis=((4300.0, 100.0), (4300.0, 5300.0)), thickness=200.0) + door = Opening(id="d1", wall="w5", kind="door", offset=1100.0, width=900.0) + closures, omitted = opening_closures([wall], [door]) + # u = (0, 1), left normal n = (-1, 0); face offsets -100 and +100 + assert closures == ( + ((4400.0, 1200.0), (4400.0, 2100.0)), + ((4200.0, 1200.0), (4200.0, 2100.0)), + ) + assert omitted == () + + +def test_a_left_justified_wall_closes_its_faces_on_the_axis_and_one_thickness_left(): + wall = Wall(id="w1", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0, justification="left") + window = Opening(id="p1", wall="w1", kind="window", offset=1000.0, width=1200.0) + closures, _ = opening_closures([wall], [window]) + # u = (1, 0), n = (0, 1): faces at 0 and +200 + assert closures == (((1000.0, 0.0), (2200.0, 0.0)), ((1000.0, 200.0), (2200.0, 200.0))) + + +def test_an_opening_whose_wall_has_no_record_is_omitted_not_guessed(): + door = Opening(id="d9", wall="nowhere", kind="door", offset=0.0, width=900.0) + closures, omitted = opening_closures([], [door]) + assert closures == () + assert omitted == ( + { + "element": "d9", + "reason": "host wall 'nowhere' has no record on this drawing; its gap is left open", + }, + ) + + +# ── pure: detect_rooms ────────────────────────────────────────────────────── + + +def test_detect_rooms_keeps_the_rooms_and_drops_the_wall_bodies(): + rooms = detect_rooms(two_rooms(WALL)) + # the exterior ring (mean width 2 x 5 440 000 / 54 400 = 200) and the shared + # wall body (2 x 1 000 000 / 10 400 = 192) are wall bodies, not rooms + assert [r["area"] for r in rooms] == [20_000_000.0, 20_000_000.0] + assert [r["centroid"] for r in rooms] == [(2200.0, 2700.0), (6400.0, 2700.0)] + assert {r["confidence"] for r in rooms} == {CONFIDENCE_OURS} + + +def test_one_foreign_edge_makes_the_face_foreign(): + segments = two_rooms(WALL) + # the inner outline's bottom edge from (200,200) to (8400,200) becomes a plain line + segments[4] = (segments[4][0], segments[4][1], "0") + rooms = detect_rooms(segments) + assert [r["confidence"] for r in rooms] == [CONFIDENCE_FOREIGN, CONFIDENCE_FOREIGN] + + +def test_untagged_lines_are_foreign_and_min_area_filters(): + rooms = detect_rooms(two_rooms()) + assert [r["confidence"] for r in rooms] == [0.6, 0.6] + assert detect_rooms(two_rooms(), min_area=20_000_001.0) == () + + +# ── async: label_room ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_a_4000_by_5000_room_between_200_mm_walls_is_labelled_20_m2(backend): + await draw_lines( + backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL + ) + result = await label_room( + backend, {"name": "LIVING", "number": "01", "at": [2000.0, 2500.0]}, lang="en", scale=50 + ) + assert result["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS) + assert result["room"]["area_text"] == "20.00 m²" + assert result["room"]["id"] == "R1" + assert result["loop"] == [[0.0, 0.0], [4000.0, 0.0], [4000.0, 5000.0], [0.0, 5000.0]] + labels = await texts(backend, ROOM) + assert set(labels) == {"LIVING", "01", "20.00 m²"} + raw = await backend.entity_get_xdata(result["handle"], APP_ID) + payload = decode(raw["xdata"][APP_ID]) + assert payload["kind"] == "room" + assert payload["id"] == "R1" + assert payload["area"] == pytest.approx(20_000_000.0, abs=EPS) + + +@pytest.mark.asyncio +async def test_turkish_labels_use_a_decimal_comma(backend): + await draw_lines( + backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL + ) + await label_room(backend, {"name": "SALON", "at": [2000.0, 2500.0]}, lang="tr") + assert "20,00 m²" in await texts(backend, ROOM) + + +@pytest.mark.asyncio +async def test_a_door_in_the_shared_wall_does_not_merge_the_two_rooms(backend): + door_gap = [ + *rect(0.0, 0.0, 8600.0, 5400.0), + *rect(200.0, 200.0, 8400.0, 5200.0), + # the shared wall's faces, interrupted from y=1200 to y=2100, and its two jambs + ((4200.0, 200.0), (4200.0, 1200.0)), + ((4200.0, 2100.0), (4200.0, 5200.0)), + ((4400.0, 200.0), (4400.0, 1200.0)), + ((4400.0, 2100.0), (4400.0, 5200.0)), + ((4200.0, 1200.0), (4400.0, 1200.0)), + ((4200.0, 2100.0), (4400.0, 2100.0)), + ] + await draw_lines(backend, door_gap, WALL) + await write_record( + backend, Wall(id="w5", axis=((4300.0, 100.0), (4300.0, 5300.0)), thickness=200.0), WALL + ) + await write_record( + backend, + Opening(id="d1", wall="w5", kind="door", offset=1100.0, width=900.0), + ARCH_ROLE_LAYER["door"], + ) + result = await label_room(backend, {"name": "A", "at": [2200.0, 2700.0]}) + # without the closures room A would read 20 000 000 + 20 000 000 + 900 x 200 + assert result["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS) + assert result["closures"] == 2 + + +@pytest.mark.asyncio +async def test_label_room_refusals_leave_the_drawing_untouched(backend): + await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), WALL) + before = await backend.entity_count() + with pytest.raises(ValueError, match=re.escape("room.area: a room's area is measured")): + await label_room(backend, {"name": "A", "at": [100.0, 100.0], "area": 5.0}) + with pytest.raises(ValueError, match=re.escape("room.at: (9000, 9000) lies in no closed face")): + await label_room(backend, {"name": "A", "at": [9000.0, 9000.0]}) + with pytest.raises(ValueError, match=re.escape("lang: 'fr' is not a label language")): + await label_room(backend, {"name": "A", "at": [100.0, 100.0]}, lang="fr") + assert await backend.entity_count() == before + await label_room(backend, {"id": "R7", "name": "A", "at": [100.0, 100.0]}) + with pytest.raises(ValueError, match=re.escape("room.id: 'R7' is already labelled")): + await label_room(backend, {"id": "R7", "name": "B", "at": [200.0, 200.0]}) + + +@pytest.mark.asyncio +async def test_a_label_point_inside_a_wall_body_is_refused(backend): + await draw_lines( + backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL + ) + with pytest.raises( + ValueError, match=re.escape("room.at: (-100, 2500) lies inside a wall body") + ): + await label_room(backend, {"name": "A", "at": [-100.0, 2500.0]}) + + +# ── async: rooms_detect ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_a_foreign_plan_of_plain_lines_yields_the_same_areas_and_is_not_modified(backend): + await backend.entity_create_polyline( + [[0.0, 0.0], [8600.0, 0.0], [8600.0, 5400.0], [0.0, 5400.0]], True, layer="DUVAR" + ) + await draw_lines(backend, two_rooms()[4:], "DUVAR") + await draw_lines(backend, [((-500.0, -500.0), (9000.0, -500.0))], "0") # not read + before = await backend.entity_count() + result = await rooms_detect(backend) + assert result["layers"] == ["DUVAR"] + assert [r["area"] for r in result["rooms"]] == [20_000_000.0, 20_000_000.0] + assert [r["centroid"] for r in result["rooms"]] == [[2200.0, 2700.0], [6400.0, 2700.0]] + assert result["confidence_min"] == CONFIDENCE_FOREIGN + assert result["modified"] is False + assert await backend.entity_count() == before + + +@pytest.mark.asyncio +async def test_our_own_walls_read_at_full_confidence_with_their_labels(backend): + await draw_lines(backend, two_rooms(), WALL) + await label_room(backend, {"name": "A", "number": "01", "at": [2200.0, 2700.0]}) + result = await rooms_detect(backend) + assert result["layers"] == [WALL] + assert [r["confidence"] for r in result["rooms"]] == [1.0, 1.0] + first, second = result["rooms"] + assert first["label"] == {"id": "R1", "name": "A", "number": "01", "area": 20_000_000.0} + assert second["label"] is None + + +@pytest.mark.asyncio +async def test_rooms_detect_refuses_a_layer_that_is_not_there(backend): + await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS") + with pytest.raises(ValueError, match=re.escape("layers: NOPE not in this drawing")): + await rooms_detect(backend, layers=["NOPE"]) + + +@pytest.mark.asyncio +async def test_an_arc_on_a_wall_layer_is_reported_skipped_never_approximated(backend): + await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS") + arc = await backend.entity_create_arc(2000.0, 5000.0, 500.0, 0.0, 180.0, layer="WALLS") + result = await rooms_detect(backend) + assert [r["area"] for r in result["rooms"]] == [20_000_000.0] + assert [s["handle"] for s in result["skipped"]] == [arc.handle] +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_rooms.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.rooms'`. + +- [ ] **Step 7: Implement room labels, the record reader and the foreign-plan reader** + +Create `engineering/arch/rooms.py`: + +```python +"""Rooms: labels that carry a measured area, and a reader for plans that are not ours. + +A room's area is the floor its walls enclose. `label_room` never takes it from +the caller: it reads the wall faces off the drawing, finds the planar face the +label point lies in (`engineering.arch.faces`) and writes *that* area into the +label and into the ``room`` record of ``ACADMCP_ARCH`` - so a room schedule is +a read of the drawing, and the number on the sheet is the number the geometry +gives. + +A door or a window cuts its wall, so the two rooms either side of a door would +read as one face through the gap. The opening records on the drawing close it: +for each opening, the wall's two face lines are continued across the gap - in +memory only, never drawn - which is the convention a net floor area is measured +to (the wall face, not the reveal). A plan without our records has no such +lines, and `rooms_detect` says so rather than guessing where a door was. + +`rooms_detect` runs the same finder on any lines and polylines - a foreign +plan - and reports each face with a confidence, the way `pid_graph` does: +1.0 when every edge of the face lies on our wall layer, 0.6 when any edge is a +plain line from somewhere else. It never modifies the drawing. + +DECLARED, not standard: + +* The label text heights (3.5 mm name, 2.5 mm number and area, on paper) are + two values of the ISO 3098-1 series; which line gets which is this module's + choice. The line pitch of 1.6 x the text height is this module's choice too. +* ``MIN_ROOM_WIDTH``: a face whose mean width (2 x area / perimeter) is under + 600 mm is taken for a wall body or an opening reveal, not a room. That is + this reader's heuristic, not a building rule - a 200 mm wall ring measures + 200, a 900 mm corridor 5 m long measures 763. +""" + +from __future__ import annotations + +import math +from dataclasses import replace +from typing import TYPE_CHECKING + +from engineering.arch.faces import DEFAULT_TOL, Face, face_containing, planar_faces, tagged_faces +from engineering.arch.lang import LANGS, fmt_area_m2 +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import ( + APP_ID, + Opening, + Room, + Stair, + Wall, + decode, + from_payload, + room_from_dict, + segment_of, + to_payload, + to_values, +) +from engineering.measure import polygon_area_perimeter +from engineering.mech.primitives import Pt, Text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "CONFIDENCE_FOREIGN", + "CONFIDENCE_OURS", + "DEFAULT_MIN_AREA", + "LINE_PITCH", + "MIN_ROOM_WIDTH", + "NAME_HEIGHT", + "TEXT_HEIGHT", + "WALL_LAYER_KEYWORDS", + "detect_rooms", + "label_room", + "mean_width", + "opening_closures", + "read_records", + "read_segments", + "room_label_prims", + "rooms_detect", +] + +NAME_HEIGHT = 3.5 # mm on paper (ISO 3098-1 series value) +TEXT_HEIGHT = 2.5 # mm on paper (ISO 3098-1 series value) +LINE_PITCH = 1.6 # baseline step, x the height of the line being placed +MIN_ROOM_WIDTH = 600.0 # mm; the wall-body heuristic above +DEFAULT_MIN_AREA = 1.0e6 # mm^2 = 1 m^2 +CONFIDENCE_OURS = 1.0 +CONFIDENCE_FOREIGN = 0.6 +#: A layer whose name contains one of these (any case) is read by default. +WALL_LAYER_KEYWORDS = ("WALL", "DUVAR") +#: Entity types that bound a region with a curve; the finder reads straight +#: segments only, so these are reported as skipped, never approximated. +CURVE_TYPES = frozenset({"ARC", "CIRCLE", "ELLIPSE", "SPLINE"}) + + +def _check_lang(lang: str) -> str: + if lang not in LANGS: + raise ValueError( + f"lang: {lang!r} is not a label language; languages are {', '.join(LANGS)}" + ) + return lang + + +def _positive(value, where: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") from None + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{where}: must be a finite number greater than zero, got {value!r}") + return number + + +def room_label_prims(room: Room, *, lang: str = "en", scale=50) -> tuple[Text, ...]: + """Name, number (when there is one) and measured area, stacked down from ``room.at``. + + Heights are paper millimetres times the plot-scale denominator: at 1:50 the + 3.5 mm name is 175 drawing units high. The first baseline is ``room.at``; + each next one is 1.6 x its own height lower. + """ + _check_lang(lang) + factor = _positive(scale, "scale") + x, y = float(room.at[0]), float(room.at[1]) + lines = [(room.name, NAME_HEIGHT * factor)] + if room.number: + lines.append((str(room.number), TEXT_HEIGHT * factor)) + lines.append((fmt_area_m2(room.area, lang), TEXT_HEIGHT * factor)) + prims: list[Text] = [] + baseline = y + for index, (text, height) in enumerate(lines): + if index: + baseline -= LINE_PITCH * height + prims.append(Text(at=(x, baseline), text=text, height=height, rotation=0.0, role="room")) + return tuple(prims) + + +def _face_offsets(wall: Wall) -> tuple[float, float]: + """Signed distances of the wall's two faces from its axis, + to the left.""" + t = float(wall.thickness) + if wall.justification == "left": + return (0.0, t) + if wall.justification == "right": + return (-t, 0.0) + return (-t / 2.0, t / 2.0) + + +def opening_closures(walls, openings) -> tuple[tuple[tuple[Pt, Pt], ...], tuple[dict, ...]]: + """The wall-face lines continued across every opening, and what could not be closed. + + For an opening at ``offset`` of ``width`` on its wall, the segment of the + axis it sits on gives the direction ``u`` and the left normal ``n``; each + face line runs from ``start + n*d`` to ``start + u*width + n*d`` for the + face's signed offset ``d``. An opening whose wall has no record is returned + in ``omitted`` - its gap stays open - never guessed. + """ + by_id = {wall.id: wall for wall in walls} + closures: list[tuple[Pt, Pt]] = [] + omitted: list[dict] = [] + for opening in openings: + wall = by_id.get(opening.wall) + if wall is None: + omitted.append( + { + "element": opening.id, + "reason": f"host wall {opening.wall!r} has no record on this drawing; " + "its gap is left open", + } + ) + continue + index, along = segment_of(wall, float(opening.offset)) + axis = [(float(p[0]), float(p[1])) for p in wall.axis] + p0, p1 = axis[index], axis[(index + 1) % len(axis)] + length = math.dist(p0, p1) + ux, uy = (p1[0] - p0[0]) / length, (p1[1] - p0[1]) / length + nx, ny = -uy, ux + sx, sy = p0[0] + ux * along, p0[1] + uy * along + ex, ey = sx + ux * float(opening.width), sy + uy * float(opening.width) + for d in _face_offsets(wall): + closures.append(((sx + nx * d, sy + ny * d), (ex + nx * d, ey + ny * d))) + return tuple(closures), tuple(omitted) + + +def mean_width(face: Face) -> float: + """2 x area / perimeter: the thickness of a long thin face, holes included.""" + perimeter = sum( + polygon_area_perimeter([(x, y, 0.0) for x, y in ring], True)[1] + for ring in (face.loop, *face.holes) + ) + return 2.0 * face.area / perimeter if perimeter > 0.0 else 0.0 + + +def detect_rooms( + segments, + *, + min_area: float = DEFAULT_MIN_AREA, + tol: float = DEFAULT_TOL, + min_width: float = MIN_ROOM_WIDTH, +) -> tuple[dict, ...]: + """Faces of at least ``min_area`` mm^2 and ``min_width`` mean width, with a confidence. + + ``segments`` are ``(p1, p2)`` or ``(p1, p2, layer)``. A face scores 1.0 + when every edge of it (holes included) is covered by a segment on + ``ARCH_ROLE_LAYER["wall"]``, and 0.6 otherwise - an untagged segment is a + plain line from somewhere else. The minimum of the inputs, never raised by + agreement: one foreign edge is enough to make the face 0.6. + """ + min_area = _positive(min_area, "min_area") + min_width = _positive(min_width, "min_width") + wall = ARCH_ROLE_LAYER["wall"].casefold() + rooms: list[dict] = [] + for face, tags in tagged_faces(segments, tol=tol): + if face.area < min_area or mean_width(face) < min_width: + continue + ours = all(any(isinstance(t, str) and t.casefold() == wall for t in edge) for edge in tags) + rooms.append( + { + "loop": face.loop, + "holes": face.holes, + "area": face.area, + "centroid": face.centroid, + "confidence": CONFIDENCE_OURS if ours else CONFIDENCE_FOREIGN, + } + ) + return tuple(rooms) + + +async def read_records(backend: AutoCADBackend) -> dict: + """Every ``ACADMCP_ARCH`` record on the drawing's architectural layers. + + The shape `read_plan` (Task 4) returns - ``walls``, ``openings``, ``stairs``, + ``rooms`` as model dataclasses - plus ``room_handles`` (room id -> the + handle of the label that carries it). Every layer of ``ARCH_ROLE_LAYER`` is + paged through, not sampled: a record past the 200th entity is still a + record. XDATA that is not ours or does not decode is skipped - this reader + never guesses a record - and counted in ``unreadable``. A record that + appears on several entities (a wall on each of its outlines) is kept once, + by kind and id. + """ + from engineering.sheet.bom import all_entities # lazy: sheet imports stay out of arch + + found: dict[str, list] = {"walls": [], "openings": [], "stairs": [], "rooms": []} + room_handles: dict[str, str] = {} + seen: set[tuple[str, str]] = set() + unreadable = 0 + buckets = {Wall: "walls", Opening: "openings", Stair: "stairs", Room: "rooms"} + for layer in sorted(set(ARCH_ROLE_LAYER.values())): + for entity in await all_entities(backend, layer=layer): + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + continue + try: + obj = from_payload(decode(values)) + except (ValueError, KeyError, TypeError): + obj = None + bucket = buckets.get(type(obj)) + if bucket is None: + unreadable += 1 + continue + key = (bucket, obj.id) + if key in seen: + continue + seen.add(key) + found[bucket].append(obj) + if bucket == "rooms": + room_handles[obj.id] = entity.handle + return {**found, "room_handles": room_handles, "unreadable": unreadable} + + +async def read_segments(backend: AutoCADBackend, layers) -> tuple[list, list[dict]]: + """Straight segments on ``layers`` as ``(p1, p2, layer)``, and what was skipped. + + LINE and (LW)POLYLINE are read by their properties, which is the same on + both engines (the live one names a polyline POLYLINE, the headless one + LWPOLYLINE). A bulged polyline edge or an ARC/CIRCLE/ELLIPSE/SPLINE is an + arc, and the finder reads straight segments only: it is listed in the + second return value with its handle, never flattened into chords. + """ + from engineering.sheet.bom import all_entities # lazy: sheet imports stay out of arch + + segments: list = [] + skipped: list[dict] = [] + for layer in layers: + for entity in await all_entities(backend, layer=layer): + props = entity.properties or {} + kind = str(entity.type).upper() + if "start" in props and "end" in props and "center" not in props: + segments.append((tuple(props["start"][:2]), tuple(props["end"][:2]), entity.layer)) + elif "points" in props: + points = [tuple(p[:2]) for p in props["points"]] + bulges = list(props.get("bulges") or []) + count = len(points) + arcs = 0 + for i in range(count if props.get("closed") else count - 1): + if i < len(bulges) and abs(float(bulges[i])) > 1e-12: + arcs += 1 + continue + segments.append((points[i], points[(i + 1) % count], entity.layer)) + if arcs: + skipped.append( + { + "handle": entity.handle, + "type": kind, + "reason": f"{arcs} arc segment(s): the face finder reads straight " + "segments only and does not approximate an arc", + } + ) + elif kind in CURVE_TYPES: + skipped.append( + { + "handle": entity.handle, + "type": kind, + "reason": "a curve: the face finder reads straight segments only " + "and does not approximate an arc", + } + ) + return segments, skipped + + +def _wcs_loop(loop) -> list[list[float]]: + return [[float(x), float(y)] for x, y in loop] + + +async def label_room( + backend: AutoCADBackend, room_dict: dict, *, lang: str = "en", scale=50 +) -> dict: + """Label the room around ``room_dict["at"]`` with its name, number and measured area. + + Refused before anything is drawn: an ``area`` in the request (areas are + measured, never typed), an unknown ``lang``, a non-positive ``scale``, an + ``id`` already labelled on the drawing, a point that lies in no closed face + of the wall layer, and a point inside a wall body. ``id`` defaults to the + first free ``R``. + """ + if not isinstance(room_dict, dict): + raise ValueError( + f"room: expected an object with name and at, got {type(room_dict).__name__}" + ) + if "area" in room_dict: + raise ValueError( + "room.area: a room's area is measured from its walls, never typed; remove it" + ) + _check_lang(lang) + _positive(scale, "scale") + records = await read_records(backend) + taken = {room.id for room in records["rooms"]} + data = dict(room_dict) + data.setdefault("number", None) + if not data.get("id"): + number = len(taken) + 1 + while f"R{number}" in taken: + number += 1 + data["id"] = f"R{number}" + room = room_from_dict(data, "room") + if room.id in taken: + raise ValueError( + f"room.id: {room.id!r} is already labelled on this drawing " + f"(label {records['room_handles'][room.id]}); choose another id" + ) + + wall_layer = ARCH_ROLE_LAYER["wall"] + segments, skipped = await read_segments(backend, [wall_layer]) + closures, omitted = opening_closures(records["walls"], records["openings"]) + faces = planar_faces( + [*segments, *((p1, p2, wall_layer) for p1, p2 in closures)], tol=DEFAULT_TOL + ) + x, y = float(room.at[0]), float(room.at[1]) + face = face_containing(faces, (x, y)) + if face is None: + raise ValueError( + f"room.at: ({x:g}, {y:g}) lies in no closed face of the {len(segments)} wall " + f"segment(s) on {wall_layer}; close the walls around it first (arch_wall), or read " + "a plan drawn by other means with arch_rooms_detect" + ) + width = mean_width(face) + if width < MIN_ROOM_WIDTH: + raise ValueError( + f"room.at: ({x:g}, {y:g}) lies inside a wall body (a face {width:.0f} mm wide on " + f"average, under the {MIN_ROOM_WIDTH:.0f} mm a room is taken to need), not in a room" + ) + + measured = replace(room, area=face.area) + from engineering.mech.draw import draw_prims # lazy: mech.draw imports the arch layer set + + drawn = await draw_prims( + backend, room_label_prims(measured, lang=lang, scale=scale), role_layer=ARCH_ROLE_LAYER + ) + handle = drawn["handles"][0] + await backend.entity_set_xdata(handle, APP_ID, to_values(to_payload(measured))) + return { + "ok": True, + "room": { + "id": measured.id, + "name": measured.name, + "number": measured.number, + "at": [x, y], + "area": measured.area, + "area_m2": measured.area / 1.0e6, + "area_text": fmt_area_m2(measured.area, lang), + }, + "handle": handle, + "handles": list(drawn["handles"]), + "layer": ARCH_ROLE_LAYER["room"], + "loop": _wcs_loop(face.loop), + "holes": [_wcs_loop(hole) for hole in face.holes], + "wall_segments": len(segments), + "closures": len(closures), + "skipped": skipped, + "omitted": list(omitted), + } + + +async def rooms_detect( + backend: AutoCADBackend, + *, + layers=None, + min_area: float = DEFAULT_MIN_AREA, + tol: float = DEFAULT_TOL, +) -> dict: + """Read the rooms of any plan: faces of the lines on ``layers``, measured, never drawn. + + ``layers=None`` reads every layer whose name contains WALL or DUVAR (any + case). A named layer the drawing does not have is refused with the list of + the ones it does. Our own opening records, when there are any, close door + and window gaps exactly as `label_room` does; a foreign plan's gaps stay + open, and two rooms joined by an open doorway read as one face. + """ + min_area = _positive(min_area, "min_area") + tol = _positive(tol, "tol") + available = [layer.name for layer in await backend.layer_list()] + if layers is None: + chosen = [ + name for name in available if any(key in name.upper() for key in WALL_LAYER_KEYWORDS) + ] + if not chosen: + raise ValueError( + "layers: no layer name contains " + f"{' or '.join(WALL_LAYER_KEYWORDS)}; name the layers the walls are on " + f"(layers are {', '.join(sorted(available))})" + ) + else: + lookup = {name.casefold(): name for name in available} + missing = [str(name) for name in layers if str(name).casefold() not in lookup] + if missing: + raise ValueError( + f"layers: {', '.join(missing)} not in this drawing; " + f"layers are {', '.join(sorted(available))}" + ) + chosen = [lookup[str(name).casefold()] for name in layers] + + segments, skipped = await read_segments(backend, chosen) + records = await read_records(backend) + closures, omitted = opening_closures(records["walls"], records["openings"]) + wall_layer = ARCH_ROLE_LAYER["wall"] + extra = [(p1, p2, wall_layer) for p1, p2 in closures] + rooms = detect_rooms([*segments, *extra], min_area=min_area, tol=tol) + + out: list[dict] = [] + for room in rooms: + face = Face( + loop=room["loop"], area=room["area"], centroid=room["centroid"], holes=room["holes"] + ) + label = next( + ( + {"id": r.id, "name": r.name, "number": r.number, "area": r.area} + for r in records["rooms"] + if face_containing((face,), r.at) is not None + ), + None, + ) + out.append( + { + "loop": _wcs_loop(room["loop"]), + "holes": [_wcs_loop(hole) for hole in room["holes"]], + "area": room["area"], + "area_m2": room["area"] / 1.0e6, + "centroid": [room["centroid"][0], room["centroid"][1]], + "confidence": room["confidence"], + "label": label, + } + ) + return { + "ok": True, + "modified": False, + "layers": chosen, + "segments": len(segments), + "closures": len(extra), + "count": len(out), + "rooms": out, + "confidence_min": min((room["confidence"] for room in out), default=None), + "skipped": skipped, + "omitted": list(omitted), + "min_area": min_area, + "tol": tol, + } +``` + +- [ ] **Step 8: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_faces.py tests/test_arch_rooms.py -q` +Expected: `37 passed`. + +- [ ] **Step 9: Re-export from the package** + +Task 1 wrote `engineering/arch/__init__.py` in the `engineering/mech/__init__.py` pattern (relative imports, then a sorted `__all__`). Add these two imports in alphabetical module position among the existing relative imports: + +```python +from .faces import DEFAULT_TOL, Face, face_containing, planar_faces, tagged_faces +from .rooms import ( + detect_rooms, + label_room, + opening_closures, + read_records, + room_label_prims, + rooms_detect, +) +``` + +and add these eleven names to `__all__`, keeping it sorted: + +```python + "DEFAULT_TOL", + "Face", + "detect_rooms", + "face_containing", + "label_room", + "opening_closures", + "planar_faces", + "read_records", + "room_label_prims", + "rooms_detect", + "tagged_faces", +``` + +Run: `uv run --frozen python -c "import engineering.arch as a; print(a.planar_faces, a.label_room)"` +Expected: both functions print; no `ImportError` (the lazy imports in `rooms.py` keep the package import free of `engineering.mech.draw`'s cycle). + +- [ ] **Step 10: Write the failing wire test for the two tools** + +Create `tests/test_arch_rooms_tools.py`: + +```python +"""arch_room and arch_rooms_detect over the MCP wire, on a fresh headless document.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client + +import server +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + +WALL = ARCH_ROLE_LAYER["wall"] + + +def rect(x0, y0, x1, y1): + return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] + + +@pytest_asyncio.fixture +async def client(monkeypatch): + """In-memory client on a fresh headless document - never the operator's AutoCAD.""" + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +async def draw_room(client, layer): + """A 4000 x 5000 room between 200 mm walls: outer and inner outline.""" + for (x1, y1), (x2, y2) in [ + *rect(-200.0, -200.0, 4200.0, 5200.0), + *rect(0.0, 0.0, 4000.0, 5000.0), + ]: + await client.call_tool( + "entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": layer} + ) + + +async def test_arch_room_labels_the_measured_area(client): + await draw_room(client, WALL) + result = await client.call_tool( + "arch_room", {"name": "LIVING", "number": "01", "x": 2000.0, "y": 2500.0} + ) + room = result.structured_content["room"] + assert room["area"] == pytest.approx(20_000_000.0) + assert room["area_text"] == "20.00 m²" + assert result.structured_content["layer"] == ARCH_ROLE_LAYER["room"] + + +async def test_arch_rooms_detect_reads_the_room_back_without_drawing(client): + await draw_room(client, "DUVAR") + before = (await client.call_tool("analysis_entity_stats", {})).structured_content + result = (await client.call_tool("arch_rooms_detect", {})).structured_content + assert result["layers"] == ["DUVAR"] + assert [r["area"] for r in result["rooms"]] == [pytest.approx(20_000_000.0)] + assert result["confidence_min"] == 0.6 + after = (await client.call_tool("analysis_entity_stats", {})).structured_content + assert after["total_entities"] == before["total_entities"] + + +async def test_a_point_outside_every_room_is_refused_over_the_wire(client): + await draw_room(client, WALL) + result = await client.call_tool( + "arch_room", {"name": "X", "x": 9000.0, "y": 9000.0}, raise_on_error=False + ) + assert result.is_error is True + assert "room.at: (9000, 9000) lies in no closed face" in result.content[0].text + + +async def test_the_detector_is_advertised_read_only(client): + tools = {tool.name: tool for tool in await client.list_tools()} + assert tools["arch_rooms_detect"].annotations.readOnlyHint is True + assert tools["arch_room"].annotations.destructiveHint is False +``` + +- [ ] **Step 11: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_rooms_tools.py -q` +Expected: `4 failed` — `fastmcp.exceptions.ToolError: Not found: Unknown tool: 'arch_room'` (and `'arch_rooms_detect'`), an `AssertionError` on the refusal text (the error is the unknown-tool one), and `KeyError: 'arch_rooms_detect'` in the annotations test. + +- [ ] **Step 12: Open SECTION 25 and register the two tools** + +In `server.py`, replace the RESOURCES banner + +```python +# --------------------------------------------------------------------------- +# ── RESOURCES ─────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +``` + +with + +```python +# --------------------------------------------------------------------------- +# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +# --------------------------------------------------------------------------- + + +# ── arch: rooms (group R) ── + + +@cad_tool(summary="Label a room with its name, number and measured area.", cost="mutate") +@mcp.tool( + annotations={"title": "Architecture: Label Room", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_room( + name: Annotated[str, Field(description="Room name, e.g. LIVING / SALON.")], + x: Annotated[ + float, Field(description="WCS X of a point inside the room; the label starts here.") + ], + y: Annotated[float, Field(description="WCS Y of a point inside the room.")], + number: Annotated[ + str | None, Field(default=None, description="Room number, e.g. 01; omit for none.") + ] = None, + room_id: Annotated[ + str | None, + Field(default=None, description="Record id; default is the first free R."), + ] = None, + lang: Annotated[ + str, Field(default="en", description="Label language: en | tr (tr writes 24,50 m²).") + ] = "en", + scale: Annotated[ + float, + Field(default=50.0, gt=0, description="Plot-scale denominator: 50 is 1:50 (text x 50)."), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Label the room around (x, y) with its name, number and MEASURED area. + + The area is never typed: the tool reads the wall faces on the `wall` layer + of the arch set (LINE and polyline, both engines), finds the closed face + the point lies in and writes that face's area - net of the wall thickness + and of any column inside it - into the label and into an `ACADMCP_ARCH` + room record on the name text, which `arch_schedule` reads. A door or window + recorded on the drawing closes its gap along the wall faces, so two rooms + either side of a door stay two rooms. + + Refused before anything is drawn: an unknown `lang` (named with en, tr), a + `number`/`name` the room model refuses, a `room_id` already labelled on the + drawing, a point in no closed face of the walls (draw or close them first, + or read a plan drawn by other means with `arch_rooms_detect`), and a point + inside a wall body. + """ + from engineering.arch.rooms import label_room + + room = {"name": name, "number": number, "at": [x, y]} + if room_id: + room["id"] = room_id + await ctx.info(f"arch_room: {name!r} at ({x}, {y})") + try: + return await label_room(_backend(ctx), room, lang=lang, scale=scale) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool(summary="Find the rooms of any plan and measure them; never modifies it.", cost="read") +@mcp.tool( + annotations={"title": "Architecture: Detect Rooms", "readOnlyHint": True}, + tags={"arch", "query"}, +) +async def arch_rooms_detect( + layers: Annotated[ + list[str] | None, + Field( + default=None, + description="Layers holding the walls; default: every layer whose name contains " + "WALL or DUVAR.", + ), + ] = None, + min_area: Annotated[ + float, + Field(default=1.0e6, gt=0, description="Smallest room reported, mm² (1e6 = 1 m²)."), + ] = 1.0e6, + tol: Annotated[ + float, + Field(default=1.0, gt=0, description="Endpoints closer than this (mm) are one corner."), + ] = 1.0, + ctx: Context = None, +) -> dict: + """Read the rooms of a plan - ours or a foreign one made of plain lines. + + Every LINE and polyline on the layers is split where lines cross or touch, + and the closed faces they bound are measured (mm², net of nested pieces). + Faces under `min_area`, and thin faces (mean width under 600 mm - wall + bodies and reveals, this reader's heuristic) are left out. Each room + reports `confidence`: 1.0 when every edge lies on the arch wall layer, 0.6 + when any edge is a plain line from elsewhere - read `confidence_min` before + trusting a foreign plan. A room label already on the drawing is reported + with the face it sits in. + + Never modifies the drawing. Arcs and bulged polyline edges are listed in + `skipped`, never flattened into chords. A foreign plan's open doorways are + not closed - two rooms joined by one read as one face. Refused: a named + layer the drawing does not have (with the list of the ones it has), and no + layer matching WALL or DUVAR when `layers` is omitted. + """ + from engineering.arch.rooms import rooms_detect + + try: + return await rooms_detect(_backend(ctx), layers=layers, min_area=min_area, tol=tol) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +# --------------------------------------------------------------------------- +# ── RESOURCES ─────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +``` + +The banner says `(2 tools)` because this branch holds two SECTION 25 tools after this task (`tests/test_release_consistency.py::test_section_header_counts_match_decorators` counts them); Task 6 makes it `(3 tools)`, the count the skeleton's banner protocol gives group R. + +`arch` is deliberately **not** added to `_GROUP_TAG_PRIORITY`, `_GROUP_TAG_LABELS`, `TOOL_PACK_NAMES` or `PACK_TOOL_NAMES` here: the pack and the group label are Task 9's, and an edit to those lists on three branches would conflict three ways (group C's part makes the same call). Until then `arch_room` (tags `arch`, `create`) files under `entity_creation` and `arch_rooms_detect` (`arch`, `query`) under `entity_query`, and both are core-pack. + +- [ ] **Step 13: Add the alias records** + +In `discovery/aliases.py`, replace the end of `TOOL_ALIASES` + +```python + "punta deligi", + ), + ), +} +``` + +with + +```python + "punta deligi", + ), + ), + # ── Architecture: rooms (SECTION 25, track F group R) ─────────────────── + # AREA and BOUNDARY stay with analysis_measure_entity and boundary_trace: + # on a plain AutoCAD seat a drafter typing them means those tools, and + # sharing them would move the golden BPOLY / area cases. The room + # vocabulary below reaches these two without either command. + "arch_room": ToolAliases( + acad=(), + synonyms=( + "room label", + "label the room", + "room name and number", + "net floor area", + "measured room area", + "oda etiketi", + "oda alani", + "mahal adi", + ), + ), + "arch_rooms_detect": ToolAliases( + acad=(), + synonyms=( + "find the rooms", + "detect rooms in a plan", + "rooms of a floor plan", + "room areas of this plan", + "read rooms from lines", + "odalari bul", + "kat plani alanlari", + ), + ), +} +``` + +Measured before writing this plan (the two tools registered in-process against the live catalog): the discovery golden set is unchanged — tuning top-1 65/76 and top-3 75/76 (the one miss is the existing `KNOWN_CORPUS_GAPS` case), holdout top-3 15/15, and the A/B stays 78 vs 33 top-1. + +- [ ] **Step 14: Run the wire test to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_rooms_tools.py -q` +Expected: `4 passed`. + +- [ ] **Step 15: Regenerate the inventory and bump the documented counts** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` +Expected output: `wrote docs/tool-inventory.json: 231 tools, 8 resources, 5 prompts, 25 groups` + +In `README.md` change the release-snapshot lines from + +``` +> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 229 is the **registered** count; a default install advertises 224 over `tools/list`, +``` + +to + +``` +> **v1.5 release snapshot:** 231 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 231 is the **registered** count; a default install advertises 226 over `tools/list`, +``` + +and the caption under the "What you get" table from `229 tools in 25 groups;` to `231 tools in 25 groups;`. Leave the collected-tests figure alone (Task 9 retakes it on main) and leave the "197 AutoCAD command names and 1158 synonym phrases … covering all 229 tools" sentence alone: all three group branches add alias records, so that sentence is recomputed once after the merge. + +- [ ] **Step 16: Bump the frozen group-size snapshot** + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: + +- the docstring's first line `"""Frozen snapshot of the surface (229 tools, 25 groups).` becomes `"""Frozen snapshot of the surface (231 tools, 25 groups).`; +- in the asserted dict, `"entity_creation": 18,` becomes `"entity_creation": 19,` and `"entity_query": 9,` becomes `"entity_query": 10,`; +- `assert sum(sizes.values()) == 229` becomes `assert sum(sizes.values()) == 231`; +- replace the docstring's closing lines + +```python + `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value. + """ +``` + +with + +```python + `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value. + + Track F group R opened SECTION 25 with the rooms: `arch_room` is tagged + `arch` and `create`, `arch_rooms_detect` `arch` and `query`. `arch` is not + in `_GROUP_TAG_PRIORITY` on this branch, so they file under their + secondary tags: `entity_creation` 18 -> 19, `entity_query` 9 -> 10. This + snapshot is branch-local: the track F merge task recomputes it once the + walls, rooms and catalogue branches have landed. + """ +``` + +- [ ] **Step 17: Gates** + +Run each and require it clean: + +``` +uv run --frozen ruff check engineering/arch/faces.py engineering/arch/rooms.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_faces.py tests/test_arch_rooms.py tests/test_arch_rooms_tools.py tests/test_tool_registry.py +uv run --frozen ruff format engineering/arch/faces.py engineering/arch/rooms.py engineering/arch/__init__.py tests/test_arch_faces.py tests/test_arch_rooms.py tests/test_arch_rooms_tools.py +uv run --frozen ruff format --check server.py discovery/aliases.py tests/test_tool_registry.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_arch_faces.py tests/test_arch_rooms.py tests/test_arch_rooms_tools.py -q +uv run --frozen pytest tests/test_tool_registry.py tests/test_tool_inventory.py tests/test_tool_packs.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_capability_contract.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +`generate_tool_inventory.py --check` prints `ok: docs/tool-inventory.json matches the live surface`. `ruff format --check server.py` must pass: the file is formatted at the base commit and the inserted section is written formatted. The deselect is the known-red collected-tests drift named in the Global Constraints — record it in the task report. `test_section_header_counts_match_decorators` must pass: the banner says `(2 tools)` and two `@mcp.tool` decorators sit under it. No capability key is added (both tools compose generic members), so `tests/test_capability_contract.py` passes untouched. + +- [ ] **Step 18: Commit** + +``` +git add engineering/arch/faces.py engineering/arch/rooms.py engineering/arch/__init__.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py tests/test_arch_faces.py tests/test_arch_rooms.py tests/test_arch_rooms_tools.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-5): planar faces, measured room labels and the foreign-plan reader + +engineering/arch/faces.py finds the closed faces a set of straight segments +bounds: endpoints within tol merge, every segment splits where another crosses +or touches it (the T-junction a partition makes), dangling edges are pruned, +and the half-edge walk turns to the next edge clockwise so every bounded face +comes out counter-clockwise. A piece nested inside a face of another piece is +a hole in it, so a ring of walls measures its body and a room with a column +measures its floor. Areas come from engineering.measure.polygon_area_perimeter, +the arithmetic analysis_measure_entity reports. + +engineering/arch/rooms.py labels a room with the area of the face its point +lies in - a 4000 x 5000 room between 200 mm walls reads 20.00 m2, never the +axis area - and writes the room record onto the name text. Opening records +close door and window gaps along the wall faces, so two rooms either side of a +door stay two rooms. rooms_detect reads any plan's lines, drops faces under +min_area and thin wall bodies (mean width under 600 mm, a declared heuristic), +reports confidence 1.0 for faces enclosed only by our wall layer and 0.6 +otherwise, lists arcs as skipped rather than flattening them, and never +modifies the drawing. + +SECTION 25 opens on this branch with arch_room and arch_rooms_detect under +the rooms marker; until Task 9 gives arch its group they file under +entity_creation and entity_query. Alias records, a regenerated inventory, the +README snapshot at 231 tools in 25 groups and the frozen group-size snapshot +follow. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 6: Door, window and room schedules + +**Files:** +- Create: `engineering/arch/schedule.py` +- Modify: `engineering/arch/__init__.py` (re-export the schedule names; additive) +- Modify: `server.py` (SECTION 25 banner `(2 tools)` → `(3 tools)`; `arch_schedule` registered under the rooms marker, directly after `arch_rooms_detect`) +- Modify: `discovery/aliases.py` (the `arch_schedule` record; the `TABLE` entry of the `SHARED_ACAD_COMMANDS` comment names it) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release-snapshot tool count 231 → 232, advertised 226 → 227; caption `231 tools in 25 groups` → `232 tools in 25 groups`) +- Modify: `tests/test_tool_registry.py` (`entity_creation` 19 → 20, total 231 → 232) +- Test: `tests/test_arch_schedule.py` +- Test: `tests/test_arch_schedule_tools.py` + +**Interfaces:** +- Consumes (Task 1, on `main`, as pinned): `engineering.arch.lang.{LANGS, vocab, tag_prefix, fmt_number, fmt_area_m2}` — `vocab(lang) -> dict[str, str]` with the keys `door_schedule, window_schedule, room_schedule, tag, width, height, sill, swing, hand, wall, number, name, area, total, in, out, left, right`; `tag_prefix(kind, lang)` (`"door"` → `"D"` | `"K"`, `"window"` → `"W"` | `"P"`); `fmt_number(value, lang, decimals=2)` (`"24.50"` | `"24,50"`); `fmt_area_m2(area_mm2, lang)`. `engineering.arch.layers.{ARCH_LAYERS, ARCH_ROLE_LAYER}` — `ARCH_LAYERS` rows are `(name, aci, linetype, lineweight_mm, description)`. `engineering.arch.model.{Wall, Opening, Room, APP_ID, to_payload, to_values}` (the tests write records with them). +- Consumes (Task 5, this branch): `engineering.arch.rooms.read_records(backend) -> dict` (`walls`, `openings`, `stairs`, `rooms` lists of model dataclasses). +- Consumes (repository, unchanged): `engineering.sheet.bom.{ROW_HEIGHT, TEXT_HEIGHT}` (7.0 and 2.5 mm); backend `entity_create_table(x, y, rows, headers=None, column_widths=None, row_height=7.0, text_height=2.5, title=None, layer="TEXT") -> EntityInfo` — both engines report `properties["rows"]` (title + header + data rows), `properties["representation"]` (`native` live, `composite` headless) and `properties["child_handles"]`; `prepare_table_layout` refuses more than 200 rows. +- Produces (`engineering/arch/schedule.py`): + - `schedule_rows(kind: str, plan: dict, *, lang="en") -> tuple[dict, ...]` (pinned) + - `async draw_schedule(backend, kind, *, at, lang="en", scale=50) -> dict` (pinned) — returns `ok, kind, lang, handle, entity_type, representation, child_handles, layer, columns, headers, title, rows, row_count, table_rows, insert, bbox`. + - `SCHEDULE_KINDS = ("doors", "windows", "rooms")`, `SCHEDULE_COLUMNS`, `SCHEDULE_TITLES`, `COLUMN_WIDTHS`, `MAX_ROWS = 198`. +- Produces (`server.py`): tool `arch_schedule(kind, x, y, lang="en", scale=50.0)`. + +Why not `draw_bom_table` itself: its `table_layout` refuses every column outside the parts list's closed `BOM_COLUMNS` vocabulary, so a schedule cannot pass through it. `draw_schedule` takes the parts list's row and text heights and its drawing contract instead — insertion at the top-left corner, the table growing downwards, the engine's representation reported rather than assumed. + +- [ ] **Step 1: Write the failing test for the schedule rows and the table** + +Create `tests/test_arch_schedule.py`: + +```python +"""Door, window and room schedules: rows from the plan model, a TABLE from the drawing. + +Records are written with `model.to_values` directly - in production the wall +engine (Task 4, another branch) and `arch_room` write them; the record shape is +pinned by the skeleton. +""" + +from __future__ import annotations + +import re + +import pytest + +from engineering.arch.lang import vocab +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import APP_ID, Opening, Room, Wall, to_payload, to_values +from engineering.arch.schedule import SCHEDULE_COLUMNS, draw_schedule, schedule_rows + +WALLS = [ + Wall(id="w1", axis=((0.0, 0.0), (6000.0, 0.0)), thickness=200.0), + Wall(id="w2", axis=((6000.0, 0.0), (6000.0, 4000.0)), thickness=200.0), +] + + +def plan(): + return { + "walls": list(WALLS), + "openings": [ + Opening( + id="da", + wall="w2", + kind="door", + offset=500.0, + width=800.0, + swing="out", + hand="right", + ), + Opening(id="db", wall="w1", kind="door", offset=3000.0, width=912.5, height=2100.0), + Opening( + id="dc", wall="w1", kind="door", offset=1000.0, width=900.0, height=2100.0, tag="D2" + ), + Opening( + id="wa", + wall="w1", + kind="window", + offset=4000.0, + width=1200.0, + height=1200.0, + sill=900.0, + ), + ], + "rooms": [ + Room(id="R1", name="KITCHEN", number="2", at=(0.0, 0.0), area=15_000_000.0), + Room(id="R2", name="LIVING", number="10", at=(0.0, 0.0), area=20_000_000.0), + Room(id="R3", name="STORE", number=None, at=(0.0, 0.0), area=10_000_000.0), + ], + } + + +def test_door_rows_generate_missing_tags_in_wall_order_and_sort_by_tag(): + rows = schedule_rows("doors", plan(), lang="en") + en = vocab("en") + # wall order: w1 at 1000 (tagged D2), w1 at 3000 -> D1, w2 at 500 -> D3 (D2 is taken) + assert [(r["id"], r["tag"], r["generated"]) for r in rows] == [ + ("db", "D1", True), + ("dc", "D2", False), + ("da", "D3", True), + ] + assert rows[0] == { + "id": "db", + "tag": "D1", + "generated": True, + "width": "912.5", + "height": "2100", + "swing": en["in"], + "hand": en["left"], + "wall": "w1", + } + # no height on the model: an empty cell, never a default + assert rows[2]["height"] == "" + assert (rows[2]["swing"], rows[2]["hand"]) == (en["out"], en["right"]) + assert set(SCHEDULE_COLUMNS["doors"]) <= set(rows[0]) + + +def test_turkish_door_rows_use_k_tags_decimal_commas_and_turkish_words(): + rows = schedule_rows("doors", plan(), lang="tr") + tr = vocab("tr") + # explicit "D2" is never renamed; generated tags take the Turkish prefix + assert [r["tag"] for r in rows] == ["D2", "K1", "K2"] + k1 = next(r for r in rows if r["tag"] == "K1") + assert k1["width"] == "912,5" + assert (k1["swing"], k1["hand"]) == (tr["in"], tr["left"]) + + +def test_window_rows_carry_sill_and_no_swing(): + (row,) = schedule_rows("windows", plan(), lang="en") + assert row == { + "id": "wa", + "tag": "W1", + "generated": True, + "width": "1200", + "height": "1200", + "sill": "900", + "wall": "w1", + } + assert [r["tag"] for r in schedule_rows("windows", plan(), lang="tr")] == ["P1"] + + +def test_room_rows_sort_by_number_with_the_unnumbered_last_and_end_in_a_total(): + rows = schedule_rows("rooms", plan(), lang="en") + assert [(r["number"], r["name"], r["area"]) for r in rows] == [ + ("2", "KITCHEN", "15.00 m²"), + ("10", "LIVING", "20.00 m²"), + ("", "STORE", "10.00 m²"), + ("", vocab("en")["total"], "45.00 m²"), # 15 + 20 + 10 + ] + assert rows[-1]["total"] is True + assert rows[-1]["area_mm2"] == 45_000_000.0 + + +def test_turkish_room_rows_write_the_total_with_a_decimal_comma(): + rows = schedule_rows("rooms", plan(), lang="tr") + assert (rows[-1]["name"], rows[-1]["area"]) == (vocab("tr")["total"], "45,00 m²") + assert rows[0]["area"] == "15,00 m²" + + +def test_an_empty_plan_has_no_rows_and_no_total(): + assert schedule_rows("rooms", {"walls": [], "openings": [], "rooms": []}) == () + assert schedule_rows("doors", {"walls": [], "openings": [], "rooms": []}) == () + + +@pytest.mark.parametrize( + ("kind", "lang", "message"), + [ + ("stairs", "en", "kind: 'stairs' is not a schedule; schedules are doors, windows, rooms"), + ("doors", "de", "lang: 'de' is not a schedule language; languages are en, tr"), + ], +) +def test_an_unknown_kind_or_language_is_refused_with_the_list(kind, lang, message): + with pytest.raises(ValueError, match=re.escape(message)): + schedule_rows(kind, plan(), lang=lang) + + +# ── async: the TABLE ──────────────────────────────────────────────────────── + + +async def write_record(backend, obj, role): + carrier = await backend.entity_create_line(0.0, 0.0, 1.0, 0.0, layer=ARCH_ROLE_LAYER[role]) + await backend.entity_set_xdata(carrier.handle, APP_ID, to_values(to_payload(obj))) + + +async def write_plan(backend): + data = plan() + for wall in data["walls"]: + await write_record(backend, wall, "wall") + for opening in data["openings"]: + await write_record(backend, opening, opening.kind) + for room in data["rooms"]: + await write_record(backend, room, "room") + + +@pytest.mark.asyncio +async def test_a_door_schedule_is_a_table_with_title_header_and_one_row_per_door(backend): + await write_plan(backend) + result = await draw_schedule(backend, "doors", at=(10_000.0, 5_000.0), lang="en", scale=50) + assert result["row_count"] == 3 + assert result["table_rows"] == 5 # title + header + 3 doors + assert result["representation"] == "composite" + assert result["layer"] == ARCH_ROLE_LAYER["symbol"] + assert result["title"] == vocab("en")["door_schedule"] + # 1:50: widths 15+20+20+20+20+20 = 115 mm -> 5750; rows 5 x 7 mm x 50 = 1750 + assert result["bbox"] == {"min": [10_000.0, 3_250.0], "max": [15_750.0, 5_000.0]} + cells = { + e.properties["text"] + for e in await backend.entity_list( + type_filter="MTEXT", layer_filter=ARCH_ROLE_LAYER["symbol"], limit=1000 + ) + } + assert {"D1", "D2", "D3", "912.5", "w2"} <= cells + + +@pytest.mark.asyncio +async def test_a_room_schedule_carries_the_total_row(backend): + await write_plan(backend) + result = await draw_schedule(backend, "rooms", at=(0.0, 0.0), lang="tr") + assert result["row_count"] == 4 # three rooms + the total + assert result["table_rows"] == 6 + assert result["rows"][-1]["area"] == "45,00 m²" + + +@pytest.mark.asyncio +async def test_nothing_to_schedule_is_refused_before_anything_is_drawn(backend): + before = await backend.entity_count() + with pytest.raises(ValueError, match=re.escape("kind: this drawing carries no window records")): + await draw_schedule(backend, "windows", at=(0.0, 0.0)) + with pytest.raises( + ValueError, match=re.escape("scale: must be a finite number greater than zero") + ): + await draw_schedule(backend, "doors", at=(0.0, 0.0), scale=-1) + assert await backend.entity_count() == before +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_schedule.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.schedule'`. + +- [ ] **Step 3: Implement the schedules** + +Create `engineering/arch/schedule.py`: + +```python +"""Door, window and room schedules - a read of the drawing, drawn as a real TABLE. + +`schedule_rows` turns plan data (the shape `read_plan` returns: ``walls``, +``openings``, ``rooms`` lists of model dataclasses) into rows; `draw_schedule` +reads that data off the drawing's ``ACADMCP_ARCH`` records and draws it through +the same `entity_create_table` path the ISO 7573 parts list takes +(`engineering/sheet/bom.py::draw_bom_table`): insertion at the top-left corner, +the table growing downwards, and the engine's representation reported rather +than assumed - a real ACAD_TABLE live, a composite of rules and MTEXT headless. + +Rows: + +* doors - tag, width, height, swing, hand, wall; windows - tag, width, + height, sill, wall. Sorted by tag, numbers compared as numbers (D2 before + D10). An opening without a tag gets the next free ```` in wall + order (the order of ``walls``, then the offset along the wall); an explicit + tag is never renumbered and a generated one never repeats it. +* rooms - number, name, area (the measured one the label carries), sorted by + number with the unnumbered last, plus a total row. + +A value the model does not carry (a door without a height) is an empty cell, +never a default. Headers come from `vocab(lang)`; ``lang="tr"`` also writes +decimal commas. + +DECLARED, not standard: the paper column widths below and the reuse of the +parts list's 7 mm row and 2.5 mm text (`engineering.sheet.bom.ROW_HEIGHT`, +`TEXT_HEIGHT`) - no standard fixes a schedule's layout. +""" + +from __future__ import annotations + +import math +import re +from typing import TYPE_CHECKING + +from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "COLUMN_WIDTHS", + "MAX_ROWS", + "SCHEDULE_COLUMNS", + "SCHEDULE_KINDS", + "SCHEDULE_TITLES", + "draw_schedule", + "schedule_rows", +] + +SCHEDULE_KINDS: tuple[str, ...] = ("doors", "windows", "rooms") + +SCHEDULE_COLUMNS: dict[str, tuple[str, ...]] = { + "doors": ("tag", "width", "height", "swing", "hand", "wall"), + "windows": ("tag", "width", "height", "sill", "wall"), + "rooms": ("number", "name", "area"), +} + +#: The `vocab` key of each schedule's title row. +SCHEDULE_TITLES: dict[str, str] = { + "doors": "door_schedule", + "windows": "window_schedule", + "rooms": "room_schedule", +} + +#: Column widths in paper millimetres (x the scale denominator in the drawing). +COLUMN_WIDTHS: dict[str, float] = { + "tag": 15.0, + "width": 20.0, + "height": 20.0, + "swing": 20.0, + "hand": 20.0, + "sill": 20.0, + "wall": 20.0, + "number": 15.0, + "name": 45.0, + "area": 25.0, +} + +#: `entity_create_table` takes at most 200 rows, the title and header included. +MAX_ROWS = 198 + +_OPENING_KIND = {"doors": "door", "windows": "window"} + + +def _check(kind: str, lang: str) -> None: + if kind not in SCHEDULE_KINDS: + raise ValueError( + f"kind: {kind!r} is not a schedule; schedules are {', '.join(SCHEDULE_KINDS)}" + ) + if lang not in LANGS: + raise ValueError( + f"lang: {lang!r} is not a schedule language; languages are {', '.join(LANGS)}" + ) + + +def _natural(text: str) -> tuple: + """``D10`` after ``D2``: digit runs compare as numbers.""" + return tuple( + (0, int(part), "") if part.isdigit() else (1, 0, part) + for part in re.findall(r"\d+|\D+", str(text)) + ) + + +def _mm(value, lang: str) -> str: + """A length in mm: whole numbers without decimals, others to 0.1 mm; None is blank.""" + if value is None: + return "" + number = float(value) + decimals = 0 if abs(number - round(number)) < 1e-9 else 1 + return fmt_number(number, lang, decimals) + + +def _opening_rows(kind: str, plan: dict, lang: str) -> tuple[dict, ...]: + words = vocab(lang) + wanted = _OPENING_KIND[kind] + wall_order = {wall.id: index for index, wall in enumerate(plan.get("walls") or ())} + openings = [o for o in plan.get("openings") or () if o.kind == wanted] + openings.sort( + key=lambda o: (wall_order.get(o.wall, len(wall_order)), o.wall, float(o.offset), o.id) + ) + prefix = tag_prefix(wanted, lang) + used = {str(o.tag) for o in openings if o.tag} + counter = 0 + rows: list[dict] = [] + for opening in openings: + tag = str(opening.tag) if opening.tag else None + generated = tag is None + if generated: + counter += 1 + while f"{prefix}{counter}" in used: + counter += 1 + tag = f"{prefix}{counter}" + used.add(tag) + row = { + "id": opening.id, + "tag": tag, + "generated": generated, + "width": _mm(opening.width, lang), + "height": _mm(opening.height, lang), + "wall": opening.wall, + } + if kind == "doors": + row["swing"] = words[opening.swing] + row["hand"] = words[opening.hand] + else: + row["sill"] = _mm(opening.sill, lang) + rows.append(row) + rows.sort(key=lambda row: _natural(row["tag"])) + return tuple(rows) + + +def _room_rows(plan: dict, lang: str) -> tuple[dict, ...]: + rooms = sorted( + plan.get("rooms") or (), + key=lambda r: ( + r.number is None or str(r.number) == "", + _natural(r.number or ""), + r.name, + r.id, + ), + ) + if not rooms: + return () + rows = [ + { + "id": room.id, + "number": "" if room.number is None else str(room.number), + "name": room.name, + "area": fmt_area_m2(room.area, lang), + "area_mm2": float(room.area), + "total": False, + } + for room in rooms + ] + total = math.fsum(float(room.area) for room in rooms) + rows.append( + { + "id": "", + "number": "", + "name": vocab(lang)["total"], + "area": fmt_area_m2(total, lang), + "area_mm2": total, + "total": True, + } + ) + return tuple(rows) + + +def schedule_rows(kind: str, plan: dict, *, lang: str = "en") -> tuple[dict, ...]: + """The rows of one schedule, each a dict holding every column of ``SCHEDULE_COLUMNS[kind]`` + as display text, plus ``id`` (and ``generated`` for openings, ``area_mm2`` and + ``total`` for rooms). Refused: an unknown kind or language, named with the list.""" + _check(kind, lang) + if kind == "rooms": + return _room_rows(plan, lang) + return _opening_rows(kind, plan, lang) + + +async def _ensure_layer(backend: AutoCADBackend, name: str) -> None: + """Create the schedule layer from its `ARCH_LAYERS` row when the drawing lacks it. + + Live ActiveX will not create a layer on assignment (the repository rule), + so the layer exists before the table does. + """ + if name.casefold() in {layer.name.casefold() for layer in await backend.layer_list()}: + return + row = next(r for r in ARCH_LAYERS if r[0] == name) + await backend.layer_create(name=row[0], color=row[1], linetype=row[2], lineweight=row[3]) + + +async def draw_schedule( + backend: AutoCADBackend, kind: str, *, at, lang: str = "en", scale=50 +) -> dict: + """Read the drawing's records and draw one schedule as a TABLE whose top-left is ``at``. + + Refused before anything is drawn: an unknown kind or language, a scale that + is not a finite positive number, an ``at`` that is not two finite numbers, + a drawing with nothing to schedule, and more rows than one TABLE takes. + """ + from engineering.arch.rooms import read_records # the one ACADMCP_ARCH reader + from engineering.sheet.bom import ROW_HEIGHT, TEXT_HEIGHT + + _check(kind, lang) + try: + factor = float(scale) + except (TypeError, ValueError): + raise ValueError(f"scale: expected a number, got {scale!r}") from None + if not math.isfinite(factor) or factor <= 0.0: + raise ValueError(f"scale: must be a finite number greater than zero, got {scale!r}") + try: + x, y = float(at[0]), float(at[1]) + except (TypeError, ValueError, IndexError): + raise ValueError(f"at: expected [x, y], got {at!r}") from None + if not (math.isfinite(x) and math.isfinite(y)): + raise ValueError(f"at: must be two finite numbers, got {at!r}") + + plan = await read_records(backend) + rows = schedule_rows(kind, plan, lang=lang) + if not rows: + raise ValueError( + f"kind: this drawing carries no {kind[:-1]} records to schedule " + "(ACADMCP_ARCH on the architectural layers); draw the plan first" + ) + if len(rows) > MAX_ROWS: + raise ValueError( + f"kind: {len(rows)} {kind} rows do not fit one TABLE ({MAX_ROWS} data rows at most)" + ) + words = vocab(lang) + columns = SCHEDULE_COLUMNS[kind] + layer = ARCH_ROLE_LAYER["symbol"] + await _ensure_layer(backend, layer) + entity = await backend.entity_create_table( + x, + y, + [[row[c] for c in columns] for row in rows], + headers=[words[c] for c in columns], + column_widths=[COLUMN_WIDTHS[c] * factor for c in columns], + row_height=ROW_HEIGHT * factor, + text_height=TEXT_HEIGHT * factor, + title=words[SCHEDULE_TITLES[kind]], + layer=layer, + ) + width = sum(COLUMN_WIDTHS[c] for c in columns) * factor + height = (len(rows) + 2) * ROW_HEIGHT * factor + return { + "ok": True, + "kind": kind, + "lang": lang, + "handle": entity.handle, + "entity_type": entity.type, + "representation": entity.properties.get("representation", "native"), + "child_handles": list(entity.properties.get("child_handles") or []), + "layer": layer, + "columns": list(columns), + "headers": [words[c] for c in columns], + "title": words[SCHEDULE_TITLES[kind]], + "rows": [dict(row) for row in rows], + "row_count": len(rows), + "table_rows": entity.properties.get("rows"), + "insert": [x, y], + "bbox": {"min": [x, y - height], "max": [x + width, y]}, + } +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_schedule.py -q` +Expected: `11 passed`. + +- [ ] **Step 5: Re-export from the package** + +In `engineering/arch/__init__.py` add, in alphabetical module position among the relative imports, + +```python +from .schedule import SCHEDULE_KINDS, draw_schedule, schedule_rows +``` + +and add `"SCHEDULE_KINDS"`, `"draw_schedule"` and `"schedule_rows"` to `__all__`, keeping it sorted. + +Run: `uv run --frozen python -c "import engineering.arch as a; print(a.schedule_rows, a.SCHEDULE_KINDS)"` +Expected: the function and `('doors', 'windows', 'rooms')` print; no `ImportError`. + +- [ ] **Step 6: Write the failing wire test for the tool** + +Create `tests/test_arch_schedule_tools.py`: + +```python +"""arch_schedule over the MCP wire: rooms labelled by arch_room, scheduled as a TABLE.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client + +import server +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + +WALL = ARCH_ROLE_LAYER["wall"] + + +def rect(x0, y0, x1, y1): + return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] + + +@pytest_asyncio.fixture +async def client(monkeypatch): + """In-memory client on a fresh headless document - never the operator's AutoCAD.""" + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +async def draw_two_rooms(client): + """Rooms (200,200)-(4200,5200) and (4400,200)-(8400,5200) between 200 mm walls.""" + for (x1, y1), (x2, y2) in [ + *rect(0.0, 0.0, 8600.0, 5400.0), + *rect(200.0, 200.0, 8400.0, 5200.0), + ((4200.0, 200.0), (4200.0, 5200.0)), + ((4400.0, 200.0), (4400.0, 5200.0)), + ]: + await client.call_tool( + "entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": WALL} + ) + + +async def test_a_room_schedule_reads_the_labels_arch_room_wrote(client): + await draw_two_rooms(client) + await client.call_tool("arch_room", {"name": "LIVING", "number": "1", "x": 2200.0, "y": 2700.0}) + await client.call_tool("arch_room", {"name": "BED", "number": "2", "x": 6400.0, "y": 2700.0}) + result = ( + await client.call_tool("arch_schedule", {"kind": "rooms", "x": 9000.0, "y": 5400.0}) + ).structured_content + assert result["table_rows"] == 5 # title + header + two rooms + total + assert [(r["number"], r["area"]) for r in result["rows"]] == [ + ("1", "20.00 m²"), + ("2", "20.00 m²"), + ("", "40.00 m²"), + ] + + +async def test_nothing_to_schedule_is_refused_over_the_wire(client): + result = await client.call_tool( + "arch_schedule", {"kind": "doors", "x": 0.0, "y": 0.0}, raise_on_error=False + ) + assert result.is_error is True + assert "kind: this drawing carries no door records" in result.content[0].text +``` + +- [ ] **Step 7: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_schedule_tools.py -q` +Expected: `2 failed` — `fastmcp.exceptions.ToolError: Not found: Unknown tool: 'arch_schedule'` in the first, and an `AssertionError` in the second (the error text is the unknown-tool one, not the refusal). + +- [ ] **Step 8: Register `arch_schedule` under the rooms marker** + +In `server.py`, change the banner line + +```python +# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +``` + +to + +```python +# ── SECTION 25: Architecture (3 tools) ────────────────────────────────────── +``` + +then replace the end of `arch_rooms_detect` + +```python + try: + return await rooms_detect(_backend(ctx), layers=layers, min_area=min_area, tol=tol) + except ValueError as exc: + raise ToolError(str(exc)) from exc +``` + +with + +```python + try: + return await rooms_detect(_backend(ctx), layers=layers, min_area=min_area, tol=tol) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool(summary="Draw a door, window or room schedule from the plan records.", cost="mutate") +@mcp.tool( + annotations={"title": "Architecture: Schedule", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_schedule( + kind: Annotated[str, Field(description="doors | windows | rooms")], + x: Annotated[float, Field(description="WCS X of the table's top-left corner.")], + y: Annotated[float, Field(description="WCS Y of the table's top-left corner.")], + lang: Annotated[ + str, Field(default="en", description="Headers and numbers: en | tr (decimal comma).") + ] = "en", + scale: Annotated[ + float, + Field(default=50.0, gt=0, description="Plot-scale denominator: 50 is 1:50."), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Draw a door, window or room schedule as a real TABLE - a read of the drawing. + + The rows come from the `ACADMCP_ARCH` records on the drawing (what + `arch_wall` / `arch_opening` / `arch_room` write), never from the caller: + doors by tag with width, height, swing, hand and wall; windows by tag with + width, height, sill and wall; rooms by number with name and the measured + area, plus a total. An opening drawn without a tag gets the next free D1 / + W1 (K1 / P1 in Turkish) in wall order; an explicit tag is kept. A value the + model does not carry is an empty cell, never a default. `representation` + reports what was drawn: `native` (ACAD_TABLE, live) or `composite` (rules + and MTEXT, headless). + + Refused before anything is drawn: an unknown `kind` or `lang` (named with + the list), nothing of that kind on the drawing, and more rows than one + TABLE takes (198). + """ + from engineering.arch.schedule import draw_schedule + + await ctx.info(f"arch_schedule: {kind} at ({x}, {y})") + try: + return await draw_schedule(_backend(ctx), kind, at=(x, y), lang=lang, scale=scale) + except ValueError as exc: + raise ToolError(str(exc)) from exc +``` + +- [ ] **Step 9: Add the alias record** + +In `discovery/aliases.py`, the `SHARED_ACAD_COMMANDS` comment — replace + +```python +# TABLE -- one command makes an empty table (entity_create_table) or the +# ISO 7573 parts list (bom_table); both are correct destinations. +``` + +with + +```python +# TABLE -- one command makes an empty table (entity_create_table), the +# ISO 7573 parts list (bom_table) or a door / window / room schedule +# (arch_schedule); all three are correct destinations. +``` + +(`TABLE` is already in the frozenset; nothing else there changes.) Then replace the end of the `arch_rooms_detect` record + +```python + "odalari bul", + "kat plani alanlari", + ), + ), +} +``` + +with + +```python + "odalari bul", + "kat plani alanlari", + ), + ), + "arch_schedule": ToolAliases( + acad=("TABLE",), + synonyms=( + "door schedule", + "window schedule", + "room schedule", + "opening schedule", + "kapi cizelgesi", + "pencere cizelgesi", + "mahal listesi", + ), + ), +} +``` + +Measured before writing this plan with all three R tools registered in-process: the golden set is unchanged (tuning top-1 65/76, top-3 75/76; holdout top-3 15/15; A/B 78 vs 33 top-1), `"door schedule"` ranks `arch_schedule` first, and `"TABLE"` still ranks `entity_create_table` first with `arch_schedule` third. + +- [ ] **Step 10: Run the wire test to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_schedule_tools.py tests/test_arch_rooms_tools.py -q` +Expected: `6 passed`. + +- [ ] **Step 11: Regenerate the inventory and bump the documented counts** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` +Expected output: `wrote docs/tool-inventory.json: 232 tools, 8 resources, 5 prompts, 25 groups` + +In `README.md` change the release-snapshot lines from + +``` +> **v1.5 release snapshot:** 231 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 231 is the **registered** count; a default install advertises 226 over `tools/list`, +``` + +to + +``` +> **v1.5 release snapshot:** 232 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 232 is the **registered** count; a default install advertises 227 over `tools/list`, +``` + +and the caption `231 tools in 25 groups;` to `232 tools in 25 groups;`. The collected-tests figure and the alias-corpus sentence stay as they are (Task 9 and the merge retake them). + +- [ ] **Step 12: Bump the frozen group-size snapshot** + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: + +- `"""Frozen snapshot of the surface (231 tools, 25 groups).` becomes `"""Frozen snapshot of the surface (232 tools, 25 groups).`; +- `"entity_creation": 19,` becomes `"entity_creation": 20,`; +- `assert sum(sizes.values()) == 231` becomes `assert sum(sizes.values()) == 232`; +- replace the docstring paragraph Task 5 added + +```python + Track F group R opened SECTION 25 with the rooms: `arch_room` is tagged + `arch` and `create`, `arch_rooms_detect` `arch` and `query`. `arch` is not + in `_GROUP_TAG_PRIORITY` on this branch, so they file under their + secondary tags: `entity_creation` 18 -> 19, `entity_query` 9 -> 10. This + snapshot is branch-local: the track F merge task recomputes it once the + walls, rooms and catalogue branches have landed. +``` + +with + +```python + Track F group R opened SECTION 25 with the rooms: `arch_room` and + `arch_schedule` are tagged `arch` and `create`, `arch_rooms_detect` `arch` + and `query`. `arch` is not in `_GROUP_TAG_PRIORITY` on this branch, so they + file under their secondary tags: `entity_creation` 18 -> 20, + `entity_query` 9 -> 10. This snapshot is branch-local: the track F merge + task recomputes it once the walls, rooms and catalogue branches have landed. +``` + +- [ ] **Step 13: Gates** + +Run each and require it clean: + +``` +uv run --frozen ruff check engineering/arch/schedule.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_schedule.py tests/test_arch_schedule_tools.py tests/test_tool_registry.py +uv run --frozen ruff format engineering/arch/schedule.py engineering/arch/__init__.py tests/test_arch_schedule.py tests/test_arch_schedule_tools.py +uv run --frozen ruff format --check server.py discovery/aliases.py tests/test_tool_registry.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_arch_schedule.py tests/test_arch_schedule_tools.py tests/test_arch_faces.py tests/test_arch_rooms.py tests/test_arch_rooms_tools.py -q +uv run --frozen pytest tests/test_tool_registry.py tests/test_tool_inventory.py tests/test_tool_packs.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_capability_contract.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +`generate_tool_inventory.py --check` prints `ok: docs/tool-inventory.json matches the live surface`. The deselect is the known-red collected-tests drift named in the Global Constraints — record it in the task report. `test_section_header_counts_match_decorators` must pass with the banner at `(3 tools)`. No capability key is added. + +- [ ] **Step 14: Commit** + +``` +git add engineering/arch/schedule.py engineering/arch/__init__.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py tests/test_arch_schedule.py tests/test_arch_schedule_tools.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-6): door, window and room schedules read from the drawing + +engineering/arch/schedule.py turns the plan records into schedule rows: doors +by tag with width, height, swing, hand and wall; windows by tag with width, +height, sill and wall; rooms by number with name and the measured area the +label carries, plus a total. Tags compare as numbers (D2 before D10). An +opening without a tag gets the next free D1 / W1 - K1 / P1 in Turkish - in +wall order; an explicit tag is never renumbered and a generated one never +repeats it. A value the model does not carry is an empty cell, never a default, +and Turkish output writes decimal commas. + +draw_schedule reads the ACADMCP_ARCH records off the drawing through the one +reader rooms.py already has and draws a real TABLE the way the ISO 7573 parts +list does - top-left insertion, the table growing down, the parts list's 7 mm +rows and 2.5 mm text at the plot scale, the engine's representation reported. +Nothing to schedule, an unknown kind or language and more rows than one TABLE +takes are refused before anything is drawn. + +arch_schedule joins arch_room and arch_rooms_detect under SECTION 25's rooms +marker (banner 3 tools), shares the TABLE command with entity_create_table and +bom_table, and the counts follow: 232 tools in 25 groups. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 7: The furniture and sanitary catalogue + +**Files:** +- Create: `engineering/arch/catalogue.py` +- Create: `tests/test_arch_catalogue.py` +- Create: `tests/test_arch_catalogue_tools.py` +- Modify: `engineering/arch/__init__.py` (re-export the catalogue names) +- Modify: `server.py` (open SECTION 25 on this branch: the banner `(2 tools)`, the `# ── arch: catalogue (group C) ──` marker, `arch_catalogue_list` and `arch_catalogue_insert`, immediately **above** the `# ── RESOURCES ──` banner pair) +- Modify: `discovery/aliases.py` (one `ToolAliases` record per new tool) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release-snapshot tool count 229 → 231, advertised 224 → 226, caption 229 → 231 tools) +- Modify: `tests/test_tool_registry.py` (frozen group sizes: `entity_query` 9 → 10, `entity_creation` 18 → 19, total 231) + +**Interfaces:** +- Consumes (Task 1, already on `main` when this branch was cut): + - `engineering/arch/lang.py`: `LANGS = ("en", "tr")` + - `engineering/arch/layers.py`: `ARCH_LAYERS: list[tuple[str, int, str, float, str]]` (name, aci, linetype, lineweight_mm, description) and `ARCH_ROLE_LAYER` with `"furniture": "A-FURN-E-N"` and `"sanitary": "A-SANR-E-N"` + - `backends/block_specs.py`: the `block_define` spec vocabulary — `line` (`x1 y1 x2 y2`), `circle` (`cx cy r`), `arc` (`cx cy r start_deg end_deg`), `polyline` (`points`, `closed`), each with `layer`; ATTDEF keys `tag prompt default x y height align invisible` + - backend members `layer_list()`, `layer_create(name, color, linetype, lineweight)`, `block_list()`, `block_define(name, entities, attdefs, base_x, base_y, overwrite, create_layers) -> dict`, `block_insert(name, x, y, scale_x, scale_y, rotation, attributes, layer) -> EntityInfo` +- Produces (the skeleton's pinned signatures, plus the names later tasks and the merge read): + - `def catalogue(query: str | None = None, family: str | None = None, *, lang="en") -> tuple[dict, ...]` — rows `{"name", "family", "label", "label_en", "label_tr", "size": [w, d], "size_basis", "block", "layer"}` + - `def block_spec(name: str) -> dict` — `{"name", "entities", "attdefs", "base_x", "base_y", "item", "family", "size", "size_basis"}` + - `async def insert_item(backend, name, *, at, rotation=0.0) -> dict` — `{"ok", "handle", "block_name", "defined", "item", "family", "layer", "layer_created", "at", "rotation", "size", "size_basis", "backend"}` + - constants `FAMILIES = ("furniture", "sanitary")`, `SIZE_BASIS: str`, `ITEMS: tuple[CatalogueItem, ...]` (20 rows), `CATALOGUE_NAMES: tuple[str, ...]`, `BLOCK_PREFIX = "ARCH_"`, `ITEM_TAG = "ITEM"` + - MCP tools `arch_catalogue_list(query, family, lang)` and `arch_catalogue_insert(name, x, y, rotation)` + +Block-local frame, fixed for every item and asserted by the tests: origin at the item's **back-left corner**, the back (the wall side) along +X, the item towards +Y, every primitive inside `[0, w] x [0, d]`. Block content is on layer `0` (colour and linetype ByBlock, which is how `block_define` writes them), so the INSERT's layer decides how the item looks. The sizes are **nominal catalogue dimensions**, stated in `SIZE_BASIS`, in every row and in both tool docstrings — no standard is cited, because none fixes a bed's or a WC's size. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_arch_catalogue.py`: + +```python +"""The furniture and sanitary catalogue: authored outlines, search, blocks. + +Pure half first (every item's primitives against its declared nominal size, +the search in both languages, the refusal), then the insert through the +headless backend - the `backend` fixture in tests/conftest.py. +""" + +from __future__ import annotations + +import pytest + +from backends.block_specs import validate_attdef_specs, validate_entity_specs +from engineering.arch.catalogue import ( + CATALOGUE_NAMES, + FAMILIES, + ITEMS, + SIZE_BASIS, + block_spec, + catalogue, + insert_item, +) +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.mech.primitives import Arc, points_of + + +def _spec_points(spec: dict) -> list[tuple[float, float]]: + """Every point that bounds one block_define spec - an arc by its real sweep.""" + kind = spec["type"] + if kind == "line": + return [(spec["x1"], spec["y1"]), (spec["x2"], spec["y2"])] + if kind == "circle": + cx, cy, r = spec["cx"], spec["cy"], spec["r"] + return [(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)] + if kind == "arc": + arc = Arc((spec["cx"], spec["cy"]), spec["r"], spec["start_deg"], spec["end_deg"]) + return list(points_of(arc)) + if kind == "polyline": + return [tuple(p) for p in spec["points"]] + raise AssertionError(f"unexpected spec type {kind!r}") + + +def test_the_catalogue_holds_twenty_items_in_two_families(): + assert len(ITEMS) == 20 + assert len(set(CATALOGUE_NAMES)) == 20 + assert FAMILIES == ("furniture", "sanitary") + assert sum(item.family == "furniture" for item in ITEMS) == 13 + assert sum(item.family == "sanitary" for item in ITEMS) == 7 + assert { + "single_bed", + "double_bed", + "wardrobe", + "sofa_3_seat", + "armchair", + "dining_table_4", + "desk", + "kitchen_counter", + "fridge", + "bookshelf", + "wc", + "wall_basin", + "shower_tray", + "bathtub", + "kitchen_sink", + "washing_machine", + } <= set(CATALOGUE_NAMES) + for name in CATALOGUE_NAMES: + assert name == name.lower() and " " not in name + + +@pytest.mark.parametrize("name", CATALOGUE_NAMES) +def test_every_item_stays_inside_its_declared_nominal_size(name): + spec = block_spec(name) + w, d = spec["size"] + assert w > 0 and d > 0 + for entity in spec["entities"]: + for x, y in _spec_points(entity): + assert -1e-9 <= x <= w + 1e-9, (name, entity) + assert -1e-9 <= y <= d + 1e-9, (name, entity) + for att in spec["attdefs"]: + assert 0.0 <= att["x"] <= w and 0.0 <= att["y"] <= d + + +@pytest.mark.parametrize("name", CATALOGUE_NAMES) +def test_every_block_spec_validates_against_block_define(name): + spec = block_spec(name) + normalised = validate_entity_specs(spec["entities"]) + assert {entity["layer"] for entity in normalised} == {"0"} + (att,) = validate_attdef_specs(spec["attdefs"]) + assert att["tag"] == "ITEM" and att["default"] == name and att["invisible"] is True + assert spec["name"] == "ARCH_" + name.upper() + assert spec["size_basis"] == SIZE_BASIS + + +def test_every_outline_reaches_its_declared_size(): + """The declared size is the item's footprint, not a loose bound.""" + for name in CATALOGUE_NAMES: + spec = block_spec(name) + points = [p for entity in spec["entities"] for p in _spec_points(entity)] + w, d = spec["size"] + assert min(x for x, _ in points) == pytest.approx(0.0, abs=1e-9), name + assert max(x for x, _ in points) == pytest.approx(w, abs=1e-9), name + assert min(y for _, y in points) == pytest.approx(0.0, abs=1e-9), name + # the wc's bowl arc stops at y = 680 inside its 700 mm nominal depth + assert max(y for _, y in points) <= d + 1e-9, name + + +def test_the_wc_bowl_is_a_semicircle_joining_its_two_sides(): + spec = block_spec("wc") + arc = next(e for e in spec["entities"] if e["type"] == "arc") + assert (arc["cx"], arc["cy"], arc["r"], arc["start_deg"], arc["end_deg"]) == ( + 190.0, + 530.0, + 150.0, + 0.0, + 180.0, + ) + sides = [e for e in spec["entities"] if e["type"] == "line"] + assert [(s["x1"], s["y2"]) for s in sides] == [(40.0, 530.0), (340.0, 530.0)] + + +def test_the_sofa_cushions_split_the_seat_into_three_equal_widths(): + spec = block_spec("sofa_3_seat") + verticals = sorted( + e["x1"] for e in spec["entities"] if e["type"] == "line" and e["x1"] == e["x2"] + ) + assert verticals == [200.0, 800.0, 1400.0, 2000.0] + + +def test_search_by_english_word(): + names = [row["name"] for row in catalogue("bed")] + assert names == ["single_bed", "double_bed"] + assert [row["name"] for row in catalogue("washing machine")] == ["washing_machine"] + + +def test_search_by_turkish_word_with_and_without_diacritics(): + assert [row["name"] for row in catalogue("yatak")] == ["single_bed", "double_bed"] + assert [row["name"] for row in catalogue("buzdolabı")] == ["fridge"] + assert [row["name"] for row in catalogue("buzdolabi")] == ["fridge"] + assert [row["name"] for row in catalogue("KLOZET")] == ["wc"] + assert [row["name"] for row in catalogue("çamaşır")] == ["washing_machine"] + assert [row["name"] for row in catalogue("camasir")] == ["washing_machine"] + + +def test_search_every_word_must_match(): + assert [row["name"] for row in catalogue("masa")] == ["dining_table_4", "desk"] + assert [row["name"] for row in catalogue("yemek masa")] == ["dining_table_4"] + assert catalogue("bed sink") == () + + +def test_family_filter_and_the_label_language(): + sanitary = catalogue(family="sanitary") + assert [row["name"] for row in sanitary] == [ + "wc", + "bidet", + "wall_basin", + "shower_tray", + "bathtub", + "kitchen_sink", + "washing_machine", + ] + assert all(row["layer"] == ARCH_ROLE_LAYER["sanitary"] for row in sanitary) + (row,) = catalogue("fridge", lang="tr") + assert row["label"] == "buzdolabı" and row["label_en"] == "fridge" + assert row["size"] == [600.0, 650.0] + assert row["size_basis"] == SIZE_BASIS + assert row["block"] == "ARCH_FRIDGE" + assert len(catalogue()) == 20 + + +def test_unknown_family_and_language_are_refused_with_the_list(): + with pytest.raises(ValueError, match="furniture, sanitary"): + catalogue(family="lighting") + with pytest.raises(ValueError, match="en, tr"): + catalogue(lang="de") + + +def test_an_unknown_name_is_refused_with_the_nearest_names(): + with pytest.raises(ValueError, match=r"no item 'singlebed'; nearest: single_bed"): + block_spec("singlebed") + with pytest.raises(ValueError, match="the catalogue holds single_bed"): + block_spec("zzz") + + +def test_a_label_spelling_resolves_to_the_item(): + assert block_spec("Single Bed")["item"] == "single_bed" + assert block_spec("washing-machine")["item"] == "washing_machine" + + +@pytest.mark.asyncio +async def test_insert_defines_the_block_once_across_two_inserts(backend): + first = await insert_item(backend, "double_bed", at=(1000.0, 2000.0)) + assert first["ok"] is True + assert first["block_name"] == "ARCH_DOUBLE_BED" + assert first["defined"] is True + assert first["layer"] == ARCH_ROLE_LAYER["furniture"] + assert first["layer_created"] is True + assert first["size"] == [1600.0, 2000.0] + assert first["size_basis"] == SIZE_BASIS + + second = await insert_item(backend, "double_bed", at=(4000.0, 2000.0), rotation=90.0) + assert second["defined"] is False + assert second["layer_created"] is False + assert second["rotation"] == 90.0 + + blocks = [b.name for b in await backend.block_list() if b.name == "ARCH_DOUBLE_BED"] + assert blocks == ["ARCH_DOUBLE_BED"] + inserts = await backend.entity_list(type_filter="INSERT", limit=100) + assert sorted(e.handle for e in inserts) == sorted([first["handle"], second["handle"]]) + assert {e.layer for e in inserts} == {ARCH_ROLE_LAYER["furniture"]} + assert await backend.block_get_attributes(first["handle"]) == {"ITEM": "double_bed"} + + +@pytest.mark.asyncio +async def test_insert_creates_the_layer_from_its_arch_layers_row(backend): + result = await insert_item(backend, "wc", at=(0.0, 0.0)) + layer = ARCH_ROLE_LAYER["sanitary"] + assert result["layer"] == layer + row = next(row for row in ARCH_LAYERS if row[0] == layer) + info = next(info for info in await backend.layer_list() if info.name == layer) + assert info.color == row[1] + + +@pytest.mark.asyncio +async def test_insert_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="nearest: bathtub"): + await insert_item(backend, "bathtup", at=(0.0, 0.0)) + with pytest.raises(ValueError, match="finite"): + await insert_item(backend, "bathtub", at=(float("nan"), 0.0)) + with pytest.raises(ValueError, match="rotation must be finite"): + await insert_item(backend, "bathtub", at=(0.0, 0.0), rotation=float("inf")) + assert len(await backend.entity_list()) == before + assert "ARCH_BATHTUB" not in {b.name for b in await backend.block_list()} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_catalogue.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.catalogue'`. + +- [ ] **Step 3: Write `engineering/arch/catalogue.py`** + +```python +"""The furniture and sanitary catalogue: authored outlines in, real blocks out. + +The standard-parts pattern of track B (``engineering/mech/stdparts.py``) +applied to an architectural plan: :func:`catalogue` searches the items by +name, family and their English or Turkish label; :func:`block_spec` turns one +item into the typed primitives and the ATTDEF ``block_define`` takes; +:func:`insert_item` defines the block ``ARCH_`` once, inserts it on the +furniture or sanitary layer and reports what it did. + +**Sizes are nominal catalogue dimensions, not standards values.** A 900 x 2000 +single bed or a 600 x 600 washing machine is what a furniture or appliance +catalogue lists as a common nominal size; no standard fixes it, and every row +says so in ``SIZE_BASIS``. The outlines are plan symbols drawn inside that +footprint, not manufacturer drawings. + +Block-local frame, the same for every item: the origin is the **back-left +corner**, the back (the side that stands against a wall) runs along +X from the +origin, and the item extends towards +Y. So ``rotation=0`` puts an item's back +on a wall that runs along +X below it, and every primitive lies inside +``[0, w] x [0, d]`` - a test holds each item to that. + +Block content is on layer ``0`` (colour and linetype ByBlock, which is how +``block_define`` writes them), so an INSERT takes the look of the layer it is +inserted on: ``ARCH_ROLE_LAYER["furniture"]`` or ``ARCH_ROLE_LAYER["sanitary"]``. +""" + +from __future__ import annotations + +import difflib +import math +from typing import TYPE_CHECKING, NamedTuple + +from engineering.arch.lang import LANGS +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +FAMILIES: tuple[str, ...] = ("furniture", "sanitary") + +#: What every size in this module is - stated in each row and in the tool docstring. +SIZE_BASIS = "nominal catalogue dimensions (w x d, mm) - not a standards value" + +BLOCK_PREFIX = "ARCH_" + +#: The one ATTDEF every catalogue block carries: the item name, invisible, so a +#: count or a data extract can read what the INSERT is without parsing its name. +ITEM_TAG = "ITEM" +ITEM_TAG_HEIGHT = 100.0 + + +# -- block_define spec helpers (block-local mm, layer 0) --------------------- + + +def _line(x1: float, y1: float, x2: float, y2: float) -> dict: + return { + "type": "line", + "x1": float(x1), + "y1": float(y1), + "x2": float(x2), + "y2": float(y2), + "layer": "0", + } + + +def _circle(cx: float, cy: float, r: float) -> dict: + return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r), "layer": "0"} + + +def _arc(cx: float, cy: float, r: float, start_deg: float, end_deg: float) -> dict: + return { + "type": "arc", + "cx": float(cx), + "cy": float(cy), + "r": float(r), + "start_deg": float(start_deg), + "end_deg": float(end_deg), + "layer": "0", + } + + +def _poly(points, closed: bool) -> dict: + return { + "type": "polyline", + "points": [[float(x), float(y)] for x, y in points], + "closed": bool(closed), + "layer": "0", + } + + +def _rect(x0: float, y0: float, x1: float, y1: float) -> dict: + return _poly(((x0, y0), (x1, y0), (x1, y1), (x0, y1)), True) + + +# -- the items --------------------------------------------------------------- + + +class CatalogueItem(NamedTuple): + name: str + family: str + label_en: str + label_tr: str + w: float + d: float + entities: tuple[dict, ...] + + +def _chair(x0: float, y0: float, back_at_top: bool) -> tuple[dict, ...]: + """A 450 x 450 chair seat with its back rail 60 mm in from the back edge.""" + back_y = y0 + 390.0 if back_at_top else y0 + 60.0 + return (_rect(x0, y0, x0 + 450.0, y0 + 450.0), _line(x0, back_y, x0 + 450.0, back_y)) + + +ITEMS: tuple[CatalogueItem, ...] = ( + # -- furniture ----------------------------------------------------------- + CatalogueItem( + "single_bed", + "furniture", + "single bed", + "tek kişilik yatak", + 900.0, + 2000.0, + (_rect(0, 0, 900, 2000), _rect(100, 50, 800, 450), _line(0, 700, 900, 700)), + ), + CatalogueItem( + "double_bed", + "furniture", + "double bed", + "çift kişilik yatak", + 1600.0, + 2000.0, + ( + _rect(0, 0, 1600, 2000), + _rect(100, 50, 750, 450), + _rect(850, 50, 1500, 450), + _line(0, 700, 1600, 700), + ), + ), + CatalogueItem( + "wardrobe", + "furniture", + "wardrobe", + "gardırop", + 1200.0, + 600.0, + (_rect(0, 0, 1200, 600), _line(50, 300, 1150, 300)), + ), + CatalogueItem( + "sofa_3_seat", + "furniture", + "sofa, three seats", + "üçlü kanepe", + 2200.0, + 900.0, + ( + _rect(0, 0, 2200, 900), + _line(200, 0, 200, 900), + _line(2000, 0, 2000, 900), + _line(200, 200, 2000, 200), + _line(800, 200, 800, 900), + _line(1400, 200, 1400, 900), + ), + ), + CatalogueItem( + "armchair", + "furniture", + "armchair", + "berjer koltuk", + 900.0, + 900.0, + ( + _rect(0, 0, 900, 900), + _line(150, 0, 150, 900), + _line(750, 0, 750, 900), + _line(150, 200, 750, 200), + ), + ), + CatalogueItem( + "chair", + "furniture", + "chair", + "sandalye", + 450.0, + 450.0, + _chair(0.0, 0.0, back_at_top=False), + ), + CatalogueItem( + "dining_table_4", + "furniture", + "dining table with four chairs", + "dört kişilik yemek masası", + 1400.0, + 1700.0, + ( + _rect(0, 450, 1400, 1250), + *_chair(125.0, 0.0, back_at_top=False), + *_chair(825.0, 0.0, back_at_top=False), + *_chair(125.0, 1250.0, back_at_top=True), + *_chair(825.0, 1250.0, back_at_top=True), + ), + ), + CatalogueItem( + "desk", + "furniture", + "desk", + "çalışma masası", + 1400.0, + 700.0, + (_rect(0, 0, 1400, 700), _line(1000, 0, 1000, 700)), + ), + CatalogueItem( + "kitchen_counter", + "furniture", + "kitchen counter run", + "mutfak tezgâhı", + 2400.0, + 600.0, + ( + _rect(0, 0, 2400, 600), + _line(600, 0, 600, 600), + _line(1200, 0, 1200, 600), + _line(1800, 0, 1800, 600), + ), + ), + CatalogueItem( + "fridge", + "furniture", + "fridge", + "buzdolabı", + 600.0, + 650.0, + (_rect(0, 0, 600, 650), _line(0, 600, 600, 600)), + ), + CatalogueItem( + "bookshelf", + "furniture", + "bookshelf", + "kitaplık", + 800.0, + 300.0, + (_rect(0, 0, 800, 300), _line(0, 0, 800, 300)), + ), + CatalogueItem( + "nightstand", + "furniture", + "nightstand", + "komodin", + 500.0, + 400.0, + (_rect(0, 0, 500, 400),), + ), + CatalogueItem( + "coffee_table", + "furniture", + "coffee table", + "orta sehpa", + 1100.0, + 600.0, + (_rect(0, 0, 1100, 600),), + ), + # -- sanitary ------------------------------------------------------------ + CatalogueItem( + "wc", + "sanitary", + "wc with cistern", + "klozet", + 380.0, + 700.0, + ( + _rect(0, 0, 380, 180), + _line(40, 180, 40, 530), + _line(340, 180, 340, 530), + _arc(190, 530, 150, 0, 180), + ), + ), + CatalogueItem( + "bidet", + "sanitary", + "bidet", + "bide", + 360.0, + 560.0, + ( + _line(0, 0, 360, 0), + _line(0, 0, 0, 380), + _line(360, 0, 360, 380), + _arc(180, 380, 180, 0, 180), + _circle(180, 80, 20), + _circle(180, 380, 20), + ), + ), + CatalogueItem( + "wall_basin", + "sanitary", + "wall basin", + "lavabo", + 550.0, + 450.0, + ( + _poly(((0, 175), (0, 0), (550, 0), (550, 175)), False), + _arc(275, 175, 275, 0, 180), + _circle(275, 225, 150), + _circle(275, 225, 20), + _circle(275, 50, 15), + ), + ), + CatalogueItem( + "shower_tray", + "sanitary", + "shower tray", + "duş teknesi", + 900.0, + 900.0, + (_rect(0, 0, 900, 900), _rect(50, 50, 850, 850), _circle(450, 450, 50)), + ), + CatalogueItem( + "bathtub", + "sanitary", + "bathtub", + "küvet", + 1700.0, + 700.0, + (_rect(0, 0, 1700, 700), _rect(60, 60, 1640, 640), _circle(200, 350, 25)), + ), + CatalogueItem( + "kitchen_sink", + "sanitary", + "kitchen sink with drainer", + "eviye", + 1000.0, + 500.0, + ( + _rect(0, 0, 1000, 500), + _rect(50, 50, 450, 450), + _circle(250, 250, 30), + _line(550, 150, 950, 150), + _line(550, 250, 950, 250), + _line(550, 350, 950, 350), + ), + ), + CatalogueItem( + "washing_machine", + "sanitary", + "washing machine", + "çamaşır makinesi", + 600.0, + 600.0, + (_rect(0, 0, 600, 600), _circle(300, 300, 200)), + ), +) + +CATALOGUE_NAMES: tuple[str, ...] = tuple(item.name for item in ITEMS) +_BY_NAME: dict[str, CatalogueItem] = {item.name: item for item in ITEMS} + +#: Turkish letters folded to ASCII so "buzdolabi" finds "buzdolabı" and a +#: query typed on an English keyboard still reaches the Turkish label. +_FOLD = str.maketrans( + {"ç": "c", "ğ": "g", "ı": "i", "ö": "o", "ş": "s", "ü": "u", "â": "a", "î": "i", "û": "u"} +) + + +def _fold(text: str) -> str: + """Lower-case and fold Turkish letters. ``İ`` is mapped before ``lower()``, + which would otherwise turn it into ``i`` plus a combining dot.""" + return " ".join(str(text).replace("İ", "i").lower().translate(_FOLD).split()) + + +def _check_lang(lang: str) -> str: + if lang not in LANGS: + raise ValueError(f"lang must be one of {', '.join(LANGS)}, got {lang!r}.") + return lang + + +def _row(item: CatalogueItem, lang: str) -> dict: + return { + "name": item.name, + "family": item.family, + "label": item.label_tr if lang == "tr" else item.label_en, + "label_en": item.label_en, + "label_tr": item.label_tr, + "size": [item.w, item.d], + "size_basis": SIZE_BASIS, + "block": f"{BLOCK_PREFIX}{item.name.upper()}", + "layer": ARCH_ROLE_LAYER[item.family], + } + + +def catalogue( + query: str | None = None, family: str | None = None, *, lang: str = "en" +) -> tuple[dict, ...]: + """Every catalogue item, optionally filtered. + + ``family`` is ``furniture`` or ``sanitary`` (anything else is refused with + the list). ``query`` is split into words and every word must occur in the + item's name, family, English label or Turkish label; matching ignores case + and Turkish diacritics, so ``'bed'``, ``'yatak'``, ``'KLOZET'`` and + ``'buzdolabi'`` all work. ``lang`` picks the ``label`` column. + """ + _check_lang(lang) + if family is not None and family not in FAMILIES: + raise ValueError(f"family must be one of {', '.join(FAMILIES)}, got {family!r}.") + words = _fold(query).split() if query else [] + rows: list[dict] = [] + for item in ITEMS: + if family is not None and item.family != family: + continue + haystack = _fold( + f"{item.name.replace('_', ' ')} {item.family} {item.label_en} {item.label_tr}" + ) + if all(word in haystack for word in words): + rows.append(_row(item, lang)) + return tuple(rows) + + +def _resolve(name: str) -> CatalogueItem: + key = "_".join(str(name).replace("-", " ").lower().split()) + item = _BY_NAME.get(key) + if item is not None: + return item + close = difflib.get_close_matches(key, CATALOGUE_NAMES, n=3, cutoff=0.4) + hint = ( + f"nearest: {', '.join(close)}" + if close + else f"the catalogue holds {', '.join(CATALOGUE_NAMES)}" + ) + raise ValueError(f"arch catalogue: no item {name!r}; {hint}.") + + +def block_spec(name: str) -> dict: + """The typed primitives and the ITEM ATTDEF ``block_define`` takes for one item. + + Returns ``{"name", "entities", "attdefs", "base_x", "base_y", "item", + "family", "size", "size_basis"}``. An unknown name is refused with the + nearest catalogue names before anything is built. + """ + item = _resolve(name) + return { + "name": f"{BLOCK_PREFIX}{item.name.upper()}", + "entities": [dict(spec) for spec in item.entities], + "attdefs": [ + { + "tag": ITEM_TAG, + "prompt": "Catalogue item", + "default": item.name, + "x": item.w / 2.0, + "y": item.d / 2.0, + "height": ITEM_TAG_HEIGHT, + "align": "middle_center", + "invisible": True, + } + ], + "base_x": 0.0, + "base_y": 0.0, + "item": item.name, + "family": item.family, + "size": [item.w, item.d], + "size_basis": SIZE_BASIS, + } + + +async def _ensure_layer(backend: AutoCADBackend, name: str) -> bool: + """Create the INSERT's layer from its ``ARCH_LAYERS`` row when the drawing lacks it. + + Live ActiveX refuses ``entity.Layer = "X"`` on a missing layer (the + repository rule in CLAUDE.md); the headless engine would create it + silently with no colour or lineweight. Returns whether it was created. + """ + if name.lower() in {layer.name.lower() for layer in await backend.layer_list()}: + return False + row = next((row for row in ARCH_LAYERS if row[0] == name), None) + color, linetype, lineweight = (row[1], row[2], row[3]) if row else (7, "Continuous", 0.25) + await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight) + return True + + +async def insert_item(backend: AutoCADBackend, name: str, *, at, rotation: float = 0.0) -> dict: + """Define ``ARCH_`` once, insert it at ``at`` turned by ``rotation``. + + ``at`` is the WCS point the item's back-left corner lands on; ``rotation`` + is degrees CCW about it. Refusals - an unknown item (with the nearest + names), a non-finite coordinate or rotation - fire before any write. + """ + spec = block_spec(name) + try: + x, y = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"arch catalogue: 'at' must be two numbers, got {at!r}.") from exc + if not (math.isfinite(x) and math.isfinite(y)): + raise ValueError(f"arch catalogue: 'at' must be two finite numbers, got {at!r}.") + angle = float(rotation) + if not math.isfinite(angle): + raise ValueError(f"arch catalogue: rotation must be finite, got {rotation!r}.") + + layer = ARCH_ROLE_LAYER[spec["family"]] + layer_created = await _ensure_layer(backend, layer) + existing = {block.name.upper() for block in await backend.block_list()} + defined = spec["name"].upper() not in existing + if defined: + await backend.block_define( + spec["name"], + spec["entities"], + spec["attdefs"], + spec["base_x"], + spec["base_y"], + False, + False, + ) + ref = await backend.block_insert( + spec["name"], x, y, 1.0, 1.0, angle, {ITEM_TAG: spec["item"]}, layer + ) + return { + "ok": True, + "handle": ref.handle, + "block_name": spec["name"], + "defined": defined, + "item": spec["item"], + "family": spec["family"], + "layer": layer, + "layer_created": layer_created, + "at": [x, y], + "rotation": angle, + "size": spec["size"], + "size_basis": SIZE_BASIS, + "backend": backend.name, + } +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_catalogue.py -q` +Expected: PASS — 54 passed (16 test functions; the two parametrised over the 20 items give 40 of them). + +- [ ] **Step 5: Re-export the catalogue from `engineering/arch/__init__.py`** + +Add this block among the file's relative imports in alphabetical module order — on this branch that is first, before `.lang` (additive: the merge keeps every branch's block): + +```python +from .catalogue import ( + CATALOGUE_NAMES, + FAMILIES, + ITEMS, + SIZE_BASIS, + CatalogueItem, + block_spec, + catalogue, + insert_item, +) +``` + +and add the eight names `"CATALOGUE_NAMES"`, `"CatalogueItem"`, `"FAMILIES"`, `"ITEMS"`, `"SIZE_BASIS"`, `"block_spec"`, `"catalogue"`, `"insert_item"` to `__all__` in its alphabetical order. Then run `uv run --frozen python -c "import engineering.arch as a; print(len(a.ITEMS))"` — expected output `20`. + +- [ ] **Step 6: Write the failing tool test** + +Create `tests/test_arch_catalogue_tools.py`: + +```python +"""arch_catalogue_list / arch_catalogue_insert through a real MCP client.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server +from engineering.arch.catalogue import SIZE_BASIS +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + await connected.call_tool("drawing_new", {}) + yield connected + + +async def test_list_searches_in_turkish_and_states_the_size_basis(client): + result = ( + await client.call_tool("arch_catalogue_list", {"query": "yatak", "lang": "tr"}) + ).structured_content + assert result["count"] == 2 + assert [row["name"] for row in result["items"]] == ["single_bed", "double_bed"] + assert result["items"][0]["label"] == "tek kişilik yatak" + assert result["size_basis"] == SIZE_BASIS + assert result["families"] == ["furniture", "sanitary"] + + +async def test_insert_twice_defines_once(client): + first = ( + await client.call_tool("arch_catalogue_insert", {"name": "wc", "x": 0.0, "y": 0.0}) + ).structured_content + second = ( + await client.call_tool( + "arch_catalogue_insert", {"name": "wc", "x": 1000.0, "y": 0.0, "rotation": 180.0} + ) + ).structured_content + assert first["defined"] is True and second["defined"] is False + assert first["block_name"] == second["block_name"] == "ARCH_WC" + assert first["layer"] == ARCH_ROLE_LAYER["sanitary"] + + +async def test_an_unknown_item_is_a_tool_error_naming_the_nearest(client): + with pytest.raises(ToolError, match="nearest: wardrobe"): + await client.call_tool("arch_catalogue_insert", {"name": "wardrob", "x": 0.0, "y": 0.0}) + with pytest.raises(ToolError, match="furniture, sanitary"): + await client.call_tool("arch_catalogue_list", {"family": "garden"}) +``` + +- [ ] **Step 7: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_catalogue_tools.py -q` +Expected: FAIL — 3 failed. The first two with `fastmcp.exceptions.ToolError: Not found: Unknown tool: 'arch_catalogue_list'` / `'arch_catalogue_insert'`; `test_an_unknown_item_is_a_tool_error_naming_the_nearest` with `AssertionError: Regex pattern did not match`, because the `ToolError` it catches is that same unknown-tool error, not the catalogue's refusal. + +- [ ] **Step 8: Open SECTION 25 in `server.py` with the catalogue block** + +On this branch SECTION 25 does not exist yet (group W opens it on its own branch; the merge keeps one banner — see the skeleton's Execution section). Insert the following immediately above the two-line `# ---…` / `# ── RESOURCES ──…` banner pair that follows `drawing_export_dwg` (the end of SECTION 24), leaving that RESOURCES pair untouched: + +```python +# --------------------------------------------------------------------------- +# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +# --------------------------------------------------------------------------- +# ── arch: catalogue (group C) ── + + +@cad_tool( + summary="Search the furniture and sanitary catalogue in English or Turkish.", + cost="read", +) +@mcp.tool( + annotations={"title": "Architecture: Catalogue", "readOnlyHint": True}, + tags={"arch", "query"}, +) +async def arch_catalogue_list( + query: Annotated[ + str | None, + Field( + default=None, + description=( + "Words matched against the item name, family and its English or Turkish " + "label, ignoring case and Turkish diacritics - 'bed', 'yatak', 'klozet', " + "'buzdolabi'. Every word must match." + ), + ), + ] = None, + family: Annotated[ + str | None, + Field(default=None, description="furniture | sanitary"), + ] = None, + lang: Annotated[ + str, + Field(default="en", description="en | tr - the language of the 'label' column"), + ] = "en", + ctx: Context = None, +) -> dict: + """Catalogue rows: name, family, both labels, nominal size [w, d] in mm, block name, layer. + + Sizes are nominal catalogue dimensions - what a furniture or appliance + catalogue lists as a common size - and every row says so in `size_basis`; + they are not standards values. Twenty items: thirteen furniture, seven + sanitary. Refusals: a `family` other than furniture/sanitary and a `lang` + other than en/tr, each with the list. + """ + from engineering.arch.catalogue import FAMILIES, SIZE_BASIS, catalogue + + try: + rows = catalogue(query, family, lang=lang) + except ValueError as exc: + raise ToolError(str(exc)) from exc + return { + "count": len(rows), + "families": list(FAMILIES), + "size_basis": SIZE_BASIS, + "items": list(rows), + } + + +@cad_tool( + summary="Insert a furniture or sanitary item - bed, sofa, WC, basin, bathtub - as a block.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Catalogue Insert", "readOnlyHint": False}, + tags={"arch", "create"}, +) +async def arch_catalogue_insert( + name: Annotated[ + str, + Field( + description=( + "Catalogue name from arch_catalogue_list, e.g. 'double_bed', 'sofa_3_seat', " + "'wc', 'wall_basin', 'kitchen_counter'" + ) + ), + ], + x: Annotated[float, Field(description="X (WCS) of the item's back-left corner")], + y: Annotated[float, Field(description="Y (WCS) of the item's back-left corner")], + rotation: Annotated[ + float, + Field( + default=0.0, + description=( + "Degrees CCW about (x, y); 0 puts the item's back (its wall side) along +X " + "with the item extending towards +Y" + ), + ), + ] = 0.0, + ctx: Context = None, +) -> dict: + """Define the item's block ARCH_ once, insert it on the furniture or sanitary layer. + + The block carries an invisible ITEM attribute with the catalogue name. Its + size is a nominal catalogue dimension (reported with `size_basis`), not a + standards value; the outline is a plan symbol drawn inside that footprint. + The furniture / sanitary layer is created from the `arch` layer set when + the drawing lacks it. Refusals, all before any write: an unknown name + (with the nearest catalogue names) and a non-finite coordinate or rotation. + """ + from engineering.arch.catalogue import insert_item + + await ctx.info(f"arch catalogue: {name} at ({x}, {y})") + try: + return await insert_item(_backend(ctx), name, at=(x, y), rotation=rotation) + except ValueError as exc: + raise ToolError(str(exc)) from exc +``` + +Leave exactly two blank lines between the end of `arch_catalogue_insert` and the RESOURCES banner pair (ruff format enforces it). + +`arch` is deliberately **not** added to `_GROUP_TAG_PRIORITY`, `_GROUP_TAG_LABELS`, `TOOL_PACK_NAMES` or `PACK_TOOL_NAMES` here: the pack, the lean membership and the group label are Task 9's (the skeleton's final group), and an edit to those lists on three branches would conflict three ways. Until then the two tools file under their secondary tags (`query`, `create`) and are core-pack. + +- [ ] **Step 9: Add the alias records to `discovery/aliases.py`** + +Insert these records at the end of `TOOL_ALIASES`, after the `"std_feature_draw"` record and before the closing `}`: + +```python + # ── Architecture: catalogue (SECTION 25, track F group C) ─────────────── + "arch_catalogue_list": ToolAliases( + acad=("ADCENTER", "TOOLPALETTES"), + synonyms=( + "furniture catalogue", + "furniture library", + "sanitary fixtures", + "bathroom fixtures", + "what furniture is there", + "mobilya kutuphanesi", + "vitrifiye listesi", + ), + ), + "arch_catalogue_insert": ToolAliases( + acad=("INSERT",), + synonyms=( + "place furniture", + "put a bed in the room", + "insert a sofa", + "add a wc", + "place a toilet", + "wash basin", + "bathtub", + "kitchen sink", + "wardrobe", + "dining table", + "yatak", + "koltuk", + "klozet", + "lavabo", + "kuvet", + "mobilya yerlestir", + ), + ), +``` + +`INSERT` is already in `SHARED_ACAD_COMMANDS` (it routes to `block_insert`, `entity_create_block_ref`, `pid_symbol_insert`, `std_part_insert`), so adding a fifth owner needs no allowlist change. `ADCENTER` (DesignCenter, where AutoCAD ships its furniture and fixture block libraries) and `TOOLPALETTES` are claimed by no other record — `uv run --frozen pytest tests/test_discovery_aliases.py -q` proves it. + +- [ ] **Step 10: Run the tool test and the alias gates to verify they pass** + +Run: `uv run --frozen pytest tests/test_arch_catalogue_tools.py tests/test_discovery_aliases.py -q` +Expected: PASS — 3 tool tests plus every alias gate (every registered tool has a record, every acad token is upper-case and routes to a single tool unless shared, every record has a synonym). + +- [ ] **Step 11: Regenerate the inventory and bump the documented counts** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` +Expected output: `wrote docs/tool-inventory.json: 231 tools, 8 resources, 5 prompts, 25 groups` + +In `README.md` change + +``` +> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 229 is the **registered** count; a default install advertises 224 over `tools/list`, +``` + +to + +``` +> **v1.5 release snapshot:** 231 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 231 is the **registered** count; a default install advertises 226 over `tools/list`, +``` + +and the caption under the "What you get" table from `229 tools in 25 groups;` to `231 tools in 25 groups;`. Leave the collected-tests figure alone (the known red of the Global Constraints; Task 9 retakes it on `main`). + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: change the docstring's first line to `"""Frozen snapshot of the surface (231 tools, 25 groups).`, change `"entity_creation": 18,` to `"entity_creation": 19,` and `"entity_query": 9,` to `"entity_query": 10,`, change the last line to `assert sum(sizes.values()) == 231`, and append this paragraph to the docstring, directly after the paragraph that ends `` `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value. ``: + +``` + + Track F group C opened SECTION 25 with the furniture and sanitary + catalogue: `arch_catalogue_list` is tagged `arch` and `query`, + `arch_catalogue_insert` `arch` and `create`. `arch` is not in + `_GROUP_TAG_PRIORITY` on this branch, so they file under their secondary + tags: `entity_query` 9 -> 10, `entity_creation` 18 -> 19. This snapshot is + branch-local: the track F merge task recomputes it once the walls, rooms + and catalogue branches have landed. +``` + +- [ ] **Step 12: Gates** + +Run each and require it clean: + +``` +uv run --frozen ruff check engineering/arch/catalogue.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_catalogue.py tests/test_arch_catalogue_tools.py tests/test_tool_registry.py +uv run --frozen ruff format engineering/arch/catalogue.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_catalogue.py tests/test_arch_catalogue_tools.py tests/test_tool_registry.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_arch_catalogue.py tests/test_arch_catalogue_tools.py -q +uv run --frozen pytest tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_tool_packs.py tests/test_capability_contract.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +`generate_tool_inventory.py --check` prints `ok: docs/tool-inventory.json matches the live surface`. `ruff format` must leave `server.py` byte-identical outside the new section (the file is formatted at the base commit). The deselect is the known-red collected-tests drift named in the Global Constraints — record it in the task report. `tests/test_release_consistency.py::test_section_header_counts_match_decorators` must pass: the new banner says `(2 tools)` and two `@mcp.tool` decorators sit under it. No capability key is added, so `tests/test_capability_contract.py` passes untouched. `tests/test_discovery_transform.py` runs the discovery golden set; it must stay green with the new synonyms (none of them contains `block`, `grid`, `section` or `level`, the words the golden queries lean on). + +- [ ] **Step 13: Commit** + +``` +git add engineering/arch/catalogue.py engineering/arch/__init__.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_arch_catalogue.py tests/test_arch_catalogue_tools.py tests/test_tool_registry.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-7): SECTION 25 catalogue - arch_catalogue_list, arch_catalogue_insert + +engineering/arch/catalogue.py is the standard-parts pattern of track B for an +architectural plan: twenty authored plan symbols in two families - thirteen +furniture items (single and double bed, wardrobe, three-seat sofa, armchair, +chair, dining table with four chairs, desk, kitchen counter run, fridge, +bookshelf, nightstand, coffee table) and seven sanitary items (WC, bidet, wall +basin, shower tray, bathtub, kitchen sink, washing machine). Every size is a +nominal catalogue dimension and says so in SIZE_BASIS, in each row and in both +tool docstrings; no standard is cited because none fixes these sizes. + +Each item is typed block_define primitives on layer 0 inside [0, w] x [0, d], +origin at the back-left corner with the wall side along +X - a test holds all +twenty to their footprint. catalogue() searches name, family and the English +and Turkish labels, ignoring case and Turkish diacritics (buzdolabi finds +buzdolabı). insert_item() defines ARCH_ once, creates the furniture or +sanitary layer from its ARCH_LAYERS row when missing (live ActiveX refuses a +missing layer), inserts with an invisible ITEM attribute, and refuses an +unknown name with the nearest catalogue names before any write. + +SECTION 25 opens on this branch with the two tools, alias records (INSERT, +ADCENTER, TOOLPALETTES and English/Turkish synonyms), a regenerated inventory, +the README snapshot at 231 tools, and the branch-local group snapshot +(entity_query 10, entity_creation 19) that the track F merge recomputes. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 8: The structural grid and the architectural symbols + +**Files:** +- Create: `engineering/arch/symbols.py` +- Create: `tests/test_arch_symbols.py` +- Create: `tests/test_arch_symbol_tools.py` +- Modify: `engineering/arch/__init__.py` (re-export the symbol names) +- Modify: `server.py` (SECTION 25 banner `(2 tools)` → `(4 tools)`; `arch_grid` and `arch_symbol` appended under the catalogue block, directly above the `# ── RESOURCES ──` banner pair) +- Modify: `discovery/aliases.py` (one `ToolAliases` record per new tool) +- Modify: `docs/tool-inventory.json` (regenerated) +- Modify: `README.md` (release-snapshot tool count 231 → 233, advertised 226 → 228, caption 231 → 233 tools) +- Modify: `tests/test_tool_registry.py` (frozen group sizes: `entity_creation` 19 → 21, total 233) + +**Interfaces:** +- Consumes: + - Task 1: `async def draw_prims(backend, prims, *, at=(0.0, 0.0), rotation=0.0, layer_prefix="", role_layer: Mapping[str, str] | None = None) -> dict` (returns `{"handles", "counts"}`; ensures every target layer first, an `ARCH_LAYERS` layer with its row's colour, linetype and lineweight); `ARCH_ROLE_LAYER` (`"grid": "A-GRID-E-N"`, `"symbol": "A-SYMB-T-N"`); `ARCH_LAYERS`; `def vocab(lang: str) -> dict[str, str]` (key `"north"`); `def fmt_number(value: float, lang: str, decimals: int = 2) -> str` (`"24.50"` | `"24,50"`) + - `engineering/mech/primitives.py`: `Line(p1, p2, role)`, `Circle(center, radius, role)`, `Poly(points, closed, role)`, `Text(at, text, height, rotation, role)`, `Prim`, `Pt` + - `engineering/sheet/frames.py`: `def centred_text(cx: float, cy: float, text: str, height: float) -> Text` (left baseline at `(cx - 0.3 * height * len(text), cy - 0.5 * height)`) +- Produces (the skeleton's pinned signatures, plus the async layer the tools call): + - `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, ...]` — kinds `north_arrow` (`rotation`), `section_mark` (`p1`, `p2`, `label`, `side`), `level` (`value`), `elevation_mark` (`label`, `directions`) + - `def grid_axes(x_axes, y_axes, *, labels=None) -> dict` — `{"x": [{"label", "position"}], "y": [...]}`, sorted by position + - `def fmt_level(value: float, lang: str) -> str` — `"±0.00"`, `"+3.00"`, `"-0.45"`, `"+3,00"` in `tr` + - `async def draw_grid(backend, x_axes, y_axes, *, labels=None, extension=1500.0, scale=50) -> dict` — `{"ok", "axes", "bubble_radius", "handles", "counts", "layers", "backend"}` + - `async def draw_symbol(backend, kind, at, *, lang="en", scale=50, **params) -> dict` — `{"ok", "kind", "at", "handles", "counts", "layer", "backend"}` + - constants `SYMBOL_KINDS`, `ELEVATION_DIRECTIONS`, `SECTION_SIDES` and the declared paper sizes `BUBBLE_RADIUS = 5.0`, `LABEL_HEIGHT = 3.5`, `NORTH_RADIUS = 8.0`, `SECTION_ARROW_LENGTH = 6.0`, `SECTION_ARROW_HALF_WIDTH = 2.0`, `LEVEL_TRIANGLE = 2.5`, `LEVEL_LINE = 15.0`, `LEVEL_TEXT_HEIGHT = 2.5`, `LEVEL_TEXT_GAP = 1.0` (mm on paper) + - MCP tools `arch_grid(x_axes, y_axes, labels, extension, scale)` and `arch_symbol(kind, x, y, params, lang, scale)` + +Every size is paper millimetres times `scale` and is this module's declared drafting choice — the module docstring and both tool docstrings say so; no standard is quoted for a bubble diameter or a north-arrow length. Axis lines are on the `grid` role (the CENTER linetype comes from the layer row, no primitive sets one); bubbles, labels and every symbol are on the `symbol` role, because a bubble on the grid layer would inherit CENTER and print dashed. + +The hand-computed coordinates the tests assert (1:50, so `k = 50`): + +- **Grid** `x_axes=[0, 5000, 9000]`, `y_axes=[0, 6000]`, `extension=1500`: vertical axes run y −1500 → 7500, horizontal axes x −1500 → 10500; bubble radius 5 × 50 = 250 centred 250 beyond each end — axis 1 at (0, −1750) and (0, 7750), axis B at (−1750, 6000) and (10750, 6000); label height 3.5 × 50 = 175, so `"1"` sits at (0 − 0.3·175·1, −1750 − 87.5) = (−52.5, −1837.5). +- **North arrow** at (1000, 2000): R = 8 × 50 = 400; the arrow is tip (0, R), barbs (∓0.4R, −0.6R), notch (0, −0.3R) → (1000, 2400), (840, 1760), (1000, 1880), (1160, 1760); the letter is centred at R + 175 = 575 above the centre. Turned 90° CCW the tip is (600, 2000), the left barb (−160, −240) becomes (240, −160) → (1240, 1840), the letter centre (425, 2000), still upright. +- **Section mark** p1 (0, 0) → p2 (4000, 0), side `left` (normal (0, 1)): bubbles at (−250, 0) and (4250, 0); arrow half-width 2 × 50 = 100 and length 6 × 50 = 300 → triangles (0, 0) (200, 0) (100, 300) and (4000, 0) (3800, 0) (3900, 300). A cut line of 300 is refused (≤ 4 × 100). +- **Level** at (1000, 1000), value 2.8: triangle (1000, 1000) (875, 1125) (1125, 1125) with t = 2.5 × 50 = 125; line (1125, 1125) → (1875, 1125) (15 × 50 = 750); text `"+2.80"` height 125 at (1175, 1175) (gap 1 × 50). +- **Elevation mark** r = 250, direction `up`: apex at r√2 = 353.553… on +Y, the tangent points at 45° and 135° → (176.776…, 176.776…) and (−176.776…, 176.776…). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_arch_symbols.py`: + +```python +"""The structural grid and the architectural symbols. + +Pure geometry: the emitted primitives are asserted against coordinates worked +out by hand in each test (1:50 unless a test says otherwise, so a 5 mm paper +bubble is 250 drawing units). The last tests draw through the headless backend. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.arch.lang import vocab +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.arch.symbols import ( + SYMBOL_KINDS, + draw_grid, + draw_symbol, + fmt_level, + grid_axes, + grid_prims, + symbol_prims, +) +from engineering.mech.primitives import Circle, Line, Poly, Text + +TOL = 1e-9 + + +def close(a, b) -> bool: + return abs(a[0] - b[0]) <= TOL and abs(a[1] - b[1]) <= TOL + + +def of(prims, kind): + return [p for p in prims if isinstance(p, kind)] + + +def centred_at(text: Text, cx: float, cy: float) -> bool: + """The inverse of engineering.sheet.frames.centred_text.""" + h = text.height + return close(text.at, (cx - 0.3 * h * len(text.text), cy - 0.5 * h)) + + +# -- the grid ------------------------------------------------------------------ + + +def test_grid_numbers_along_x_and_letters_along_y(): + axes = grid_axes([0.0, 5000.0, 9000.0], [0.0, 6000.0]) + assert axes == { + "x": [ + {"label": "1", "position": 0.0}, + {"label": "2", "position": 5000.0}, + {"label": "3", "position": 9000.0}, + ], + "y": [{"label": "A", "position": 0.0}, {"label": "B", "position": 6000.0}], + } + + +def test_grid_lines_run_past_the_outer_axes_by_the_extension(): + prims = grid_prims([0.0, 5000.0, 9000.0], [0.0, 6000.0], extension=1500.0, scale=50) + lines = of(prims, Line) + assert len(lines) == 5 + assert all(line.role == "grid" for line in lines) + # vertical axes: y from 0 - 1500 to 6000 + 1500 + assert close(lines[0].p1, (0.0, -1500.0)) and close(lines[0].p2, (0.0, 7500.0)) + assert close(lines[2].p1, (9000.0, -1500.0)) and close(lines[2].p2, (9000.0, 7500.0)) + # horizontal axes: x from 0 - 1500 to 9000 + 1500 + assert close(lines[3].p1, (-1500.0, 0.0)) and close(lines[3].p2, (10500.0, 0.0)) + assert close(lines[4].p1, (-1500.0, 6000.0)) and close(lines[4].p2, (10500.0, 6000.0)) + + +def test_grid_bubbles_touch_both_ends_and_carry_the_labels(): + prims = grid_prims([0.0, 5000.0, 9000.0], [0.0, 6000.0], extension=1500.0, scale=50) + bubbles = of(prims, Circle) + texts = of(prims, Text) + assert len(bubbles) == 10 and len(texts) == 10 + assert all(b.radius == 250.0 and b.role == "symbol" for b in bubbles) + assert all(t.role == "symbol" and t.height == 175.0 for t in texts) + # axis 1 (x = 0): bubbles at y = -1500 - 250 and y = 7500 + 250 + assert close(bubbles[0].center, (0.0, -1750.0)) + assert close(bubbles[1].center, (0.0, 7750.0)) + # axis B (y = 6000): bubbles at x = -1500 - 250 and x = 10500 + 250 + assert close(bubbles[8].center, (-1750.0, 6000.0)) + assert close(bubbles[9].center, (10750.0, 6000.0)) + assert [t.text for t in texts] == ["1", "1", "2", "2", "3", "3", "A", "A", "B", "B"] + # "1" at (0, -1750), height 175: left baseline at (-52.5, -1837.5) + assert close(texts[0].at, (-52.5, -1837.5)) + for bubble, text in zip(bubbles, texts, strict=True): + assert centred_at(text, *bubble.center) + + +def test_grid_prims_come_line_then_bubble_label_pairs_per_axis(): + prims = grid_prims([0.0], [0.0], extension=1000.0, scale=100) + assert [type(p).__name__ for p in prims] == [ + "Line", + "Circle", + "Text", + "Circle", + "Text", + "Line", + "Circle", + "Text", + "Circle", + "Text", + ] + # 1:100 -> a 5 mm bubble is 500 units; the vertical axis spans y -1000 .. 1000 + assert close(prims[1].center, (0.0, -1500.0)) and prims[1].radius == 500.0 + + +def test_grid_unsorted_positions_are_numbered_left_to_right(): + axes = grid_axes([9000.0, 0.0, 5000.0], [6000.0, 0.0]) + assert [(a["label"], a["position"]) for a in axes["x"]] == [ + ("1", 0.0), + ("2", 5000.0), + ("3", 9000.0), + ] + assert [(a["label"], a["position"]) for a in axes["y"]] == [("A", 0.0), ("B", 6000.0)] + + +def test_grid_given_labels_follow_their_positions(): + axes = grid_axes([5000.0, 0.0], [0.0, 6000.0], labels={"x": ["B2", "B1"], "y": ["K", "L"]}) + assert [(a["label"], a["position"]) for a in axes["x"]] == [("B1", 0.0), ("B2", 5000.0)] + assert [a["label"] for a in axes["y"]] == ["K", "L"] + + +def test_grid_letters_continue_past_z(): + axes = grid_axes([0.0], [float(i) * 1000.0 for i in range(28)]) + assert [a["label"] for a in axes["y"]][24:] == ["Y", "Z", "AA", "AB"] + + +def test_grid_refusals_name_their_path(): + with pytest.raises(ValueError, match="y_axes: a grid needs at least one axis"): + grid_prims([0.0, 5000.0], []) + with pytest.raises(ValueError, match=r"x_axes: two axes at the same position 0"): + grid_prims([0.0, 0.0], [0.0]) + with pytest.raises(ValueError, match=r"x_axes\[1\]: must be finite"): + grid_prims([0.0, float("nan")], [0.0]) + with pytest.raises(ValueError, match=r"labels.x: 1 label\(s\) for 2 axis position\(s\)"): + grid_prims([0.0, 5000.0], [0.0], labels={"x": ["1"]}) + with pytest.raises(ValueError, match="labels.y: every axis label must be unique"): + grid_prims([0.0], [0.0, 10.0], labels={"y": ["A", "A"]}) + with pytest.raises(ValueError, match="labels: unknown key"): + grid_prims([0.0], [0.0], labels={"z": ["1"]}) + with pytest.raises(ValueError, match="extension: must be greater than zero"): + grid_prims([0.0], [0.0], extension=0.0) + with pytest.raises(ValueError, match="scale: must be greater than zero"): + grid_prims([0.0], [0.0], scale=-50) + + +# -- the north arrow ------------------------------------------------------------- + + +def test_north_arrow_points_up_at_zero_rotation(): + circle, arrow, label = symbol_prims("north_arrow", (1000.0, 2000.0), scale=50) + # radius 8 mm x 50 = 400 + assert isinstance(circle, Circle) and circle.radius == 400.0 + assert close(circle.center, (1000.0, 2000.0)) + assert isinstance(arrow, Poly) and arrow.closed and arrow.role == "symbol" + expected = ((1000.0, 2400.0), (840.0, 1760.0), (1000.0, 1880.0), (1160.0, 1760.0)) + assert all(close(p, q) for p, q in zip(arrow.points, expected, strict=True)) + assert label.text == vocab("en")["north"] + # label centre: 400 + 175 above the centre + assert centred_at(label, 1000.0, 2575.0) + assert label.rotation == 0.0 + + +def test_north_arrow_rotation_turns_the_tip_and_the_label_ccw(): + circle, arrow, label = symbol_prims("north_arrow", (1000.0, 2000.0), scale=50, rotation=90.0) + # CCW 90: +Y becomes -X + assert close(arrow.points[0], (600.0, 2000.0)) + # the left barb (-160, -240) turns to (240, -160) + assert close(arrow.points[1], (1240.0, 1840.0)) + assert centred_at(label, 425.0, 2000.0) + assert label.rotation == 0.0 # the letter stays upright + + +def test_north_arrow_speaks_turkish(): + *_, label = symbol_prims("north_arrow", (0.0, 0.0), lang="tr") + assert label.text == vocab("tr")["north"] + assert vocab("tr")["north"] != vocab("en")["north"] + + +# -- the section mark ------------------------------------------------------------ + + +def test_section_mark_line_bubbles_and_arrows_looking_left(): + prims = symbol_prims( + "section_mark", (0.0, 0.0), scale=50, p1=(0.0, 0.0), p2=(4000.0, 0.0), label="A" + ) + (line,) = of(prims, Line) + assert close(line.p1, (0.0, 0.0)) and close(line.p2, (4000.0, 0.0)) + bubbles = of(prims, Circle) + assert [b.radius for b in bubbles] == [250.0, 250.0] + assert close(bubbles[0].center, (-250.0, 0.0)) and close(bubbles[1].center, (4250.0, 0.0)) + texts = of(prims, Text) + assert [t.text for t in texts] == ["A", "A"] + assert centred_at(texts[0], -250.0, 0.0) and centred_at(texts[1], 4250.0, 0.0) + first, second = of(prims, Poly) + # arrow: 6 mm long, 2 mm half-width at 1:50 -> 300 and 100 + assert all( + close(p, q) + for p, q in zip(first.points, ((0.0, 0.0), (200.0, 0.0), (100.0, 300.0)), strict=True) + ) + assert all( + close(p, q) + for p, q in zip(second.points, ((4000.0, 0.0), (3800.0, 0.0), (3900.0, 300.0)), strict=True) + ) + + +def test_section_mark_looking_right_and_relative_to_at(): + prims = symbol_prims( + "section_mark", + (100.0, 200.0), + scale=50, + p1=(0.0, 0.0), + p2=(0.0, 3000.0), + label="B", + side="right", + ) + (line,) = of(prims, Line) + assert close(line.p1, (100.0, 200.0)) and close(line.p2, (100.0, 3200.0)) + first, _second = of(prims, Poly) + # up the line, looking right is +X + assert close(first.points[2], (100.0 + 300.0, 200.0 + 100.0)) + + +def test_section_mark_refusals(): + with pytest.raises(ValueError, match="p1 and p2 are required"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0.0, 0.0)) + with pytest.raises(ValueError, match="side: must be one of left, right"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(1000, 0), side="up") + with pytest.raises(ValueError, match="the cut line is 300 long"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(300, 0)) + with pytest.raises(ValueError, match="label must be a non-empty string"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(1000, 0), label=" ") + + +# -- the level mark -------------------------------------------------------------- + + +def test_level_formats_the_value_in_both_languages(): + assert fmt_level(0.0, "en") == "±0.00" + assert fmt_level(-0.004, "en") == "±0.00" + assert fmt_level(3.0, "en") == "+3.00" + assert fmt_level(-0.45, "en") == "-0.45" + assert fmt_level(3.0, "tr") == "+3,00" + assert fmt_level(0.0, "tr") == "±0,00" + + +def test_level_triangle_line_and_value(): + triangle, line, text = symbol_prims("level", (1000.0, 1000.0), scale=50, value=2.8) + # 2.5 mm x 50 = 125: apex on the level point, the top edge 125 above it + expected = ((1000.0, 1000.0), (875.0, 1125.0), (1125.0, 1125.0)) + assert triangle.closed and all( + close(p, q) for p, q in zip(triangle.points, expected, strict=True) + ) + # the line runs 15 mm x 50 = 750 to the right from the triangle's right corner + assert close(line.p1, (1125.0, 1125.0)) and close(line.p2, (1875.0, 1125.0)) + # the value sits 1 mm x 50 = 50 right of and above that corner, 2.5 mm high + assert text.text == "+2.80" and text.height == 125.0 + assert close(text.at, (1175.0, 1175.0)) + + +# -- the elevation mark ---------------------------------------------------------- + + +def test_elevation_mark_pointer_is_tangent_at_45_degrees(): + circle, label, pointer = symbol_prims("elevation_mark", (0.0, 0.0), scale=50, label="2") + assert circle.radius == 250.0 + assert label.text == "2" and centred_at(label, 0.0, 0.0) + s = 250.0 * math.sqrt(0.5) # 176.776... + expected = ((s, s), (0.0, 250.0 * math.sqrt(2.0)), (-s, s)) + assert not pointer.closed + assert all(close(p, q) for p, q in zip(pointer.points, expected, strict=True)) + + +def test_elevation_mark_one_pointer_per_direction(): + prims = symbol_prims( + "elevation_mark", (0.0, 0.0), scale=50, label="1", directions=["up", "right", "down"] + ) + pointers = of(prims, Poly) + assert len(pointers) == 3 + apex = 250.0 * math.sqrt(2.0) + assert close(pointers[1].points[1], (apex, 0.0)) + assert close(pointers[2].points[1], (0.0, -apex)) + with pytest.raises(ValueError, match=r"directions\[0\]: must be one of right, up, left, down"): + symbol_prims("elevation_mark", (0.0, 0.0), directions=["north"]) + with pytest.raises(ValueError, match="each direction once"): + symbol_prims("elevation_mark", (0.0, 0.0), directions=["up", "up"]) + + +# -- refusals common to every kind --------------------------------------------- + + +def test_an_unknown_kind_is_refused_with_the_list(): + with pytest.raises(ValueError) as excinfo: + symbol_prims("compass_rose", (0.0, 0.0)) + message = str(excinfo.value) + assert "unknown symbol kind 'compass_rose'" in message + for kind in SYMBOL_KINDS: + assert kind in message + + +def test_a_parameter_the_kind_does_not_take_is_refused(): + with pytest.raises(ValueError, match="level: unknown parameter.*takes value"): + symbol_prims("level", (0.0, 0.0), rotation=45.0) + + +def test_an_unknown_language_is_refused(): + with pytest.raises(ValueError, match="en, tr"): + symbol_prims("north_arrow", (0.0, 0.0), lang="de") + + +# -- drawn through the headless backend ------------------------------------------- + + +@pytest.mark.asyncio +async def test_draw_grid_puts_axes_on_the_grid_layer_and_bubbles_on_symbols(backend): + result = await draw_grid(backend, [0.0, 6000.0], [0.0, 4500.0], scale=50) + assert result["ok"] is True + assert len(result["handles"]) == 4 * 5 + assert result["bubble_radius"] == 250.0 + grid_layer = ARCH_ROLE_LAYER["grid"] + symbol_layer = ARCH_ROLE_LAYER["symbol"] + lines = await backend.entity_list(type_filter="LINE", limit=100) + circles = await backend.entity_list(type_filter="CIRCLE", limit=100) + texts = await backend.entity_list(type_filter="TEXT", limit=100) + assert len(lines) == 4 and {e.layer for e in lines} == {grid_layer} + assert len(circles) == 8 and {e.layer for e in circles} == {symbol_layer} + assert sorted(e.properties["text"] for e in texts) == ["1", "1", "2", "2", "A", "A", "B", "B"] + row = next(row for row in ARCH_LAYERS if row[0] == grid_layer) + info = next(info for info in await backend.layer_list() if info.name == grid_layer) + assert info.linetype.upper() == row[2].upper() + + +@pytest.mark.asyncio +async def test_draw_symbol_writes_the_north_arrow_on_the_symbol_layer(backend): + result = await draw_symbol(backend, "north_arrow", (0.0, 0.0), lang="tr", rotation=30.0) + assert result["ok"] is True and result["kind"] == "north_arrow" + assert result["counts"] == {"circle": 1, "poly": 1, "text": 1} + (text,) = await backend.entity_list(type_filter="TEXT", limit=10) + assert text.layer == ARCH_ROLE_LAYER["symbol"] + assert text.properties["text"] == vocab("tr")["north"] + + +@pytest.mark.asyncio +async def test_draw_symbol_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="kinds are north_arrow"): + await draw_symbol(backend, "compass_rose", (0.0, 0.0)) + assert len(await backend.entity_list()) == before +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_symbols.py -q` +Expected: FAIL — collection error `ModuleNotFoundError: No module named 'engineering.arch.symbols'`. + +- [ ] **Step 3: Write `engineering/arch/symbols.py`** + +```python +"""The structural grid and the architectural symbols - pure primitives. + +:func:`grid_prims` draws the axis lines of a structural grid on the ``grid`` +role (whose layer carries the CENTER linetype, so no primitive sets one) with a +bubble and its label at both ends: numbers along x, letters along y, unless +``labels`` names them. :func:`symbol_prims` draws the four plan symbols: the +north arrow, the section mark, the level mark and the elevation mark. + +Every size here is **paper millimetres times ``scale``** (the plot-scale +denominator: 50 means 1:50), so a 5 mm bubble is 250 drawing units at 1:50 and +reads 5 mm on the printed sheet. The paper sizes below are this module's own +drafting choices, declared as such - no standard in this repository fixes a grid +bubble's diameter or a north arrow's length. + +Bubbles, labels and symbols go on the ``symbol`` role, not on ``grid``: a +bubble on the grid layer would inherit CENTER and print as a dashed circle. + +Labels are centred with ``engineering.sheet.frames.centred_text`` (the +repository's one centring rule: 0.6 x height advance per character), because +``entity_create_text`` carries no alignment. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.arch.lang import fmt_number, vocab +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text +from engineering.sheet.frames import centred_text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +# -- declared paper sizes (mm on the printed sheet; multiplied by `scale`) ----- + +BUBBLE_RADIUS = 5.0 +LABEL_HEIGHT = 3.5 +NORTH_RADIUS = 8.0 +SECTION_ARROW_LENGTH = 6.0 +SECTION_ARROW_HALF_WIDTH = 2.0 +LEVEL_TRIANGLE = 2.5 +LEVEL_LINE = 15.0 +LEVEL_TEXT_HEIGHT = 2.5 +LEVEL_TEXT_GAP = 1.0 + +SYMBOL_KINDS: tuple[str, ...] = ("north_arrow", "section_mark", "level", "elevation_mark") +SECTION_SIDES: tuple[str, ...] = ("left", "right") +#: An elevation mark's direction in drawing terms, not compass terms: "up" is +#: +Y on the sheet whatever the north arrow says. +ELEVATION_DIRECTIONS: dict[str, float] = {"right": 0.0, "up": 90.0, "left": 180.0, "down": 270.0} + +_PARAMS: dict[str, tuple[str, ...]] = { + "north_arrow": ("rotation",), + "section_mark": ("p1", "p2", "label", "side"), + "level": ("value",), + "elevation_mark": ("label", "directions"), +} + + +def _finite(value, where: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}.") + return number + + +def _positive(value, where: str) -> float: + number = _finite(value, where) + if number <= 0.0: + raise ValueError(f"{where}: must be greater than zero, got {number:g}.") + return number + + +def _point(value, where: str) -> Pt: + try: + x, y = value + except (TypeError, ValueError): + raise ValueError(f"{where}: expected an [x, y] pair, got {value!r}.") from None + return (_finite(x, f"{where}.x"), _finite(y, f"{where}.y")) + + +def _label(value, where: str) -> str: + text = " ".join(str(value).split()) if value is not None else "" + if not text: + raise ValueError(f"{where}: a label must be a non-empty string.") + return text + + +def _centred(cx: float, cy: float, text: str, height: float) -> Text: + return centred_text(cx, cy, text, height)._replace(role="symbol") + + +def _turn(vx: float, vy: float, deg: float) -> Pt: + c = math.cos(math.radians(deg)) + s = math.sin(math.radians(deg)) + return (vx * c - vy * s, vx * s + vy * c) + + +# -- the structural grid ----------------------------------------------------- + + +def _letters(index: int) -> str: + """0 -> A ... 25 -> Z, 26 -> AA: every letter, none skipped.""" + out = "" + n = index + 1 + while n: + n, rest = divmod(n - 1, 26) + out = chr(ord("A") + rest) + out + return out + + +def grid_axes(x_axes, y_axes, *, labels=None) -> dict: + """The grid's axes sorted by position, each with its label. + + ``labels`` is ``{"x": [...], "y": [...]}`` (either key optional), one label + per axis in the order the positions were given. Refused by path: an empty + axis list, a non-finite or duplicate position, a label count that does not + match, a duplicate or empty label, an unknown ``labels`` key. + """ + out: dict[str, list[dict]] = {} + given = {} if labels is None else dict(labels) + unknown = sorted(set(given) - {"x", "y"}) + if unknown: + raise ValueError(f"labels: unknown key(s) {', '.join(unknown)}; keys are x, y.") + for key, positions, default in ( + ("x", x_axes, lambda i: str(i + 1)), + ("y", y_axes, _letters), + ): + where = f"{key}_axes" + values = [_finite(v, f"{where}[{i}]") for i, v in enumerate(positions or ())] + if not values: + raise ValueError(f"{where}: a grid needs at least one axis in each direction.") + names = given.get(key) + if names is None: + order = sorted(range(len(values)), key=lambda i: values[i]) + names_sorted = [default(rank) for rank in range(len(values))] + pairs = [(values[i], names_sorted[rank]) for rank, i in enumerate(order)] + else: + if len(names) != len(values): + raise ValueError( + f"labels.{key}: {len(names)} label(s) for {len(values)} axis position(s)." + ) + texts = [_label(n, f"labels.{key}[{i}]") for i, n in enumerate(names)] + if len(set(texts)) != len(texts): + raise ValueError(f"labels.{key}: every axis label must be unique, got {texts}.") + pairs = sorted(zip(values, texts, strict=True), key=lambda pair: pair[0]) + for (a, _), (b, _) in zip(pairs, pairs[1:], strict=False): + if abs(b - a) <= 1e-9: + raise ValueError(f"{where}: two axes at the same position {a:g}.") + out[key] = [{"label": text, "position": value} for value, text in pairs] + return out + + +def grid_prims( + x_axes, y_axes, *, labels=None, extension: float = 1500.0, scale: float = 50 +) -> tuple[Prim, ...]: + """Axis lines on ``grid`` with a labelled bubble at both ends of each. + + ``x_axes`` are the x positions of the vertical axes (numbered 1, 2, 3 ... + from left to right), ``y_axes`` the y positions of the horizontal axes + (lettered A, B, C ... from bottom to top). Every axis runs ``extension`` + drawing units past the outermost crossing axis; the bubble (radius + ``BUBBLE_RADIUS * scale``) sits beyond that, touching the line's end. + Per axis the primitives are: the line, then for each end its bubble and + its label. + """ + ext = _positive(extension, "extension") + k = _positive(scale, "scale") + axes = grid_axes(x_axes, y_axes, labels=labels) + r = BUBBLE_RADIUS * k + h = LABEL_HEIGHT * k + xs = [axis["position"] for axis in axes["x"]] + ys = [axis["position"] for axis in axes["y"]] + y_lo, y_hi = min(ys) - ext, max(ys) + ext + x_lo, x_hi = min(xs) - ext, max(xs) + ext + prims: list[Prim] = [] + for axis in axes["x"]: + x = axis["position"] + prims.append(Line((x, y_lo), (x, y_hi), "grid")) + for cy in (y_lo - r, y_hi + r): + prims.append(Circle((x, cy), r, "symbol")) + prims.append(_centred(x, cy, axis["label"], h)) + for axis in axes["y"]: + y = axis["position"] + prims.append(Line((x_lo, y), (x_hi, y), "grid")) + for cx in (x_lo - r, x_hi + r): + prims.append(Circle((cx, y), r, "symbol")) + prims.append(_centred(cx, y, axis["label"], h)) + return tuple(prims) + + +# -- the four symbols ---------------------------------------------------------- + + +def _north_arrow(at: Pt, k: float, lang: str, rotation=0.0) -> tuple[Prim, ...]: + """A circle, an arrow whose tip is north, the north letter beyond the tip. + + ``rotation`` is degrees CCW from the sheet's +Y: 0 puts north straight up. + The letter stays upright; only its position turns with the arrow. + """ + turn = _finite(rotation, "north_arrow.rotation") + big_r = NORTH_RADIUS * k + h = LABEL_HEIGHT * k + ax, ay = at + local = ( + (0.0, big_r), + (-0.4 * big_r, -0.6 * big_r), + (0.0, -0.3 * big_r), + (0.4 * big_r, -0.6 * big_r), + ) + arrow = tuple((ax + dx, ay + dy) for dx, dy in (_turn(x, y, turn) for x, y in local)) + lx, ly = _turn(0.0, big_r + h, turn) + return ( + Circle((ax, ay), big_r, "symbol"), + Poly(arrow, True, "symbol"), + _centred(ax + lx, ay + ly, vocab(lang)["north"], h), + ) + + +def _section_mark(at: Pt, k: float, lang: str, p1=None, p2=None, label="A", side="left"): + """The cut line, a labelled bubble beyond each end, an arrow at each end. + + ``p1`` and ``p2`` are relative to ``at`` (so ``at=(0, 0)`` makes them WCS). + ``side`` is the side of the line the section looks towards, seen from + ``p1`` looking at ``p2``. + """ + if p1 is None or p2 is None: + raise ValueError("section_mark: p1 and p2 are required (the two ends of the cut line).") + a = _point(p1, "section_mark.p1") + b = _point(p2, "section_mark.p2") + text = _label(label, "section_mark.label") + if side not in SECTION_SIDES: + raise ValueError( + f"section_mark.side: must be one of {', '.join(SECTION_SIDES)}, got {side!r}." + ) + x1, y1 = at[0] + a[0], at[1] + a[1] + x2, y2 = at[0] + b[0], at[1] + b[1] + span = math.hypot(x2 - x1, y2 - y1) + r = BUBBLE_RADIUS * k + h = LABEL_HEIGHT * k + length = SECTION_ARROW_LENGTH * k + half = SECTION_ARROW_HALF_WIDTH * k + if span <= 4.0 * half: + raise ValueError( + f"section_mark: the cut line is {span:g} long; it must be longer than " + f"{4.0 * half:g} ({4.0 * SECTION_ARROW_HALF_WIDTH:g} mm at 1:{k:g}) so the two " + "arrows do not overlap." + ) + ux, uy = (x2 - x1) / span, (y2 - y1) / span + dx, dy = (-uy, ux) if side == "left" else (uy, -ux) + prims: list[Prim] = [Line((x1, y1), (x2, y2), "symbol")] + for cx, cy in ((x1 - ux * r, y1 - uy * r), (x2 + ux * r, y2 + uy * r)): + prims.append(Circle((cx, cy), r, "symbol")) + prims.append(_centred(cx, cy, text, h)) + for ex, ey, sign in ((x1, y1, 1.0), (x2, y2, -1.0)): + inner = (ex + sign * ux * 2.0 * half, ey + sign * uy * 2.0 * half) + mid = (ex + sign * ux * half, ey + sign * uy * half) + apex = (mid[0] + dx * length, mid[1] + dy * length) + prims.append(Poly(((ex, ey), inner, apex), True, "symbol")) + return tuple(prims) + + +def fmt_level(value: float, lang: str) -> str: + """``±0.00`` at zero, ``+3.00`` above, ``-0.45`` below; ``tr`` writes a comma.""" + number = _finite(value, "level.value") + if abs(number) < 0.005: + return "±" + fmt_number(0.0, lang, 2) + sign = "+" if number > 0 else "-" + return sign + fmt_number(abs(number), lang, 2) + + +def _level(at: Pt, k: float, lang: str, value=0.0) -> tuple[Prim, ...]: + """A triangle whose apex is the level point, a line, the value above the line. + + ``value`` is in metres, as a level is written on a plan. + """ + text = fmt_level(value, lang) + t = LEVEL_TRIANGLE * k + ax, ay = at + gap = LEVEL_TEXT_GAP * k + return ( + Poly(((ax, ay), (ax - t, ay + t), (ax + t, ay + t)), True, "symbol"), + Line((ax + t, ay + t), (ax + t + LEVEL_LINE * k, ay + t), "symbol"), + Text((ax + t + gap, ay + t + gap), text, LEVEL_TEXT_HEIGHT * k, 0.0, "symbol"), + ) + + +def _elevation_mark(at: Pt, k: float, lang: str, label="1", directions=("up",)): + """A circle, the label in it, and one pointer per viewing direction. + + Each pointer is the two tangents from an apex at ``r * sqrt(2)`` along the + direction to the circle, touching it at the direction +/- 45 degrees. + """ + text = _label(label, "elevation_mark.label") + names = list(directions) if not isinstance(directions, str) else [directions] + if not names: + raise ValueError("elevation_mark.directions: name at least one direction.") + for i, name in enumerate(names): + if name not in ELEVATION_DIRECTIONS: + raise ValueError( + f"elevation_mark.directions[{i}]: must be one of " + f"{', '.join(ELEVATION_DIRECTIONS)}, got {name!r}." + ) + if len(set(names)) != len(names): + raise ValueError(f"elevation_mark.directions: each direction once, got {names}.") + r = BUBBLE_RADIUS * k + ax, ay = at + prims: list[Prim] = [Circle((ax, ay), r, "symbol"), _centred(ax, ay, text, LABEL_HEIGHT * k)] + for name in names: + theta = ELEVATION_DIRECTIONS[name] + apex_r = r * math.sqrt(2.0) + apex = ( + ax + apex_r * math.cos(math.radians(theta)), + ay + apex_r * math.sin(math.radians(theta)), + ) + side_a = ( + ax + r * math.cos(math.radians(theta - 45.0)), + ay + r * math.sin(math.radians(theta - 45.0)), + ) + side_b = ( + ax + r * math.cos(math.radians(theta + 45.0)), + ay + r * math.sin(math.radians(theta + 45.0)), + ) + prims.append(Poly((side_a, apex, side_b), False, "symbol")) + return tuple(prims) + + +_BUILDERS = { + "north_arrow": _north_arrow, + "section_mark": _section_mark, + "level": _level, + "elevation_mark": _elevation_mark, +} + + +def symbol_prims( + kind: str, at: Pt, *, lang: str = "en", scale: float = 50, **params +) -> tuple[Prim, ...]: + """One architectural symbol at ``at``, sized in paper mm times ``scale``. + + ``kind`` and its ``params``: ``north_arrow`` (``rotation``), + ``section_mark`` (``p1``, ``p2``, ``label``, ``side``), ``level`` + (``value`` in metres), ``elevation_mark`` (``label``, ``directions``). + Refused before anything is built: an unknown kind (with the list), a + parameter the kind does not take (with the ones it does), an unknown + ``lang``, a non-finite or non-positive ``scale``. + """ + if kind not in SYMBOL_KINDS: + raise ValueError(f"unknown symbol kind {kind!r}; kinds are {', '.join(SYMBOL_KINDS)}.") + extra = sorted(set(params) - set(_PARAMS[kind])) + if extra: + raise ValueError( + f"{kind}: unknown parameter(s) {', '.join(extra)}; it takes {', '.join(_PARAMS[kind])}." + ) + vocab(lang) + k = _positive(scale, "scale") + origin = _point(at, "at") + return _BUILDERS[kind](origin, k, lang, **params) + + +# -- the async layer: the prims through draw_prims ------------------------------ + + +async def draw_grid( + backend: AutoCADBackend, + x_axes, + y_axes, + *, + labels=None, + extension: float = 1500.0, + scale: float = 50, +) -> dict: + """Draw :func:`grid_prims` through ``draw_prims`` with the arch role map.""" + from engineering.mech.draw import draw_prims + + axes = grid_axes(x_axes, y_axes, labels=labels) + prims = grid_prims(x_axes, y_axes, labels=labels, extension=extension, scale=scale) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + return { + "ok": True, + "axes": axes, + "bubble_radius": BUBBLE_RADIUS * float(scale), + "handles": drawn["handles"], + "counts": drawn["counts"], + "layers": sorted({ARCH_ROLE_LAYER["grid"], ARCH_ROLE_LAYER["symbol"]}), + "backend": backend.name, + } + + +async def draw_symbol( + backend: AutoCADBackend, + kind: str, + at: Pt, + *, + lang: str = "en", + scale: float = 50, + **params, +) -> dict: + """Draw :func:`symbol_prims` through ``draw_prims`` with the arch role map.""" + from engineering.mech.draw import draw_prims + + prims = symbol_prims(kind, at, lang=lang, scale=scale, **params) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + return { + "ok": True, + "kind": kind, + "at": [float(at[0]), float(at[1])], + "handles": drawn["handles"], + "counts": drawn["counts"], + "layer": ARCH_ROLE_LAYER["symbol"], + "backend": backend.name, + } +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_symbols.py -q` +Expected: PASS — 24 passed. The three backend tests lean on Task 1's `draw_prims(role_layer=...)`: if `test_draw_grid_puts_axes_on_the_grid_layer_and_bubbles_on_symbols` fails on the linetype assertion, `draw_prims` did not create `A-GRID-E-N` from its `ARCH_LAYERS` row — that is a Task 1 defect to report, not a reason to weaken the assertion. + +- [ ] **Step 5: Re-export the symbols from `engineering/arch/__init__.py`** + +Add this block among the relative imports in alphabetical module order — on this branch that is last, after `.model`: + +```python +from .symbols import ( + SYMBOL_KINDS, + draw_grid, + draw_symbol, + fmt_level, + grid_axes, + grid_prims, + symbol_prims, +) +``` + +and add `"SYMBOL_KINDS"`, `"draw_grid"`, `"draw_symbol"`, `"fmt_level"`, `"grid_axes"`, `"grid_prims"`, `"symbol_prims"` to `__all__` in alphabetical order. Then run `uv run --frozen python -c "import engineering.arch as a; print(a.SYMBOL_KINDS)"` — expected `('north_arrow', 'section_mark', 'level', 'elevation_mark')`. + +- [ ] **Step 6: Write the failing tool test** + +Create `tests/test_arch_symbol_tools.py`: + +```python +"""arch_grid / arch_symbol through a real MCP client.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + await connected.call_tool("drawing_new", {}) + yield connected + + +async def test_grid_reports_its_axes_and_bubble_size(client): + result = ( + await client.call_tool( + "arch_grid", {"x_axes": [0.0, 4000.0, 8000.0], "y_axes": [0.0, 5000.0], "scale": 100} + ) + ).structured_content + assert [axis["label"] for axis in result["axes"]["x"]] == ["1", "2", "3"] + assert [axis["label"] for axis in result["axes"]["y"]] == ["A", "B"] + assert result["bubble_radius"] == 500.0 + assert len(result["handles"]) == 5 * 5 + assert result["layers"] == sorted({ARCH_ROLE_LAYER["grid"], ARCH_ROLE_LAYER["symbol"]}) + + +async def test_symbol_level_and_north_arrow_in_turkish(client): + result = ( + await client.call_tool( + "arch_symbol", + {"kind": "level", "x": 0.0, "y": 0.0, "params": {"value": 3.0}, "lang": "tr"}, + ) + ).structured_content + assert result["counts"] == {"poly": 1, "line": 1, "text": 1} + north = ( + await client.call_tool( + "arch_symbol", {"kind": "north_arrow", "x": 0.0, "y": 0.0, "lang": "tr"} + ) + ).structured_content + assert north["layer"] == ARCH_ROLE_LAYER["symbol"] + assert north["counts"] == {"circle": 1, "poly": 1, "text": 1} + + +async def test_refusals_are_tool_errors(client): + with pytest.raises( + ToolError, match="kinds are north_arrow, section_mark, level, elevation_mark" + ): + await client.call_tool("arch_symbol", {"kind": "compass", "x": 0.0, "y": 0.0}) + with pytest.raises(ToolError, match="p1 and p2 are required"): + await client.call_tool( + "arch_symbol", {"kind": "section_mark", "x": 0.0, "y": 0.0, "params": {"label": "A"}} + ) + with pytest.raises(ToolError, match="y_axes: a grid needs at least one axis"): + await client.call_tool("arch_grid", {"x_axes": [0.0], "y_axes": []}) +``` + +- [ ] **Step 7: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_symbol_tools.py -q` +Expected: FAIL — 3 failed. The first two with `fastmcp.exceptions.ToolError: Not found: Unknown tool: 'arch_grid'` / `'arch_symbol'`; `test_refusals_are_tool_errors` with `AssertionError: Regex pattern did not match`, because the `ToolError` it catches is the unknown-tool error, not the symbol refusal. + +- [ ] **Step 8: Register the two tools in `server.py`** + +(a) Change the SECTION 25 banner line + +``` +# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +``` + +to + +``` +# ── SECTION 25: Architecture (4 tools) ────────────────────────────────────── +``` + +(the line stays 78 characters wide: the digit swaps in place). + +(b) Insert the following directly after `arch_catalogue_insert` (it ends `raise ToolError(str(exc)) from exc` followed by two blank lines), i.e. immediately above the `# ---…` / `# ── RESOURCES ──…` banner pair: + +```python +@cad_tool( + summary="Draw a structural grid: axis lines with numbered and lettered bubbles at both ends.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Structural Grid", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_grid( + x_axes: Annotated[ + list[float], + Field(description="X positions (WCS) of the vertical axes; numbered 1, 2, 3 left to right"), + ], + y_axes: Annotated[ + list[float], + Field( + description="Y positions (WCS) of the horizontal axes; lettered A, B, C bottom to top" + ), + ], + labels: Annotated[ + dict | None, + Field( + default=None, + description=( + "Override the labels: {'x': [...], 'y': [...]} (either key), one label per " + "position in the order the positions were given" + ), + ), + ] = None, + extension: Annotated[ + float, + Field( + default=1500.0, + gt=0, + description="Drawing units each axis runs past the outermost crossing axis", + ), + ] = 1500.0, + scale: Annotated[ + float, + Field( + default=50.0, + gt=0, + description="Plot-scale denominator (50 = 1:50): bubbles and labels are paper mm x scale", + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Axis lines on the arch grid layer (its CENTER linetype comes from the layer) and a + 5 mm bubble with its label at both ends of each axis, on the symbol layer. + + Numbers run along x and letters along y (A..Z, then AA, AB - no letter is + skipped) unless `labels` names them. The bubble and label sizes are this + server's declared drafting sizes in paper millimetres, not a standards value. + Refusals, all before any write: an empty axis list in either direction, a + non-finite or duplicate position, a label list whose length does not match + its positions, a duplicate or empty label, an unknown `labels` key, and a + non-positive `extension` or `scale`. + """ + from engineering.arch.symbols import draw_grid + + await ctx.info(f"arch grid: {len(x_axes)} x {len(y_axes)} axes at 1:{scale:g}") + try: + return await draw_grid( + _backend(ctx), x_axes, y_axes, labels=labels, extension=extension, scale=scale + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a plan symbol: north arrow, section mark, level mark or elevation mark.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Symbol", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_symbol( + kind: Annotated[ + str, + Field(description="north_arrow | section_mark | level | elevation_mark"), + ], + x: Annotated[float, Field(description="Symbol origin X (WCS)")], + y: Annotated[float, Field(description="Symbol origin Y (WCS)")], + params: Annotated[ + dict | None, + Field( + default=None, + description=( + "north_arrow: {rotation} (degrees CCW from +Y; 0 = north up). " + "section_mark: {p1, p2, label, side} - p1/p2 are [x, y] relative to (x, y), " + "side left|right is where the section looks, seen from p1 towards p2. " + "level: {value} in metres (0 -> '±0.00'). " + "elevation_mark: {label, directions} - directions from up|right|down|left" + ), + ), + ] = None, + lang: Annotated[ + str, + Field(default="en", description="en | tr - the north letter and the decimal separator"), + ] = "en", + scale: Annotated[ + float, + Field( + default=50.0, + gt=0, + description="Plot-scale denominator (50 = 1:50): symbol sizes are paper mm x scale", + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Draw one architectural symbol on the arch symbol layer. + + Sizes are this server's declared drafting sizes in paper millimetres times + `scale` (a 16 mm north-arrow circle, 10 mm section and elevation bubbles), + not standards values. Refusals, all before any write: an unknown `kind` + (with the list), a parameter the kind does not take (with the ones it + does), a section mark without p1/p2, with an empty label, an unknown side + or a cut line too short for its two arrows, an elevation direction outside + up/right/down/left or named twice, a non-finite level value, and a `lang` + other than en/tr. + """ + from engineering.arch.symbols import draw_symbol + + await ctx.info(f"arch symbol: {kind} at ({x}, {y})") + try: + return await draw_symbol( + _backend(ctx), kind, (x, y), lang=lang, scale=scale, **(params or {}) + ) + except (TypeError, ValueError) as exc: + raise ToolError(str(exc)) from exc +``` + +Again leave exactly two blank lines between the end of `arch_symbol` and the RESOURCES banner pair. + +- [ ] **Step 9: Add the alias records to `discovery/aliases.py`** + +Insert after the `"arch_catalogue_insert"` record, before the closing `}` of `TOOL_ALIASES`: + +```python + "arch_grid": ToolAliases( + acad=("COLUMNGRIDADD",), + synonyms=( + "structural grid", + "column grid", + "axis grid", + "grid bubbles", + "grid lines with bubbles", + "numbered and lettered axes", + "aks sistemi", + "akslar", + "kolon aksi", + ), + ), + "arch_symbol": ToolAliases( + acad=(), + synonyms=( + "north arrow", + "north point", + "section mark", + "section cut symbol", + "level mark", + "floor level symbol", + "elevation mark", + "kuzey oku", + "kot isareti", + "kesit isareti", + "gorunus isareti", + ), + ), +``` + +`GRID` is not claimed: it belongs to `drawing_settings` (the drafting grid), and a structural grid is a different thing. `COLUMNGRIDADD` is the AutoCAD Architecture command a drafter types for a column grid and no other record claims it. `arch_symbol` ships an empty `acad`: plain AutoCAD has no north-arrow, section-mark or level command, and the corpus contract says a wrong alias misroutes a search. No synonym contains `grid spacing`, `turn the grid on` or `section view`, the phrasings the discovery golden set routes to `drawing_settings` and `mech_view_add`. + +- [ ] **Step 10: Run the tool test and the alias gates to verify they pass** + +Run: `uv run --frozen pytest tests/test_arch_symbol_tools.py tests/test_discovery_aliases.py -q` +Expected: PASS — 3 tool tests plus every alias gate. + +- [ ] **Step 11: Regenerate the inventory and bump the documented counts** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` +Expected output: `wrote docs/tool-inventory.json: 233 tools, 8 resources, 5 prompts, 25 groups` + +In `README.md` change `> **v1.5 release snapshot:** 231 tools ·` to `> **v1.5 release snapshot:** 233 tools ·`, `> 231 is the **registered** count; a default install advertises 226 over` to `> 233 is the **registered** count; a default install advertises 228 over`, and `231 tools in 25 groups;` to `233 tools in 25 groups;`. The collected-tests figure stays as it is (Task 9). + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: change the docstring's first line to `"""Frozen snapshot of the surface (233 tools, 25 groups).`, change `"entity_creation": 19,` to `"entity_creation": 21,`, change the last line to `assert sum(sizes.values()) == 233`, and append to the docstring, directly after the Task 7 paragraph (the one ending `and catalogue branches have landed.`): + +``` + Group C's second task added the structural grid and the plan symbols + (`arch_grid` / `arch_symbol`), both tagged `arch` and `create`: + `entity_creation` 19 -> 21, for the same branch-local reason. +``` + +- [ ] **Step 12: Gates** + +Run each and require it clean: + +``` +uv run --frozen ruff check engineering/arch/symbols.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_symbols.py tests/test_arch_symbol_tools.py tests/test_tool_registry.py +uv run --frozen ruff format engineering/arch/symbols.py engineering/arch/__init__.py server.py discovery/aliases.py tests/test_arch_symbols.py tests/test_arch_symbol_tools.py tests/test_tool_registry.py +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest tests/test_arch_symbols.py tests/test_arch_symbol_tools.py tests/test_arch_catalogue.py tests/test_arch_catalogue_tools.py -q +uv run --frozen pytest tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_tool_packs.py tests/test_capability_contract.py -q +uv run --frozen pytest tests/test_release_consistency.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree +``` + +Expected: the four arch files give 84 passed (54 + 3 + 24 + 3); `test_section_header_counts_match_decorators` passes with the `(4 tools)` banner over four decorators; the deselect is the known red of the Global Constraints and goes in the task report. + +- [ ] **Step 13: Commit** + +``` +git add engineering/arch/symbols.py engineering/arch/__init__.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_arch_symbols.py tests/test_arch_symbol_tools.py tests/test_tool_registry.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(task-8): SECTION 25 grid and symbols - arch_grid, arch_symbol + +engineering/arch/symbols.py is pure: grid_prims() draws each structural axis on +the grid role - its CENTER linetype comes from the A-GRID-E-N row, no primitive +sets one - with a 5 mm (paper) bubble and its label touching both ends, on the +symbol role so the circle does not print dashed. Numbers run along x and +letters along y (A..Z, AA, ... none skipped) unless labels names them; +positions are sorted, duplicates and label-count mismatches refused by path. + +symbol_prims() draws the four plan symbols: the north arrow (a circle, an +arrow turned CCW from +Y, the vocab(lang) north letter upright beyond the tip), +the section mark (the cut line, a labelled bubble past each end and a viewing +arrow at each end, p1/p2 relative to the origin), the level mark (the triangle, +its line and the value formatted +3.00 / ±0.00, with a decimal comma in +Turkish) and the elevation mark (a circle whose pointers are the tangents from +r*sqrt(2) touching it at +/-45 degrees). An unknown kind is refused with the +list, a parameter the kind does not take with the ones it does. Every size is +a declared paper-mm drafting choice times the plot scale, not a standards value. + +arch_grid and arch_symbol draw through draw_prims with ARCH_ROLE_LAYER; alias +records (COLUMNGRIDADD, English and Turkish synonyms), a regenerated inventory, +the README snapshot at 233 tools and the branch-local group snapshot +(entity_creation 21) complete the registration. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 9: Integration on main — arch_plan_from_spec, the arch pack, lean, critique focuses, aliases, counts + +**Files:** +- Create: `engineering/arch/critique.py` +- Create: `engineering/arch/spec.py` +- Create: `tests/test_arch_critique.py` +- Create: `tests/test_arch_spec.py` +- Modify: `engineering/plan_spec.py` (three names into the `CritiqueFocus` Literal and `ALL_CRITIQUE_FOCUSES`) +- Modify: `engineering/critique.py` (register `ARCH_DISPATCH` after `MECH_DISPATCH`) +- Modify: `server.py` (`arch_plan_from_spec` at the end of SECTION 25 and the banner 11 → 12; `TOOL_PACK_NAMES` gains `"arch"`; `PACK_TOOL_NAMES["arch"]`; four names into `LEAN_TOOL_NAMES`; `arch` in `_GROUP_TAG_PRIORITY` / `_GROUP_TAG_LABELS` checked, not added — group W's Task 4 added both, and Step 14 only restores them if the merge lost them; the focus lists of `drawing_critique` and `drawing_finalize`) +- Modify: `discovery/aliases.py` (the `arch_plan_from_spec` record; `SHARED_ACAD_COMMANDS` reconciled with what the merged records share) +- Modify: `tests/data/discovery_golden.py` (six tuning cases, two Turkish) +- Modify: `tests/test_tool_packs.py` (replaced: the `arch` pack, lean 64, 26 groups) +- Modify: `tests/test_tool_registry.py` (frozen group-size snapshot: 241 tools, 26 groups) +- Modify: `docs/tool-inventory.json` (regenerated, never hand-edited) +- Modify: `README.md` (release snapshot tools / advertised / collected tests, the alias-corpus sentence, the Discovery row, the `` pack line) +- Test: `tests/test_arch_critique.py`, `tests/test_arch_spec.py` + +**Interfaces:** +- Consumes: from Task 1 — `engineering/arch/model.py::APP_ID, Wall, Opening, Stair, Room, axis_segments(wall), decode(rows), from_payload(payload), to_payload(obj), to_values(payload), wall_from_dict, opening_from_dict, stair_from_dict, room_from_dict, validate_openings(walls, openings)`, `engineering/arch/layers.py::ARCH_ROLE_LAYER`, `engineering/arch/lang.py::LANGS`; from Task 2 — `engineering/arch/walls.py::wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry` (`.omitted: tuple[dict, ...]`); from Task 3 — `engineering/arch/stairs.py::stair_prims(stair, *, lang="en", scale=50) -> tuple[Prim, ...]`; from Task 4 — `engineering/arch/draw.py::draw_walls(backend, walls, openings=(), *, poche=True) -> dict` (only its `"omitted": [{"element", "reason"}]` is read; group W returns its `"walls"` / `"openings"` as per-element dicts, so the ids reported come from the validated spec, not from that result), `draw_stair(backend, stair, *, lang="en", scale=50) -> dict`, `draw_chains(backend, walls, openings, *, sides=("bottom", "left"), scale=50) -> dict`, `read_plan(backend) -> {"walls": [Wall], "openings": [Opening], "stairs": [Stair], "rooms": [Room], ...}`; from Task 5 — `engineering/arch/rooms.py::label_room(backend, room_dict, *, lang="en", scale=50) -> dict` (refuses with `ValueError` a point that lies in no closed face) and `rooms_detect(backend, *, layers=None, min_area=1.0e6, tol=1.0) -> dict` whose `"rooms"` is a list of `{"loop": [[x, y], ...], "holes": [...], "area": float (mm²), "area_m2": float, "centroid": [x, y], "confidence": float, "label": dict | None}` beside `"confidence_min"` (Task 5's return shape, exactly as its `rooms.py` builds it; it refuses with `ValueError` a named layer the drawing lacks, which `build_index` reads as no faces); from Task 6 — `engineering/arch/schedule.py::draw_schedule(backend, kind, *, at, lang="en", scale=50) -> dict`; `engineering/sheet/bom.py::all_entities(backend, *, type_filter=None, layer=None) -> tuple`; `engineering/plan_spec.py::Issue(severity, focus, message, handles=[], detail={})`; `backend.transaction_begin() -> {"ok": bool, ...}`, `transaction_commit()`, `transaction_rollback()`, `drawing_plan(intent, sheet_size, scale, layer_set_id)`, `drawing_apply_iso_layers(standard)`, `get_plan_spec() -> dict | None`, `entity_get_xdata(handle, app) -> {"xdata": {app: [values]}}`. +- Produces: `engineering/arch/critique.py::ARCH_FOCUSES = ("arch_room_unlabelled", "arch_wall_gap", "arch_opening_clash")`, `GAP_MM = 50.0`, `JOIN_TOL_MM = 1.0`, `build_index(backend) -> dict`, `has_arch_content(index) -> bool`, `issues_for(focus, index) -> list[Issue]`, `ARCH_DISPATCH: dict[str, callable]` (`async check(backend, shared)`, `check.needs_shared = True`), and the pure helpers `body_distance(point, a, b, band) -> float`, `wall_gaps(walls, *, gap=GAP_MM, tol=JOIN_TOL_MM) -> list[dict]`, `opening_clashes(walls, openings) -> list[dict]`, `point_in_loop(point, loop) -> bool`, `unlabelled_faces(faces, rooms) -> list[dict]`; `engineering/arch/spec.py::EXAMPLE_SPEC: dict`, `validate_spec(spec) -> dict`, `draw_plan_from_spec(backend, spec) -> dict` (`{"walls": [ids], "openings": [ids drawn], "stairs", "rooms", "chains", "schedules", "omitted", "drawn", "critique", "backend"}`); the tool `arch_plan_from_spec(spec)`; `server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech", "arch")`, `server.PACK_TOOL_NAMES["arch"]` (12 names), `len(server.LEAN_TOOL_NAMES) == 64`. + +- [ ] **Step 1: Write the failing test for the three focuses — the enum, the silence rule, a positive and a negative fixture each** + +Create `tests/test_arch_critique.py`: + +```python +"""Three architectural focuses, each with a positive and a negative fixture. + +All three are silent on a drawing with no ACADMCP_ARCH record, so +`focus=None` stays cheap - and honest - on a mechanical sheet, a P&ID or a +foreign plan of plain lines. + +A fixture writes the records onto entities of the layers they live on in a +real plan (a wall record on a wall line, an opening record on a door line, a +room record on its label), then asks the index. The geometry of the gaps and +clashes is computed by hand in each test. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.critique import ( + ARCH_FOCUSES, + GAP_MM, + body_distance, + build_index, + has_arch_content, + issues_for, + opening_clashes, + point_in_loop, + wall_gaps, +) +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import ( + APP_ID, + Room, + opening_from_dict, + to_payload, + to_values, + wall_from_dict, +) +from engineering.plan_spec import ALL_CRITIQUE_FOCUSES + +WALL_LAYER = ARCH_ROLE_LAYER["wall"] + + +def _wall(wall_id, axis, thickness=200.0, **kw): + return wall_from_dict({"id": wall_id, "axis": axis, "thickness": thickness, **kw}) + + +def _opening(opening_id, wall, offset, width, kind="door"): + return opening_from_dict( + {"id": opening_id, "wall": wall, "kind": kind, "offset": offset, "width": width} + ) + + +async def _record(backend, obj, layer, at=(0.0, 0.0)): + """A short line on ``layer`` carrying ``obj``'s ACADMCP_ARCH record.""" + ent = await backend.entity_create_line(at[0], at[1], at[0] + 10.0, at[1], layer=layer) + await backend.entity_set_xdata(ent.handle, APP_ID, to_values(to_payload(obj))) + return ent.handle + + +async def _square(backend, x0, y0, x1, y1, layer=WALL_LAYER): + corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] + handles = [] + for a, b in zip(corners, corners[1:] + corners[:1], strict=True): + ent = await backend.entity_create_line(a[0], a[1], b[0], b[1], layer=layer) + handles.append(ent.handle) + return handles + + +# -- the closed enum and the silence rule --------------------------------------- + + +def test_the_three_focuses_are_the_spec_ones_and_are_in_the_closed_enum(): + assert ARCH_FOCUSES == ("arch_room_unlabelled", "arch_wall_gap", "arch_opening_clash") + for focus in ARCH_FOCUSES: + assert focus in ALL_CRITIQUE_FOCUSES + assert GAP_MM == 50.0 + + +@pytest.mark.asyncio +async def test_every_focus_is_silent_on_a_drawing_with_no_arch_record(backend): + """A closed room of plain lines on a WALL layer, nothing labelled, two + wall-like lines 30 mm apart - every trigger present, and not one issue, + because nothing on the drawing claims to be our plan.""" + await _square(backend, 0.0, 0.0, 4000.0, 4000.0, layer="WALLS") + await backend.entity_create_line(5000.0, 0.0, 9000.0, 0.0, layer="WALLS") + await backend.entity_create_line(9030.0, 0.0, 12000.0, 0.0, layer="WALLS") + index = await build_index(backend) + assert has_arch_content(index) is False + for focus in ARCH_FOCUSES: + assert issues_for(focus, index) == [] + + +# -- arch_wall_gap ----------------------------------------------------------------- + + +def test_body_distance_is_zero_inside_and_on_the_edge(): + # a 200 mm centre wall from (0, 0) to (4000, 0): body x 0..4000, y -100..100 + band = (-100.0, 100.0) + assert body_distance((2000.0, 50.0), (0.0, 0.0), (4000.0, 0.0), band) == 0.0 + assert body_distance((4000.0, 100.0), (0.0, 0.0), (4000.0, 0.0), band) == 0.0 + assert body_distance((4040.0, 0.0), (0.0, 0.0), (4000.0, 0.0), band) == pytest.approx(40.0) + # 30 past the end and 40 past the face: the corner distance, 50 + assert body_distance((4030.0, 140.0), (0.0, 0.0), (4000.0, 0.0), band) == pytest.approx(50.0) + + +def test_two_collinear_walls_40_mm_apart_are_one_gap(): + walls = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4040, 0], [8000, 0]])] + (row,) = wall_gaps(walls) + assert row["walls"] == ("W1", "W2") + assert row["gap_mm"] == pytest.approx(40.0, abs=1e-9) + + +def test_an_l_corner_and_a_t_junction_are_joined(): + l_corner = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4000, 0], [4000, 3000]])] + # the stem ends on the crossing wall's axis, inside its body + t_junction = [_wall("W1", [[0, 0], [6000, 0]]), _wall("W2", [[3000, 0], [3000, 3000]], 100)] + assert wall_gaps(l_corner) == [] + assert wall_gaps(t_junction) == [] + + +def test_a_stem_stopping_30_mm_short_of_the_crossing_face_is_a_gap(): + # crossing wall body y -100..100; the stem starts at y = 130 + walls = [_wall("W1", [[0, 0], [6000, 0]]), _wall("W2", [[3000, 130], [3000, 3000]], 100)] + (row,) = wall_gaps(walls) + assert row["end"] == "W2.start" and row["other"] == "W1" + assert row["gap_mm"] == pytest.approx(30.0, abs=1e-9) + + +def test_a_wall_further_than_50_mm_away_is_not_a_gap(): + walls = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4051, 0], [8000, 0]])] + assert wall_gaps(walls) == [] + + +def test_a_wall_that_nearly_closes_on_itself_leaks(): + # the last point stops 40 mm above the first segment's face (y = 100) + ring = _wall("W1", [[0, 0], [4000, 0], [4000, 3000], [0, 3000], [0, 140]]) + (row,) = wall_gaps([ring]) + assert row["walls"] == ("W1", "W1") and row["end"] == "W1.end" + assert row["gap_mm"] == pytest.approx(40.0, abs=1e-9) + + +@pytest.mark.asyncio +async def test_wall_gap_fires_on_the_drawing(backend): + await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER) + await _record(backend, _wall("W2", [[4040, 0], [8000, 0]]), WALL_LAYER, at=(4040.0, 0.0)) + issues = issues_for("arch_wall_gap", await build_index(backend)) + assert len(issues) == 1 + assert issues[0].severity == "error" + assert issues[0].detail["gap_mm"] == pytest.approx(40.0) + assert len(issues[0].handles) == 2 + + +@pytest.mark.asyncio +async def test_wall_gap_is_quiet_on_a_joined_pair(backend): + await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER) + await _record(backend, _wall("W2", [[4000, 0], [4000, 3000]]), WALL_LAYER, at=(4000.0, 0.0)) + assert issues_for("arch_wall_gap", await build_index(backend)) == [] + + +# -- arch_opening_clash ------------------------------------------------------------ + + +def test_overlap_and_overrun_are_both_reported(): + wall = _wall("W1", [[0, 0], [5000, 0]]) + rows = opening_clashes( + [wall], + [ + _opening("D1", "W1", 1000, 900), + _opening("W2", "W1", 1500, 1200, "window"), + _opening("D3", "W1", 4500, 900), + ], + ) + assert [row["openings"] for row in rows] == [("D1", "W2"), ("D3",)] + assert "overlap (1000-1900 and 1500-2700)" in rows[0]["reason"] + assert "runs 400 mm past the end" in rows[1]["reason"] + + +def test_an_opening_across_an_axis_vertex_and_one_on_a_missing_wall(): + wall = _wall("W1", [[0, 0], [5000, 0], [5000, 4000]]) + rows = opening_clashes([wall], [_opening("D1", "W1", 4500, 900), _opening("D2", "W9", 0, 900)]) + assert "across the axis vertex at 5000" in rows[0]["reason"] + assert "'W9' has no record" in rows[1]["reason"] + + +@pytest.mark.asyncio +async def test_opening_clash_fires_on_the_drawing(backend): + await _record(backend, _wall("W1", [[0, 0], [5000, 0]]), WALL_LAYER) + await _record(backend, _opening("D1", "W1", 1000, 900), ARCH_ROLE_LAYER["door"]) + await _record(backend, _opening("D2", "W1", 1500, 900), ARCH_ROLE_LAYER["door"], (1500.0, 0.0)) + (issue,) = issues_for("arch_opening_clash", await build_index(backend)) + assert issue.severity == "error" + assert issue.detail == {"openings": ["D1", "D2"], "wall": "W1"} + assert len(issue.handles) == 2 + + +@pytest.mark.asyncio +async def test_opening_clash_is_quiet_on_two_openings_that_only_touch(backend): + await _record(backend, _wall("W1", [[0, 0], [5000, 0]]), WALL_LAYER) + await _record(backend, _opening("D1", "W1", 1000, 900), ARCH_ROLE_LAYER["door"]) + await _record(backend, _opening("D2", "W1", 1900, 900), ARCH_ROLE_LAYER["door"], (1900.0, 0.0)) + assert issues_for("arch_opening_clash", await build_index(backend)) == [] + + +# -- arch_room_unlabelled -------------------------------------------------------------- + + +def test_point_in_loop(): + loop = [[0, 0], [4000, 0], [4000, 3000], [0, 3000]] + assert point_in_loop((2000.0, 1500.0), loop) is True + assert point_in_loop((4000.0, 1500.0), loop) is True # on an edge + assert point_in_loop((4100.0, 1500.0), loop) is False + + +async def _ring(backend): + """A 200 mm ring on axis (0,0)-(4000,4000): its faces drawn as lines on the + wall layer, its record carried by the first outer face line - where a real + plan carries it. Interior 3800 x 3800.""" + outer = await _square(backend, -100.0, -100.0, 4100.0, 4100.0) + await _square(backend, 100.0, 100.0, 3900.0, 3900.0) + ring = _wall("W1", [[0, 0], [4000, 0], [4000, 4000], [0, 4000]], closed=True) + await backend.entity_set_xdata(outer[0], APP_ID, to_values(to_payload(ring))) + + +@pytest.mark.asyncio +async def test_room_unlabelled_fires_on_a_closed_room_with_no_label(backend): + await _ring(backend) + index = await build_index(backend) + (issue,) = issues_for("arch_room_unlabelled", index) + assert issue.severity == "warning" + assert issue.detail["area_mm2"] == pytest.approx(3800.0 * 3800.0) + assert "14.44 m²" in issue.message + + +@pytest.mark.asyncio +async def test_room_unlabelled_is_quiet_once_the_room_carries_a_label(backend): + await _ring(backend) + room = Room(id="R1", name="ROOM", number="01", at=(2000.0, 2000.0), area=3800.0 * 3800.0) + await _record(backend, room, ARCH_ROLE_LAYER["room"], at=(2000.0, 2000.0)) + assert issues_for("arch_room_unlabelled", await build_index(backend)) == [] + + +# -- the dispatch -------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_the_dispatch_entries_share_one_index_per_run(backend): + from engineering.arch.critique import ARCH_DISPATCH + + await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER) + shared: dict = {} + for focus in ARCH_FOCUSES: + check = ARCH_DISPATCH[focus] + assert check.needs_shared is True + await check(backend, shared) + assert list(shared) == ["arch_index"] + + +@pytest.mark.asyncio +async def test_run_critique_reaches_the_arch_focuses(backend): + from engineering.critique import run_critique + + await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER) + await _record(backend, _wall("W2", [[4040, 0], [8000, 0]]), WALL_LAYER, at=(4040.0, 0.0)) + (issue,) = await run_critique(backend, ["arch_wall_gap"]) + assert issue.focus == "arch_wall_gap" and issue.severity == "error" + assert "stops 40 mm short of wall" in issue.message +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_critique.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.arch.critique'`. + +- [ ] **Step 3: Write `engineering/arch/critique.py`** + +```python +"""The three architectural critique focuses (spec §9). + +Every check is pure over one index built once per critique run and shared +through ``run_critique``'s context, the way the P&ID and mechanical focuses +are. A drawing with no ``ACADMCP_ARCH`` record on it yields no issue at all: a +mechanical sheet, a P&ID or a foreign plan of plain lines must not be scored +against architectural conventions, and ``focus=None`` has to stay cheap for +them. + +What the index reads, and why only that: every plan record this server writes +(a wall on its outline, an opening on its leaf or frame, a room on its label) +lands on a layer of ``ARCH_ROLE_LAYER``, so XDATA is read on those layers only, +every entity of them (paged, not the first 200). A record carried by several +entities is kept once, by kind and id, with every handle that carries it. The +room faces come from ``rooms_detect`` on the wall layer - the same reader +``arch_rooms_detect`` runs, which closes door and window gaps from the opening +records - so the focus and the tool cannot disagree about what a room is. + +* ``arch_room_unlabelled`` (warning): a face of at least ``rooms_detect``'s + minimum area (1 m²) with no room record whose label point lies inside it. +* ``arch_wall_gap`` (error): a free wall end that comes within ``GAP_MM`` + (50 mm) of another wall - its end or its body - without joining it + (``JOIN_TOL_MM``, 1 mm, the wall engine's ``join_tol``). The room leaks, and + its area is measured through the gap. One issue per pair of walls. +* ``arch_opening_clash`` (error): two openings overlapping on one wall, an + opening running past the end of its wall's axis or across an axis vertex, + and an opening whose host wall has no record on the drawing. + +The two thresholds are declared by the design spec, not taken from a +standard. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from engineering.plan_spec import Issue + +from .layers import ARCH_ROLE_LAYER +from .model import APP_ID, Opening, Room, Stair, Wall, axis_segments, decode, from_payload + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +ARCH_FOCUSES = ("arch_room_unlabelled", "arch_wall_gap", "arch_opening_clash") +_KEY = "arch_index" + +#: Two wall ends closer than this without joining are a gap (spec §9). +GAP_MM = 50.0 +#: Within this a wall end joins the wall it touches - the wall engine's join_tol. +JOIN_TOL_MM = 1.0 +#: Below this, two lengths along a wall are the same length. +EPS = 1e-9 + +_BUCKETS = {Wall: "walls", Opening: "openings", Stair: "stairs", Room: "rooms"} + + +# ── the index ─────────────────────────────────────────────────────────────── + + +async def build_index(backend: AutoCADBackend) -> dict: + """One read of the drawing: every ACADMCP_ARCH record, and the room faces. + + ``{"walls" | "openings" | "stairs" | "rooms": {id: {"obj", "handles"}}, + "faces": [{"loop", "area", ...}]}``. The faces are read only when there is + a wall record to bound them; a ``rooms_detect`` refusal (no wall layer) + reads as no faces. + """ + from engineering.sheet.bom import all_entities + + index: dict = {"walls": {}, "openings": {}, "stairs": {}, "rooms": {}, "faces": []} + for layer in sorted(set(ARCH_ROLE_LAYER.values())): + try: + entities = await all_entities(backend, layer=layer) + except Exception: + continue + for entity in entities: + try: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + except Exception: + continue + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + continue + try: + obj = from_payload(decode(values)) + except (ValueError, KeyError, TypeError): + # Not ours, or corrupt: the codec refused it, and guessing at + # it here would undo that. + continue + bucket = index[_BUCKETS[type(obj)]] + entry = bucket.setdefault(obj.id, {"obj": obj, "handles": []}) + entry["handles"].append(entity.handle) + if index["walls"]: + from engineering.arch.rooms import rooms_detect + + try: + detected = await rooms_detect(backend, layers=[ARCH_ROLE_LAYER["wall"]]) + except ValueError: + detected = {"rooms": []} + index["faces"] = list(detected.get("rooms") or []) + return index + + +def has_arch_content(index: dict) -> bool: + return any(index[bucket] for bucket in ("walls", "openings", "stairs", "rooms")) + + +# ── geometry ──────────────────────────────────────────────────────────────── + + +def _band(wall: Wall) -> tuple[float, float]: + """Where the two faces lie along the segment's left normal, low to high.""" + t = float(wall.thickness) + if wall.justification == "left": + return (0.0, t) + if wall.justification == "right": + return (-t, 0.0) + return (-t / 2.0, t / 2.0) + + +def body_distance(point, a, b, band: tuple[float, float]) -> float: + """Distance from ``point`` to the body of one straight wall segment. + + The body is the rectangle between the segment's two faces, from ``a`` to + ``b``: 0 inside it or on its edge. + """ + length = math.dist(a, b) + ux, uy = (b[0] - a[0]) / length, (b[1] - a[1]) / length + dx, dy = point[0] - a[0], point[1] - a[1] + along = dx * ux + dy * uy + across = -dx * uy + dy * ux + out_along = max(0.0 - along, 0.0, along - length) + out_across = max(band[0] - across, 0.0, across - band[1]) + return math.hypot(out_along, out_across) + + +def _ends(wall: Wall) -> list[tuple[str, tuple[float, float], int]]: + """The free ends of a wall: (which end, the point, the segment it belongs to).""" + if wall.closed: + return [] + last = len(wall.axis) - 2 + return [("start", wall.axis[0], 0), ("end", wall.axis[-1], last)] + + +def wall_gaps( + walls: Sequence[Wall], *, gap: float = GAP_MM, tol: float = JOIN_TOL_MM +) -> list[dict]: + """Free wall ends within ``gap`` of another wall that do not join it. + + An end joins a wall when it lies within ``tol`` of that wall's body (an L + corner's shared end, a T stem ending in the crossing wall). The walls an + end is measured against are every other wall, and its own wall's segments + other than the one it ends and that segment's neighbours - a wall that + nearly closes on itself leaks too. One row per pair of walls, the smallest + gap kept: ``{"walls": (a, b), "end": "W2.start", "other": "W1", "gap_mm", "at"}``. + """ + found: dict[frozenset, dict] = {} + for wall in walls: + for end, point, own in _ends(wall): + best: tuple[float, str] | None = None + joined = False + for other in walls: + band = _band(other) + segments = axis_segments(other) + for index, (a, b) in enumerate(segments): + if other.id == wall.id and abs(index - own) <= 1: + continue + distance = body_distance(point, a, b, band) + if distance <= tol: + joined = True + break + if best is None or distance < best[0]: + best = (distance, other.id) + if joined: + break + if joined or best is None or best[0] > gap: + continue + pair = frozenset((wall.id, best[1])) + row = { + "walls": tuple(sorted(pair)) if len(pair) == 2 else (wall.id, wall.id), + "end": f"{wall.id}.{end}", + "other": best[1], + "gap_mm": round(best[0], 3), + "at": (float(point[0]), float(point[1])), + } + if pair not in found or row["gap_mm"] < found[pair]["gap_mm"]: + found[pair] = row + return sorted(found.values(), key=lambda row: row["walls"]) + + +def opening_clashes(walls: Sequence[Wall], openings: Sequence[Opening]) -> list[dict]: + """Every opening its wall cannot host: ``{"openings": (ids), "wall", "reason"}``. + + The same rules ``validate_openings`` refuses before a draw, collected + rather than raised - the drawing already exists, and every clash on it is + reported, not only the first. + """ + by_id = {wall.id: wall for wall in walls} + rows: list[dict] = [] + spans: dict[str, list[tuple[float, float, str]]] = {} + for opening in openings: + wall = by_id.get(opening.wall) + if wall is None: + rows.append( + { + "openings": (opening.id,), + "wall": opening.wall, + "reason": f"its host wall {opening.wall!r} has no record on this drawing", + } + ) + continue + start = float(opening.offset) + end = start + float(opening.width) + runs = [] + run = 0.0 + for a, b in axis_segments(wall): + run += math.dist(a, b) + runs.append(run) + total = runs[-1] + if end > total + EPS: + rows.append( + { + "openings": (opening.id,), + "wall": wall.id, + "reason": f"it runs {end - total:g} mm past the end of the wall " + f"({start:g}-{end:g} on an axis {total:g} long)", + } + ) + for vertex in runs[:-1]: + if start + EPS < vertex < end - EPS: + rows.append( + { + "openings": (opening.id,), + "wall": wall.id, + "reason": f"it runs across the axis vertex at {vertex:g} " + f"({start:g}-{end:g})", + } + ) + for other_start, other_end, other_id in spans.get(wall.id, ()): + if start < other_end - EPS and other_start < end - EPS: + rows.append( + { + "openings": (other_id, opening.id), + "wall": wall.id, + "reason": f"they overlap ({other_start:g}-{other_end:g} and " + f"{start:g}-{end:g})", + } + ) + spans.setdefault(wall.id, []).append((start, end, opening.id)) + return rows + + +def point_in_loop(point, loop) -> bool: + """Even-odd ray cast; a point on an edge counts as inside.""" + x, y = float(point[0]), float(point[1]) + pts = [(float(p[0]), float(p[1])) for p in loop] + inside = False + for i, (x1, y1) in enumerate(pts): + x2, y2 = pts[(i + 1) % len(pts)] + cross = (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) + if ( + abs(cross) <= 1e-9 * max(1.0, math.dist((x1, y1), (x2, y2))) + and min(x1, x2) - 1e-9 <= x <= max(x1, x2) + 1e-9 + and min(y1, y2) - 1e-9 <= y <= max(y1, y2) + 1e-9 + ): + return True + if (y1 > y) != (y2 > y): + if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1): + inside = not inside + return inside + + +def unlabelled_faces(faces: Sequence[dict], rooms: Sequence[Room]) -> list[dict]: + """The faces no room record's label point lies in.""" + return [ + face for face in faces if not any(point_in_loop(room.at, face["loop"]) for room in rooms) + ] + + +# ── the three checks ──────────────────────────────────────────────────────── + + +def _objs(index: dict, bucket: str) -> list: + return [entry["obj"] for entry in index[bucket].values()] + + +def _handles(index: dict, bucket: str, ids) -> list[str]: + return [h for i in ids if i in index[bucket] for h in index[bucket][i]["handles"]] + + +def _room_unlabelled(index: dict) -> list[Issue]: + out: list[Issue] = [] + for face in unlabelled_faces(index["faces"], _objs(index, "rooms")): + centroid = face.get("centroid") or face["loop"][0] + out.append( + Issue( + "warning", + "arch_room_unlabelled", + f"A closed face of {float(face['area']) / 1.0e6:.2f} m² around " + f"({float(centroid[0]):.0f}, {float(centroid[1]):.0f}) carries no room label; " + "label it with arch_room so its measured area reaches the room schedule.", + [], + {"area_mm2": float(face["area"]), "centroid": [float(c) for c in centroid]}, + ) + ) + return out + + +def _wall_gap(index: dict) -> list[Issue]: + out: list[Issue] = [] + for row in wall_gaps(_objs(index, "walls")): + a, b = row["walls"] + out.append( + Issue( + "error", + "arch_wall_gap", + f"Wall end {row['end']} stops {row['gap_mm']:g} mm short of wall " + f"{row['other']} without joining it: the room leaks and its area is measured " + "through the gap. Extend the wall to the joint (arch_wall) or move it clear.", + _handles(index, "walls", sorted({a, b})), + {"walls": [a, b], "gap_mm": row["gap_mm"], "at": list(row["at"])}, + ) + ) + return out + + +def _opening_clash(index: dict) -> list[Issue]: + out: list[Issue] = [] + for row in opening_clashes(_objs(index, "walls"), _objs(index, "openings")): + names = " and ".join(row["openings"]) + noun = "Openings" if len(row["openings"]) > 1 else "Opening" + out.append( + Issue( + "error", + "arch_opening_clash", + f"{noun} {names} on wall {row['wall']}: {row['reason']}.", + _handles(index, "openings", row["openings"]), + {"openings": list(row["openings"]), "wall": row["wall"]}, + ) + ) + return out + + +_CHECKS = { + "arch_room_unlabelled": _room_unlabelled, + "arch_wall_gap": _wall_gap, + "arch_opening_clash": _opening_clash, +} + + +def issues_for(focus: str, index: dict) -> list[Issue]: + if not has_arch_content(index): + return [] + return _CHECKS[focus](index) + + +def _make(focus: str): + async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]: + index = shared.get(_KEY) + if index is None: + index = shared[_KEY] = await build_index(backend) + return issues_for(focus, index) + + check.needs_shared = True # type: ignore[attr-defined] + check.__name__ = f"check_{focus}" + return check + + +ARCH_DISPATCH = {focus: _make(focus) for focus in ARCH_FOCUSES} +``` + +- [ ] **Step 4: Run it to verify only the registration is missing** + +Run: `uv run --frozen pytest tests/test_arch_critique.py -q` +Expected: `2 failed, 17 passed` — `test_the_three_focuses_are_the_spec_ones_and_are_in_the_closed_enum` (`AssertionError: assert 'arch_room_unlabelled' in ('iso128', 'layer_color', ...)`) and `test_run_critique_reaches_the_arch_focuses` (`run_critique` answers the unregistered focus with its `"Unknown critique focus 'arch_wall_gap' (closed enum)."` warning, so `issue.severity == "error"` fails). + +- [ ] **Step 5: Register the three focuses in the closed enum and the dispatch** + +In `engineering/plan_spec.py`, inside the `CritiqueFocus` Literal, after `"mech_bom_balloon_mismatch",` add: + +```python + "arch_room_unlabelled", + "arch_wall_gap", + "arch_opening_clash", +``` + +and the same three names, in the same order, after `"mech_bom_balloon_mismatch",` in `ALL_CRITIQUE_FOCUSES`. + +In `engineering/critique.py`, directly under the mechanical registration + +```python +from .mech.critique import MECH_DISPATCH # noqa: E402 (same reason: after the base focuses) + +_FOCUS_DISPATCH.update(MECH_DISPATCH) +``` + +add + +```python + +from .arch.critique import ARCH_DISPATCH # noqa: E402 (same reason: after the base focuses) + +_FOCUS_DISPATCH.update(ARCH_DISPATCH) +``` + +- [ ] **Step 6: Run the focuses with every other critique suite** + +Run: `uv run --frozen pytest tests/test_arch_critique.py tests/test_mech_critique.py tests/test_pid_critique.py tests/test_meta_tools.py -q` +Expected: all pass. The mechanical, P&ID and meta-tool suites are in the list because `focus=None` now runs three more checks: a P&ID sheet or a part drawing carries no `ACADMCP_ARCH` record, so each returns nothing there, and this is where it would show if one did not. + +- [ ] **Step 7: Write the failing test for `arch_plan_from_spec`** + +Create `tests/test_arch_spec.py`: + +```python +"""``arch_plan_from_spec``: validation by path, one transaction, the shielded rollback. + +Headless ezdxf against a real document. The areas are computed by hand from +``EXAMPLE_SPEC``: the exterior ring runs on axis (0,0)-(9000,6000) at 250 mm, +so its inner faces are x = 125 / 8875 and y = 125 / 5875; the interior wall +runs on x = 5000 at 100 mm, faces x = 4950 / 5050. Room 01 is therefore +(4950 - 125) x (5875 - 125) = 4825 x 5750 = 27 743 750 mm², room 02 +(8875 - 5050) x 5750 = 3825 x 5750 = 21 993 750 mm² - the net floor, not the +axis area. +""" + +from __future__ import annotations + +import copy + +import anyio +import pytest + +from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec, validate_spec + +ROOM_01 = 4825.0 * 5750.0 +ROOM_02 = 3825.0 * 5750.0 + + +def _spec(**over) -> dict: + spec = copy.deepcopy(EXAMPLE_SPEC) + spec.update(over) + return spec + + +async def _count(backend) -> int: + return len(await backend.entity_list(limit=5000)) + + +# -- validation, before anything is drawn --------------------------------------- + + +@pytest.mark.parametrize( + ("over", "message"), + [ + ({"colour": "red"}, r"^spec: unknown key\(s\) colour; keys are sheet"), + ({"walls": []}, r"^walls: name at least one wall"), + ({"lang": "de"}, r"^lang: unknown language 'de'; languages are en, tr"), + ({"sheet": {"scale": 0}}, r"^sheet\.scale: must be a finite number greater than zero"), + ({"chains": {"sides": ["north"]}}, r"^chains\.sides: \['north'\]"), + ( + {"schedules": [{"kind": "stairs", "at": [0, 0]}]}, + r"^schedules\[0\]\.kind: 'stairs' is not a schedule", + ), + ], +) +@pytest.mark.asyncio +async def test_the_document_is_refused_by_path(backend, over, message): + with pytest.raises(ValueError, match=message): + await draw_plan_from_spec(backend, _spec(**over)) + assert await _count(backend) == 0 + + +@pytest.mark.asyncio +async def test_a_bad_wall_is_named_by_its_index(backend): + spec = _spec() + spec["walls"][1]["thickness"] = -100 + with pytest.raises(ValueError, match=r"^walls\[1\]\.thickness: must be greater than zero"): + await draw_plan_from_spec(backend, spec) + assert await _count(backend) == 0 + + +@pytest.mark.asyncio +async def test_two_overlapping_openings_are_refused_naming_both(backend): + spec = _spec() + spec["openings"][0]["offset"] = 1000 + spec["openings"].append( + {"id": "D9", "wall": "EXT", "kind": "door", "offset": 1500, "width": 900} + ) + with pytest.raises(ValueError, match=r"openings D1 and D9 overlap on wall EXT"): + await draw_plan_from_spec(backend, spec) + assert await _count(backend) == 0 + + +@pytest.mark.asyncio +async def test_a_typed_room_area_is_refused(backend): + spec = _spec() + spec["rooms"][0]["area"] = 27.0e6 + with pytest.raises(ValueError, match=r"^rooms\[0\]\.area: an area is measured"): + await draw_plan_from_spec(backend, spec) + assert await _count(backend) == 0 + + +def test_validate_spec_turns_dicts_into_the_model(): + norm = validate_spec(EXAMPLE_SPEC) + assert [w.id for w in norm["walls"]] == ["EXT", "INT"] + assert [o.id for o in norm["openings"]] == ["D1", "D2", "W1", "W2"] + assert norm["scale"] == 50.0 and norm["lang"] == "en" + assert norm["chains"] == ("bottom", "left") + assert [s["kind"] for s in norm["schedules"]] == ["doors", "windows", "rooms"] + + +# -- the whole plan -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_the_example_plan_draws_in_one_transaction_and_measures_its_rooms(backend): + from engineering.arch.draw import read_plan + + result = await draw_plan_from_spec(backend, EXAMPLE_SPEC) + assert result["walls"] == ["EXT", "INT"] + assert result["openings"] == ["D1", "D2", "W1", "W2"] + assert result["omitted"] == [] + assert len(result["rooms"]) == 2 and len(result["schedules"]) == 3 + assert result["critique"] == [] + plan = await read_plan(backend) + areas = {room.number: room.area for room in plan["rooms"]} + assert areas["01"] == pytest.approx(ROOM_01, abs=0.5) + assert areas["02"] == pytest.approx(ROOM_02, abs=0.5) + assert backend.get_plan_spec()["layer_set_id"] == "arch" + + +@pytest.mark.asyncio +async def test_a_room_outside_every_face_rolls_the_whole_plan_back(backend): + spec = _spec() + spec["rooms"][1]["at"] = [20000, 20000] + with pytest.raises(ValueError, match=r"^rooms\[1\]: "): + await draw_plan_from_spec(backend, spec) + assert await _count(backend) == 0 + begun = await backend.transaction_begin() + assert begun["ok"] is True, "the rollback must have closed the transaction" + await backend.transaction_rollback() + + +@pytest.mark.asyncio +async def test_a_cancelled_request_still_rolls_back(backend, monkeypatch): + """The MCP SDK cancels a request by cancelling an anyio scope; the rollback + runs under a shield, so the cancelled plan leaves nothing behind.""" + from engineering.arch import rooms + + async def hang(*_args, **_kwargs): + await anyio.sleep_forever() + + monkeypatch.setattr(rooms, "label_room", hang) + with anyio.move_on_after(0.5): + await draw_plan_from_spec(backend, EXAMPLE_SPEC) + assert await _count(backend) == 0 + begun = await backend.transaction_begin() + assert begun["ok"] is True + await backend.transaction_rollback() + + +@pytest.mark.asyncio +async def test_the_tool_is_registered_and_refuses_through_tool_error(): + from fastmcp import Client + from fastmcp.exceptions import ToolError + + import server + + async with Client(server.mcp) as client: + await client.call_tool("drawing_new", {}) + result = ( + await client.call_tool("arch_plan_from_spec", {"spec": EXAMPLE_SPEC}) + ).structured_content + assert result["critique"] == [] + with pytest.raises(ToolError, match=r"walls: name at least one wall"): + await client.call_tool("arch_plan_from_spec", {"spec": {"walls": []}}) +``` + +- [ ] **Step 8: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_arch_spec.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'engineering.arch.spec'`. + +- [ ] **Step 9: Write `engineering/arch/spec.py`** + +The draw, room and schedule modules are imported as modules and called through their attributes (`_rooms.label_room(...)`), so a test can replace one step with `monkeypatch.setattr(rooms, "label_room", ...)` — this task's `test_a_cancelled_request_still_rolls_back` and Task 10's `test_arch_roundtrip_fails_loudly_when_a_room_label_is_removed` do exactly that. + +```python +"""``arch_plan_from_spec``: a whole plan from one declarative document, in one transaction. + +Walls with their hosted openings, stairs, room labels (each area measured off +the walls just drawn), the exterior dimension chains and the schedules — in +that order, because each step reads what the one before it drew: a room label +measures the wall faces, and a schedule reads the ``ACADMCP_ARCH`` records. + +Everything that can be refused without a drawing is refused before the +transaction opens, by path (``walls[2].thickness``, ``openings[1]: ...``): the +model, the openings against their walls, the junctions (the wall engine is +pure and is run once in memory for exactly this), every stair's plan symbol, +the rooms (a typed ``area`` included), the chain sides and the schedule kinds. +What only the drawing can refuse - a room point that lies in no closed face - +refuses inside the transaction, and the transaction is rolled back. + +The rollback is shielded for the reason ``engineering/pid/spec.py`` documents +at length: the MCP SDK cancels a request by cancelling an anyio scope, anyio +cancellation is level-triggered, and a bare ``await`` in the handler would +itself be cancelled before the rollback body ran - leaving half a plan inside +an open transaction. +""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING + +import anyio + +from engineering.arch import draw as _draw +from engineering.arch import rooms as _rooms +from engineering.arch import schedule as _schedule + +from .lang import LANGS +from .model import ( + opening_from_dict, + room_from_dict, + stair_from_dict, + validate_openings, + wall_from_dict, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +log = logging.getLogger(__name__) + +SPEC_KEYS = ( + "sheet", + "lang", + "poche", + "walls", + "openings", + "stairs", + "rooms", + "chains", + "schedules", +) +SHEET_KEYS = ("intent", "sheet_size", "scale") +CHAIN_SIDES = ("bottom", "top", "left", "right") +SCHEDULE_KINDS = ("doors", "windows", "rooms") + +#: A two-room plan the tool docstring and the tests use: an entrance door and +#: an interior door, two windows, a straight stair, both rooms labelled, the +#: two exterior chains and all three schedules. The room numbers are "01" / +#: "02", not "R1": the finalize validator reads R-and-a-digit as a radius +#: callout typed as text. +EXAMPLE_SPEC: dict = { + "sheet": {"intent": "Two-room ground floor plan", "sheet_size": "A3", "scale": 50}, + "lang": "en", + "walls": [ + { + "id": "EXT", + "axis": [[0, 0], [9000, 0], [9000, 6000], [0, 6000]], + "thickness": 250, + "closed": True, + "material": "brick", + }, + {"id": "INT", "axis": [[5000, 0], [5000, 6000]], "thickness": 100, "material": "aac"}, + ], + "openings": [ + {"id": "D1", "wall": "EXT", "kind": "door", "offset": 1000, "width": 1000, "tag": "D1"}, + { + "id": "D2", + "wall": "INT", + "kind": "door", + "offset": 2000, + "width": 900, + "hand": "right", + "tag": "D2", + }, + { + "id": "W1", + "wall": "EXT", + "kind": "window", + "offset": 20500, + "width": 1500, + "sill": 900, + "height": 1200, + "tag": "W1", + }, + { + "id": "W2", + "wall": "EXT", + "kind": "window", + "offset": 11000, + "width": 1200, + "sill": 900, + "height": 1200, + "tag": "W2", + }, + ], + "stairs": [ + { + "id": "S1", + "start": [6000, 1000], + "direction_deg": 90, + "width": 900, + "risers": 16, + "riser_height": 175, + "going": 280, + } + ], + "rooms": [ + {"id": "R1", "name": "LIVING", "number": "01", "at": [2500, 3000]}, + {"id": "R2", "name": "HALL", "number": "02", "at": [7900, 3000]}, + ], + "chains": {"sides": ["bottom", "left"]}, + "schedules": [ + {"kind": "doors", "at": [10500, 6000]}, + {"kind": "windows", "at": [10500, 4000]}, + {"kind": "rooms", "at": [10500, 2000]}, + ], +} + + +def _number(value, where: str) -> float: + if isinstance(value, bool): + raise ValueError(f"{where}: expected a number, got {value!r}") + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") from None + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{where}: must be a finite number greater than zero, got {value!r}") + return number + + +def _point(value, where: str) -> tuple[float, float]: + if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)) or len(value) != 2: + raise ValueError(f"{where}: expected [x, y], got {value!r}") + try: + x, y = float(value[0]), float(value[1]) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected [x, y], got {value!r}") from None + if not (math.isfinite(x) and math.isfinite(y)): + raise ValueError(f"{where}: must be two finite numbers, got {value!r}") + return (x, y) + + +def _list(spec: dict, key: str) -> list: + value = spec.get(key) + if value is None: + return [] + if isinstance(value, (str, bytes, dict)) or not isinstance(value, (list, tuple)): + raise ValueError(f"{key}: expected a list, got {type(value).__name__}") + return list(value) + + +def validate_spec(spec: dict) -> dict: + """The whole document checked and normalised - nothing is drawn here. + + Returns ``{"sheet", "lang", "scale", "poche", "walls", "openings", + "stairs", "rooms", "chains", "schedules"}`` with model objects in place of + dicts (rooms stay dicts: ``label_room`` takes the request, not a Room). + """ + from engineering.arch.stairs import stair_prims + from engineering.arch.walls import wall_geometry + + if not isinstance(spec, dict): + raise ValueError(f"spec: expected a dict, got {type(spec).__name__}") + unknown = sorted(set(spec) - set(SPEC_KEYS)) + if unknown: + raise ValueError( + f"spec: unknown key(s) {', '.join(unknown)}; keys are {', '.join(SPEC_KEYS)}" + ) + sheet = spec.get("sheet") or {} + if not isinstance(sheet, dict): + raise ValueError(f"sheet: expected a dict, got {type(sheet).__name__}") + extra = sorted(set(sheet) - set(SHEET_KEYS)) + if extra: + raise ValueError( + f"sheet: unknown key(s) {', '.join(extra)}; keys are {', '.join(SHEET_KEYS)}" + ) + scale = _number(sheet.get("scale", 50), "sheet.scale") + lang = spec.get("lang", "en") + if lang not in LANGS: + raise ValueError(f"lang: unknown language {lang!r}; languages are {', '.join(LANGS)}") + poche = spec.get("poche", True) + if not isinstance(poche, bool): + raise ValueError(f"poche: expected true or false, got {poche!r}") + + raw_walls = _list(spec, "walls") + if not raw_walls: + raise ValueError("walls: name at least one wall - a plan without walls has no rooms") + walls = [wall_from_dict(item, f"walls[{i}]") for i, item in enumerate(raw_walls)] + openings = [ + opening_from_dict(item, f"openings[{i}]") for i, item in enumerate(_list(spec, "openings")) + ] + try: + validate_openings(walls, openings) + geometry = wall_geometry(walls, openings, poche=poche) + except ValueError as exc: + raise ValueError(f"walls/openings: {exc}") from exc + + stairs = [] + for i, item in enumerate(_list(spec, "stairs")): + stair = stair_from_dict(item, f"stairs[{i}]") + try: + stair_prims(stair, lang=lang, scale=scale) + except ValueError as exc: + raise ValueError(f"stairs[{i}]: {exc}") from exc + stairs.append(stair) + + rooms = [] + seen_rooms: set[str] = set() + for i, item in enumerate(_list(spec, "rooms")): + room = room_from_dict(item, f"rooms[{i}]") + if room.id in seen_rooms: + raise ValueError(f"rooms[{i}].id: {room.id!r} is used twice") + seen_rooms.add(room.id) + rooms.append(dict(item)) + + chains = spec.get("chains") + sides: tuple[str, ...] | None = None + if chains not in (None, False): + if chains is True: + chains = {} + if not isinstance(chains, dict) or set(chains) - {"sides"}: + raise ValueError(f"chains: expected {{sides: [...]}}, got {chains!r}") + sides = tuple(chains.get("sides") or ("bottom", "left")) + bad = [side for side in sides if side not in CHAIN_SIDES] + if bad or not sides: + raise ValueError(f"chains.sides: {bad or sides!r} - sides are {', '.join(CHAIN_SIDES)}") + + schedules = [] + for i, item in enumerate(_list(spec, "schedules")): + if not isinstance(item, dict) or set(item) - {"kind", "at"}: + raise ValueError(f"schedules[{i}]: expected {{kind, at}}, got {item!r}") + kind = item.get("kind") + if kind not in SCHEDULE_KINDS: + raise ValueError( + f"schedules[{i}].kind: {kind!r} is not a schedule; " + f"schedules are {', '.join(SCHEDULE_KINDS)}" + ) + schedules.append({"kind": kind, "at": _point(item.get("at"), f"schedules[{i}].at")}) + + return { + "sheet": { + "intent": str(sheet.get("intent") or "architectural plan"), + "sheet_size": str(sheet.get("sheet_size") or "A3"), + }, + "scale": scale, + "lang": lang, + "poche": poche, + "walls": walls, + "openings": openings, + "omitted": list(geometry.omitted), + "stairs": stairs, + "rooms": rooms, + "chains": sides, + "schedules": schedules, + } + + +async def draw_plan_from_spec(backend: AutoCADBackend, spec: dict) -> dict: + """Validate, then draw the whole plan inside one transaction. + + Returns ``{"walls", "openings", "stairs", "rooms", "chains", "schedules", + "omitted", "drawn", "critique", "backend"}``: the wall and opening ids that + were drawn (an opening the wall engine could not cut is in ``omitted``, not + in ``openings``), each drawing step's own result (``drawn`` is + ``draw_walls``'), and the three ``arch_*`` critique focuses run over the + committed drawing. + """ + from engineering.arch.critique import ARCH_FOCUSES, build_index, issues_for + + norm = validate_spec(spec) + scale = norm["scale"] + lang = norm["lang"] + if backend.get_plan_spec() is None: + await backend.drawing_plan( + norm["sheet"]["intent"], norm["sheet"]["sheet_size"], 1.0 / scale, "arch" + ) + + begun = await backend.transaction_begin() + if not isinstance(begun, dict) or not begun.get("ok"): + # a refusal, not a crash: nothing has been drawn yet + detail = begun.get("error") if isinstance(begun, dict) else begun + raise ValueError( + f"arch_plan_from_spec: could not open a transaction ({detail}); nothing was drawn. " + "If one is already open, close it with transaction_commit or " + "transaction_rollback and retry." + ) + + result: dict = { + "walls": None, + "stairs": [], + "rooms": [], + "chains": None, + "schedules": [], + } + try: + await backend.drawing_apply_iso_layers("arch") + result["walls"] = await _draw.draw_walls( + backend, norm["walls"], norm["openings"], poche=norm["poche"] + ) + for i, stair in enumerate(norm["stairs"]): + try: + result["stairs"].append( + await _draw.draw_stair(backend, stair, lang=lang, scale=scale) + ) + except ValueError as exc: + raise ValueError(f"stairs[{i}]: {exc}") from exc + for i, room in enumerate(norm["rooms"]): + try: + result["rooms"].append( + await _rooms.label_room(backend, room, lang=lang, scale=scale) + ) + except ValueError as exc: + raise ValueError(f"rooms[{i}]: {exc}") from exc + if norm["chains"] is not None: + result["chains"] = await _draw.draw_chains( + backend, norm["walls"], norm["openings"], sides=norm["chains"], scale=scale + ) + for i, item in enumerate(norm["schedules"]): + try: + result["schedules"].append( + await _schedule.draw_schedule( + backend, item["kind"], at=item["at"], lang=lang, scale=scale + ) + ) + except ValueError as exc: + raise ValueError(f"schedules[{i}]: {exc}") from exc + except BaseException as exc: + cancelled = isinstance(exc, anyio.get_cancelled_exc_class()) + with anyio.CancelScope(shield=True): + try: + await backend.transaction_rollback() + except Exception as rollback_exc: + if not cancelled: + raise + log.error( + "arch_plan_from_spec: rollback after a cancelled request failed (%s: %s); " + "the plan may be half-drawn - check system_status and " + "transaction_rollback before retrying", + type(rollback_exc).__name__, + rollback_exc, + ) + raise + await backend.transaction_commit() + + index = await build_index(backend) + critique = [issue.to_dict() for focus in ARCH_FOCUSES for issue in issues_for(focus, index)] + drawn = result["walls"] or {} + omitted = list(drawn.get("omitted", norm["omitted"])) + skipped = {str(item.get("element")) for item in omitted} + return { + "walls": [wall.id for wall in norm["walls"]], + "openings": [o.id for o in norm["openings"] if o.id not in skipped], + "stairs": result["stairs"], + "rooms": result["rooms"], + "chains": result["chains"], + "schedules": result["schedules"], + "omitted": omitted, + "drawn": drawn, + "critique": critique, + "backend": backend.name, + } +``` + +- [ ] **Step 10: Run it to verify only the tool is missing** + +Run: `uv run --frozen pytest tests/test_arch_spec.py -q` +Expected: `1 failed, 13 passed` — `test_the_tool_is_registered_and_refuses_through_tool_error` with `fastmcp.exceptions.ToolError: Not found: Unknown tool: 'arch_plan_from_spec'`. + +- [ ] **Step 11: Register `arch_plan_from_spec` — the twelfth tool of SECTION 25** + +In `server.py`, change the merged SECTION 25 banner's count from `(11 tools)` to `(12 tools)`: + +```python +# ── SECTION 25: Architecture (12 tools) ───────────────────────────────────── +``` + +(keep the line's dash padding as the merge left it; only the number changes). Then insert the block below at the end of SECTION 25 — after the last tool of the `# ── arch: catalogue (group C) ──` block and before the two separator/`# ── RESOURCES` lines: + +```python +# ── arch: the whole plan (integration) ── + + +@cad_tool( + summary="Draw a whole floor plan - walls, doors, windows, stairs, rooms, chains, schedules - in one transaction.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Plan From Spec", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_plan_from_spec( + spec: Annotated[ + dict, + Field( + description=( + "{sheet: {intent, sheet_size, scale (denominator, 50 = 1:50)}, lang: 'en'|'tr', " + "poche: bool, walls: [{id, axis: [[x,y],...], thickness, justification, " + "material, closed}], openings: [{id, wall, kind: 'door'|'window', offset, " + "width, swing, hand, sill, height, tag}], stairs: [{id, start, direction_deg, " + "width, risers, riser_height, going, kind, turn}], rooms: [{id, name, number, " + "at}], chains: {sides: ['bottom','left']}, schedules: [{kind: " + "'doors'|'windows'|'rooms', at: [x,y]}]}" + ) + ), + ], + ctx: Context = None, +) -> dict: + """A whole plan drawn inside one transaction: walls with their hosted doors and + windows, stairs, room labels whose areas are measured off the walls just drawn, + the exterior dimension chains and the door / window / room schedules. + + Every item is validated before the transaction opens, so a refusal leaves the + drawing untouched and names its path (`walls[1].thickness: ...`, + `rooms[0].area: ...`). What only the drawing can refuse - a room point in no + closed face - rolls the transaction back, and so does a cancelled request (the + rollback runs under an anyio shield). The result carries each step's own + result, the wall engine's `omitted` list and the three `arch_*` critique + focuses run over the committed plan. + + Refused by name: an unknown key, no walls, an unknown language or material, a + non-finite or non-positive number, an arc segment in a wall axis, an opening + that overlaps another or runs past its wall segment (both ids named), a typed + room `area` (areas are measured, never typed), an unknown chain side or + schedule kind, and a transaction that cannot be opened. Catalogue furniture, + the grid and symbols are separate tools (`arch_catalogue_insert`, `arch_grid`, + `arch_symbol`). + """ + from engineering.arch.spec import draw_plan_from_spec + + await ctx.info( + f"arch_plan_from_spec: {len((spec or {}).get('walls') or [])} wall(s), " + f"{len((spec or {}).get('openings') or [])} opening(s)" + ) + try: + return await draw_plan_from_spec(_backend(ctx), spec) + except ValueError as exc: + raise ToolError(str(exc)) from exc +``` + +In `discovery/aliases.py`, add the record at the end of `TOOL_ALIASES`, directly after the last `arch_*` record the merge brought in (the closing `}` of the dict follows it): + +```python + # ── Architecture: the whole plan (SECTION 25, track F integration) ────── + "arch_plan_from_spec": ToolAliases( + acad=(), + synonyms=( + "draw a floor plan", + "whole floor plan from a spec", + "plan with walls doors and windows", + "draw a house plan", + "apartment plan", + "two room plan", + "architectural plan in one go", + "kat planı", + "kat planı çiz", + "mimari plan", + ), + ), +``` + +`acad` is empty on purpose: no single AutoCAD command draws a plan, and a wrong command misroutes a search (the field contract at the top of the module). + +- [ ] **Step 12: Run it to verify it passes** + +Run: `uv run --frozen pytest tests/test_arch_spec.py tests/test_discovery_aliases.py -q` +Expected: all pass — the spec suite, and the alias gate that refuses a registered tool without a record. + +- [ ] **Step 13: Name the three focuses where the server describes the focus list** + +In `drawing_critique`'s `focus` Field description, replace + +```python + "mech_bom_balloon_mismatch (silent on a drawing with no mechanical part). " + "None = run all.", +``` + +with + +```python + "mech_bom_balloon_mismatch (silent on a drawing with no mechanical part), and " + "the architectural focuses arch_room_unlabelled, arch_wall_gap, " + "arch_opening_clash (silent on a drawing with no ACADMCP_ARCH record). " + "None = run all.", +``` + +and in `drawing_finalize`'s docstring replace + +``` + mech_bom_balloon_mismatch — silent on a sheet with no mechanical part), +``` + +with + +``` + mech_bom_balloon_mismatch — silent on a sheet with no mechanical part, plus the + architectural focuses arch_room_unlabelled, arch_wall_gap, arch_opening_clash — + silent on a sheet with no ACADMCP_ARCH record), +``` + +- [ ] **Step 14: The `arch` pack, the four lean names and the group** + +In `server.py`, replace + +```python +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech") +``` + +with + +```python +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech", "arch") +``` + +add to `PACK_TOOL_NAMES`, after the `"mech"` entry (before the dict's closing `}`): + +```python + # SECTION 25 - the architectural plan: the wall network with its openings, + # stairs, rooms and their reader, schedules, grid and symbols, the + # furniture / sanitary catalogue, the exterior chains and the whole plan + # from one spec (track F spec §8). + "arch": frozenset( + { + "arch_wall", + "arch_opening", + "arch_stair", + "arch_room", + "arch_rooms_detect", + "arch_schedule", + "arch_grid", + "arch_symbol", + "arch_catalogue_list", + "arch_catalogue_insert", + "arch_dimension_chains", + "arch_plan_from_spec", + } + ), +``` + +add to `LEAN_TOOL_NAMES`, after `"titleblock_apply",` (before the set's closing `}`): + +```python + # Architecture (track F) - the four a lean client needs to draw a + # plan: walls, the openings they host, a measured room label, and the + # whole plan in one call. `arch`-pack tools, so TOOL_PACKS=core hides + # them from a lean surface too; with the five mechanical/sheet ones + # above, lean is 64. + "arch_wall", + "arch_opening", + "arch_room", + "arch_plan_from_spec", +``` + +The group: group W's Task 4 already inserted `"arch"` directly after `"mech"` in `_GROUP_TAG_PRIORITY` and `"arch": "architecture",` into `_GROUP_TAG_LABELS`. Confirm the merge kept exactly one of each: + +``` +uv run --frozen python -c "import server; print(server._GROUP_TAG_PRIORITY.count('arch'), server._GROUP_TAG_PRIORITY.index('arch') == server._GROUP_TAG_PRIORITY.index('mech') + 1, server._GROUP_TAG_LABELS.get('arch'))" +``` + +Expected: `1 True architecture`. If the merge lost them, write them back exactly so — + +```python +_GROUP_TAG_PRIORITY = ( + "pid", + "mech", + "arch", + "sheet", +``` + +(every other entry keeps its position) and, after `"mech": "mechanical",` in `_GROUP_TAG_LABELS`: + +```python + "arch": "architecture", +``` + +Every SECTION 25 tool must carry the `arch` tag, or it files under its secondary tag (`create`, `query`, `dimension`, `block`). Check it rather than trust it: + +``` +uv run --frozen python -c "import asyncio, server; tools = asyncio.run(server._registered_tools()); missing = sorted(t.name for t in tools if t.name in server.PACK_TOOL_NAMES['arch'] and 'arch' not in (t.tags or set())); print('untagged:', missing)" +``` + +Expected: `untagged: []`. For every name printed, add `"arch"` to that tool's `tags={...}` in server.py (its other tags stay). + +- [ ] **Step 15: Update the pack test to the new surface** + +Replace `tests/test_tool_packs.py` with: + +```python +# tests/test_tool_packs.py +"""TOOL_PACKS: advertise only the vertical packs a client uses; core is always on. + +v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools. +Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting +essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment +surface and nothing else. + +v1.6 tracks B+G add the `mech` pack: SECTION 21's six part-model tools and +SECTION 22's three standard-parts tools and SECTION 23's five annotation +symbols - 14 in all. SECTION 24 (the sheet: frames, title blocks, revisions, +the parts list, balloons, xrefs, images, DWG) stays in `core` - universal +drafting, not a vertical - and files under its own `sheet` group. + +v1.6 track F adds the `arch` pack: SECTION 25's twelve architectural tools - +the wall network, openings, stairs, rooms and their reader, schedules, the +grid and symbols, the furniture and sanitary catalogue, the exterior chains +and the whole plan from one spec. Lean gains four of them. +""" + +from __future__ import annotations + +import logging + +import pytest +import pytest_asyncio + +import config +import server + +pytestmark = pytest.mark.asyncio + +PID_TOOLS = { + "pid_symbol_list", + "pid_symbol_insert", + "pid_line_draw", + "pid_tag_parse", + "pid_graph", + "pid_instrument_index", + "pid_line_list", + "pid_equipment_list", + "pid_from_spec", +} + +STYLE_TOOLS = { + "dimstyle_list", + "dimstyle_create", + "dimstyle_modify", + "dimstyle_set_current", + "textstyle_list", + "textstyle_create", + "textstyle_set_current", + "mleaderstyle_list", + "mleaderstyle_create", + "drawing_apply_standard", +} + +PAGE_SETUP_TOOLS = { + "page_setup_list", + "page_setup_apply", + "plot_style_list", + "batch_plot", + "drawing_template_list", + "drawing_template_save", +} + +SETTINGS_TOOLS = { + "document_list", + "document_activate", + "document_close", + "layer_state_save", + "layer_state_restore", + "layer_state_list", + "layer_state_delete", + "view_named_save", + "view_named_restore", + "view_named_list", + "ucs_list", + "ucs_set", + "ucs_restore", + "system_launch", + "system_preferences_get", + "system_preferences_set", + "drawing_properties_get", + "drawing_properties_set", + "system_variable_describe", + "user_pick_point", + "user_select", + "system_prompt_message", +} + +MECH_TOOLS = { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", + "std_part_list", + "std_part_insert", + "std_feature_draw", + "surface_texture", + "weld_symbol", + "centre_marks", + "section_line", + "hatch_material", +} + +SHEET_TOOLS = { + "sheet_frame", + "titleblock_apply", + "revision_add", + "bom_extract", + "bom_table", + "balloon_add", + "data_extract", + "xref_attach", + "xref_manage", + "image_attach", + "drawing_export_dwg", +} + +ARCH_TOOLS = { + "arch_wall", + "arch_opening", + "arch_stair", + "arch_room", + "arch_rooms_detect", + "arch_schedule", + "arch_grid", + "arch_symbol", + "arch_catalogue_list", + "arch_catalogue_insert", + "arch_dimension_chains", + "arch_plan_from_spec", +} + +LEAN_ARCH_ESSENTIALS = { + "arch_wall", + "arch_opening", + "arch_room", + "arch_plan_from_spec", +} + +LEAN_MECH_ESSENTIALS = { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "sheet_frame", + "titleblock_apply", +} + +LEAN_SETTINGS_ESSENTIALS = { + "drawing_apply_standard", + "page_setup_apply", + "batch_plot", + "dimstyle_set_current", + "textstyle_set_current", +} + +#: Declaration order - what `tool_packs["available"]` reports. +ALL_PACKS = ["core", "pid", "settings", "mech", "arch"] +#: `tool_packs["enabled"]` is sorted, which is no longer the same list. +ALL_PACKS_ENABLED = sorted(ALL_PACKS) + + +@pytest_asyncio.fixture(autouse=True) +async def _restore(monkeypatch): + yield + monkeypatch.setattr(config.settings, "tool_packs", "all") + await server._apply_tool_profile("full") + + +def test_pack_registry_names_real_tools_and_only_them(): + assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech", "arch") + assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS) + assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS) + assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS) + assert server.PACK_TOOL_NAMES["arch"] == frozenset(ARCH_TOOLS) + assert len(SETTINGS_TOOLS) == 22 + assert len(MECH_TOOLS) == 14 + assert len(ARCH_TOOLS) == 12 + assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"]) + assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"]) + assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"]) + for pack in ("pid", "settings", "mech"): + assert not (server.PACK_TOOL_NAMES["arch"] & server.PACK_TOOL_NAMES[pack]), pack + assert not (SHEET_TOOLS & set().union(*server.PACK_TOOL_NAMES.values())), ( + "SECTION 24 is core drafting, not a vertical pack" + ) + + +async def test_every_pack_member_is_a_registered_tool(): + registered = {tool.name for tool in await server._registered_tools()} + for pack, names in server.PACK_TOOL_NAMES.items(): + missing = names - registered + assert not missing, f"pack {pack!r} names unregistered tools: {sorted(missing)}" + + +async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(monkeypatch): + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("full") + disabled = set(info["disabled_tools"]) + assert PID_TOOLS <= disabled + assert SETTINGS_TOOLS <= disabled + assert MECH_TOOLS <= disabled + assert ARCH_TOOLS <= disabled + assert not (SHEET_TOOLS & disabled) + assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials" + assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core" + assert "block_define" not in disabled and "entity_set_xdata" not in disabled + assert info["tool_packs"] == {"enabled": ["core"], "available": ALL_PACKS, "ignored": []} + + +async def test_settings_pack_alone_keeps_pid_hidden(monkeypatch): + monkeypatch.setattr(config.settings, "tool_packs", "settings") + info = await server._apply_tool_profile("full") + disabled = set(info["disabled_tools"]) + assert PID_TOOLS <= disabled + assert not (SETTINGS_TOOLS & disabled) + assert info["tool_packs"]["enabled"] == ["core", "settings"] + + +async def test_all_is_the_default_and_enables_every_pack(monkeypatch): + monkeypatch.setattr(config.settings, "tool_packs", "all") + info = await server._apply_tool_profile("full") + disabled = set(info["disabled_tools"]) + assert not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS | ARCH_TOOLS) & disabled) + assert info["tool_packs"]["enabled"] == ALL_PACKS_ENABLED + + +async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( + monkeypatch, caplog +): + monkeypatch.setattr(config.settings, "tool_packs", "pid,mech3000") + with caplog.at_level(logging.WARNING, logger="autocad_mcp"): + info = await server._apply_tool_profile("full") + assert info["tool_packs"] == { + "enabled": ["core", "pid"], + "available": ALL_PACKS, + "ignored": ["mech3000"], + } + assert "mech3000" in caplog.text + + +async def test_lean_intersects_with_packs(monkeypatch): + assert {"pid_symbol_insert", "pid_line_draw", "pid_graph"} <= server.LEAN_TOOL_NAMES + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("lean") + assert "pid_graph" in info["disabled_tools"] + assert "entity_create_line" not in info["disabled_tools"] + + +async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack( + monkeypatch, +): + """Spec §8.2: lean = 64 (55 + the five mechanical/sheet essentials + track + F's four architectural ones). The five settings essentials are core-pack + tools, so `TOOL_PACKS=core` leaves them on a lean surface; no environment + tool is lean.""" + assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES + assert LEAN_MECH_ESSENTIALS <= server.LEAN_TOOL_NAMES + assert LEAN_ARCH_ESSENTIALS <= server.LEAN_TOOL_NAMES + assert server.LEAN_TOOL_NAMES & ARCH_TOOLS == LEAN_ARCH_ESSENTIALS + assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES) + assert len(server.LEAN_TOOL_NAMES) == 64 + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("lean") + disabled = set(info["disabled_tools"]) + assert not (LEAN_SETTINGS_ESSENTIALS & disabled) + assert not ({"sheet_frame", "titleblock_apply"} & disabled), "sheet tools are core" + assert info["enabled_count"] == 64 - 10, ( + "lean minus the three pid, three mech and four arch tools" + ) + + +async def test_the_three_track_e_sections_file_under_their_own_groups(): + """Pack, group and SECTION are the same list, so `system_about` and + `docs/tool-inventory.json` describe the surface the packs gate.""" + groups = await server._tool_groups() + assert set(groups["styles"]) == STYLE_TOOLS + assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS + assert set(groups["environment"]) == SETTINGS_TOOLS + assert set(groups["mechanical"]) == MECH_TOOLS + assert set(groups["sheet"]) == SHEET_TOOLS + assert set(groups["architecture"]) == ARCH_TOOLS + assert len(groups) == 26 + + +async def test_system_about_reports_packs(): + from fastmcp import Client + + async with Client(server.mcp) as client: + about = (await client.call_tool("system_about", {})).structured_content + assert about["tool_packs"]["available"] == ALL_PACKS +``` + +- [ ] **Step 16: Run the pack and profile gates** + +Run: `uv run --frozen pytest tests/test_tool_packs.py tests/test_tool_profiles.py tests/test_styles.py -q` +Expected: all pass. If `enabled_count` is not `64 - 10`, print the measured number with `uv run --frozen python -c "import asyncio, config, server; config.settings.tool_packs='core'; print(asyncio.run(server._apply_tool_profile('lean'))['enabled_count'])"` and find which lean name a pack does or does not claim — the count is data, not a target. + +- [ ] **Step 17: Add the six golden tuning cases** + +In `tests/data/discovery_golden.py`, append to `TUNING_CASES`, after `GoldenCase("save this drawing as a dwg", "drawing_export_dwg", "paraphrase"),` and before the closing `)`: + +```python + # v1.6 track F - the architectural plan. Written before the merged alias + # records were measured against them; a miss is fixed by adding vocabulary + # to that tool's record in discovery/aliases.py, never by moving the + # expectation. Two Turkish queries again: this shop searches in Turkish. + GoldenCase("draw the walls of a floor plan", "arch_wall", "paraphrase"), + GoldenCase("put a door in this wall", "arch_opening", "paraphrase"), + GoldenCase("label the room with its measured area", "arch_room", "paraphrase"), + GoldenCase("draw the whole floor plan from a spec", "arch_plan_from_spec", "paraphrase"), + GoldenCase("kapı pencere listesi", "arch_schedule", "synonym"), + GoldenCase("merdiven çiz", "arch_stair", "synonym"), +``` + +and change the module docstring's first line from `Ninety-one cases (76 tuning + 15 holdout) split into a **tuning** set and a` to `Ninety-seven cases (82 tuning + 15 holdout) split into a **tuning** set and a`. + +- [ ] **Step 18: Make each golden query reachable, then measure** + +The corpus is what BM25 indexes, so every query above must have its distinctive words in its tool's record: + +``` +uv run --frozen python -c " +from discovery.aliases import TOOL_ALIASES +from tests.data.discovery_golden import TUNING_CASES +for c in TUNING_CASES: + if not c.expect.startswith('arch_'): + continue + rec = TOOL_ALIASES.get(c.expect) + text = ' '.join((*(rec.acad if rec else ()), *(rec.synonyms if rec else ()))).lower() + missing = [w for w in c.query.lower().split() if len(w) > 3 and w not in text] + print(c.expect, '|', c.query, '->', 'OK' if not missing else f'add: {missing}') +" +``` + +For every `add:` row, append the whole query phrase (lowercase, trimmed) to that tool's `synonyms` tuple in `discovery/aliases.py`. Never add `"wall thickness"` to an `arch_*` record: it is the holdout case `GoldenCase("wall thickness", "entity_offset", "synonym")`, and pulling it to `arch_wall` would buy a tuning point with a holdout miss. Then measure: + +Run: `uv run --frozen pytest tests/test_discovery_transform.py tests/test_discovery_aliases.py -q` +Expected: every tuning case passes, the tuning and holdout bars hold (`TUNING_TOP1_BAR = 0.85`, `TUNING_TOP3_BAR = 0.94`, `HOLDOUT_TOP3_BAR = 0.80`). A case that still misses is fixed with more vocabulary in its record, never by editing the expectation; a holdout case that slips is fixed by a new tuning case capturing the same class of failure, never by editing the holdout tuple. + +- [ ] **Step 19: Reconcile the AutoCAD commands the merged records now share** + +The three branches wrote their own records, so a command may now be claimed twice (the spec names WALL, DOOR, WINDOW, STAIR, BOUNDARY and AREA as the commands a drafter reaches for; BOUNDARY already routes to `boundary_trace`, AREA to `analysis_measure_entity` and GRID to `drawing_settings`). Compute the real list: + +``` +uv run --frozen python -c " +from discovery.aliases import TOOL_ALIASES, SHARED_ACAD_COMMANDS +own = {} +for tool, rec in TOOL_ALIASES.items(): + for cmd in rec.acad: own.setdefault(cmd, []).append(tool) +shared = {c: sorted(n) for c, n in own.items() if len(n) > 1} +print('now shared:', {c: shared[c] for c in sorted(shared)}) +print('to add :', sorted(set(shared) - SHARED_ACAD_COMMANDS)) +print('stale :', sorted(SHARED_ACAD_COMMANDS - set(shared))) +" +``` + +For every name in `to add`, decide: a command that genuinely means either tool goes into `SHARED_ACAD_COMMANDS` in alphabetical position, with a reason line in the comment block above the set, in this shape — + +``` +# AREA -- one command measures a picked boundary (analysis_measure_entity) +# or, in a plan, is what a drafter types to get a room's floor area +# (arch_room writes the measured area into the label); a drafter +# typing it could mean either. +# BOUNDARY -- one command traces a closed polyline from loose edges +# (boundary_trace) or finds the rooms a plan's walls enclose +# (arch_rooms_detect); a drafter typing it could mean either. +``` + +— and a command that belongs to one tool only is removed from the other record instead: GRID, for one, is AutoCAD's drawing-aid dot grid (`drawing_settings`), not a structural grid, so an `arch_grid` record claiming it loses it rather than sharing it. If `stale` is non-empty, that command stopped being shared and comes *out* of the set. + +Run: `uv run --frozen pytest tests/test_discovery_aliases.py -q` +Expected: all pass, including `test_each_acad_command_routes_to_a_single_tool` and `test_shared_command_allowlist_has_no_stale_entries`. + +- [ ] **Step 20: Regenerate the inventory and retake the frozen registry snapshot** + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen python -c " +import asyncio, server +sizes = {k: len(v) for k, v in asyncio.run(server._tool_groups()).items()} +print(sizes); print('total', sum(sizes.values()), 'groups', len(sizes)) +" +``` + +Expected: `total 241 groups 26` — `architecture: 12`, and every other group back at its pre-track-F value (group R's and C's branches filed their SECTION 25 tools under `create` / `query` while `arch` was unranked on them). In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`, change the docstring's first line to `Frozen snapshot of the surface (241 tools, 26 groups).` and replace group W's paragraph that begins `` `architecture` appeared with v1.6's track F: SECTION 25 opened with the `` (the last paragraph of the docstring after the merge) with: + +``` + `architecture` appeared with v1.6's track F (12, SECTION 25: the wall + network, openings, stairs, rooms and their reader, schedules, grid, + symbols, the catalogue, the exterior chains and the whole plan from one + spec). The `arch` tag is ranked directly after `mech` in + `_GROUP_TAG_PRIORITY`, so a SECTION 25 tool that also carries `create`, + `query` or `dimension` files under its own section; `architecture` is + exactly `PACK_TOOL_NAMES["arch"]`, and every group the track-F branches + had grown while the tag was unranked on them returned to its + pre-track-F value. +``` + +(if the merge carried no such paragraph, append this one after the sentence ending `every one of them its pre-SECTION-24 value.`), and replace the asserted dict and total with: + +```python + assert sizes == { + "analysis": 12, + "architecture": 12, + "batch": 3, + "blocks": 9, + "corner_ops": 4, + "dimensions": 5, + "drawing": 11, + "engineering": 10, + "entity_creation": 18, + "entity_modification": 16, + "entity_query": 9, + "environment": 22, + "layers": 14, + "layouts": 12, + "mechanical": 14, + "page_setup": 6, + "pid": 9, + "premium": 12, + "sheet": 11, + "solids": 5, + "styles": 10, + "system": 7, + "templates": 2, + "transactions": 3, + "validation": 1, + "view": 4, + } + assert sum(sizes.values()) == 241 +``` + +If the printed dict differs from this one anywhere but by a tool that lacks its `arch` tag (Step 14's check), stop and find out why before writing it: a group that moved without a tool being added is a regression, not a snapshot. + +Run: `uv run --frozen pytest tests/test_tool_registry.py tests/test_tool_inventory.py tests/test_release_consistency.py::test_section_header_counts_match_decorators -q` +Expected: all pass — the SECTION 25 banner says 12 and 12 decorators sit under it. + +- [ ] **Step 21: Retake the README figures the tree derives** + +``` +uv run --frozen python scripts/check_doc_numbers.py --list +uv run --frozen pytest --collect-only -q | tail -1 +uv run --frozen python -c " +from discovery.aliases import TOOL_ALIASES +acad = {c for r in TOOL_ALIASES.values() for c in r.acad} +syn = [s for r in TOOL_ALIASES.values() for s in r.synonyms] +print(len(TOOL_ALIASES), 'records', len(acad), 'commands', len(syn), 'synonyms') +" +``` + +Update `README.md` from the printed values: + +- the release-snapshot line `> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests.` — `241 tools` and the collected-tests figure `check_doc_numbers --list` printed (the same number `pytest --collect-only -q` prints); +- the line under it — `> 241 is the **registered** count; a default install advertises 236 over \`tools/list\`,` (241 minus the five `ENABLE_3D` solids); +- the alias sentence — `an authored corpus of ** AutoCAD command names and synonym phrases** covering all 241 tools`, with the two numbers the last command printed; +- the Discovery row of "What you get" — `each rank **#1** of the 236-tool advertised catalog`; +- the `` line under "What you get" — its first sentence becomes: + +``` +241 tools in 26 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (TOOL_PACKS=core,mech keeps the mechanical ones, TOOL_PACKS=core,arch the architectural ones) from a client that needs none of them. +``` + +(the rest of that `` line — the eight resources and five prompt templates — is unchanged). Then: + +Run: `uv run --frozen python scripts/check_doc_numbers.py` +Expected: exit 0. This task changes no benchmark figure — `correctness_suite.py` still holds the 39 checks the published A/B report was run over — so the only numbers that moved are the ones just rewritten. A `DRIFT` line names the file and the figure; fix that figure, never the checker. + +- [ ] **Step 22: Gates** + +``` +uv run --frozen ruff format engineering/arch/critique.py engineering/arch/spec.py engineering/critique.py engineering/plan_spec.py server.py discovery/aliases.py tests/test_arch_critique.py tests/test_arch_spec.py tests/test_tool_packs.py tests/test_tool_registry.py tests/data/discovery_golden.py +uv run --frozen ruff check engineering server.py discovery tests +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen pytest -q +``` + +Expected: ruff clean; no inventory drift; the whole suite green, `tests/test_release_consistency.py` in full included — the collected-tests figure was retaken in Step 21, so the test every worktree task deselected passes again on main. + +- [ ] **Step 23: Commit** + +``` +git add engineering/arch/critique.py engineering/arch/spec.py engineering/critique.py engineering/plan_spec.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_arch_critique.py tests/test_arch_spec.py tests/test_tool_packs.py tests/test_tool_registry.py tests/data/discovery_golden.py +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +feat(arch): arch_plan_from_spec, three arch_* critique focuses, the arch pack, lean 64 and the counts + +The three track-F branches are merged; this is the integration that makes the +twelve SECTION 25 tools one surface. + +- engineering/arch/spec.py: a whole plan from one document - walls with their + openings, stairs, room labels measured off the walls just drawn, the + exterior chains and the schedules - inside one transaction. Everything the + model, the wall engine or the stair symbol can refuse is refused before the + transaction opens, by path; a room point in no closed face rolls it back, + and so does a cancelled request, under the anyio shield of pid/spec.py. + EXAMPLE_SPEC's rooms measure 4825 x 5750 and 3825 x 5750 - the net floor, + computed by hand in the tests. +- engineering/arch/critique.py: arch_room_unlabelled, arch_wall_gap (50 mm, + one issue per pair of walls, measured to the wall body so an L corner and a + T stem both read as joined) and arch_opening_clash. One index per critique + run; a drawing without an ACADMCP_ARCH record yields nothing. Each focus + has a positive and a negative fixture. +- TOOL_PACK_NAMES gains "arch" (the twelve SECTION 25 tools); lean gains + arch_wall, arch_opening, arch_room and arch_plan_from_spec and reaches 64; + the arch tag ranks after mech, so SECTION 25 files as one `architecture` + group - 241 tools in 26 groups. +- Six golden tuning cases, two of them Turkish, the arch_plan_from_spec alias + record, the shared-command allowlist reconciled with what the merged records + claim, the inventory regenerated, the registry snapshot retaken, and the + README figures taken from check_doc_numbers rather than typed. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +``` + +--- + +### Task 10: Evidence — benchmark arch_roundtrip, correctness checks, the live smoke, docs, full gate + +**Files:** +- Create: `benchmarks/tasks_v6.py` +- Create: `tests/test_benchmark_v6.py` +- Create: `scripts/smoke_arch_com.py` +- Modify: `benchmarks/adapters/autocad_mcp_pro.py` (import from `tasks_v6`, `ROUNDTRIP_AREAS_MM2`, `_task_arch_roundtrip`) +- Modify: `benchmarks/render_matrix_chart.py`, `benchmarks/run_competitors.py` (import the v6 matrix) +- Modify: `benchmarks/correctness_suite.py` (39 → 43: `arch_junction_l_t_x`, `arch_room_area_net`, `arch_opening_cuts_wall`, `arch_rooms_detect_foreign`) +- Modify: `tests/test_benchmark_v4.py`, `tests/test_benchmark_v5.py` (their `len(CHECKS) == 39` pins become 43) +- Modify: `benchmarks/token_suite.py` (a `TOOL_PACKS=core,arch` idle row) and `tests/test_token_suite.py` (five idle surfaces) +- Modify: `scripts/check_doc_numbers.py` (derive the task count from `TASKS_V6`) +- Modify: `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json` (regenerated over 43 checks) +- Modify: `CLAUDE.md`, `README.md`, `benchmarks/README.md`, `CHANGELOG.md` +- Modify: `docs/superpowers/specs/2026-09-23-v1.6-arch-design.md` (the status line) +- Test: `tests/test_benchmark_v6.py` + +**Interfaces:** +- Consumes: `benchmarks/tasks_v5.py::TASKS_V5, TaskSpec`; `benchmarks/tasks_v2.py / tasks_v3.py / tasks_v4.py::TASKS_V2, TASKS_V3, TASKS_V4`; `benchmarks/adapters/autocad_mcp_pro.py::AutoCADMCPProAdapter` (`setup(artifact_dir)`, `_reset()`, `cleanup()`, `self.backend`, `self.artifact_dir`); `engineering/scoring.py::combine(*severity_dicts) -> dict`; `engineering/validator.py::DrawingValidator().run(backend) -> ValidationResult` (`.summary`, `.findings[].code`); `engineering/critique.py::run_critique(backend, focus) -> list[Issue]`; `engineering/measure.py::polygon_area_perimeter(vertices, closed) -> (area, perimeter)`; from Task 1 — `engineering/arch/model.py::wall_from_dict, opening_from_dict`, `engineering/arch/lang.py::fmt_area_m2`, `engineering/arch/layers.py::ARCH_ROLE_LAYER`; from Task 2 — `engineering/arch/walls.py::wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry` (`.faces`, `.jambs`: `Line(p1, p2, role)`; `.regions`: closed CCW loops; `.omitted`); from Task 4 — `engineering/arch/draw.py::draw_walls(backend, walls, openings=(), *, poche=True)`, `read_plan(backend) -> {"walls", "openings", "stairs", "rooms": [Room]}`; from Task 5 — `engineering/arch/rooms.py::label_room(backend, room_dict, *, lang="en", scale=50)`, `rooms_detect(backend, *, layers=None, min_area=1.0e6, tol=1.0) -> dict` with `"rooms": [{"loop", "area", "centroid", "confidence", ...}]` and `"confidence_min"` (Task 5's shape, as Task 9 pins it); from Task 6 — `engineering/arch/schedule.py::schedule_rows(kind, plan, *, lang="en") -> tuple[dict, ...]` whose door and window rows carry `"tag"`, and `draw_schedule(...)` results carrying `"kind"`, `"representation"`, `"row_count"`; from Task 9 — `engineering/arch/spec.py::EXAMPLE_SPEC, draw_plan_from_spec(backend, spec)`, `engineering/arch/critique.py::point_in_loop(point, loop)`. +- Produces: `benchmarks/tasks_v6.py::NEW_TASKS_V6, TASKS_V6, MATRICES, DEFAULT_MATRIX = "v6", task_by_id(task_id, matrix=DEFAULT_MATRIX)`; `benchmarks/adapters/autocad_mcp_pro.py::ROUNDTRIP_AREAS_MM2` and `AutoCADMCPProAdapter._task_arch_roundtrip()`; the four `CHECKS` rows (category `"Architecture"`); `scripts/smoke_arch_com.py::main(keep_document=False) -> int`. + +- [ ] **Step 1: Write the failing test for the v6 matrix, the roundtrip and the four checks** + +Create `tests/test_benchmark_v6.py`: + +```python +# tests/test_benchmark_v6.py +"""The v6 task matrix: v5 plus what track F added. + +``arch_roundtrip`` is track F's evidence - a two-room plan drawn from one +spec, read back off the drawing by the room reader that never sees the spec, +and gated on the numbers the design spec fixed in advance: every room's +detected area within 0.1 % of its label's and of the hand-computed net floor, +the schedules listing the drawn tags, ``drawing_critique(focus=None)`` at +zero and the finalize score at least 90. The published competitor reports +were never asked it and the chart shows them ``not_run``. +""" + +from __future__ import annotations + +import pytest + +from benchmarks.tasks_v5 import TASKS_V5 +from benchmarks.tasks_v6 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V6, TASKS_V6, task_by_id + + +def test_v6_extends_v5_without_touching_it(): + assert TASKS_V6[: len(TASKS_V5)] == TASKS_V5 + assert [t.task_id for t in NEW_TASKS_V6] == ["arch_roundtrip"] + assert len(TASKS_V6) == 19 + assert MATRICES["v5"] == TASKS_V5 and MATRICES["v6"] == TASKS_V6 + assert DEFAULT_MATRIX == "v6" + assert task_by_id("arch_roundtrip").category == "arch" + + +def test_the_adapter_implements_every_v6_task(): + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + for task in TASKS_V6: + assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id + + +def test_the_hand_areas_are_the_net_floor_not_the_axis_area(): + from benchmarks.adapters.autocad_mcp_pro import ROUNDTRIP_AREAS_MM2 + + assert ROUNDTRIP_AREAS_MM2 == {"01": 27_743_750.0, "02": 21_993_750.0} + # the axis areas the rooms would report if the wall thickness were ignored + assert 5000.0 * 6000.0 not in ROUNDTRIP_AREAS_MM2.values() + assert 4000.0 * 6000.0 not in ROUNDTRIP_AREAS_MM2.values() + + +def test_the_suite_carries_the_four_architectural_checks(): + from benchmarks.correctness_suite import CHECKS + + for name in ( + "arch_junction_l_t_x", + "arch_room_area_net", + "arch_opening_cuts_wall", + "arch_rooms_detect_foreign", + ): + assert CHECKS[name][1] == "Architecture" + assert len(CHECKS) == 43 + + +@pytest.mark.asyncio +async def test_arch_roundtrip_passes_headlessly(tmp_path): + """The benchmark task itself, run once here so a regression in the wall + engine, the room reader, the schedules or the critique fails the suite and + not only the matrix.""" + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + adapter = AutoCADMCPProAdapter(backend="ezdxf") + await adapter.setup(tmp_path) + try: + await adapter._reset() + passed, metrics, _artifacts = await adapter._task_arch_roundtrip() + finally: + await adapter.cleanup() + assert passed, metrics + assert metrics["critique_issues"] == 0, metrics["critique_focuses"] + assert metrics["score"] >= 90.0 + assert metrics["door_tags"] == ["D1", "D2"] and metrics["window_tags"] == ["W1", "W2"] + for number in ("01", "02"): + assert metrics["rooms"][number]["agrees"], metrics["rooms"] + + +@pytest.mark.asyncio +async def test_arch_roundtrip_fails_loudly_when_a_room_label_is_removed(tmp_path, monkeypatch): + """The gate has to be able to fail. One room left without its label is + enough: the reader still finds the face, the critique names + arch_room_unlabelled, and the room is missing from the area comparison.""" + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + from engineering.arch import rooms + + real = rooms.label_room + + async def skip_the_hall(backend, room_dict, **kw): + if room_dict.get("number") == "02": + return {"ok": True, "skipped": True} + return await real(backend, room_dict, **kw) + + monkeypatch.setattr(rooms, "label_room", skip_the_hall) + adapter = AutoCADMCPProAdapter(backend="ezdxf") + await adapter.setup(tmp_path) + try: + await adapter._reset() + passed, metrics, _artifacts = await adapter._task_arch_roundtrip() + finally: + await adapter.cleanup() + assert passed is False + assert "arch_room_unlabelled" in metrics["critique_focuses"] + assert "02" not in metrics["rooms"] + + +@pytest.mark.asyncio +async def test_the_net_area_check_measures_the_label(): + from benchmarks import correctness_suite + + assert await correctness_suite.arch_room_area_net() is True + + +@pytest.mark.asyncio +async def test_the_foreign_plan_check_passes(): + from benchmarks import correctness_suite + + assert await correctness_suite.arch_rooms_detect_foreign() is True +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_benchmark_v6.py -q` +Expected: FAIL at collection — `ModuleNotFoundError: No module named 'benchmarks.tasks_v6'`. + +- [ ] **Step 3: Create `benchmarks/tasks_v6.py` and point every matrix consumer at it** + +`benchmarks/tasks_v6.py`: + +```python +"""The v6 task matrix: v5 plus what track F added. + +``arch_roundtrip`` (track F) is the roadmap evidence for the plan model: a +two-room plan - an entrance door, an interior door, two windows and a stair - +drawn by ``arch_plan_from_spec``, then read back by ``arch_rooms_detect``, +which never sees the spec. Each room's detected area has to agree with the area +its label carries within 0.1 %, and both with the net floor computed by hand; +the door and window schedules have to list the drawn tags; and the sheet is +gated on the same two numbers as ``mech_assembly``: ``drawing_critique +(focus=None)`` returns zero issues and the finalize score is at least 90. + +The published competitor reports were never asked this question; the chart +shows ``not_run`` for them, not zero. +""" + +from __future__ import annotations + +from benchmarks.tasks_v2 import TASKS_V2 +from benchmarks.tasks_v3 import TASKS_V3 +from benchmarks.tasks_v4 import TASKS_V4 +from benchmarks.tasks_v5 import TASKS_V5, TaskSpec + +#: Added in v1.6.0 by track F. +NEW_TASKS_V6: tuple[TaskSpec, ...] = ( + TaskSpec( + "arch_roundtrip", + "arch", + "Draw a two-room plan from one spec, read its rooms back from the drawing " + "within 0.1 %, schedule its tags, with a clean critique and a finalize score " + "of at least 90", + 1.0, + ), +) + +TASKS_V6: tuple[TaskSpec, ...] = TASKS_V5 + NEW_TASKS_V6 + +#: Runner-selectable task sets. v2 to v5 stay addressable so an earlier report +#: can be reproduced exactly rather than only described. +MATRICES: dict[str, tuple[TaskSpec, ...]] = { + "v2": TASKS_V2, + "v3": TASKS_V3, + "v4": TASKS_V4, + "v5": TASKS_V5, + "v6": TASKS_V6, +} + +DEFAULT_MATRIX = "v6" + + +def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec: + for task in MATRICES[matrix]: + if task.task_id == task_id: + return task + raise KeyError(f"{task_id} is not in matrix {matrix}") +``` + +In `benchmarks/adapters/autocad_mcp_pro.py`, change + +```python +from benchmarks.tasks_v5 import TaskSpec +``` + +to + +```python +from benchmarks.tasks_v6 import TaskSpec +``` + +and the module docstring's first line to `"""Reference adapter for this repository, covering the v6 task matrix.`. In `benchmarks/render_matrix_chart.py`, change `from benchmarks.tasks_v5 import TASKS_V5` to `from benchmarks.tasks_v6 import TASKS_V6` and both `TASKS_V5` uses in its body to `TASKS_V6`. In `benchmarks/run_competitors.py`, change `from benchmarks.tasks_v5 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id` to `from benchmarks.tasks_v6 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id`. Then confirm nothing else still takes v5 as the current matrix: + +``` +git grep --untracked -l "tasks_v5" -- "*.py" +``` + +Expected hits only: `benchmarks/tasks_v6.py` (deliberate — v6 extends v5), `tests/test_benchmark_v5.py`, `tests/test_benchmark_v6.py` and `scripts/check_doc_numbers.py` (Step 11 repoints it). The published task-matrix SVG is not re-rendered: it has not been since v1.5.0, and the competitor reports it draws were never asked the v3-v6 tasks. + +- [ ] **Step 4: Run it to verify the matrix half passes and the rest fails for the right reasons** + +Run: `uv run --frozen pytest tests/test_benchmark_v6.py -q` +Expected: `test_v6_extends_v5_without_touching_it` passes; `test_the_adapter_implements_every_v6_task` FAILS with `AssertionError: arch_roundtrip`; `test_the_hand_areas_are_the_net_floor_not_the_axis_area` FAILS with `ImportError: cannot import name 'ROUNDTRIP_AREAS_MM2'`; `test_the_suite_carries_the_four_architectural_checks` FAILS with `KeyError: 'arch_junction_l_t_x'`; the two roundtrip tests FAIL with `AttributeError: 'AutoCADMCPProAdapter' object has no attribute '_task_arch_roundtrip'`; the two check tests FAIL with `AttributeError: module 'benchmarks.correctness_suite' has no attribute ...`. + +- [ ] **Step 5: Add `ROUNDTRIP_AREAS_MM2` and `_task_arch_roundtrip` to the adapter** + +In `benchmarks/adapters/autocad_mcp_pro.py`, directly under `TOKEN_CEILING = 2_000`, add: + +```python + +#: The net floor of the two rooms of ``engineering/arch/spec.py::EXAMPLE_SPEC``, +#: computed by hand: the 250 mm ring on axis (0,0)-(9000,6000) has its inner +#: faces at x = 125 / 8875 and y = 125 / 5875, the 100 mm wall on x = 5000 its +#: faces at 4950 / 5050. Room 01: 4825 x 5750; room 02: 3825 x 5750. +ROUNDTRIP_AREAS_MM2: dict[str, float] = { + "01": 4825.0 * 5750.0, + "02": 3825.0 * 5750.0, +} +``` + +and insert into `AutoCADMCPProAdapter`, directly before `async def _task_auditable_delivery(self):`: + +```python + async def _task_arch_roundtrip(self): + """A two-room plan drawn from one spec and read back off the drawing. + + Track F's evidence (spec §10). ``arch_plan_from_spec`` draws the plan of + ``engineering/arch/spec.py::EXAMPLE_SPEC`` - a 9 x 6 m brick ring at 250 + mm split by a 100 mm AAC wall, an entrance door, an interior door, two + windows, a straight stair, two labelled rooms, the exterior chains and + the three schedules. Then the drawing is read, never the spec: + + * ``rooms_detect`` on the wall layer must find each labelled room, and + its area must agree with the area the label's record carries within + 0.1 %, and with the net floor computed by hand in + ``ROUNDTRIP_AREAS_MM2`` - so a label that typed the axis area fails; + * the door and window schedules read off the records must list exactly + the drawn tags; + * ``run_critique(focus=None)`` returns zero issues and the finalize + score - ``combine(validator.summary, critique)`` over the saved plan - + is at least 90. + + Any one slipping fails the task, and the metrics name what slipped. + """ + from engineering import DrawingValidator + from engineering.arch.critique import point_in_loop + from engineering.arch.draw import read_plan + from engineering.arch.layers import ARCH_ROLE_LAYER + from engineering.arch.rooms import rooms_detect + from engineering.arch.schedule import schedule_rows + from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec + from engineering.critique import run_critique + from engineering.scoring import combine + + drawn = await draw_plan_from_spec(self.backend, EXAMPLE_SPEC) + plan = await read_plan(self.backend) + detected = await rooms_detect(self.backend, layers=[ARCH_ROLE_LAYER["wall"]]) + faces = list(detected.get("rooms") or []) + + areas: dict[str, dict] = {} + for room in plan["rooms"]: + face = next((f for f in faces if point_in_loop(room.at, f["loop"])), None) + detected_area = float(face["area"]) if face is not None else 0.0 + hand = ROUNDTRIP_AREAS_MM2.get(str(room.number)) + areas[str(room.number)] = { + "label_mm2": float(room.area), + "detected_mm2": detected_area, + "hand_mm2": hand, + "agrees": face is not None + and hand is not None + and abs(detected_area - float(room.area)) <= 1e-3 * float(room.area) + and abs(detected_area - hand) <= 1e-3 * hand, + } + rooms_ok = ( + set(areas) == set(ROUNDTRIP_AREAS_MM2) + and len(faces) == len(ROUNDTRIP_AREAS_MM2) + and all(row["agrees"] for row in areas.values()) + ) + + door_tags = sorted(str(row["tag"]) for row in schedule_rows("doors", plan, lang="en")) + window_tags = sorted(str(row["tag"]) for row in schedule_rows("windows", plan, lang="en")) + tags_ok = door_tags == ["D1", "D2"] and window_tags == ["W1", "W2"] + + sheet = self.artifact_dir / "arch_roundtrip.dxf" + await self.backend.drawing_save_as(str(sheet)) + issues = await run_critique(self.backend, None) + validation = await DrawingValidator().run(self.backend) + summary = {"error": 0, "warning": 0, "info": 0} + for issue in issues: + summary[issue.severity] = summary.get(issue.severity, 0) + 1 + score = combine(validation.summary, summary) + passed = rooms_ok and tags_ok and len(issues) == 0 and float(score["score"]) >= 90.0 + return ( + passed, + { + "rooms": areas, + "faces_detected": len(faces), + "confidence_min": detected.get("confidence_min"), + "door_tags": door_tags, + "window_tags": window_tags, + "omitted": drawn.get("omitted"), + "critique_issues": len(issues), + "critique_focuses": sorted({issue.focus for issue in issues}), + "score": float(score["score"]), + "grade": score["grade"], + "invalidity_ratio": score["invalidity_ratio"], + "validator_findings": sorted(f.code for f in validation.findings), + }, + [str(sheet)], + ) +``` + +- [ ] **Step 6: Run the matrix half** + +Run: `uv run --frozen pytest tests/test_benchmark_v6.py -q -k "extends or adapter or hand or roundtrip"` +Expected: `test_v6_extends_v5_without_touching_it`, `test_the_adapter_implements_every_v6_task`, `test_the_hand_areas_are_the_net_floor_not_the_axis_area`, `test_arch_roundtrip_passes_headlessly` and `test_arch_roundtrip_fails_loudly_when_a_room_label_is_removed` pass. If `test_arch_roundtrip_passes_headlessly` fails, its metrics name what slipped — `rooms` (label, detected and hand areas per room), `door_tags` / `window_tags`, `critique_focuses`, `validator_findings`: fix the *plan code*, not the gate. The numbers are the criterion. + +- [ ] **Step 7: Add the four correctness checks** + +In `benchmarks/correctness_suite.py`, insert directly before `CHECKS = {`: + +```python +# ── Architecture (track F) ────────────────────────────────────────────────── + + +def _arch_points(geometry) -> set: + """Every endpoint of a drawn face or jamb, rounded to a micron.""" + return { + (round(float(x), 6), round(float(y), 6)) + for line in (*geometry.faces, *geometry.jambs) + for x, y in (line.p1, line.p2) + } + + +def _arch_passes_through(geometry, point) -> bool: + """Does a drawn face run *through* ``point`` (not merely end on it)?""" + px, py = point + for line in geometry.faces: + (x1, y1), (x2, y2) = line.p1, line.p2 + length = math.hypot(x2 - x1, y2 - y1) + cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1) + along = ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / (length * length) + if abs(cross) <= 1e-6 * length and 1e-9 < along < 1.0 - 1e-9: + return True + return False + + +def _arch_region_area(geometry) -> float: + from engineering.measure import polygon_area_perimeter + + return sum( + polygon_area_perimeter([(x, y, 0.0) for x, y in loop], True)[0] for loop in geometry.regions + ) + + +async def arch_junction_l_t_x(): + """The three junction kinds, against coordinates worked out by hand. + + L: one 200 mm centre wall turning at (4000, 0) - the outer corner is + (4100, -100), the inner (3900, 100), and a mitred corner keeps the area at + axis length x thickness: 7000 x 200 = 1 400 000 mm². + T: a 100 mm stem on x = 3000 ending on the axis of a 200 mm wall - its faces + stop on the near face at (2950, 100) and (3050, 100), the near face is open + between them, the far face runs through; 6000 x 200 + 2900 x 100 = 1 490 000. + X: two 200 mm walls crossing at (3000, 0) - the first stays whole, the second + stops on its faces, all four faces are cut at x = 2900 / 3100 and y = +-100; + 6000 x 200 + 2 x 2900 x 200 = 2 360 000. + """ + from engineering.arch.model import wall_from_dict + from engineering.arch.walls import wall_geometry + + def wall(wall_id, axis, thickness): + return wall_from_dict({"id": wall_id, "axis": axis, "thickness": thickness}) + + l_geo = wall_geometry([wall("L", [[0, 0], [4000, 0], [4000, 3000]], 200)], poche=False) + t_geo = wall_geometry( + [wall("A", [[0, 0], [6000, 0]], 200), wall("B", [[3000, 0], [3000, 3000]], 100)], + poche=False, + ) + x_geo = wall_geometry( + [wall("A", [[0, 0], [6000, 0]], 200), wall("B", [[3000, -3000], [3000, 3000]], 200)], + poche=False, + ) + l_ok = ( + {(4100.0, -100.0), (3900.0, 100.0)} <= _arch_points(l_geo) + and not {(4100.0, 100.0), (3900.0, -100.0)} & _arch_points(l_geo) + and abs(_arch_region_area(l_geo) - 1_400_000.0) < 1e-6 + ) + t_ok = ( + {(2950.0, 100.0), (3050.0, 100.0)} <= _arch_points(t_geo) + and not _arch_passes_through(t_geo, (3000.0, 100.0)) + and _arch_passes_through(t_geo, (3000.0, -100.0)) + and abs(_arch_region_area(t_geo) - 1_490_000.0) < 1e-6 + ) + x_ok = ( + {(2900.0, 100.0), (3100.0, 100.0), (2900.0, -100.0), (3100.0, -100.0)} + <= _arch_points(x_geo) + and not any( + _arch_passes_through(x_geo, p) + for p in ((3000.0, 100.0), (3000.0, -100.0), (2900.0, 0.0), (3100.0, 0.0)) + ) + and abs(_arch_region_area(x_geo) - 2_360_000.0) < 1e-6 + ) + return l_ok and t_ok and x_ok + + +async def arch_room_area_net(): + """A 4 x 5 m room between 200 mm walls measures 20.00 m², not its axis area. + + The ring's axis runs (-100, -100) to (4100, 5100), so its inner faces are + x = 0 / 4000 and y = 0 / 5000: the floor is 4000 x 5000 = 20 000 000 mm², + while the axis encloses 4200 x 5200 = 21 840 000. The number is read back + out of the room record the label carries, and the label text must say it. + """ + from engineering.arch.draw import draw_walls, read_plan + from engineering.arch.lang import fmt_area_m2 + from engineering.arch.model import wall_from_dict + from engineering.arch.rooms import label_room + + b = await _b() + ring = wall_from_dict( + { + "id": "W1", + "axis": [[-100, -100], [4100, -100], [4100, 5100], [-100, 5100]], + "thickness": 200, + "closed": True, + } + ) + await draw_walls(b, [ring]) + await label_room(b, {"id": "R1", "name": "ROOM", "number": "01", "at": [2000, 2500]}) + (room,) = (await read_plan(b))["rooms"] + texts = [e.properties.get("text", "") for e in await b.entity_list(type_filter="TEXT")] + return ( + abs(float(room.area) - 20_000_000.0) < 0.5 + and fmt_area_m2(room.area, "en") == "20.00 m²" + and any("20.00 m²" in str(text) for text in texts) + ) + + +async def arch_opening_cuts_wall(): + """A 900 door at 1000 along a 5 m, 200 mm wall interrupts both faces. + + No face runs through x = 1450 (the middle of the door) on either face line + y = +-100 while one still runs through x = 500, and two jambs close the cut + at x = 1000 and x = 1900. + """ + from engineering.arch.model import opening_from_dict, wall_from_dict + from engineering.arch.walls import wall_geometry + + wall = wall_from_dict({"id": "W1", "axis": [[0, 0], [5000, 0]], "thickness": 200}) + door = opening_from_dict( + {"id": "D1", "wall": "W1", "kind": "door", "offset": 1000, "width": 900} + ) + geo = wall_geometry([wall], [door], poche=False) + jambs = { + round(float(line.p1[0]), 6) + for line in geo.jambs + if abs(line.p1[0] - line.p2[0]) < 1e-9 and abs(abs(line.p1[1] - line.p2[1]) - 200.0) < 1e-9 + } + return ( + not _arch_passes_through(geo, (1450.0, 100.0)) + and not _arch_passes_through(geo, (1450.0, -100.0)) + and _arch_passes_through(geo, (500.0, 100.0)) + and {1000.0, 1900.0} <= jambs + and tuple(geo.omitted) == () + ) + + +async def arch_rooms_detect_foreign(): + """A plan of plain lines - no record of ours - yields the net areas. + + Two rooms drawn as the inner outlines of 200 mm walls on a layer named + WALLS: 4000 x 5000 and 3000 x 5000, inside an outer outline (-200, -200) + to (7400, 5200). The reader must find exactly those two faces - 20 000 000 + and 15 000 000 mm² - at the foreign-plan confidence 0.6, and must not have + written anything. + """ + from engineering.arch.rooms import rooms_detect + + b = await _b() + for x0, y0, x1, y1 in ( + (-200.0, -200.0, 7400.0, 5200.0), + (0.0, 0.0, 4000.0, 5000.0), + (4200.0, 0.0, 7200.0, 5000.0), + ): + corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] + for a, c in zip(corners, corners[1:] + corners[:1], strict=True): + await b.entity_create_line(a[0], a[1], c[0], c[1], layer="WALLS") + before = len(await b.entity_list(limit=1000)) + found = await rooms_detect(b) + areas = sorted(round(float(room["area"]), 3) for room in found["rooms"]) + return ( + areas == [15_000_000.0, 20_000_000.0] + and all(float(room["confidence"]) == 0.6 for room in found["rooms"]) + and len(await b.entity_list(limit=1000)) == before + ) +``` + +and register them at the end of `CHECKS`, after `"bom_balloon_link": (bom_balloon_link, "Sheet"),`: + +```python + "arch_junction_l_t_x": (arch_junction_l_t_x, "Architecture"), + "arch_room_area_net": (arch_room_area_net, "Architecture"), + "arch_opening_cuts_wall": (arch_opening_cuts_wall, "Architecture"), + "arch_rooms_detect_foreign": (arch_rooms_detect_foreign, "Architecture"), +``` + +`math` is already imported at the top of the module (`import math`); `_b()` is the suite's own fresh-document helper. + +- [ ] **Step 8: Move the two older suite-size pins, then run the checks one at a time** + +In `tests/test_benchmark_v4.py` and in `tests/test_benchmark_v5.py`, change `assert len(CHECKS) == 39` to `assert len(CHECKS) == 43` (both files pin the whole suite's size, and it has grown). + +``` +uv run --frozen python benchmarks/correctness_suite.py --list +uv run --frozen python benchmarks/correctness_suite.py arch_junction_l_t_x +uv run --frozen python benchmarks/correctness_suite.py arch_room_area_net +uv run --frozen python benchmarks/correctness_suite.py arch_opening_cuts_wall +uv run --frozen python benchmarks/correctness_suite.py arch_rooms_detect_foreign +uv run --frozen pytest tests/test_benchmark_v4.py tests/test_benchmark_v5.py tests/test_benchmark_v6.py -q +``` + +Expected: 43 rows listed, four `PASS` lines, and the three test files green. A `FAIL` is investigated by running that one check alone and fixing the code it exercises; the hand-computed numbers in the docstrings are the one thing that is never edited to fit. + +- [ ] **Step 9: Write `scripts/smoke_arch_com.py`** + +```python +"""Exercise track F once against the live AutoCAD on this machine. + +Creates a NEW document (never touches the operator's open drawings), then runs +the whole plan through the same code the tools call: + + draw_plan_from_spec(EXAMPLE_SPEC) (one transaction:) + drawing_apply_iso_layers("arch") -> draw_walls (junctions, cut + openings, poche, ACADMCP_ARCH records) -> draw_stair -> label_room x2 + (areas measured off the walls just drawn) -> draw_chains + -> draw_schedule("doors" / "windows" / "rooms") + read_plan -> rooms_detect (wall layer) -> run_critique(focus=None) + +and prints what came back as JSON. Exit codes: + + 0 the plan drew, both rooms read back within 0.1 % of their labels and of + the hand-computed net floor, and the critique returned nothing + 1 it drew, but a room disagreed or the critique found something + 2 no live CAD application reachable (pywin32 missing, or the ProgID + cannot be created) -- the COM paths then remain fake-tested only + +The backend's ``connect()`` is lazy on purpose (the server must start before +AutoCAD is open), so reachability is read from ``system_status()`` rather than +from ``connect()``. Every document this run opens is closed on the way out, +also when a step fails, unless ``--keep`` is given. + + AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_arch_com.py + uv run --frozen python scripts/smoke_arch_com.py --keep # leave it open +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +#: The net floor of EXAMPLE_SPEC's two rooms, by hand (see +#: benchmarks/adapters/autocad_mcp_pro.py::ROUNDTRIP_AREAS_MM2). +HAND_AREAS_MM2 = {"01": 4825.0 * 5750.0, "02": 3825.0 * 5750.0} + +#: ``RPC_E_CALL_REJECTED``: AutoCAD's message filter refused the call because +#: it was busy (a document still initialising after ``Documents.Add`` is the +#: case measured on this seat by scripts/smoke_mech_com.py). A refused call +#: was not executed. +RPC_E_CALL_REJECTED = -2147418111 + +#: How long a refused call is retried before the smoke gives up on it. +REJECTED_PATIENCE_S = 90.0 + +#: Names of the documents this run opened and has not closed yet. +_OPENED: list[str] = [] + + +def _patient(backend) -> None: + """Retry any backend call AutoCAD refused with ``RPC_E_CALL_REJECTED``.""" + run = backend._run + + async def patient(func, *args, **kwargs): + deadline = time.monotonic() + REJECTED_PATIENCE_S + pause = 0.5 + while True: + try: + return await run(func, *args, **kwargs) + except RuntimeError as exc: + cause = exc.__cause__ + hr = cause.args[0] if cause is not None and cause.args else None + if hr != RPC_E_CALL_REJECTED or time.monotonic() + pause > deadline: + raise + await asyncio.sleep(pause) + pause = min(pause * 2.0, 5.0) + + backend._run = patient + + +async def _names(backend) -> set[str]: + return {row["name"] for row in await backend.document_list()} + + +async def _new_document(backend) -> str: + """``drawing_new``, recording every document it added (normally one).""" + before = await _names(backend) + await backend.drawing_new() + added = sorted((await _names(backend)) - before) + _OPENED.extend(name for name in added if name not in _OPENED) + return added[-1] if added else "" + + +async def _close_leftovers(backend) -> None: + open_names = {row["name"] for row in await backend.document_list()} + for name in list(_OPENED): + if name in open_names: + try: + await backend.document_close(name, discard=True) + except Exception as exc: # best effort on the way out + print(f"could not close {name}: {exc}", file=sys.stderr) + _OPENED.remove(name) + + +def _inside(point, loop) -> bool: + from engineering.arch.critique import point_in_loop + + return point_in_loop(point, loop) + + +async def _plan(backend) -> dict: + from engineering.arch.draw import read_plan + from engineering.arch.layers import ARCH_ROLE_LAYER + from engineering.arch.rooms import rooms_detect + from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec + from engineering.critique import run_critique + + await _new_document(backend) + drawn = await draw_plan_from_spec(backend, EXAMPLE_SPEC) + plan = await read_plan(backend) + detected = await rooms_detect(backend, layers=[ARCH_ROLE_LAYER["wall"]]) + faces = list(detected.get("rooms") or []) + rooms = {} + for room in plan["rooms"]: + face = next((f for f in faces if _inside(room.at, f["loop"])), None) + found = float(face["area"]) if face is not None else 0.0 + hand = HAND_AREAS_MM2.get(str(room.number), 0.0) + rooms[str(room.number)] = { + "label_mm2": round(float(room.area), 3), + "detected_mm2": round(found, 3), + "hand_mm2": hand, + "agrees": face is not None + and abs(found - float(room.area)) <= 1e-3 * float(room.area) + and abs(found - hand) <= 1e-3 * hand, + } + issues = await run_critique(backend, None) + return { + "walls": drawn["walls"], + "openings": drawn["openings"], + "omitted": drawn["omitted"], + "stairs": len(drawn["stairs"]), + "schedules": [ + { + "kind": item.get("kind"), + "representation": item.get("representation"), + "rows": item.get("row_count"), + } + for item in drawn["schedules"] + ], + "rooms": rooms, + "faces_detected": len(faces), + "confidence_min": detected.get("confidence_min"), + "critique_issues": len(issues), + "critique": [issue.to_dict() for issue in issues], + } + + +async def main(keep_document: bool = False) -> int: + try: + from backends.com_backend import ComBackend + except ImportError as exc: + print(f"pywin32 not available: {exc}") + return 2 + backend = ComBackend() + _patient(backend) + try: + await backend.connect() + except Exception as exc: + print(f"no live AutoCAD reachable: {exc}") + return 2 + try: + status = await backend.system_status() + if not status.get("connected"): + print(f"no live AutoCAD reachable: {status.get('error', status)}") + return 2 + report = { + "autocad": status.get("autocad_version") or status.get("cad_progid"), + **await _plan(backend), + } + print(json.dumps(report, indent=2, ensure_ascii=False)) + ok = ( + report["critique_issues"] == 0 + and set(report["rooms"]) == set(HAND_AREAS_MM2) + and all(row["agrees"] for row in report["rooms"].values()) + ) + return 0 if ok else 1 + finally: + if not keep_document: + await _close_leftovers(backend) + await backend.disconnect() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main(keep_document="--keep" in sys.argv[1:]))) +``` + +- [ ] **Step 10: Run the smoke against the live seat and keep its output** + +Open AutoCAD 2026 first (a cold start can exceed `COM_CALL_TIMEOUT`), then: + +``` +AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_arch_com.py +``` + +Expected: exit 0 and a JSON report whose `critique_issues` is 0 and whose two `rooms` rows read `"agrees": true`. Save the printed JSON verbatim — Step 15 quotes it in the CHANGELOG. If a live defect shows (an ActiveX member or a layer the headless engine forgave), fix the *code* to the measured behaviour with a test that pins it, never the smoke's expectation, and record it under **Fixed**. If AutoCAD is not reachable (exit 2), say so in the task report and do not write a smoke record the run did not produce. + +- [ ] **Step 11: Point the document-number checker at the v6 matrix** + +In `scripts/check_doc_numbers.py`, change + +```python + from benchmarks.tasks_v5 import TASKS_V5 +``` + +to + +```python + from benchmarks.tasks_v6 import TASKS_V6 +``` + +and + +```python + "matrix_tasks": len(TASKS_V5), +``` + +to + +```python + "matrix_tasks": len(TASKS_V6), +``` + +- [ ] **Step 12: Regenerate the published A/B report over the grown suite** + +`scripts/check_doc_numbers.py` refuses while the suite holds more checks than the published report was run over — the report is re-run, never re-typed: + +``` +uv run --frozen python benchmarks/compare_versions.py v1.5.1 --json benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json +uv run --frozen python -c "import json; print(json.load(open('benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json'))['summary'])" +``` + +Expected: `total` 43, `old_pass` 26, `new_pass` 43, `old_pct` 60.5, `fixed` 17, `regressed` 0 — the four new checks are `miss` on v1.5.1 (`engineering.arch` does not exist there) and `pass` now. **A non-zero `regressed` stops the task**: find the regression and fix the code, never the report. + +- [ ] **Step 13: Add the `TOOL_PACKS=core,arch` idle row to the token suite and remeasure** + +In `benchmarks/token_suite.py::idle_lane`, replace + +```python + config.settings.tool_packs = previous_packs + srv._apply_discovery_mode("search") +``` + +with + +```python + config.settings.tool_packs = "core,arch" + rows.append( + await _idle_row( + "packs_core_arch", + "TOOL_PACKS=core,arch - an architectural client that never opens a " + "P&ID, a machine part or a live seat.", + counter, + turns, + ) + ) + config.settings.tool_packs = previous_packs + srv._apply_discovery_mode("search") +``` + +(the `finally:` block already restores `tool_packs`). In `tests/test_token_suite.py`, replace the whole `test_the_idle_lane_covers_the_four_advertised_surfaces` function with: + +```python +def test_the_idle_lane_covers_the_five_advertised_surfaces(report): + """Default, lean, search - and the two single-vertical clients: + TOOL_PACKS=core,mech (the default surface minus exactly the pid and settings + packs, and the arch pack) and TOOL_PACKS=core,arch (minus pid, settings and + mech).""" + variants = {row["variant"]: row for row in report["idle"]} + assert set(variants) == {"default", "lean", "packs_core_mech", "packs_core_arch", "search"} + assert variants["default"]["advertised_tools"] > 100 + assert variants["lean"]["advertised_tools"] == len(server.LEAN_TOOL_NAMES) + assert variants["search"]["advertised_tools"] == 2 + packs = server.PACK_TOOL_NAMES + assert variants["packs_core_mech"]["advertised_tools"] == ( + variants["default"]["advertised_tools"] + - len(packs["pid"]) + - len(packs["settings"]) + - len(packs["arch"]) + ) + assert variants["packs_core_arch"]["advertised_tools"] == ( + variants["default"]["advertised_tools"] + - len(packs["pid"]) + - len(packs["settings"]) + - len(packs["mech"]) + ) + for narrowed in ("packs_core_mech", "packs_core_arch"): + assert ( + variants["default"]["chars"] + > variants[narrowed]["chars"] + > variants["lean"]["chars"] + > variants["search"]["chars"] + ) +``` + +Then measure every row of the README table — the two single-pack rows the suite does not produce are measured with `TOOL_PACKS` set, reading their `default` row: + +``` +uv run --frozen pytest tests/test_token_suite.py -q +uv run --frozen python -m benchmarks.token_suite --tokenizer ratio +uv run --frozen python -c "import json; r = json.load(open('benchmarks/results/token-cost.json', encoding='utf-8')); [print(x['variant'], x['advertised_tools'], x['tokens']['value']) for x in r['idle']]" +TOOL_PACKS=core,settings uv run --frozen python -m benchmarks.token_suite --tokenizer ratio --out benchmarks/results/token-core-settings.json +TOOL_PACKS=core uv run --frozen python -m benchmarks.token_suite --tokenizer ratio --out benchmarks/results/token-core.json +uv run --frozen python -c "import json; [print(f, [(x['advertised_tools'], x['tokens']['value']) for x in json.load(open(f, encoding='utf-8'))['idle'] if x['variant'] == 'default']) for f in ('benchmarks/results/token-core-settings.json', 'benchmarks/results/token-core.json')]" +``` + +Expected: the token tests green; `default` 236 tools, `packs_core_mech` 193, `packs_core_arch` 191, `lean` 64, `search` 2; `core,settings` 201 and `core` 179. Keep every printed `advertised_tools` / `tokens` pair — Step 15 writes them into the README. (`benchmarks/results/*` is git-ignored: these files are measurements, not artefacts.) + +- [ ] **Step 14: Update `CLAUDE.md`** + +Append to the numbered tool-section list, after item 24: + +``` +25. Architecture (12 tools, pack `arch`): `arch_wall`, `arch_opening`, `arch_stair`, `arch_room`, `arch_rooms_detect`, `arch_schedule`, `arch_grid`, `arch_symbol`, `arch_catalogue_list`, `arch_catalogue_insert`, `arch_dimension_chains`, `arch_plan_from_spec`. One plan model — a wall network (axis polyline, thickness, justification, material) with hosted doors and windows — and one engine: L / T / X junctions resolved, openings cut, cut faces hatched by material (`engineering/arch/materials.py`: ISO 128-50 general hatching for brick, the JIS A 0150 RC / stone / wood symbols, AutoCAD's AR-CONC / AR-SAND, AAC as a declared convention), and anything it cannot place listed in `omitted: [{element, reason}]`, never dropped. Rooms are *computed*: a label's area is the planar face its point lies in, and `arch_rooms_detect` reads rooms from any plan (confidence 1.0 for our walls, 0.6 for plain lines) without modifying it. Schedules are a read of the `ACADMCP_ARCH` records, drawn as real TABLEs. Labels and schedules speak English, or Turkish with `lang="tr"` (decimal comma). The COM paths were executed live once by `scripts/smoke_arch_com.py` (AutoCAD 2026). +``` + +In the **Tool profiles** paragraph, replace `` `lean` = 60 curated drafting tools (54 with `TOOL_PACKS=core`, which drops the three `pid_*` and the three `mech_*` lean members; `` with `` `lean` = 64 curated drafting tools (54 with `TOOL_PACKS=core`, which drops the three `pid_*`, the three `mech_*` and the four `arch_*` lean members; ``. + +In the **Tool packs** paragraph, replace `` `TOOL_PACKS=all|core,pid,settings,mech` `` with `` `TOOL_PACKS=all|core,pid,settings,mech,arch` `` and `` `mech` the 14 SECTION 21-23 mechanical tools; `` with `` `mech` the 14 SECTION 21-23 mechanical tools, `arch` the 12 SECTION 25 architectural tools; ``. + +In the **Benchmark matrix** paragraph, make three replacements: `` `benchmarks/tasks_v5.py` is the current set (18 tasks; `--matrix v4` / `v3` / `v2` reproduce the earlier sets) `` becomes `` `benchmarks/tasks_v6.py` is the current set (19 tasks; `--matrix v5` / `v4` / `v3` / `v2` reproduce the earlier sets) ``; `` `page_setup_truth` or the v5 `mech_assembly` `` becomes `` `page_setup_truth`, the v5 `mech_assembly` ``, and `` a finalize score of at least 90) — the chart shows them `not run` `` becomes `` a finalize score of at least 90) or the v6 `arch_roundtrip` (a two-room plan drawn by `arch_plan_from_spec`, its rooms read back by `arch_rooms_detect` within 0.1 % of the label and of the hand-computed net floor, the schedules listing the drawn tags, gated like `mech_assembly`) — the chart shows them `not run` ``; and `` `benchmarks/correctness_suite.py` (39 checks) `` becomes `` `benchmarks/correctness_suite.py` (43 checks) ``. + +In **Key Conventions**, replace `` - **`TOOL_PACKS=all|core,pid,settings,mech`** advertises `` with `` - **`TOOL_PACKS=all|core,pid,settings,mech,arch`** advertises ``, and append after the bullet that begins `- **A diameter's text stands beyond the FIRST chord point**`: + +``` +- **The plan model, not lines**: a wall is `arch_wall` (an axis, a thickness, a justification, a material) and a door or window is hosted in it by `arch_opening` — never hand-drawn lines. The engine resolves the junctions and cuts the openings; what it cannot place is in `omitted`. +- **Areas are measured, never typed**: a room's area is the planar face its label point lies in, written into the label and the `room` record by `arch_room`; no tool accepts an area from its caller, and `arch_plan_from_spec` refuses a typed `area` by path. +- **Plan text follows the plot scale**: `scale` is a denominator (50 = 1:50) and a paper height `h` is drawn at `h × scale` drawing units — no annotative objects. `lang="tr"` switches words and the decimal separator; any other language is refused with the list. +- **Three `arch_*` critique focuses** (`arch_room_unlabelled`, `arch_wall_gap` — two wall ends within 50 mm that do not join, `arch_opening_clash`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing with no `ACADMCP_ARCH` record. +- **Nominal, not standard**: the furniture and sanitary blocks carry nominal catalogue dimensions and say so; the stair tool reports the Blondel relation (2R + G within 600–650 mm) as a design rule, never as a building code; the `arch` layer colours are a repository convention (ISO 13567 fixes names, not colours). +``` + +- [ ] **Step 15: Update `README.md`, `benchmarks/README.md`, `CHANGELOG.md` and the spec status line** + +`README.md`: + +- add a "What you get" row after the `Sheet & delivery` row: + +``` +| Architecture | one plan model — walls with hosted doors and windows — and one engine: L / T / X junctions resolved, openings cut, poché by material; rooms measured from the faces the walls enclose (never typed) and read back from any plan with a confidence; door / window / room schedules as real TABLEs; a furniture and sanitary catalogue at nominal sizes; structural grid, north arrow, section / level / elevation marks; exterior dimension chains; English or Turkish labels (`lang="tr"`, decimal comma) | +``` + +- replace the token table's rows with the ones Step 13 measured — a new `TOOL_PACKS=core,arch` row after the `core,mech` one. The tool counts are the ones Step 13 printed (236 / 201 / 193 / 191 / 179 / 64 / 2 when the merge added exactly twelve tools); the token column is that run's `tokens` value for the row, written with thousands separators like the existing rows: + +``` +| `TOOL_PROFILE=full` (default) | 236 | tokens | +| `TOOL_PACKS=core,settings` (full profile) | 201 | tokens | +| `TOOL_PACKS=core,mech` (full profile) | 193 | tokens | +| `TOOL_PACKS=core,arch` (full profile) | 191 | tokens | +| `TOOL_PACKS=core` (full profile) | 179 | tokens | +| `TOOL_PROFILE=lean` | 64 | tokens | +| `DISCOVERY_MODE=search` | 2 | ** tokens** | +``` + + (the angle-bracketed cells are measurements from Step 13's output, copied, never estimated), and take the sentence above the table (`The full catalog costs a client **N tokens** … costs **M**.`) and the `token_budget` row of the task-matrix table (`N → M tokens`) from the `default` and `search` rows of the same run; +- in the task-matrix section, change `1.6 adds three more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness, and one where a whole mechanical sheet has to come out clean.` to `1.6 adds four more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness, one where a whole mechanical sheet has to come out clean, and one where a floor plan's rooms are read back off the drawing.` and add after the `mech_assembly` row: + +``` +| `arch_roundtrip` | a two-room plan — an entrance door, an interior door, two windows, a stair — drawn by `arch_plan_from_spec` and read back by `arch_rooms_detect`, which never sees the spec: each room within 0.1 % of its label and of the net floor computed by hand (4825 × 5750 and 3825 × 5750 mm), the door and window schedules listing D1, D2, W1, W2, `drawing_critique(focus=None)` at **zero** and a finalize score of at least **90**; a test removes one room label and watches the gate fail | +``` + +- in the A/B section, change `39 deterministic headless checks` to `43 deterministic headless checks`, replace the two table rows with the regenerated report's numbers — + +``` +| v1.5.1 *(baseline)* | 26 / 43 | 60.5 % | — | — | +| **v1.6.0-dev** *(this branch)* | **43 / 43** | **100 %** | 17 | **0** | +``` + + (if Step 12's `summary` printed other numbers, write those) — and change the prose opening `The thirteen fixed rows are new capability (\`miss → pass\`). Tracks B and G add seven:` to: + +``` +The seventeen fixed rows are new capability (`miss → pass`). Track F adds four: `arch_junction_l_t_x` (an L corner mitres at (4100, −100) / (3900, 100), a T stem stops on the near face at (2950, 100) / (3050, 100), an X cuts all four faces, and the wall areas are 1 400 000, 1 490 000 and 2 360 000 mm² — computed by hand), `arch_room_area_net` (a 4 × 5 m room between 200 mm walls measures 20.00 m² in its label and its record, not the 21.84 m² its axes enclose), `arch_opening_cuts_wall` (both faces are interrupted across a 900 door and two jambs close it) and `arch_rooms_detect_foreign` (a plan of plain lines on a `WALLS` layer yields 20 and 15 m² at confidence 0.6, and nothing is written). Tracks B and G add seven: +``` + +`benchmarks/README.md`: + +- after the last paragraph of `### Matrix v5 (v1.6) — the mechanical sheet` (the one ending `The competitor reports carry no result for it.`), add: + +``` +### Matrix v6 (v1.6) — the architectural plan + +`tasks_v6.py` keeps the v5 eighteen unchanged and adds track F's evidence: + +| Task | Category | Verified against | +|---|---|---| +| `arch_roundtrip` | arch | the plan of `engineering/arch/spec.py::EXAMPLE_SPEC` — a 9 × 6 m brick ring at 250 mm split by a 100 mm AAC wall, an entrance door, an interior door, two windows, a straight stair, two labelled rooms, the exterior chains and the door / window / room schedules — drawn in one transaction by `draw_plan_from_spec`, then read back without the spec: `rooms_detect` on the wall layer must find each labelled room within 0.1 % of the area its label carries **and** of the net floor computed by hand (4825 × 5750 = 27 743 750 and 3825 × 5750 = 21 993 750 mm²), `schedule_rows` must list exactly D1, D2 and W1, W2, and the saved plan is gated like `mech_assembly`: `run_critique(focus=None)` returns **zero** issues and `combine(validator, critique)` scores at least **90**. `tests/test_benchmark_v6.py` removes one room label to prove the gate fails and names `arch_room_unlabelled` | + +The competitor reports carry no result for it. +``` + +- in `### Result — this branch (1.6.0-dev) vs v1.5.1 (release gate)`, change `39 checks, ezdxf backend` to `43 checks, ezdxf backend`, replace the two table rows with + +``` +| **v1.5.1** (baseline) | 26 / 43 | 60.5 % | — | — | +| **v1.6.0-dev** (this branch) | 43 / 43 | 100 % | 17 | **0** | +``` + + and change `The thirteen are all \`miss → pass\` — v1.5.1 has none of the methods. Seven` to `The seventeen are all \`miss → pass\` — v1.5.1 has none of the methods. Four are track F: \`arch_junction_l_t_x\` (L, T and X junction outlines against hand-computed corners and wall areas), \`arch_room_area_net\` (a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not its 21.84 m² axis area), \`arch_opening_cuts_wall\` (both faces interrupted across a door, two jambs) and \`arch_rooms_detect_foreign\` (plain lines on a WALLS layer read as 20 and 15 m² at confidence 0.6, nothing written). Seven`. + +`CHANGELOG.md` — add to `## [Unreleased]`, after the tracks B and G paragraph: + +``` +Track F of 1.6: architectural 2D, the plan model. 12 tools in a new SECTION +25 — a wall network with hosted doors and windows drawn by one engine (L / T / +X junctions resolved, openings cut, poché by material), rooms computed as the +faces the walls enclose and read back from any plan, schedules read off the +drawing, a furniture and sanitary catalogue, the structural grid and plan +symbols, exterior dimension chains and the whole plan in one transaction — a +new `arch` pack, three `arch_*` critique focuses, and English or Turkish +labels. No new contract member. Spec: +`docs/superpowers/specs/2026-09-23-v1.6-arch-design.md`. +``` + +and under `### Added`, directly after the tracks B and G bullet that begins `- **Live COM smoke.** \`scripts/smoke_mech_com.py\``: + +``` +- **Architecture — 12 tools, pack `arch`.** `arch_wall`, `arch_opening`, + `arch_stair`, `arch_room`, `arch_rooms_detect`, `arch_schedule`, + `arch_grid`, `arch_symbol`, `arch_catalogue_list`, `arch_catalogue_insert`, + `arch_dimension_chains`, `arch_plan_from_spec`. The plan travels as + `ACADMCP_ARCH` XDATA through the mechanical codec, generalised to take an + application id; `draw_prims` takes a role map. The `arch` layer set is + ISO 13567-style with ISO 128 lineweights; wall poché follows + `engineering/arch/materials.py`, whose rows each name their source. Areas + are measured, never typed. Lean gains four and reaches 64. +- **Benchmark evidence for track F.** `benchmarks/tasks_v6.py` (19 tasks, the + default matrix; v5 to v2 stay addressable) adds `arch_roundtrip`: the example + plan drawn by `arch_plan_from_spec` and read back by `arch_rooms_detect` + within 0.1 % of each label and of the hand-computed net floor, the schedules + listing the drawn tags, gated on a zero-issue `drawing_critique(focus=None)` + and a finalize score of at least 90; a test removes one room label and + watches the gate fail by name. The correctness suite grows to 43 with + `arch_junction_l_t_x`, `arch_room_area_net`, `arch_opening_cuts_wall` and + `arch_rooms_detect_foreign`. A/B against `v1.5.1`: **26 / 43 → 43 / 43, + seventeen `miss → pass`, zero regressed**. The token suite measures a + `TOOL_PACKS=core,arch` row. +- **Live COM smoke.** `scripts/smoke_arch_com.py` executed once on AutoCAD + 2026: the example plan drawn in one transaction, both rooms read back within + 0.1 % of their labels and of the hand-computed net floor, and + `drawing_critique(focus=None)` returned 0 issues (record below). +``` + +(write the `autocad` version string and the issue count exactly as Step 10's JSON printed them), and a new section directly after the `### Live COM smoke — mechanical parts and the sheet (tracks B + G)` section, before the next `## ` heading: + +```` +### Live COM smoke — architecture (track F) + +`scripts/smoke_arch_com.py`, AutoCAD 2026, Windows 11, in a new document only — +`draw_plan_from_spec(EXAMPLE_SPEC)` (walls, openings, stair, two room labels, +chains, three schedules in one transaction) → `read_plan` → `rooms_detect` → +`run_critique(focus=None)`, all through the COM engine, exit 0, the scratch +document closed afterwards: + +```json + +``` +```` + +(this slot and the token cells of the README table above are the only values in this plan that cannot be written in advance: they are the live seat's and the token run's own output, pasted, never retyped or estimated). + +`docs/superpowers/specs/2026-09-23-v1.6-arch-design.md` — change `**Status:** design approved in conversation` to `**Status:** shipped (v1.6.0-dev, track F)`. + +- [ ] **Step 16: The full gate** + +``` +uv run --frozen ruff format benchmarks scripts tests +uv run --frozen ruff check . +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen python scripts/check_doc_numbers.py --list +uv run --frozen python scripts/check_doc_numbers.py +uv run --frozen pytest -q +uv run --frozen pytest tests/test_capability_contract.py tests/test_tool_registry.py tests/test_discovery_aliases.py tests/test_tool_packs.py tests/test_release_consistency.py tests/test_benchmark_v6.py -q +``` + +Expected: ruff clean; no inventory drift; `check_doc_numbers` exits 0 with every figure derived (`matrix_tasks` 19, `suite_checks` = `ab_total` = 43); the whole suite green, `tests/test_release_consistency.py::test_document_numbers_match_the_tree` included. This task added test files, so the collected-tests figure moved after Task 9: if `check_doc_numbers` reports it, retake it from its own `--list` output into the README release-snapshot line. + +- [ ] **Step 17: Commit** + +``` +git add benchmarks/tasks_v6.py benchmarks/adapters/autocad_mcp_pro.py benchmarks/render_matrix_chart.py benchmarks/run_competitors.py benchmarks/correctness_suite.py benchmarks/token_suite.py benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json scripts/smoke_arch_com.py scripts/check_doc_numbers.py tests/test_benchmark_v4.py tests/test_benchmark_v5.py tests/test_benchmark_v6.py tests/test_token_suite.py CLAUDE.md README.md benchmarks/README.md CHANGELOG.md docs/superpowers/specs/2026-09-23-v1.6-arch-design.md +git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit -m "$(cat <<'MSG' +bench: matrix v6 adds arch_roundtrip; four architectural correctness checks; the live arch smoke + +The evidence for track F, in the shape tracks A, E and B + G used. + +- benchmarks/tasks_v6.py: 19 tasks, DEFAULT_MATRIX = "v6"; v5 to v2 stay + addressable. arch_roundtrip draws the example plan with + arch_plan_from_spec and reads it back without the spec: rooms_detect must + find each labelled room within 0.1 % of its label and of the net floor + computed by hand (4825 x 5750 and 3825 x 5750), the schedules must list + D1, D2, W1, W2, and the plan is gated on a zero-issue critique and a + finalize score of at least 90. A test removes one room label and the gate + fails naming arch_room_unlabelled. +- Four correctness checks, every number worked out by hand in the docstring: + L / T / X junction corners and wall areas, a 4 x 5 m room labelled 20.00 m2 + rather than its 21.84 m2 axis area, a door interrupting both faces, and a + plan of plain lines read as 20 and 15 m2 at confidence 0.6. The published + A/B report is re-run over 43 checks: 26 / 43 -> 43 / 43, zero regressed. +- scripts/smoke_arch_com.py runs the whole plan once on a live seat and + closes every document it opens; the CHANGELOG records what it returned. +- The token suite measures a TOOL_PACKS=core,arch row; check_doc_numbers + derives the task count from the v6 matrix; CLAUDE.md, both READMEs and + the spec status line say what shipped. + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd +MSG +)" +```