From 67d578e09adb0735f0e80638fb9c4296bc505cf2 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:33:37 +0300 Subject: [PATCH] feat(task-5): planar faces, measured room labels and the foreign-plan reader engineering/arch/faces.py finds the closed faces a set of straight segments bounds: endpoints within tol merge, every segment splits where another crosses or touches it (the T-junction a partition makes), dangling edges are pruned, and the half-edge walk turns to the next edge clockwise so every bounded face comes out counter-clockwise. A piece nested inside a face of another piece is a hole in it, so a ring of walls measures its body and a room with a column measures its floor. Areas come from engineering.measure.polygon_area_perimeter, the arithmetic analysis_measure_entity reports. engineering/arch/rooms.py labels a room with the area of the face its point lies in - a 4000 x 5000 room between 200 mm walls reads 20.00 m2, never the axis area - and writes the room record onto the name text. Opening records close door and window gaps along the wall faces, so two rooms either side of a door stay two rooms. rooms_detect reads any plan's lines, drops faces under min_area and thin wall bodies (mean width under 600 mm, a declared heuristic), reports confidence 1.0 for faces enclosed only by our wall layer and 0.6 otherwise, lists arcs as skipped rather than flattening them, and never modifies the drawing. Two hardenings beyond the plan text, each with a test: read_records pages the current space once and filters to the arch layers locally (the plan paged it once per arch layer - twelve full scans, each a cross-process call per entity on a live seat), keeping the same layer-then-drawing order; and an opening record whose offset falls outside its wall (a stale record) is listed in omitted instead of raising out of the read-only detector. No new ActiveX member is called: both tools compose entity_list, entity_get/set_xdata, layer_list and draw_prims, whose COM paths are already live-verified. SECTION 25 opens on this branch with arch_room and arch_rooms_detect under the rooms marker; until Task 9 gives arch its group they file under entity_creation and entity_query. Alias records, a regenerated inventory, the README snapshot at 231 tools in 25 groups and the frozen group-size snapshot follow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- discovery/aliases.py | 30 ++ docs/tool-inventory.json | 34 ++- engineering/arch/__init__.py | 20 ++ engineering/arch/faces.py | 398 ++++++++++++++++++++++++++ engineering/arch/rooms.py | 508 +++++++++++++++++++++++++++++++++ server.py | 112 ++++++++ tests/test_arch_faces.py | 240 ++++++++++++++++ tests/test_arch_rooms.py | 349 ++++++++++++++++++++++ tests/test_arch_rooms_tools.py | 77 +++++ tests/test_tool_registry.py | 15 +- 11 files changed, 1779 insertions(+), 10 deletions(-) create mode 100644 engineering/arch/faces.py create mode 100644 engineering/arch/rooms.py create mode 100644 tests/test_arch_faces.py create mode 100644 tests/test_arch_rooms.py create mode 100644 tests/test_arch_rooms_tools.py diff --git a/README.md b/README.md index 6320f44..6c4c2b7 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests. -> 229 is the **registered** count; a default install advertises 224 over `tools/list`, +> **v1.5 release snapshot:** 231 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 231 is the **registered** count; a default install advertises 226 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -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 | -229 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +231 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index 5099993..fdf95fc 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -2230,6 +2230,36 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "punta deligi", ), ), + # ── Architecture: rooms (SECTION 25, track F group R) ─────────────────── + # 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. + "arch_room": ToolAliases( + acad=(), + synonyms=( + "room label", + "label the room", + "room name and number", + "net floor area", + "measured room area", + "oda etiketi", + "oda alani", + "mahal adi", + ), + ), + "arch_rooms_detect": ToolAliases( + acad=(), + synonyms=( + "find the rooms", + "detect rooms in a plan", + "rooms of a floor plan", + "room areas of this plan", + "read rooms from lines", + "odalari bul", + "kat plani alanlari", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 50d7a81..30bb0a9 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 229, + "tools": 231, "resources": 8, "prompts": 5, "groups": 25 @@ -16,9 +16,9 @@ "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 18, + "entity_creation": 19, "entity_modification": 16, - "entity_query": 9, + "entity_query": 10, "environment": 22, "layers": 14, "layouts": 12, @@ -180,6 +180,34 @@ "readOnlyHint": true } }, + { + "name": "arch_room", + "group": "entity_creation", + "cost": "mutate", + "summary": "Label a room with its name, number and measured area.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Label Room", + "destructiveHint": false + } + }, + { + "name": "arch_rooms_detect", + "group": "entity_query", + "cost": "read", + "summary": "Find the rooms of any plan and measure them; never modifies it.", + "tags": [ + "arch", + "query" + ], + "annotations": { + "title": "Architecture: Detect Rooms", + "readOnlyHint": true + } + }, { "name": "balloon_add", "group": "sheet", diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 505a99e..5292a46 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -6,6 +6,7 @@ and alphabetical - keep every side's names. from __future__ import annotations +from .faces import DEFAULT_TOL, Face, face_containing, planar_faces, tagged_faces from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch @@ -30,6 +31,14 @@ from .model import ( wall_from_dict, wall_length, ) +from .rooms import ( + detect_rooms, + label_room, + opening_closures, + read_records, + room_label_prims, + rooms_detect, +) __all__ = [ "APP_ID", @@ -37,6 +46,8 @@ __all__ = [ "ARCH_LAYER_DEFS", "ARCH_LAYER_ROLES", "ARCH_ROLE_LAYER", + "DEFAULT_TOL", + "Face", "KINDS", "LANGS", "Opening", @@ -47,15 +58,24 @@ __all__ = [ "Wall", "axis_segments", "decode", + "detect_rooms", "encode", + "face_containing", "fmt_area_m2", "fmt_number", "from_payload", + "label_room", + "opening_closures", "opening_from_dict", + "planar_faces", + "read_records", "room_from_dict", + "room_label_prims", + "rooms_detect", "segment_of", "stair_from_dict", "tag_prefix", + "tagged_faces", "to_payload", "to_values", "validate_openings", diff --git a/engineering/arch/faces.py b/engineering/arch/faces.py new file mode 100644 index 0000000..98a006d --- /dev/null +++ b/engineering/arch/faces.py @@ -0,0 +1,398 @@ +"""The planar-face finder: straight segments in, the closed faces they bound out. + +A room is the floor the walls enclose, so its area is a *face* of the planar +graph the wall outlines draw - never the axis rectangle and never a number the +caller types. This module finds those faces from plain segments, which is what +lets `arch_room` measure a room and `arch_rooms_detect` read a foreign plan made +of LINEs on both engines: the live engine has no `boundary_trace`, and it does +not need one. + +The algorithm, in the order it runs: + +1. **Merge.** Endpoints closer than ``tol`` become one vertex (the first one + seen keeps its coordinates), so a corner drawn 0.4 mm short still closes. +2. **Split.** Every segment is split at every point where another one crosses + it and at every vertex that lies on it within ``tol`` - the T-touch, where a + partition wall stops on the face of the wall it meets. Collinear overlaps + fall out of the same rule: each overlapping end splits the other segment and + the coincident pieces become one edge. +3. **Prune.** An edge that ends in a vertex of degree one bounds nothing - a + dangling line, an overshoot past a crossing - and is removed, repeatedly, + until no such edge is left. +4. **Walk.** Each undirected edge is two half-edges. From the half-edge + ``u -> v`` the walk continues at ``v`` along the next edge *clockwise* from + ``v -> u``, which keeps the face on the left. A bounded face comes out + counter-clockwise (positive signed area); the outer boundary of each + connected piece comes out clockwise. +5. **Nest.** A clockwise boundary that lies inside a bounded face of *another* + piece is a hole in the smallest such face: the island a column makes in a + room, or the whole inner outline of a ring of walls inside its outer outline. + A face's ``area`` is its loop's area minus its holes'. + +Areas come from `engineering.measure.polygon_area_perimeter`, the same +arithmetic `analysis_measure_entity` reports; this module computes the *sign* of +the shoelace itself only to tell a face from a boundary, because the shared +function returns the magnitude by design. + +Pairs of segments are tested against each other, which is quadratic: a floor +plan of a few thousand wall segments is well inside it, a city block is not. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +from engineering.measure import polygon_area_perimeter + +Pt = tuple[float, float] + +__all__ = ["DEFAULT_TOL", "Face", "face_containing", "planar_faces", "tagged_faces"] + +#: Endpoints closer than this (drawing units, mm) are one vertex. +DEFAULT_TOL = 1.0 + +_EPS = 1e-12 + + +@dataclass(frozen=True) +class Face: + """One bounded face. + + ``loop`` is counter-clockwise and starts at its lowest, then leftmost, + vertex. ``holes`` are the clockwise outer boundaries of the pieces nested + inside it. ``area`` (mm^2) is the loop's area minus the holes'; ``centroid`` + is the centroid of that net region. + """ + + loop: tuple[Pt, ...] + area: float + centroid: Pt + holes: tuple[tuple[Pt, ...], ...] = () + + +class _Vertices: + """Snap points to vertices: a point within ``tol`` of a vertex *is* that vertex.""" + + def __init__(self, tol: float) -> None: + self.tol = tol + self.points: list[Pt] = [] + self._grid: dict[tuple[int, int], list[int]] = {} + + def _cell(self, p: Pt) -> tuple[int, int]: + return (math.floor(p[0] / self.tol), math.floor(p[1] / self.tol)) + + def index(self, p: Pt) -> int: + cx, cy = self._cell(p) + best: int | None = None + best_d = self.tol + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for i in self._grid.get((cx + dx, cy + dy), ()): + d = math.dist(self.points[i], p) + if d <= best_d: + best, best_d = i, d + if best is not None: + return best + i = len(self.points) + self.points.append((float(p[0]), float(p[1]))) + self._grid.setdefault((cx, cy), []).append(i) + return i + + +def _number(value, where: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}") + return number + + +def _point(value, where: str) -> Pt: + try: + x, y = value[0], value[1] + except (TypeError, IndexError, KeyError): + raise ValueError(f"{where}: expected [x, y], got {value!r}") from None + return (_number(x, f"{where}[0]"), _number(y, f"{where}[1]")) + + +def _segments(segments: Iterable) -> list[tuple[Pt, Pt, object]]: + """``(p1, p2)`` or ``(p1, p2, tag)``; the tag rides along to the edges.""" + out: list[tuple[Pt, Pt, object]] = [] + for i, seg in enumerate(segments): + try: + count = len(seg) + except TypeError: + raise ValueError(f"segments[{i}]: expected (p1, p2) or (p1, p2, tag)") from None + if count not in (2, 3): + raise ValueError( + f"segments[{i}]: expected (p1, p2) or (p1, p2, tag), got {count} items" + ) + tag = seg[2] if count == 3 else None + out.append((_point(seg[0], f"segments[{i}][0]"), _point(seg[1], f"segments[{i}][1]"), tag)) + return out + + +def _cross(ax: float, ay: float, bx: float, by: float) -> float: + return ax * by - ay * bx + + +def _crossing(p1: Pt, p2: Pt, q1: Pt, q2: Pt) -> Pt | None: + """Where two segments meet, ends included; None for parallel or apart.""" + rx, ry = p2[0] - p1[0], p2[1] - p1[1] + sx, sy = q2[0] - q1[0], q2[1] - q1[1] + denom = _cross(rx, ry, sx, sy) + if abs(denom) <= _EPS * math.hypot(rx, ry) * math.hypot(sx, sy): + return None + qx, qy = q1[0] - p1[0], q1[1] - p1[1] + t = _cross(qx, qy, sx, sy) / denom + u = _cross(qx, qy, rx, ry) / denom + if -_EPS <= t <= 1.0 + _EPS and -_EPS <= u <= 1.0 + _EPS: + return (p1[0] + t * rx, p1[1] + t * ry) + return None + + +def _along(p: Pt, a: Pt, b: Pt) -> tuple[float, float]: + """(distance along a->b of p's foot, distance of p from the line).""" + dx, dy = b[0] - a[0], b[1] - a[1] + length = math.hypot(dx, dy) + ux, uy = dx / length, dy / length + px, py = p[0] - a[0], p[1] - a[1] + return (px * ux + py * uy, abs(_cross(ux, uy, px, py))) + + +def _signed_area(points: Sequence[Pt]) -> float: + total = 0.0 + for i in range(len(points)): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % len(points)] + total += x1 * y2 - x2 * y1 + return total / 2.0 + + +def _area(points: Sequence[Pt]) -> float: + return polygon_area_perimeter([(x, y, 0.0) for x, y in points], True)[0] + + +def _centroid(points: Sequence[Pt]) -> tuple[Pt, float]: + """(centroid, signed area) of a simple loop; a zero-area loop answers its vertex mean.""" + signed = _signed_area(points) + if abs(signed) < _EPS: + n = len(points) + return (sum(p[0] for p in points) / n, sum(p[1] for p in points) / n), 0.0 + cx = cy = 0.0 + for i in range(len(points)): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % len(points)] + w = x1 * y2 - x2 * y1 + cx += (x1 + x2) * w + cy += (y1 + y2) * w + return (cx / (6.0 * signed), cy / (6.0 * signed)), signed + + +def _inside(point: Pt, loop: Sequence[Pt]) -> bool: + """Even-odd ray cast; a point exactly on an edge may answer either way.""" + x, y = point + inside = False + n = len(loop) + for i in range(n): + x1, y1 = loop[i] + x2, y2 = loop[(i + 1) % n] + if (y1 > y) != (y2 > y): + xs = x1 + (y - y1) * (x2 - x1) / (y2 - y1) + if xs > x: + inside = not inside + return inside + + +def _start_low_left(loop: list[int], points: list[Pt]) -> list[int]: + k = min(range(len(loop)), key=lambda i: (points[loop[i]][1], points[loop[i]][0])) + return loop[k:] + loop[:k] + + +def tagged_faces( + segments, *, tol: float = DEFAULT_TOL +) -> tuple[tuple[Face, tuple[frozenset, ...]], ...]: + """`planar_faces`, plus, per face, the tag set of every edge it is bounded by. + + An edge carries the tags of every input segment that covers it, so a reader + can ask whether a face is enclosed *only* by segments of a kind - which is + what `detect_rooms` turns into a confidence. + """ + tol = _number(tol, "tol") + if tol <= 0.0: + raise ValueError(f"tol: must be greater than zero, got {tol:g}") + raw = _segments(segments) + verts = _Vertices(tol) + + # 1. merge endpoints + snapped: list[tuple[int, int, object]] = [] + for p1, p2, tag in raw: + a, b = verts.index(p1), verts.index(p2) + if a != b: + snapped.append((a, b, tag)) + + # 2. split at crossings and at vertices lying on a segment + splits: list[set[int]] = [set() for _ in snapped] + boxes = [] + for a, b, _tag in snapped: + (ax, ay), (bx, by) = verts.points[a], verts.points[b] + boxes.append((min(ax, bx) - tol, min(ay, by) - tol, max(ax, bx) + tol, max(ay, by) + tol)) + for i in range(len(snapped)): + ai, bi, _ = snapped[i] + for j in range(i + 1, len(snapped)): + bx0, by0, bx1, by1 = boxes[j] + if boxes[i][0] > bx1 or bx0 > boxes[i][2] or boxes[i][1] > by1 or by0 > boxes[i][3]: + continue + aj, bj, _ = snapped[j] + hit = _crossing(verts.points[ai], verts.points[bi], verts.points[aj], verts.points[bj]) + if hit is not None: + v = verts.index(hit) + splits[i].add(v) + splits[j].add(v) + for i, (a, b, _tag) in enumerate(snapped): + pa, pb = verts.points[a], verts.points[b] + length = math.dist(pa, pb) + x0, y0, x1, y1 = boxes[i] + for v, p in enumerate(verts.points): + if v in (a, b) or not (x0 <= p[0] <= x1 and y0 <= p[1] <= y1): + continue + s, off = _along(p, pa, pb) + if off <= tol and tol < s < length - tol: + splits[i].add(v) + + edges: dict[tuple[int, int], set] = {} + for i, (a, b, tag) in enumerate(snapped): + pa, pb = verts.points[a], verts.points[b] + chain = ( + [a] + + sorted( + (v for v in splits[i] if v not in (a, b)), + key=lambda v: _along(verts.points[v], pa, pb)[0], + ) + + [b] + ) + for u, v in zip(chain, chain[1:], strict=False): + if u != v: + edges.setdefault((min(u, v), max(u, v)), set()).add(tag) + + # 3. prune dangling edges + adjacent: dict[int, set[int]] = {} + for u, v in edges: + adjacent.setdefault(u, set()).add(v) + adjacent.setdefault(v, set()).add(u) + loose = [v for v, ns in adjacent.items() if len(ns) == 1] + while loose: + v = loose.pop() + for w in list(adjacent.get(v, ())): + adjacent[w].discard(v) + adjacent[v].discard(w) + edges.pop((min(v, w), max(v, w)), None) + if len(adjacent[w]) == 1: + loose.append(w) + adjacent = {v: ns for v, ns in adjacent.items() if ns} + + # 4. walk: the next half-edge is the next one clockwise at the far vertex + pts = verts.points + order: dict[int, list[int]] = { + v: sorted(ns, key=lambda w, v=v: math.atan2(pts[w][1] - pts[v][1], pts[w][0] - pts[v][0])) + for v, ns in adjacent.items() + } + seen: set[tuple[int, int]] = set() + cycles: list[list[int]] = [] + for u, ns in adjacent.items(): + for v in ns: + if (u, v) in seen: + continue + cycle: list[int] = [] + a, b = u, v + while (a, b) not in seen: + seen.add((a, b)) + cycle.append(a) + around = order[b] + a, b = b, around[around.index(a) - 1] + cycles.append(cycle) + + component: dict[int, int] = {} + for start in adjacent: + if start in component: + continue + stack = [start] + component[start] = start + while stack: + v = stack.pop() + for w in adjacent[v]: + if w not in component: + component[w] = start + stack.append(w) + + bounded: list[list[int]] = [] + boundaries: list[list[int]] = [] + for cycle in cycles: + signed = _signed_area([pts[v] for v in cycle]) + if signed > _EPS: + bounded.append(_start_low_left(cycle, pts)) + elif signed < -_EPS: + boundaries.append(cycle) + + # 5. nest each piece's outer boundary in the smallest face of another piece + gross = [_area([pts[v] for v in loop]) for loop in bounded] + holes: list[list[list[int]]] = [[] for _ in bounded] + for boundary in boundaries: + probe = pts[boundary[0]] + home = None + for k, loop in enumerate(bounded): + if component[loop[0]] == component[boundary[0]]: + continue + if _inside(probe, [pts[v] for v in loop]) and (home is None or gross[k] < gross[home]): + home = k + if home is not None: + holes[home].append(_start_low_left(boundary, pts)) + + out: list[tuple[Face, tuple[frozenset, ...]]] = [] + for k, loop in enumerate(bounded): + outer = [pts[v] for v in loop] + (gx, gy), g_signed = _centroid(outer) + net = gross[k] + mx, my, weight = gx * g_signed, gy * g_signed, g_signed + hole_loops = [] + for hole in holes[k]: + ring = [pts[v] for v in hole] + (hx, hy), h_signed = _centroid(ring) # clockwise, so negative: it subtracts + net -= _area(ring) + mx, my, weight = mx + hx * h_signed, my + hy * h_signed, weight + h_signed + hole_loops.append(tuple(ring)) + centroid = (mx / weight, my / weight) if abs(weight) > _EPS else (gx, gy) + tags = [] + for ring in [loop, *holes[k]]: + for i in range(len(ring)): + u, v = ring[i], ring[(i + 1) % len(ring)] + tags.append(frozenset(edges[(min(u, v), max(u, v))])) + face = Face(loop=tuple(outer), area=net, centroid=centroid, holes=tuple(hole_loops)) + out.append((face, tuple(tags))) + out.sort(key=lambda item: (item[0].loop[0][1], item[0].loop[0][0], item[0].area)) + return tuple(out) + + +def planar_faces(segments, *, tol: float = DEFAULT_TOL) -> tuple[Face, ...]: + """Every bounded face the segments enclose, CCW, net of nested pieces, in mm^2. + + Refused by path, before anything is computed: a segment that is not + ``(p1, p2)`` or ``(p1, p2, tag)``, a coordinate that is not a finite + number, a ``tol`` that is not positive. + """ + return tuple(face for face, _tags in tagged_faces(segments, tol=tol)) + + +def face_containing(faces: Iterable[Face], point) -> Face | None: + """The smallest face whose loop holds ``point`` and none of whose holes do.""" + p = _point(point, "point") + found = [ + face + for face in faces + if _inside(p, face.loop) and not any(_inside(p, hole) for hole in face.holes) + ] + return min(found, key=lambda face: face.area) if found else None diff --git a/engineering/arch/rooms.py b/engineering/arch/rooms.py new file mode 100644 index 0000000..7064881 --- /dev/null +++ b/engineering/arch/rooms.py @@ -0,0 +1,508 @@ +"""Rooms: labels that carry a measured area, and a reader for plans that are not ours. + +A room's area is the floor its walls enclose. `label_room` never takes it from +the caller: it reads the wall faces off the drawing, finds the planar face the +label point lies in (`engineering.arch.faces`) and writes *that* area into the +label and into the ``room`` record of ``ACADMCP_ARCH`` - so a room schedule is +a read of the drawing, and the number on the sheet is the number the geometry +gives. + +A door or a window cuts its wall, so the two rooms either side of a door would +read as one face through the gap. The opening records on the drawing close it: +for each opening, the wall's two face lines are continued across the gap - in +memory only, never drawn - which is the convention a net floor area is measured +to (the wall face, not the reveal). A plan without our records has no such +lines, and `rooms_detect` says so rather than guessing where a door was. + +`rooms_detect` runs the same finder on any lines and polylines - a foreign +plan - and reports each face with a confidence, the way `pid_graph` does: +1.0 when every edge of the face lies on our wall layer, 0.6 when any edge is a +plain line from somewhere else. It never modifies the drawing. + +DECLARED, not standard: + +* The label text heights (3.5 mm name, 2.5 mm number and area, on paper) are + two values of the ISO 3098-1 series; which line gets which is this module's + choice. The line pitch of 1.6 x the text height is this module's choice too. +* ``MIN_ROOM_WIDTH``: a face whose mean width (2 x area / perimeter) is under + 600 mm is taken for a wall body or an opening reveal, not a room. That is + this reader's heuristic, not a building rule - a 200 mm wall ring measures + 200, a 900 mm corridor 5 m long measures 763. +""" + +from __future__ import annotations + +import math +from dataclasses import replace +from typing import TYPE_CHECKING + +from engineering.arch.faces import DEFAULT_TOL, Face, face_containing, planar_faces, tagged_faces +from engineering.arch.lang import LANGS, fmt_area_m2 +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import ( + APP_ID, + Opening, + Room, + Stair, + Wall, + decode, + from_payload, + room_from_dict, + segment_of, + to_payload, + to_values, +) +from engineering.measure import polygon_area_perimeter +from engineering.mech.primitives import Pt, Text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "CONFIDENCE_FOREIGN", + "CONFIDENCE_OURS", + "DEFAULT_MIN_AREA", + "LINE_PITCH", + "MIN_ROOM_WIDTH", + "NAME_HEIGHT", + "TEXT_HEIGHT", + "WALL_LAYER_KEYWORDS", + "detect_rooms", + "label_room", + "mean_width", + "opening_closures", + "read_records", + "read_segments", + "room_label_prims", + "rooms_detect", +] + +NAME_HEIGHT = 3.5 # mm on paper (ISO 3098-1 series value) +TEXT_HEIGHT = 2.5 # mm on paper (ISO 3098-1 series value) +LINE_PITCH = 1.6 # baseline step, x the height of the line being placed +MIN_ROOM_WIDTH = 600.0 # mm; the wall-body heuristic above +DEFAULT_MIN_AREA = 1.0e6 # mm^2 = 1 m^2 +CONFIDENCE_OURS = 1.0 +CONFIDENCE_FOREIGN = 0.6 +#: A layer whose name contains one of these (any case) is read by default. +WALL_LAYER_KEYWORDS = ("WALL", "DUVAR") +#: Entity types that bound a region with a curve; the finder reads straight +#: segments only, so these are reported as skipped, never approximated. +CURVE_TYPES = frozenset({"ARC", "CIRCLE", "ELLIPSE", "SPLINE"}) + + +def _check_lang(lang: str) -> str: + if lang not in LANGS: + raise ValueError( + f"lang: {lang!r} is not a label language; languages are {', '.join(LANGS)}" + ) + return lang + + +def _positive(value, where: str) -> float: + 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 room_label_prims(room: Room, *, lang: str = "en", scale=50) -> tuple[Text, ...]: + """Name, number (when there is one) and measured area, stacked down from ``room.at``. + + Heights are paper millimetres times the plot-scale denominator: at 1:50 the + 3.5 mm name is 175 drawing units high. The first baseline is ``room.at``; + each next one is 1.6 x its own height lower. + """ + _check_lang(lang) + factor = _positive(scale, "scale") + x, y = float(room.at[0]), float(room.at[1]) + lines = [(room.name, NAME_HEIGHT * factor)] + if room.number: + lines.append((str(room.number), TEXT_HEIGHT * factor)) + lines.append((fmt_area_m2(room.area, lang), TEXT_HEIGHT * factor)) + prims: list[Text] = [] + baseline = y + for index, (text, height) in enumerate(lines): + if index: + baseline -= LINE_PITCH * height + prims.append(Text(at=(x, baseline), text=text, height=height, rotation=0.0, role="room")) + return tuple(prims) + + +def _face_offsets(wall: Wall) -> tuple[float, float]: + """Signed distances of the wall's two faces from its axis, + to the left.""" + 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 opening_closures(walls, openings) -> tuple[tuple[tuple[Pt, Pt], ...], tuple[dict, ...]]: + """The wall-face lines continued across every opening, and what could not be closed. + + For an opening at ``offset`` of ``width`` on its wall, the segment of the + axis it sits on gives the direction ``u`` and the left normal ``n``; each + face line runs from ``start + n*d`` to ``start + u*width + n*d`` for the + face's signed offset ``d``. An opening whose wall has no record is returned + in ``omitted`` - its gap stays open - never guessed. + """ + by_id = {wall.id: wall for wall in walls} + closures: list[tuple[Pt, Pt]] = [] + omitted: list[dict] = [] + for opening in openings: + wall = by_id.get(opening.wall) + if wall is None: + omitted.append( + { + "element": opening.id, + "reason": f"host wall {opening.wall!r} has no record on this drawing; " + "its gap is left open", + } + ) + continue + try: + index, along = segment_of(wall, float(opening.offset)) + except ValueError as exc: + # a stale record (its wall was shortened since): the reader is + # read-only and answers for every other room, so it says so here + omitted.append({"element": opening.id, "reason": f"{exc}; its gap is left open"}) + continue + axis = [(float(p[0]), float(p[1])) for p in wall.axis] + p0, p1 = axis[index], axis[(index + 1) % len(axis)] + length = math.dist(p0, p1) + ux, uy = (p1[0] - p0[0]) / length, (p1[1] - p0[1]) / length + nx, ny = -uy, ux + sx, sy = p0[0] + ux * along, p0[1] + uy * along + ex, ey = sx + ux * float(opening.width), sy + uy * float(opening.width) + for d in _face_offsets(wall): + closures.append(((sx + nx * d, sy + ny * d), (ex + nx * d, ey + ny * d))) + return tuple(closures), tuple(omitted) + + +def mean_width(face: Face) -> float: + """2 x area / perimeter: the thickness of a long thin face, holes included.""" + perimeter = sum( + polygon_area_perimeter([(x, y, 0.0) for x, y in ring], True)[1] + for ring in (face.loop, *face.holes) + ) + return 2.0 * face.area / perimeter if perimeter > 0.0 else 0.0 + + +def detect_rooms( + segments, + *, + min_area: float = DEFAULT_MIN_AREA, + tol: float = DEFAULT_TOL, + min_width: float = MIN_ROOM_WIDTH, +) -> tuple[dict, ...]: + """Faces of at least ``min_area`` mm^2 and ``min_width`` mean width, with a confidence. + + ``segments`` are ``(p1, p2)`` or ``(p1, p2, layer)``. A face scores 1.0 + when every edge of it (holes included) is covered by a segment on + ``ARCH_ROLE_LAYER["wall"]``, and 0.6 otherwise - an untagged segment is a + plain line from somewhere else. The minimum of the inputs, never raised by + agreement: one foreign edge is enough to make the face 0.6. + """ + min_area = _positive(min_area, "min_area") + min_width = _positive(min_width, "min_width") + wall = ARCH_ROLE_LAYER["wall"].casefold() + rooms: list[dict] = [] + for face, tags in tagged_faces(segments, tol=tol): + if face.area < min_area or mean_width(face) < min_width: + continue + ours = all(any(isinstance(t, str) and t.casefold() == wall for t in edge) for edge in tags) + rooms.append( + { + "loop": face.loop, + "holes": face.holes, + "area": face.area, + "centroid": face.centroid, + "confidence": CONFIDENCE_OURS if ours else CONFIDENCE_FOREIGN, + } + ) + return tuple(rooms) + + +async def read_records(backend: AutoCADBackend) -> dict: + """Every ``ACADMCP_ARCH`` record on the drawing's architectural layers. + + The shape `read_plan` (Task 4) returns - ``walls``, ``openings``, ``stairs``, + ``rooms`` as model dataclasses - plus ``room_handles`` (room id -> the + handle of the label that carries it). The current space is paged through + once, not sampled and not once per layer: a record past the 200th entity is + still a record, and a live seat, which pays a cross-process call per entity + per scan, scans once rather than once for each of the twelve arch layers. + Entities on the ``ARCH_ROLE_LAYER`` layers (any case) are read in layer-name + order, drawing order within a layer. XDATA that is not ours or does not + decode is skipped - this reader never guesses a record - and counted in + ``unreadable``. A record that appears on several entities (a wall on each + of its outlines) is kept once, by kind and id. + """ + from engineering.sheet.bom import all_entities # lazy: sheet imports stay out of arch + + found: dict[str, list] = {"walls": [], "openings": [], "stairs": [], "rooms": []} + room_handles: dict[str, str] = {} + seen: set[tuple[str, str]] = set() + unreadable = 0 + buckets = {Wall: "walls", Opening: "openings", Stair: "stairs", Room: "rooms"} + arch_layers = {name.casefold(): name for name in ARCH_ROLE_LAYER.values()} + ours = [ + entity + for entity in await all_entities(backend) + if str(entity.layer or "").casefold() in arch_layers + ] + ours.sort(key=lambda entity: arch_layers[str(entity.layer).casefold()]) # stable + for entity in ours: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + continue + try: + obj = from_payload(decode(values)) + except (ValueError, KeyError, TypeError): + obj = None + bucket = buckets.get(type(obj)) + if bucket is None: + unreadable += 1 + continue + key = (bucket, obj.id) + if key in seen: + continue + seen.add(key) + found[bucket].append(obj) + if bucket == "rooms": + room_handles[obj.id] = entity.handle + return {**found, "room_handles": room_handles, "unreadable": unreadable} + + +async def read_segments(backend: AutoCADBackend, layers) -> tuple[list, list[dict]]: + """Straight segments on ``layers`` as ``(p1, p2, layer)``, and what was skipped. + + LINE and (LW)POLYLINE are read by their properties, which is the same on + both engines (the live one names a polyline POLYLINE, the headless one + LWPOLYLINE). A bulged polyline edge or an ARC/CIRCLE/ELLIPSE/SPLINE is an + arc, and the finder reads straight segments only: it is listed in the + second return value with its handle, never flattened into chords. + """ + from engineering.sheet.bom import all_entities # lazy: sheet imports stay out of arch + + segments: list = [] + skipped: list[dict] = [] + for layer in layers: + for entity in await all_entities(backend, layer=layer): + props = entity.properties or {} + kind = str(entity.type).upper() + if "start" in props and "end" in props and "center" not in props: + segments.append((tuple(props["start"][:2]), tuple(props["end"][:2]), entity.layer)) + elif "points" in props: + points = [tuple(p[:2]) for p in props["points"]] + bulges = list(props.get("bulges") or []) + count = len(points) + arcs = 0 + for i in range(count if props.get("closed") else count - 1): + if i < len(bulges) and abs(float(bulges[i])) > 1e-12: + arcs += 1 + continue + segments.append((points[i], points[(i + 1) % count], entity.layer)) + if arcs: + skipped.append( + { + "handle": entity.handle, + "type": kind, + "reason": f"{arcs} arc segment(s): the face finder reads straight " + "segments only and does not approximate an arc", + } + ) + elif kind in CURVE_TYPES: + skipped.append( + { + "handle": entity.handle, + "type": kind, + "reason": "a curve: the face finder reads straight segments only " + "and does not approximate an arc", + } + ) + return segments, skipped + + +def _wcs_loop(loop) -> list[list[float]]: + return [[float(x), float(y)] for x, y in loop] + + +async def label_room( + backend: AutoCADBackend, room_dict: dict, *, lang: str = "en", scale=50 +) -> dict: + """Label the room around ``room_dict["at"]`` with its name, number and measured area. + + Refused before anything is drawn: an ``area`` in the request (areas are + measured, never typed), an unknown ``lang``, a non-positive ``scale``, an + ``id`` already labelled on the drawing, a point that lies in no closed face + of the wall layer, and a point inside a wall body. ``id`` defaults to the + first free ``R``. + """ + if not isinstance(room_dict, dict): + raise ValueError( + f"room: expected an object with name and at, got {type(room_dict).__name__}" + ) + if "area" in room_dict: + raise ValueError( + "room.area: a room's area is measured from its walls, never typed; remove it" + ) + _check_lang(lang) + _positive(scale, "scale") + records = await read_records(backend) + taken = {room.id for room in records["rooms"]} + data = dict(room_dict) + data.setdefault("number", None) + if not data.get("id"): + number = len(taken) + 1 + while f"R{number}" in taken: + number += 1 + data["id"] = f"R{number}" + room = room_from_dict(data, "room") + if room.id in taken: + raise ValueError( + f"room.id: {room.id!r} is already labelled on this drawing " + f"(label {records['room_handles'][room.id]}); choose another id" + ) + + wall_layer = ARCH_ROLE_LAYER["wall"] + segments, skipped = await read_segments(backend, [wall_layer]) + closures, omitted = opening_closures(records["walls"], records["openings"]) + faces = planar_faces( + [*segments, *((p1, p2, wall_layer) for p1, p2 in closures)], tol=DEFAULT_TOL + ) + x, y = float(room.at[0]), float(room.at[1]) + face = face_containing(faces, (x, y)) + if face is None: + raise ValueError( + f"room.at: ({x:g}, {y:g}) lies in no closed face of the {len(segments)} wall " + f"segment(s) on {wall_layer}; close the walls around it first (arch_wall), or read " + "a plan drawn by other means with arch_rooms_detect" + ) + width = mean_width(face) + if width < MIN_ROOM_WIDTH: + raise ValueError( + f"room.at: ({x:g}, {y:g}) lies inside a wall body (a face {width:.0f} mm wide on " + f"average, under the {MIN_ROOM_WIDTH:.0f} mm a room is taken to need), not in a room" + ) + + measured = replace(room, area=face.area) + from engineering.mech.draw import draw_prims # lazy: mech.draw imports the arch layer set + + drawn = await draw_prims( + backend, room_label_prims(measured, lang=lang, scale=scale), role_layer=ARCH_ROLE_LAYER + ) + handle = drawn["handles"][0] + await backend.entity_set_xdata(handle, APP_ID, to_values(to_payload(measured))) + return { + "ok": True, + "room": { + "id": measured.id, + "name": measured.name, + "number": measured.number, + "at": [x, y], + "area": measured.area, + "area_m2": measured.area / 1.0e6, + "area_text": fmt_area_m2(measured.area, lang), + }, + "handle": handle, + "handles": list(drawn["handles"]), + "layer": ARCH_ROLE_LAYER["room"], + "loop": _wcs_loop(face.loop), + "holes": [_wcs_loop(hole) for hole in face.holes], + "wall_segments": len(segments), + "closures": len(closures), + "skipped": skipped, + "omitted": list(omitted), + } + + +async def rooms_detect( + backend: AutoCADBackend, + *, + layers=None, + min_area: float = DEFAULT_MIN_AREA, + tol: float = DEFAULT_TOL, +) -> dict: + """Read the rooms of any plan: faces of the lines on ``layers``, measured, never drawn. + + ``layers=None`` reads every layer whose name contains WALL or DUVAR (any + case). A named layer the drawing does not have is refused with the list of + the ones it does. Our own opening records, when there are any, close door + and window gaps exactly as `label_room` does; a foreign plan's gaps stay + open, and two rooms joined by an open doorway read as one face. + """ + min_area = _positive(min_area, "min_area") + tol = _positive(tol, "tol") + available = [layer.name for layer in await backend.layer_list()] + if layers is None: + chosen = [ + name for name in available if any(key in name.upper() for key in WALL_LAYER_KEYWORDS) + ] + if not chosen: + raise ValueError( + "layers: no layer name contains " + f"{' or '.join(WALL_LAYER_KEYWORDS)}; name the layers the walls are on " + f"(layers are {', '.join(sorted(available))})" + ) + else: + lookup = {name.casefold(): name for name in available} + missing = [str(name) for name in layers if str(name).casefold() not in lookup] + if missing: + raise ValueError( + f"layers: {', '.join(missing)} not in this drawing; " + f"layers are {', '.join(sorted(available))}" + ) + chosen = [lookup[str(name).casefold()] for name in layers] + + segments, skipped = await read_segments(backend, chosen) + records = await read_records(backend) + closures, omitted = opening_closures(records["walls"], records["openings"]) + wall_layer = ARCH_ROLE_LAYER["wall"] + extra = [(p1, p2, wall_layer) for p1, p2 in closures] + rooms = detect_rooms([*segments, *extra], min_area=min_area, tol=tol) + + out: list[dict] = [] + for room in rooms: + face = Face( + loop=room["loop"], area=room["area"], centroid=room["centroid"], holes=room["holes"] + ) + label = next( + ( + {"id": r.id, "name": r.name, "number": r.number, "area": r.area} + for r in records["rooms"] + if face_containing((face,), r.at) is not None + ), + None, + ) + out.append( + { + "loop": _wcs_loop(room["loop"]), + "holes": [_wcs_loop(hole) for hole in room["holes"]], + "area": room["area"], + "area_m2": room["area"] / 1.0e6, + "centroid": [room["centroid"][0], room["centroid"][1]], + "confidence": room["confidence"], + "label": label, + } + ) + return { + "ok": True, + "modified": False, + "layers": chosen, + "segments": len(segments), + "closures": len(extra), + "count": len(out), + "rooms": out, + "confidence_min": min((room["confidence"] for room in out), default=None), + "skipped": skipped, + "omitted": list(omitted), + "min_area": min_area, + "tol": tol, + } diff --git a/server.py b/server.py index d6f0808..8513062 100644 --- a/server.py +++ b/server.py @@ -9780,6 +9780,118 @@ async def drawing_export_dwg( return await _backend(ctx).drawing_export_dwg(str(validated), version) +# --------------------------------------------------------------------------- +# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +# --------------------------------------------------------------------------- + + +# ── arch: rooms (group R) ── + + +@cad_tool(summary="Label a room with its name, number and measured area.", cost="mutate") +@mcp.tool( + annotations={"title": "Architecture: Label Room", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_room( + name: Annotated[str, Field(description="Room name, e.g. LIVING / SALON.")], + x: Annotated[ + float, Field(description="WCS X of a point inside the room; the label starts here.") + ], + y: Annotated[float, Field(description="WCS Y of a point inside the room.")], + number: Annotated[ + str | None, Field(default=None, description="Room number, e.g. 01; omit for none.") + ] = None, + room_id: Annotated[ + str | None, + Field(default=None, description="Record id; default is the first free R."), + ] = None, + lang: Annotated[ + str, Field(default="en", description="Label language: en | tr (tr writes 24,50 m²).") + ] = "en", + scale: Annotated[ + float, + Field(default=50.0, gt=0, description="Plot-scale denominator: 50 is 1:50 (text x 50)."), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Label the room around (x, y) with its name, number and MEASURED area. + + The area is never typed: the tool reads the wall faces on the `wall` layer + of the arch set (LINE and polyline, both engines), finds the closed face + the point lies in and writes that face's area - net of the wall thickness + and of any column inside it - into the label and into an `ACADMCP_ARCH` + room record on the name text, which `arch_schedule` reads. A door or window + recorded on the drawing closes its gap along the wall faces, so two rooms + either side of a door stay two rooms. + + Refused before anything is drawn: an unknown `lang` (named with en, tr), a + `number`/`name` the room model refuses, a `room_id` already labelled on the + drawing, a point in no closed face of the walls (draw or close them first, + or read a plan drawn by other means with `arch_rooms_detect`), and a point + inside a wall body. + """ + from engineering.arch.rooms import label_room + + room = {"name": name, "number": number, "at": [x, y]} + if room_id: + room["id"] = room_id + await ctx.info(f"arch_room: {name!r} at ({x}, {y})") + try: + return await label_room(_backend(ctx), room, lang=lang, scale=scale) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool(summary="Find the rooms of any plan and measure them; never modifies it.", cost="read") +@mcp.tool( + annotations={"title": "Architecture: Detect Rooms", "readOnlyHint": True}, + tags={"arch", "query"}, +) +async def arch_rooms_detect( + layers: Annotated[ + list[str] | None, + Field( + default=None, + description="Layers holding the walls; default: every layer whose name contains " + "WALL or DUVAR.", + ), + ] = None, + min_area: Annotated[ + float, + Field(default=1.0e6, gt=0, description="Smallest room reported, mm² (1e6 = 1 m²)."), + ] = 1.0e6, + tol: Annotated[ + float, + Field(default=1.0, gt=0, description="Endpoints closer than this (mm) are one corner."), + ] = 1.0, + ctx: Context = None, +) -> dict: + """Read the rooms of a plan - ours or a foreign one made of plain lines. + + Every LINE and polyline on the layers is split where lines cross or touch, + and the closed faces they bound are measured (mm², net of nested pieces). + Faces under `min_area`, and thin faces (mean width under 600 mm - wall + bodies and reveals, this reader's heuristic) are left out. Each room + reports `confidence`: 1.0 when every edge lies on the arch wall layer, 0.6 + when any edge is a plain line from elsewhere - read `confidence_min` before + trusting a foreign plan. A room label already on the drawing is reported + with the face it sits in. + + Never modifies the drawing. Arcs and bulged polyline edges are listed in + `skipped`, never flattened into chords. A foreign plan's open doorways are + not closed - two rooms joined by one read as one face. Refused: a named + layer the drawing does not have (with the list of the ones it has), and no + layer matching WALL or DUVAR when `layers` is omitted. + """ + from engineering.arch.rooms import rooms_detect + + try: + return await rooms_detect(_backend(ctx), layers=layers, min_area=min_area, tol=tol) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_arch_faces.py b/tests/test_arch_faces.py new file mode 100644 index 0000000..620dcc2 --- /dev/null +++ b/tests/test_arch_faces.py @@ -0,0 +1,240 @@ +"""The planar-face finder: segments in, closed faces out, areas measured. + +Every expected coordinate and area below is computed by hand in the comment +beside it; nothing is read back from the implementation. +""" + +from __future__ import annotations + +import re + +import pytest + +from engineering.arch.faces import Face, face_containing, planar_faces, tagged_faces + +EPS = 1e-9 + + +def rect(x0, y0, x1, y1): + """The four sides of an axis-aligned rectangle as segments.""" + return [ + ((x0, y0), (x1, y0)), + ((x1, y0), (x1, y1)), + ((x1, y1), (x0, y1)), + ((x0, y1), (x0, y0)), + ] + + +def two_rooms(): + """Two 4000 x 5000 rooms between 200 mm walls, as clean outlines. + + Outer outline (0,0)-(8600,5400). Inner face of the exterior walls + (200,200)-(8400,5200), with the shared wall's two faces x=4200 and x=4400 + stopping on it at T-junctions - exactly what a clean wall engine draws. + Room A is (200,200)-(4200,5200), room B (4400,200)-(8400,5200). + """ + return [ + *rect(0.0, 0.0, 8600.0, 5400.0), + *rect(200.0, 200.0, 8400.0, 5200.0), + ((4200.0, 200.0), (4200.0, 5200.0)), + ((4400.0, 200.0), (4400.0, 5200.0)), + ] + + +def test_one_rectangle_is_one_ccw_face_starting_low_left(): + (face,) = planar_faces(rect(0.0, 0.0, 4000.0, 3000.0)) + assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0)) + assert abs(face.area - 12_000_000.0) < EPS # 4000 x 3000 + assert face.centroid == pytest.approx((2000.0, 1500.0), abs=EPS) + assert face.holes == () + + +def test_the_same_rectangle_drawn_clockwise_is_still_a_ccw_face(): + clockwise = [ + ((0.0, 0.0), (0.0, 3000.0)), + ((0.0, 3000.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (4000.0, 0.0)), + ((4000.0, 0.0), (0.0, 0.0)), + ] + (face,) = planar_faces(clockwise) + assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0)) + + +def test_two_adjacent_rooms_are_two_room_faces_plus_the_wall_bodies(): + faces = planar_faces(two_rooms()) + # room A, room B, the shared wall body, and the exterior ring + assert len(faces) == 4 + room_a = face_containing(faces, (2200.0, 2700.0)) + room_b = face_containing(faces, (6400.0, 2700.0)) + assert abs(room_a.area - 20_000_000.0) < EPS # 4000 x 5000 + assert abs(room_b.area - 20_000_000.0) < EPS + assert room_a.loop == ((200.0, 200.0), (4200.0, 200.0), (4200.0, 5200.0), (200.0, 5200.0)) + assert room_b.loop == ((4400.0, 200.0), (8400.0, 200.0), (8400.0, 5200.0), (4400.0, 5200.0)) + assert room_a.centroid == pytest.approx((2200.0, 2700.0), abs=EPS) + shared = face_containing(faces, (4300.0, 2700.0)) + assert abs(shared.area - 1_000_000.0) < EPS # 200 x 5000 + ring = face_containing(faces, (100.0, 2700.0)) + # 8600 x 5400 = 46 440 000 minus the inner outline 8200 x 5000 = 41 000 000 + assert abs(ring.area - 5_440_000.0) < EPS + # the hole is the inner outline, clockwise, split where the shared wall meets it + assert ring.holes == ( + ( + (200.0, 200.0), + (200.0, 5200.0), + (4200.0, 5200.0), + (4400.0, 5200.0), + (8400.0, 5200.0), + (8400.0, 200.0), + (4400.0, 200.0), + (4200.0, 200.0), + ), + ) + # the ring's net region is symmetric about the outline's centre + assert ring.centroid == pytest.approx((4300.0, 2700.0), abs=1e-6) + + +def test_a_4000_by_5000_room_between_200_mm_walls_measures_20_m2_not_the_axis_area(): + # walls on axes (-100,-100)-(4100,5100); the axis rectangle is 4200 x 5200 + faces = planar_faces([*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)]) + room = face_containing(faces, (2000.0, 2500.0)) + assert abs(room.area - 20_000_000.0) < EPS # 20.00 m2, not 21.84 m2 + wall = face_containing(faces, (-100.0, 2500.0)) + # 4400 x 5400 = 23 760 000 minus 20 000 000 + assert abs(wall.area - 3_760_000.0) < EPS + + +def test_a_t_junction_splits_the_line_it_touches(): + # a partition from (2000,0) to (2000,3000) stops on both long sides + faces = planar_faces([*rect(0.0, 0.0, 4000.0, 3000.0), ((2000.0, 0.0), (2000.0, 3000.0))]) + assert len(faces) == 2 + left, right = faces + assert left.loop == ((0.0, 0.0), (2000.0, 0.0), (2000.0, 3000.0), (0.0, 3000.0)) + assert right.loop == ((2000.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (2000.0, 3000.0)) + assert abs(left.area - 6_000_000.0) < EPS # 2000 x 3000 + assert abs(right.area - 6_000_000.0) < EPS + + +def test_a_t_touch_within_tol_still_splits(): + # the partition stops 0.5 mm short of the bottom side: within tol=1.0 + faces = planar_faces( + [*rect(0.0, 0.0, 4000.0, 3000.0), ((2000.0, 0.5), (2000.0, 3000.0))], tol=1.0 + ) + assert len(faces) == 2 + # the split vertex is the partition's own end (2000, 0.5), which bends the + # bottom side by 0.5 mm; each half is 2000 x 3000 minus a 0.5 mm-high sliver + # triangle of base 2000: 6 000 000 - 0.5 * 2000 * 0.5 = 5 999 500 + assert [round(f.area, 6) for f in faces] == [5_999_500.0, 5_999_500.0] + + +def test_crossing_lines_split_each_other_and_their_overshoots_are_pruned(): + segments = [ + *rect(0.0, 0.0, 4000.0, 3000.0), + ((-500.0, 1500.0), (4500.0, 1500.0)), # overshoots 500 at both ends + ((2000.0, -500.0), (2000.0, 3500.0)), + ] + faces = planar_faces(segments) + assert len(faces) == 4 + assert all(abs(f.area - 3_000_000.0) < EPS for f in faces) # 2000 x 1500 each + assert faces[0].loop == ((0.0, 0.0), (2000.0, 0.0), (2000.0, 1500.0), (0.0, 1500.0)) + + +def test_a_dangling_segment_is_ignored(): + segments = [ + *rect(0.0, 0.0, 4000.0, 3000.0), + ((4000.0, 3000.0), (5000.0, 4000.0)), # a spur off a corner + ((1000.0, 1000.0), (1500.0, 1200.0)), # a stray line inside the room + ] + (face,) = planar_faces(segments) + assert abs(face.area - 12_000_000.0) < EPS + + +def test_endpoints_within_tol_merge_and_a_wider_gap_leaves_the_loop_open(): + closing = ((0.0, 3000.4), (0.0, 0.0)) # starts 0.4 mm off the corner (0, 3000) + sides = [ + ((0.0, 0.0), (4000.0, 0.0)), + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ] + (face,) = planar_faces([*sides, closing], tol=1.0) + # (0, 3000) was seen first, so it is the vertex: the area is exact + assert abs(face.area - 12_000_000.0) < EPS + open_gap = ((0.0, 2995.0), (0.0, 0.0)) # stops 5 mm short of (0, 3000) + assert planar_faces([*sides, open_gap], tol=1.0) == () + + +def test_an_overshoot_through_a_corner_still_closes_the_loop(): + sides = [ + ((0.0, 0.0), (4000.0, 0.0)), + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ] + # the closing side runs 5 mm past the corner (0, 3000): the corner splits it + # and the 5 mm stub is a dangling edge + (face,) = planar_faces([*sides, ((0.0, 3005.0), (0.0, 0.0))]) + assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0)) + assert abs(face.area - 12_000_000.0) < EPS + + +def test_collinear_overlaps_become_one_edge(): + segments = [ + ((0.0, 0.0), (3000.0, 0.0)), + ((1000.0, 0.0), (4000.0, 0.0)), # overlaps the first from 1000 to 3000 + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ((0.0, 3000.0), (0.0, 0.0)), + ] + (face,) = planar_faces(segments) + assert face.loop == ( + (0.0, 0.0), + (1000.0, 0.0), + (3000.0, 0.0), + (4000.0, 0.0), + (4000.0, 3000.0), + (0.0, 3000.0), + ) + assert abs(face.area - 12_000_000.0) < EPS + + +def test_a_column_inside_a_room_is_a_hole_in_the_room(): + segments = [*rect(0.0, 0.0, 4000.0, 3000.0), *rect(1000.0, 1000.0, 1400.0, 1400.0)] + faces = planar_faces(segments) + room = face_containing(faces, (3000.0, 2000.0)) + column = face_containing(faces, (1200.0, 1200.0)) + assert abs(room.area - 11_840_000.0) < EPS # 12 000 000 - 400 x 400 + assert abs(column.area - 160_000.0) < EPS + assert len(room.holes) == 1 + # net centroid: (12e6 * (2000, 1500) - 160 000 * (1200, 1200)) / 11 840 000 + assert room.centroid == pytest.approx( + ( + (12e6 * 2000.0 - 160_000.0 * 1200.0) / 11_840_000.0, + (12e6 * 1500.0 - 160_000.0 * 1200.0) / 11_840_000.0, + ), + abs=1e-6, + ) + + +def test_a_point_in_no_face_answers_none(): + faces = planar_faces(rect(0.0, 0.0, 4000.0, 3000.0)) + assert face_containing(faces, (5000.0, 5000.0)) is None + + +def test_edge_tags_ride_along_to_every_face_edge(): + tagged = [(p1, p2, "WALL") for p1, p2 in rect(0.0, 0.0, 4000.0, 3000.0)] + tagged[0] = (tagged[0][0], tagged[0][1], "0") + ((face, tags),) = tagged_faces(tagged) + assert tags == (frozenset({"0"}), frozenset({"WALL"}), frozenset({"WALL"}), frozenset({"WALL"})) + assert isinstance(face, Face) + + +@pytest.mark.parametrize( + ("segments", "tol", "message"), + [ + ([((0.0, 0.0),)], 1.0, "segments[0]: expected (p1, p2) or (p1, p2, tag), got 1 items"), + ([((0.0, "x"), (1.0, 1.0))], 1.0, "segments[0][0][1]: expected a number, got 'x'"), + ([((0.0, float("nan")), (1.0, 1.0))], 1.0, "segments[0][0][1]: must be finite"), + (rect(0.0, 0.0, 1.0, 1.0), 0.0, "tol: must be greater than zero, got 0"), + ], +) +def test_bad_input_is_refused_by_path(segments, tol, message): + with pytest.raises(ValueError, match=re.escape(message)): + planar_faces(segments, tol=tol) diff --git a/tests/test_arch_rooms.py b/tests/test_arch_rooms.py new file mode 100644 index 0000000..e795c1c --- /dev/null +++ b/tests/test_arch_rooms.py @@ -0,0 +1,349 @@ +"""Room labels with a measured area, and the foreign-plan reader. + +The pure half asserts emitted primitives and hand-computed coordinates; the +async half runs against the headless backend (the `backend` fixture in +tests/conftest.py). The wall and opening records a drawn plan carries are +written with `model.to_values` directly - the wall engine that writes them in +production is Task 4's, on another branch, and the record shape is pinned by +the skeleton. +""" + +from __future__ import annotations + +import re + +import pytest + +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import APP_ID, Opening, Room, Wall, decode, to_payload, to_values +from engineering.arch.rooms import ( + CONFIDENCE_FOREIGN, + CONFIDENCE_OURS, + detect_rooms, + label_room, + opening_closures, + read_records, + room_label_prims, + rooms_detect, +) +from engineering.mech.primitives import Text + +WALL = ARCH_ROLE_LAYER["wall"] +ROOM = ARCH_ROLE_LAYER["room"] +EPS = 1e-9 + + +def rect(x0, y0, x1, y1): + return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] + + +def two_rooms(tag=None): + """Two 4000 x 5000 rooms between 200 mm walls (see tests/test_arch_faces.py).""" + segments = [ + *rect(0.0, 0.0, 8600.0, 5400.0), + *rect(200.0, 200.0, 8400.0, 5200.0), + ((4200.0, 200.0), (4200.0, 5200.0)), + ((4400.0, 200.0), (4400.0, 5200.0)), + ] + return segments if tag is None else [(p1, p2, tag) for p1, p2 in segments] + + +async def draw_lines(backend, segments, layer): + for (x1, y1), (x2, y2) in segments: + await backend.entity_create_line(x1, y1, x2, y2, layer=layer) + + +async def write_record(backend, obj, layer): + """A record the way a drawn plan carries it: XDATA on an entity of its layer.""" + carrier = await backend.entity_create_line(0.0, -1000.0, 1.0, -1000.0, layer=layer) + await backend.entity_set_xdata(carrier.handle, APP_ID, to_values(to_payload(obj))) + return carrier.handle + + +async def texts(backend, layer): + return { + e.properties.get("text"): e + for e in await backend.entity_list(type_filter="TEXT", layer_filter=layer, limit=1000) + } + + +# ── pure: the label ───────────────────────────────────────────────────────── + + +def test_label_prims_stack_name_number_area_down_from_the_point_at_plot_scale(): + room = Room(id="R1", name="LIVING", number="01", at=(2000.0, 2500.0), area=20_000_000.0) + prims = room_label_prims(room, lang="en", scale=50) + # 1:50 -> name 3.5 x 50 = 175, number and area 2.5 x 50 = 125; + # baselines 2500, 2500 - 1.6 x 125 = 2300, 2300 - 1.6 x 125 = 2100 + assert prims == ( + Text(at=(2000.0, 2500.0), text="LIVING", height=175.0, rotation=0.0, role="room"), + Text(at=(2000.0, 2300.0), text="01", height=125.0, rotation=0.0, role="room"), + Text(at=(2000.0, 2100.0), text="20.00 m²", height=125.0, rotation=0.0, role="room"), + ) + + +def test_label_without_a_number_puts_the_area_on_the_second_line_and_tr_uses_a_comma(): + room = Room(id="R2", name="SALON", number=None, at=(0.0, 0.0), area=24_500_000.0) + prims = room_label_prims(room, lang="tr", scale=100) + # 1:100 -> name 350, area 250 at 0 - 1.6 x 250 = -400 + assert [(p.at, p.text, p.height) for p in prims] == [ + ((0.0, 0.0), "SALON", 350.0), + ((0.0, -400.0), "24,50 m²", 250.0), + ] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"lang": "de"}, "lang: 'de' is not a label language; languages are en, tr"), + ({"scale": 0}, "scale: must be a finite number greater than zero, got 0"), + ], +) +def test_label_prims_refuse_an_unknown_language_or_scale(kwargs, message): + room = Room(id="R1", name="A", number=None, at=(0.0, 0.0), area=1.0) + with pytest.raises(ValueError, match=re.escape(message)): + room_label_prims(room, **kwargs) + + +# ── pure: openings close their gap ────────────────────────────────────────── + + +def test_an_opening_is_closed_along_both_wall_faces(): + # the shared wall's axis runs up x=4300 from y=100 to y=5300, 200 thick, centred; + # a 900 door 1100 from the axis start spans y=1200..2100. + wall = Wall(id="w5", axis=((4300.0, 100.0), (4300.0, 5300.0)), thickness=200.0) + door = Opening(id="d1", wall="w5", kind="door", offset=1100.0, width=900.0) + closures, omitted = opening_closures([wall], [door]) + # u = (0, 1), left normal n = (-1, 0); face offsets -100 and +100 + assert closures == ( + ((4400.0, 1200.0), (4400.0, 2100.0)), + ((4200.0, 1200.0), (4200.0, 2100.0)), + ) + assert omitted == () + + +def test_a_left_justified_wall_closes_its_faces_on_the_axis_and_one_thickness_left(): + wall = Wall(id="w1", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0, justification="left") + window = Opening(id="p1", wall="w1", kind="window", offset=1000.0, width=1200.0) + closures, _ = opening_closures([wall], [window]) + # u = (1, 0), n = (0, 1): faces at 0 and +200 + assert closures == (((1000.0, 0.0), (2200.0, 0.0)), ((1000.0, 200.0), (2200.0, 200.0))) + + +def test_an_opening_whose_wall_has_no_record_is_omitted_not_guessed(): + door = Opening(id="d9", wall="nowhere", kind="door", offset=0.0, width=900.0) + closures, omitted = opening_closures([], [door]) + assert closures == () + assert omitted == ( + { + "element": "d9", + "reason": "host wall 'nowhere' has no record on this drawing; its gap is left open", + }, + ) + + +def test_a_stale_opening_past_the_end_of_its_wall_is_omitted_not_raised(): + # a record left behind after its wall was shortened: 9000 on a 5000 axis. + # The reader is read-only and must still answer for every other room. + wall = Wall(id="w1", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0) + stale = Opening(id="d4", wall="w1", kind="door", offset=9000.0, width=900.0) + closures, omitted = opening_closures([wall], [stale]) + assert closures == () + assert omitted == ( + { + "element": "d4", + "reason": "offset 9000.0 is outside wall w1 (axis length 5000); its gap is left open", + }, + ) + + +# ── pure: detect_rooms ────────────────────────────────────────────────────── + + +def test_detect_rooms_keeps_the_rooms_and_drops_the_wall_bodies(): + rooms = detect_rooms(two_rooms(WALL)) + # the exterior ring (mean width 2 x 5 440 000 / 54 400 = 200) and the shared + # wall body (2 x 1 000 000 / 10 400 = 192) are wall bodies, not rooms + assert [r["area"] for r in rooms] == [20_000_000.0, 20_000_000.0] + assert [r["centroid"] for r in rooms] == [(2200.0, 2700.0), (6400.0, 2700.0)] + assert {r["confidence"] for r in rooms} == {CONFIDENCE_OURS} + + +def test_one_foreign_edge_makes_the_face_foreign(): + segments = two_rooms(WALL) + # the inner outline's bottom edge from (200,200) to (8400,200) becomes a plain line + segments[4] = (segments[4][0], segments[4][1], "0") + rooms = detect_rooms(segments) + assert [r["confidence"] for r in rooms] == [CONFIDENCE_FOREIGN, CONFIDENCE_FOREIGN] + + +def test_untagged_lines_are_foreign_and_min_area_filters(): + rooms = detect_rooms(two_rooms()) + assert [r["confidence"] for r in rooms] == [0.6, 0.6] + assert detect_rooms(two_rooms(), min_area=20_000_001.0) == () + + +# ── async: label_room ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_a_4000_by_5000_room_between_200_mm_walls_is_labelled_20_m2(backend): + await draw_lines( + backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL + ) + result = await label_room( + backend, {"name": "LIVING", "number": "01", "at": [2000.0, 2500.0]}, lang="en", scale=50 + ) + assert result["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS) + assert result["room"]["area_text"] == "20.00 m²" + assert result["room"]["id"] == "R1" + assert result["loop"] == [[0.0, 0.0], [4000.0, 0.0], [4000.0, 5000.0], [0.0, 5000.0]] + labels = await texts(backend, ROOM) + assert set(labels) == {"LIVING", "01", "20.00 m²"} + raw = await backend.entity_get_xdata(result["handle"], APP_ID) + payload = decode(raw["xdata"][APP_ID]) + assert payload["kind"] == "room" + assert payload["id"] == "R1" + assert payload["area"] == pytest.approx(20_000_000.0, abs=EPS) + + +@pytest.mark.asyncio +async def test_turkish_labels_use_a_decimal_comma(backend): + await draw_lines( + backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL + ) + await label_room(backend, {"name": "SALON", "at": [2000.0, 2500.0]}, lang="tr") + assert "20,00 m²" in await texts(backend, ROOM) + + +@pytest.mark.asyncio +async def test_a_door_in_the_shared_wall_does_not_merge_the_two_rooms(backend): + door_gap = [ + *rect(0.0, 0.0, 8600.0, 5400.0), + *rect(200.0, 200.0, 8400.0, 5200.0), + # the shared wall's faces, interrupted from y=1200 to y=2100, and its two jambs + ((4200.0, 200.0), (4200.0, 1200.0)), + ((4200.0, 2100.0), (4200.0, 5200.0)), + ((4400.0, 200.0), (4400.0, 1200.0)), + ((4400.0, 2100.0), (4400.0, 5200.0)), + ((4200.0, 1200.0), (4400.0, 1200.0)), + ((4200.0, 2100.0), (4400.0, 2100.0)), + ] + await draw_lines(backend, door_gap, WALL) + await write_record( + backend, Wall(id="w5", axis=((4300.0, 100.0), (4300.0, 5300.0)), thickness=200.0), WALL + ) + await write_record( + backend, + Opening(id="d1", wall="w5", kind="door", offset=1100.0, width=900.0), + ARCH_ROLE_LAYER["door"], + ) + result = await label_room(backend, {"name": "A", "at": [2200.0, 2700.0]}) + # without the closures room A would read 20 000 000 + 20 000 000 + 900 x 200 + assert result["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS) + assert result["closures"] == 2 + + +@pytest.mark.asyncio +async def test_label_room_refusals_leave_the_drawing_untouched(backend): + await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), WALL) + before = await backend.entity_count() + with pytest.raises(ValueError, match=re.escape("room.area: a room's area is measured")): + await label_room(backend, {"name": "A", "at": [100.0, 100.0], "area": 5.0}) + with pytest.raises(ValueError, match=re.escape("room.at: (9000, 9000) lies in no closed face")): + await label_room(backend, {"name": "A", "at": [9000.0, 9000.0]}) + with pytest.raises(ValueError, match=re.escape("lang: 'fr' is not a label language")): + await label_room(backend, {"name": "A", "at": [100.0, 100.0]}, lang="fr") + assert await backend.entity_count() == before + await label_room(backend, {"id": "R7", "name": "A", "at": [100.0, 100.0]}) + with pytest.raises(ValueError, match=re.escape("room.id: 'R7' is already labelled")): + await label_room(backend, {"id": "R7", "name": "B", "at": [200.0, 200.0]}) + + +@pytest.mark.asyncio +async def test_a_label_point_inside_a_wall_body_is_refused(backend): + await draw_lines( + backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL + ) + with pytest.raises( + ValueError, match=re.escape("room.at: (-100, 2500) lies inside a wall body") + ): + await label_room(backend, {"name": "A", "at": [-100.0, 2500.0]}) + + +# ── async: rooms_detect ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_a_foreign_plan_of_plain_lines_yields_the_same_areas_and_is_not_modified(backend): + await backend.entity_create_polyline( + [[0.0, 0.0], [8600.0, 0.0], [8600.0, 5400.0], [0.0, 5400.0]], True, layer="DUVAR" + ) + await draw_lines(backend, two_rooms()[4:], "DUVAR") + await draw_lines(backend, [((-500.0, -500.0), (9000.0, -500.0))], "0") # not read + before = await backend.entity_count() + result = await rooms_detect(backend) + assert result["layers"] == ["DUVAR"] + assert [r["area"] for r in result["rooms"]] == [20_000_000.0, 20_000_000.0] + assert [r["centroid"] for r in result["rooms"]] == [[2200.0, 2700.0], [6400.0, 2700.0]] + assert result["confidence_min"] == CONFIDENCE_FOREIGN + assert result["modified"] is False + assert await backend.entity_count() == before + + +@pytest.mark.asyncio +async def test_our_own_walls_read_at_full_confidence_with_their_labels(backend): + await draw_lines(backend, two_rooms(), WALL) + await label_room(backend, {"name": "A", "number": "01", "at": [2200.0, 2700.0]}) + result = await rooms_detect(backend) + assert result["layers"] == [WALL] + assert [r["confidence"] for r in result["rooms"]] == [1.0, 1.0] + first, second = result["rooms"] + assert first["label"] == {"id": "R1", "name": "A", "number": "01", "area": 20_000_000.0} + assert second["label"] is None + + +@pytest.mark.asyncio +async def test_the_record_reader_pages_the_drawing_once_not_once_per_arch_layer( + backend, monkeypatch +): + # a live seat pays one cross-process call per entity per scan; twelve arch + # layers must not mean twelve scans of the whole space + await write_record( + backend, Wall(id="w1", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0), WALL + ) + await write_record( + backend, + Opening(id="d1", wall="w1", kind="door", offset=1000.0, width=900.0), + ARCH_ROLE_LAYER["door"], + ) + await backend.entity_create_line(0.0, 0.0, 1.0, 1.0, layer="0") # not an arch layer + calls = [] + original = backend.entity_list + + async def counting(*args, **kwargs): + calls.append(kwargs) + return await original(*args, **kwargs) + + monkeypatch.setattr(backend, "entity_list", counting) + records = await read_records(backend) + assert len(calls) == 1 + assert [w.id for w in records["walls"]] == ["w1"] + assert [o.id for o in records["openings"]] == ["d1"] + assert records["unreadable"] == 0 + + +@pytest.mark.asyncio +async def test_rooms_detect_refuses_a_layer_that_is_not_there(backend): + await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS") + with pytest.raises(ValueError, match=re.escape("layers: NOPE not in this drawing")): + await rooms_detect(backend, layers=["NOPE"]) + + +@pytest.mark.asyncio +async def test_an_arc_on_a_wall_layer_is_reported_skipped_never_approximated(backend): + await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS") + arc = await backend.entity_create_arc(2000.0, 5000.0, 500.0, 0.0, 180.0, layer="WALLS") + result = await rooms_detect(backend) + assert [r["area"] for r in result["rooms"]] == [20_000_000.0] + assert [s["handle"] for s in result["skipped"]] == [arc.handle] diff --git a/tests/test_arch_rooms_tools.py b/tests/test_arch_rooms_tools.py new file mode 100644 index 0000000..b09cd99 --- /dev/null +++ b/tests/test_arch_rooms_tools.py @@ -0,0 +1,77 @@ +"""arch_room and arch_rooms_detect over the MCP wire, on a fresh headless document.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client + +import server +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + +WALL = ARCH_ROLE_LAYER["wall"] + + +def rect(x0, y0, x1, y1): + return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] + + +@pytest_asyncio.fixture +async def client(monkeypatch): + """In-memory client on a fresh headless document - never the operator's AutoCAD.""" + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +async def draw_room(client, layer): + """A 4000 x 5000 room between 200 mm walls: outer and inner outline.""" + for (x1, y1), (x2, y2) in [ + *rect(-200.0, -200.0, 4200.0, 5200.0), + *rect(0.0, 0.0, 4000.0, 5000.0), + ]: + await client.call_tool( + "entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": layer} + ) + + +async def test_arch_room_labels_the_measured_area(client): + await draw_room(client, WALL) + result = await client.call_tool( + "arch_room", {"name": "LIVING", "number": "01", "x": 2000.0, "y": 2500.0} + ) + room = result.structured_content["room"] + assert room["area"] == pytest.approx(20_000_000.0) + assert room["area_text"] == "20.00 m²" + assert result.structured_content["layer"] == ARCH_ROLE_LAYER["room"] + + +async def test_arch_rooms_detect_reads_the_room_back_without_drawing(client): + await draw_room(client, "DUVAR") + before = (await client.call_tool("analysis_entity_stats", {})).structured_content + result = (await client.call_tool("arch_rooms_detect", {})).structured_content + assert result["layers"] == ["DUVAR"] + assert [r["area"] for r in result["rooms"]] == [pytest.approx(20_000_000.0)] + assert result["confidence_min"] == 0.6 + after = (await client.call_tool("analysis_entity_stats", {})).structured_content + assert after["total_entities"] == before["total_entities"] + + +async def test_a_point_outside_every_room_is_refused_over_the_wire(client): + await draw_room(client, WALL) + result = await client.call_tool( + "arch_room", {"name": "X", "x": 9000.0, "y": 9000.0}, raise_on_error=False + ) + assert result.is_error is True + assert "room.at: (9000, 9000) lies in no closed face" in result.content[0].text + + +async def test_the_detector_is_advertised_read_only(client): + tools = {tool.name: tool for tool in await client.list_tools()} + assert tools["arch_rooms_detect"].annotations.readOnlyHint is True + assert tools["arch_room"].annotations.destructiveHint is False diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index aa37309..12ed93e 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (229 tools, 25 groups). + """Frozen snapshot of the surface (231 tools, 25 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 @@ -581,6 +581,13 @@ async def test_tool_group_sizes_are_unchanged(): `sheet`, so pack, group and SECTION are one list again: `engineering` returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value. + + Track F group R opened SECTION 25 with the rooms: `arch_room` is 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 -> 19, `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. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -591,9 +598,9 @@ async def test_tool_group_sizes_are_unchanged(): "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 18, + "entity_creation": 19, "entity_modification": 16, - "entity_query": 9, + "entity_query": 10, "environment": 22, "layers": 14, "layouts": 12, @@ -610,4 +617,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 229 + assert sum(sizes.values()) == 231