feat(arch): arch_plan_from_spec, three arch_* critique focuses, the arch pack, lean 64 and the counts

The three track-F branches are merged; this is the integration that makes the
twelve SECTION 25 tools one surface.

- engineering/arch/spec.py: a whole plan from one document - walls with their
  openings, stairs, room labels measured off the walls just drawn, the
  exterior chains and the schedules - inside one transaction. Everything the
  model, the wall engine or the stair symbol can refuse is refused before the
  transaction opens, by path; a room point in no closed face rolls it back,
  and so does a cancelled request, under the anyio shield of pid/spec.py.
  EXAMPLE_SPEC's rooms measure 4825 x 5750 and 3825 x 5750 - the net floor,
  computed by hand in the tests.
- engineering/arch/critique.py: arch_room_unlabelled, arch_wall_gap (50 mm,
  one issue per pair of walls, measured to the wall body so an L corner and a
  T stem both read as joined) and arch_opening_clash. One index per critique
  run; a drawing without an ACADMCP_ARCH record yields nothing. Each focus
  has a positive and a negative fixture.
- TOOL_PACK_NAMES gains "arch" (the twelve SECTION 25 tools); lean gains
  arch_wall, arch_opening, arch_room and arch_plan_from_spec and reaches 64;
  the arch tag ranks after mech, so SECTION 25 files as one `architecture`
  group - 241 tools in 26 groups.
- Six golden tuning cases, two of them Turkish, the arch_plan_from_spec alias
  record, the shared-command allowlist reconciled with what the merged records
  claim, the inventory regenerated, the registry snapshot retaken, and the
  README figures taken from check_doc_numbers rather than typed.
- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 23:20:05 +03:00
co-authored by Claude Opus 5.5
parent ad5a32a34a
commit 6e004cc658
14 changed files with 1440 additions and 57 deletions
+5 -5
View File
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **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 |
<sub>240 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>241 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**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.
+56 -2
View File
@@ -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",
),
),
}
+16 -2
View File
@@ -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",
+372
View File
@@ -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}
+380
View File
@@ -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,
}
+4
View File
@@ -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,
+6
View File
@@ -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",
)
+97 -4
View File
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+15 -1
View File
@@ -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"),
)
+265
View File
@@ -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
+162
View File
@@ -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": []}})
+3 -3
View File
@@ -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
)
+47 -9
View File
@@ -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():
+12 -31
View File
@@ -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