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:
co-authored by
Claude Opus 5.5
parent
ad5a32a34a
commit
6e004cc658
@@ -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
@@ -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",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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": []}})
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user