From 6e004cc658232491141ac9751f7707c2380f8065 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 23:20:05 +0300 Subject: [PATCH] feat(arch): arch_plan_from_spec, three arch_* critique focuses, the arch pack, lean 64 and the counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. - Two more Turkish golden cases close the reconciliation's alias gaps ("vaziyet planı çiz" -> arch_plan_from_spec, "oda alanlarını hesapla" -> arch_rooms_detect); the arch records carry vaziyet / alan / oda / kat planı in diacritic and ASCII-folded spellings, because the search tokenizer splits on a non-ASCII letter. BOUNDARY and AREA stay with boundary_trace and analysis_measure_entity on purpose, recorded in the arch alias comment. Tuning top-1 73/84, top-3 83/84; holdout top-3 15/15. - tests/test_token_suite.py: TOOL_PACKS=core,mech now drops the arch pack too (measured 193 = 236 - 9 - 22 - 12). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 10 +- discovery/aliases.py | 58 ++++- docs/tool-inventory.json | 18 +- engineering/arch/critique.py | 372 ++++++++++++++++++++++++++++++++ engineering/arch/spec.py | 380 +++++++++++++++++++++++++++++++++ engineering/critique.py | 4 + engineering/plan_spec.py | 6 + server.py | 101 ++++++++- tests/data/discovery_golden.py | 16 +- tests/test_arch_critique.py | 265 +++++++++++++++++++++++ tests/test_arch_spec.py | 162 ++++++++++++++ tests/test_token_suite.py | 6 +- tests/test_tool_packs.py | 56 ++++- tests/test_tool_registry.py | 43 ++-- 14 files changed, 1440 insertions(+), 57 deletions(-) create mode 100644 engineering/arch/critique.py create mode 100644 engineering/arch/spec.py create mode 100644 tests/test_arch_critique.py create mode 100644 tests/test_arch_spec.py 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