diff --git a/README.md b/README.md
index b305a80..d447317 100644
--- a/README.md
+++ b/README.md
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 240 tools · 8 resources · 5 prompt templates · 4005 collected tests.
-> 240 is the **registered** count; a default install advertises 235 over `tools/list`,
+> **v1.5 release snapshot:** 241 tools · 8 resources · 5 prompt templates · 4507 collected tests.
+> 241 is the **registered** count; a default install advertises 236 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
**A big MCP server is expensive to be connected to.** The full catalog costs a client **72,163 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
-**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **197 AutoCAD command names and 1158 synonym phrases** covering all 229 tools. A test refuses to let a tool exist without one.
+**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **205 AutoCAD command names and 1318 synonym phrases** covering all 241 tools. A test refuses to let a tool exist without one.
| Advertised surface | Tools seen | Idle cost |
|---|---:|---:|
@@ -106,7 +106,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Sheet & delivery | ISO 5457 frames A4-A0 with zones and trim marks, ISO 7200 title blocks for every size, revision blocks with clouds, ISO 7573 parts lists with ISO 6433 balloons linked by XDATA, CSV/XLSX extraction, xrefs and images on both engines, real `.dwg` on a live seat |
| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
| Styles & standards | ISO-25 / ANSI dimension styles, ISOCP / ROMANS text styles, ISO / ANSI leader styles from authored presets; `drawing_apply_standard("iso")` sets all of it plus units and the `mech` layers in one call; `changed` reports only what moved |
-| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 224-tool advertised catalog |
+| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 236-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |
| Page setup & templates | ISO 216 / ANSI Y14.1 sheets, ctb catalog, `batch_plot` verified by the PDF's own `/MediaBox`, five bundled templates built by the server's own tools and pinned reproducible (`drawing_new(template="iso_a3_mech")`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`), save any drawing as a template (`.dwt` on live AutoCAD, `dwt_write` refused headlessly) |
@@ -120,7 +120,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-240 tools in 26 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+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. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index be6377a..7286ead 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -62,7 +62,8 @@ class ToolAliases:
# splits read and write.
# ERASE, INSERT -- the server genuinely exposes one operation through more
# than one tool (single vs. batch delete; block insert vs. block-reference
-# creation vs. the P&ID catalogue insert), so each is a correct destination.
+# creation vs. the P&ID, standard-parts and furniture / sanitary
+# catalogue inserts), so each is a correct destination.
# QSELECT -- AutoCAD's Quick Select dialog is both "filter by properties"
# (selection_filter) and "find things like this one"
# (entity_select_smart); a drafter typing it could mean either.
@@ -508,6 +509,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"what is wrong with this drawing",
"drafting mistakes",
"check before finalising",
+ "anything wrong before i finish",
),
),
"drawing_deliver": ToolAliases(
@@ -2240,6 +2242,8 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"exterior wall",
"partition wall",
"floor plan walls",
+ "walls of a floor plan",
+ "draw the walls",
"clean wall corners",
"t junction of walls",
"wall poche",
@@ -2309,7 +2313,12 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
# 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.
+ # vocabulary below reaches these two without either command. Task 9's
+ # reconciliation of the merged records kept that decision on purpose:
+ # neither command goes into SHARED_ACAD_COMMANDS and neither moves to an
+ # arch_* record. The Turkish room vocabulary ("oda", "alan", "oda alanları")
+ # carries both spellings, diacritic and ASCII-folded, because the search
+ # tokenizer splits on a non-ASCII letter instead of folding it.
"arch_room": ToolAliases(
acad=(),
synonyms=(
@@ -2318,8 +2327,12 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"room name and number",
"net floor area",
"measured room area",
+ "oda",
"oda etiketi",
+ "oda alanı",
"oda alani",
+ "alan",
+ "mahal adı",
"mahal adi",
),
),
@@ -2331,7 +2344,18 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"rooms of a floor plan",
"room areas of this plan",
"read rooms from lines",
+ "oda",
+ "odalar",
+ "odaları bul",
"odalari bul",
+ "alan",
+ "oda alanları",
+ "oda alanlari",
+ "oda alanlarını hesapla",
+ "oda alanlarini hesapla",
+ "alan hesabı",
+ "alan hesabi",
+ "kat planı alanları",
"kat plani alanlari",
),
),
@@ -2342,7 +2366,14 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"window schedule",
"room schedule",
"opening schedule",
+ "kapı çizelgesi",
"kapi cizelgesi",
+ "kapı listesi",
+ "kapi listesi",
+ "pencere listesi",
+ "kapı pencere listesi",
+ "kapi pencere listesi",
+ "pencere çizelgesi",
"pencere cizelgesi",
"mahal listesi",
),
@@ -2411,6 +2442,29 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"gorunus isareti",
),
),
+ # ── 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 plani",
+ "kat planı çiz",
+ "kat plani ciz",
+ "vaziyet",
+ "vaziyet planı",
+ "vaziyet plani",
+ "vaziyet planı çiz",
+ "vaziyet plani ciz",
+ "mimari plan",
+ ),
+ ),
}
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index a8e489d..6874746 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,14 +3,14 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 240,
+ "tools": 241,
"resources": 8,
"prompts": 5,
"groups": 26
},
"groups": {
"analysis": 12,
- "architecture": 11,
+ "architecture": 12,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
@@ -251,6 +251,20 @@
"destructiveHint": false
}
},
+ {
+ "name": "arch_plan_from_spec",
+ "group": "architecture",
+ "cost": "mutate",
+ "summary": "Draw a whole floor plan - walls, doors, windows, stairs, rooms, chains, schedules - in one transaction.",
+ "tags": [
+ "arch",
+ "create"
+ ],
+ "annotations": {
+ "title": "Architecture: Plan From Spec",
+ "destructiveHint": false
+ }
+ },
{
"name": "arch_room",
"group": "architecture",
diff --git a/engineering/arch/critique.py b/engineering/arch/critique.py
new file mode 100644
index 0000000..2415c6c
--- /dev/null
+++ b/engineering/arch/critique.py
@@ -0,0 +1,372 @@
+"""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}
diff --git a/engineering/arch/spec.py b/engineering/arch/spec.py
new file mode 100644
index 0000000..abe3065
--- /dev/null
+++ b/engineering/arch/spec.py
@@ -0,0 +1,380 @@
+"""``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,
+ }
diff --git a/engineering/critique.py b/engineering/critique.py
index efa3848..6c84155 100644
--- a/engineering/critique.py
+++ b/engineering/critique.py
@@ -416,6 +416,10 @@ from .mech.critique import MECH_DISPATCH # noqa: E402 (same reason: after the
_FOCUS_DISPATCH.update(MECH_DISPATCH)
+from .arch.critique import ARCH_DISPATCH # noqa: E402 (same reason: after the base focuses)
+
+_FOCUS_DISPATCH.update(ARCH_DISPATCH)
+
async def run_critique(
backend: AutoCADBackend,
diff --git a/engineering/plan_spec.py b/engineering/plan_spec.py
index f1d959c..812d8e5 100644
--- a/engineering/plan_spec.py
+++ b/engineering/plan_spec.py
@@ -33,6 +33,9 @@ CritiqueFocus = Literal[
"mech_duplicate_dimension",
"mech_thread_unrepresented",
"mech_bom_balloon_mismatch",
+ "arch_room_unlabelled",
+ "arch_wall_gap",
+ "arch_opening_clash",
]
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
@@ -56,6 +59,9 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
"mech_duplicate_dimension",
"mech_thread_unrepresented",
"mech_bom_balloon_mismatch",
+ "arch_room_unlabelled",
+ "arch_wall_gap",
+ "arch_opening_clash",
)
diff --git a/server.py b/server.py
index 6b3c3ff..9e3704d 100644
--- a/server.py
+++ b/server.py
@@ -964,6 +964,15 @@ LEAN_TOOL_NAMES = frozenset(
"mech_dimension_part",
"sheet_frame",
"titleblock_apply",
+ # 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",
}
)
@@ -977,7 +986,7 @@ SOLID_TOOL_NAMES = frozenset(
# mechanically should not pay for the P&ID surface, and a client that never
# talks to a live seat should not pay for the environment surface. Styles and
# page setup (SECTION 18/19) are drafting essentials and stay in core.
-TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
+TOOL_PACK_NAMES = ("core", "pid", "settings", "mech", "arch")
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"pid": frozenset(
{
@@ -1041,6 +1050,26 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"hatch_material",
}
),
+ # 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",
+ }
+ ),
}
_active_tool_profile: dict | None = None
@@ -5715,7 +5744,9 @@ async def drawing_finalize(
pid_unconnected_equipment — silent on a sheet with no P&ID symbols, plus the
mechanical focuses mech_missing_centreline, mech_unhatched_section,
mech_view_misaligned, mech_duplicate_dimension, mech_thread_unrepresented,
- mech_bom_balloon_mismatch — silent on a sheet with no mechanical part),
+ 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),
then saves to disk, exports a screenshot, and returns the DWG path.
Raises ToolError if any validator 'error' finding is present, or if critique reports an
@@ -5976,7 +6007,9 @@ async def drawing_critique(
"(silent on a drawing with no P&ID symbols), and the mechanical focuses "
"mech_missing_centreline, mech_unhatched_section, mech_view_misaligned, "
"mech_duplicate_dimension, mech_thread_unrepresented, "
- "mech_bom_balloon_mismatch (silent on a drawing with no mechanical part). "
+ "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.",
),
] = None,
@@ -9783,7 +9816,7 @@ async def drawing_export_dwg(
# ---------------------------------------------------------------------------
-# ── SECTION 25: Architecture (11 tools) ─────────────────────────────────────
+# ── SECTION 25: Architecture (12 tools) ─────────────────────────────────────
# ---------------------------------------------------------------------------
@@ -10461,6 +10494,66 @@ async def arch_symbol(
raise ToolError(str(exc)) from exc
+# ── 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
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/data/discovery_golden.py b/tests/data/discovery_golden.py
index e85aabc..7c285f6 100644
--- a/tests/data/discovery_golden.py
+++ b/tests/data/discovery_golden.py
@@ -1,6 +1,6 @@
"""Golden query set for tool discovery: English in, one right tool out.
-Ninety-one cases (76 tuning + 15 holdout) split into a **tuning** set and a
+Ninety-nine cases (84 tuning + 15 holdout) split into a **tuning** set and a
**holdout** set. The split is
the point of the file. Ranking work is measured against
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
@@ -190,6 +190,20 @@ TUNING_CASES: tuple[GoldenCase, ...] = (
GoldenCase("put an m12 hex bolt on the drawing", "std_part_insert", "paraphrase"),
GoldenCase("draw the parts list table on the sheet", "bom_table", "paraphrase"),
GoldenCase("save this drawing as a dwg", "drawing_export_dwg", "paraphrase"),
+ # 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. Turkish queries again: this shop searches in Turkish, and
+ # the last two close the reconciliation's gaps - a site plan ("vaziyet")
+ # and the room areas ("alan"), neither of which any record carried.
+ 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"),
+ GoldenCase("vaziyet planı çiz", "arch_plan_from_spec", "synonym"),
+ GoldenCase("oda alanlarını hesapla", "arch_rooms_detect", "synonym"),
)
diff --git a/tests/test_arch_critique.py b/tests/test_arch_critique.py
new file mode 100644
index 0000000..e40474c
--- /dev/null
+++ b/tests/test_arch_critique.py
@@ -0,0 +1,265 @@
+"""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
diff --git a/tests/test_arch_spec.py b/tests/test_arch_spec.py
new file mode 100644
index 0000000..1fd9c8c
--- /dev/null
+++ b/tests/test_arch_spec.py
@@ -0,0 +1,162 @@
+"""``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": []}})
diff --git a/tests/test_token_suite.py b/tests/test_token_suite.py
index 7455bf6..686d7d8 100644
--- a/tests/test_token_suite.py
+++ b/tests/test_token_suite.py
@@ -163,14 +163,14 @@ def test_the_cache_arithmetic_follows_the_published_multipliers():
def test_the_idle_lane_covers_the_four_advertised_surfaces(report):
"""Default, lean, search - and TOOL_PACKS=core,mech, the mechanical client
- that never opens a P&ID or a live seat: the default surface minus exactly
- the pid and settings packs."""
+ that never opens a P&ID, a live seat or a floor plan: the default surface
+ minus exactly the pid, settings and arch packs (track F added `arch`)."""
variants = {row["variant"]: row for row in report["idle"]}
assert set(variants) == {"default", "lean", "packs_core_mech", "search"}
assert variants["default"]["advertised_tools"] > 100
assert variants["lean"]["advertised_tools"] == len(server.LEAN_TOOL_NAMES)
assert variants["search"]["advertised_tools"] == 2
- dropped = len(server.PACK_TOOL_NAMES["pid"]) + len(server.PACK_TOOL_NAMES["settings"])
+ dropped = sum(len(server.PACK_TOOL_NAMES[pack]) for pack in ("pid", "settings", "arch"))
assert variants["packs_core_mech"]["advertised_tools"] == (
variants["default"]["advertised_tools"] - dropped
)
diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py
index 42769cb..d8111e1 100644
--- a/tests/test_tool_packs.py
+++ b/tests/test_tool_packs.py
@@ -11,6 +11,11 @@ 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
@@ -115,6 +120,28 @@ SHEET_TOOLS = {
"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",
@@ -132,7 +159,7 @@ LEAN_SETTINGS_ESSENTIALS = {
}
#: Declaration order - what `tool_packs["available"]` reports.
-ALL_PACKS = ["core", "pid", "settings", "mech"]
+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)
@@ -145,15 +172,19 @@ async def _restore(monkeypatch):
def test_pack_registry_names_real_tools_and_only_them():
- assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")
+ 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"
)
@@ -173,6 +204,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(
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"
@@ -193,7 +225,7 @@ 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) & disabled)
+ assert not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS | ARCH_TOOLS) & disabled)
assert info["tool_packs"]["enabled"] == ALL_PACKS_ENABLED
@@ -222,19 +254,24 @@ async def test_lean_intersects_with_packs(monkeypatch):
async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack(
monkeypatch,
):
- """Spec §8.2: lean = 60 (55 + the five mechanical/sheet essentials). The
- five settings essentials are core-pack tools, so `TOOL_PACKS=core` leaves
- them on a lean surface; no environment tool is lean."""
+ """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) == 60
+ 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"] == 60 - 6, "lean minus the three pid and three mech tools"
+ 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():
@@ -246,7 +283,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
assert set(groups["environment"]) == SETTINGS_TOOLS
assert set(groups["mechanical"]) == MECH_TOOLS
assert set(groups["sheet"]) == SHEET_TOOLS
- assert len(groups) == 26 # + architecture (SECTION 25, track F)
+ assert set(groups["architecture"]) == ARCH_TOOLS
+ assert len(groups) == 26
async def test_system_about_reports_packs():
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 5aa42bc..e8a1ecb 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
- """Frozen snapshot of the surface (240 tools, 26 groups).
+ """Frozen snapshot of the surface (241 tools, 26 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -582,39 +582,20 @@ async def test_tool_group_sizes_are_unchanged():
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
- `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.
-
- 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.
-
- 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.
-
- 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.
-
- After the track F merge all eleven SECTION 25 tools file under
- `architecture` (the `arch` tag outranks `create` / `query` / `dimension`),
- so `entity_creation` and `entity_query` are back at 18 and 9.
+ `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.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 12,
- "architecture": 11,
+ "architecture": 12,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
@@ -640,4 +621,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 240
+ assert sum(sizes.values()) == 241