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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d5c5e90406
commit
67d578e09a
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user