Files
Umut CANandClaude Opus 5.5 6e004cc658 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
2026-09-23 23:20:05 +03:00

373 lines
14 KiB
Python

"""The three architectural critique focuses (spec §9).
Every check is pure over one index built once per critique run and shared
through ``run_critique``'s context, the way the P&ID and mechanical focuses
are. A drawing with no ``ACADMCP_ARCH`` record on it yields no issue at all: a
mechanical sheet, a P&ID or a foreign plan of plain lines must not be scored
against architectural conventions, and ``focus=None`` has to stay cheap for
them.
What the index reads, and why only that: every plan record this server writes
(a wall on its outline, an opening on its leaf or frame, a room on its label)
lands on a layer of ``ARCH_ROLE_LAYER``, so XDATA is read on those layers only,
every entity of them (paged, not the first 200). A record carried by several
entities is kept once, by kind and id, with every handle that carries it. The
room faces come from ``rooms_detect`` on the wall layer - the same reader
``arch_rooms_detect`` runs, which closes door and window gaps from the opening
records - so the focus and the tool cannot disagree about what a room is.
* ``arch_room_unlabelled`` (warning): a face of at least ``rooms_detect``'s
minimum area (1 m²) with no room record whose label point lies inside it.
* ``arch_wall_gap`` (error): a free wall end that comes within ``GAP_MM``
(50 mm) of another wall - its end or its body - without joining it
(``JOIN_TOL_MM``, 1 mm, the wall engine's ``join_tol``). The room leaks, and
its area is measured through the gap. One issue per pair of walls.
* ``arch_opening_clash`` (error): two openings overlapping on one wall, an
opening running past the end of its wall's axis or across an axis vertex,
and an opening whose host wall has no record on the drawing.
The two thresholds are declared by the design spec, not taken from a
standard.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from typing import TYPE_CHECKING
from engineering.plan_spec import Issue
from .layers import ARCH_ROLE_LAYER
from .model import APP_ID, Opening, Room, Stair, Wall, axis_segments, decode, from_payload
if TYPE_CHECKING:
from backends.base import AutoCADBackend
ARCH_FOCUSES = ("arch_room_unlabelled", "arch_wall_gap", "arch_opening_clash")
_KEY = "arch_index"
#: Two wall ends closer than this without joining are a gap (spec §9).
GAP_MM = 50.0
#: Within this a wall end joins the wall it touches - the wall engine's join_tol.
JOIN_TOL_MM = 1.0
#: Below this, two lengths along a wall are the same length.
EPS = 1e-9
_BUCKETS = {Wall: "walls", Opening: "openings", Stair: "stairs", Room: "rooms"}
# ── the index ───────────────────────────────────────────────────────────────
async def build_index(backend: AutoCADBackend) -> dict:
"""One read of the drawing: every ACADMCP_ARCH record, and the room faces.
``{"walls" | "openings" | "stairs" | "rooms": {id: {"obj", "handles"}},
"faces": [{"loop", "area", ...}]}``. The faces are read only when there is
a wall record to bound them; a ``rooms_detect`` refusal (no wall layer)
reads as no faces.
"""
from engineering.sheet.bom import all_entities
index: dict = {"walls": {}, "openings": {}, "stairs": {}, "rooms": {}, "faces": []}
for layer in sorted(set(ARCH_ROLE_LAYER.values())):
try:
entities = await all_entities(backend, layer=layer)
except Exception:
continue
for entity in entities:
try:
raw = await backend.entity_get_xdata(entity.handle, APP_ID)
except Exception:
continue
values = (raw.get("xdata") or {}).get(APP_ID) or []
if not values:
continue
try:
obj = from_payload(decode(values))
except (ValueError, KeyError, TypeError):
# Not ours, or corrupt: the codec refused it, and guessing at
# it here would undo that.
continue
bucket = index[_BUCKETS[type(obj)]]
entry = bucket.setdefault(obj.id, {"obj": obj, "handles": []})
entry["handles"].append(entity.handle)
if index["walls"]:
from engineering.arch.rooms import rooms_detect
try:
detected = await rooms_detect(backend, layers=[ARCH_ROLE_LAYER["wall"]])
except ValueError:
detected = {"rooms": []}
index["faces"] = list(detected.get("rooms") or [])
return index
def has_arch_content(index: dict) -> bool:
return any(index[bucket] for bucket in ("walls", "openings", "stairs", "rooms"))
# ── geometry ────────────────────────────────────────────────────────────────
def _band(wall: Wall) -> tuple[float, float]:
"""Where the two faces lie along the segment's left normal, low to high."""
t = float(wall.thickness)
if wall.justification == "left":
return (0.0, t)
if wall.justification == "right":
return (-t, 0.0)
return (-t / 2.0, t / 2.0)
def body_distance(point, a, b, band: tuple[float, float]) -> float:
"""Distance from ``point`` to the body of one straight wall segment.
The body is the rectangle between the segment's two faces, from ``a`` to
``b``: 0 inside it or on its edge.
"""
length = math.dist(a, b)
ux, uy = (b[0] - a[0]) / length, (b[1] - a[1]) / length
dx, dy = point[0] - a[0], point[1] - a[1]
along = dx * ux + dy * uy
across = -dx * uy + dy * ux
out_along = max(0.0 - along, 0.0, along - length)
out_across = max(band[0] - across, 0.0, across - band[1])
return math.hypot(out_along, out_across)
def _ends(wall: Wall) -> list[tuple[str, tuple[float, float], int]]:
"""The free ends of a wall: (which end, the point, the segment it belongs to)."""
if wall.closed:
return []
last = len(wall.axis) - 2
return [("start", wall.axis[0], 0), ("end", wall.axis[-1], last)]
def wall_gaps(
walls: Sequence[Wall], *, gap: float = GAP_MM, tol: float = JOIN_TOL_MM
) -> list[dict]:
"""Free wall ends within ``gap`` of another wall that do not join it.
An end joins a wall when it lies within ``tol`` of that wall's body (an L
corner's shared end, a T stem ending in the crossing wall). The walls an
end is measured against are every other wall, and its own wall's segments
other than the one it ends and that segment's neighbours - a wall that
nearly closes on itself leaks too. One row per pair of walls, the smallest
gap kept: ``{"walls": (a, b), "end": "W2.start", "other": "W1", "gap_mm", "at"}``.
"""
found: dict[frozenset, dict] = {}
for wall in walls:
for end, point, own in _ends(wall):
best: tuple[float, str] | None = None
joined = False
for other in walls:
band = _band(other)
segments = axis_segments(other)
for index, (a, b) in enumerate(segments):
if other.id == wall.id and abs(index - own) <= 1:
continue
distance = body_distance(point, a, b, band)
if distance <= tol:
joined = True
break
if best is None or distance < best[0]:
best = (distance, other.id)
if joined:
break
if joined or best is None or best[0] > gap:
continue
pair = frozenset((wall.id, best[1]))
row = {
"walls": tuple(sorted(pair)) if len(pair) == 2 else (wall.id, wall.id),
"end": f"{wall.id}.{end}",
"other": best[1],
"gap_mm": round(best[0], 3),
"at": (float(point[0]), float(point[1])),
}
if pair not in found or row["gap_mm"] < found[pair]["gap_mm"]:
found[pair] = row
return sorted(found.values(), key=lambda row: row["walls"])
def opening_clashes(walls: Sequence[Wall], openings: Sequence[Opening]) -> list[dict]:
"""Every opening its wall cannot host: ``{"openings": (ids), "wall", "reason"}``.
The same rules ``validate_openings`` refuses before a draw, collected
rather than raised - the drawing already exists, and every clash on it is
reported, not only the first.
"""
by_id = {wall.id: wall for wall in walls}
rows: list[dict] = []
spans: dict[str, list[tuple[float, float, str]]] = {}
for opening in openings:
wall = by_id.get(opening.wall)
if wall is None:
rows.append(
{
"openings": (opening.id,),
"wall": opening.wall,
"reason": f"its host wall {opening.wall!r} has no record on this drawing",
}
)
continue
start = float(opening.offset)
end = start + float(opening.width)
runs = []
run = 0.0
for a, b in axis_segments(wall):
run += math.dist(a, b)
runs.append(run)
total = runs[-1]
if end > total + EPS:
rows.append(
{
"openings": (opening.id,),
"wall": wall.id,
"reason": f"it runs {end - total:g} mm past the end of the wall "
f"({start:g}-{end:g} on an axis {total:g} long)",
}
)
for vertex in runs[:-1]:
if start + EPS < vertex < end - EPS:
rows.append(
{
"openings": (opening.id,),
"wall": wall.id,
"reason": f"it runs across the axis vertex at {vertex:g} "
f"({start:g}-{end:g})",
}
)
for other_start, other_end, other_id in spans.get(wall.id, ()):
if start < other_end - EPS and other_start < end - EPS:
rows.append(
{
"openings": (other_id, opening.id),
"wall": wall.id,
"reason": f"they overlap ({other_start:g}-{other_end:g} and "
f"{start:g}-{end:g})",
}
)
spans.setdefault(wall.id, []).append((start, end, opening.id))
return rows
def point_in_loop(point, loop) -> bool:
"""Even-odd ray cast; a point on an edge counts as inside."""
x, y = float(point[0]), float(point[1])
pts = [(float(p[0]), float(p[1])) for p in loop]
inside = False
for i, (x1, y1) in enumerate(pts):
x2, y2 = pts[(i + 1) % len(pts)]
cross = (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1)
if (
abs(cross) <= 1e-9 * max(1.0, math.dist((x1, y1), (x2, y2)))
and min(x1, x2) - 1e-9 <= x <= max(x1, x2) + 1e-9
and min(y1, y2) - 1e-9 <= y <= max(y1, y2) + 1e-9
):
return True
if (y1 > y) != (y2 > y):
if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
inside = not inside
return inside
def unlabelled_faces(faces: Sequence[dict], rooms: Sequence[Room]) -> list[dict]:
"""The faces no room record's label point lies in."""
return [
face for face in faces if not any(point_in_loop(room.at, face["loop"]) for room in rooms)
]
# ── the three checks ────────────────────────────────────────────────────────
def _objs(index: dict, bucket: str) -> list:
return [entry["obj"] for entry in index[bucket].values()]
def _handles(index: dict, bucket: str, ids) -> list[str]:
return [h for i in ids if i in index[bucket] for h in index[bucket][i]["handles"]]
def _room_unlabelled(index: dict) -> list[Issue]:
out: list[Issue] = []
for face in unlabelled_faces(index["faces"], _objs(index, "rooms")):
centroid = face.get("centroid") or face["loop"][0]
out.append(
Issue(
"warning",
"arch_room_unlabelled",
f"A closed face of {float(face['area']) / 1.0e6:.2f} m² around "
f"({float(centroid[0]):.0f}, {float(centroid[1]):.0f}) carries no room label; "
"label it with arch_room so its measured area reaches the room schedule.",
[],
{"area_mm2": float(face["area"]), "centroid": [float(c) for c in centroid]},
)
)
return out
def _wall_gap(index: dict) -> list[Issue]:
out: list[Issue] = []
for row in wall_gaps(_objs(index, "walls")):
a, b = row["walls"]
out.append(
Issue(
"error",
"arch_wall_gap",
f"Wall end {row['end']} stops {row['gap_mm']:g} mm short of wall "
f"{row['other']} without joining it: the room leaks and its area is measured "
"through the gap. Extend the wall to the joint (arch_wall) or move it clear.",
_handles(index, "walls", sorted({a, b})),
{"walls": [a, b], "gap_mm": row["gap_mm"], "at": list(row["at"])},
)
)
return out
def _opening_clash(index: dict) -> list[Issue]:
out: list[Issue] = []
for row in opening_clashes(_objs(index, "walls"), _objs(index, "openings")):
names = " and ".join(row["openings"])
noun = "Openings" if len(row["openings"]) > 1 else "Opening"
out.append(
Issue(
"error",
"arch_opening_clash",
f"{noun} {names} on wall {row['wall']}: {row['reason']}.",
_handles(index, "openings", row["openings"]),
{"openings": list(row["openings"]), "wall": row["wall"]},
)
)
return out
_CHECKS = {
"arch_room_unlabelled": _room_unlabelled,
"arch_wall_gap": _wall_gap,
"arch_opening_clash": _opening_clash,
}
def issues_for(focus: str, index: dict) -> list[Issue]:
if not has_arch_content(index):
return []
return _CHECKS[focus](index)
def _make(focus: str):
async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]:
index = shared.get(_KEY)
if index is None:
index = shared[_KEY] = await build_index(backend)
return issues_for(focus, index)
check.needs_shared = True # type: ignore[attr-defined]
check.__name__ = f"check_{focus}"
return check
ARCH_DISPATCH = {focus: _make(focus) for focus in ARCH_FOCUSES}