merge: track F group R
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:
@@ -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,546 @@
|
||||
"""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]
|
||||
|
||||
|
||||
# ── what the reader cannot read is skipped, by name ─────────────────────────
|
||||
|
||||
|
||||
def outline(x0, y0, x1, y1):
|
||||
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_old_style_polyline_is_skipped_by_name_not_dropped(backend):
|
||||
# one room of old-style 2D POLYLINEs next to one of lightweight polylines;
|
||||
# the headless engine reports the old-style kind with no vertices at all
|
||||
msp = backend._doc.modelspace()
|
||||
heavy = [
|
||||
msp.add_polyline2d(outline(*box), close=True, dxfattribs={"layer": "WALLS"}).dxf.handle
|
||||
for box in ((0.0, 0.0, 4400.0, 5400.0), (200.0, 200.0, 4200.0, 5200.0))
|
||||
]
|
||||
for box in ((10000.0, 0.0, 14400.0, 5400.0), (10200.0, 200.0, 14200.0, 5200.0)):
|
||||
await backend.entity_create_polyline([list(p) for p in outline(*box)], True, layer="WALLS")
|
||||
result = await rooms_detect(backend)
|
||||
assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
|
||||
assert [(s["handle"], s["type"]) for s in result["skipped"]] == [
|
||||
(heavy[0], "POLYLINE"),
|
||||
(heavy[1], "POLYLINE"),
|
||||
]
|
||||
assert "old-style polyline" in result["skipped"][0]["reason"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walls_inside_a_block_reference_are_skipped_and_annotation_is_passed_over(
|
||||
backend,
|
||||
):
|
||||
await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS")
|
||||
block = backend._doc.blocks.new("ROOMBLK")
|
||||
for (x1, y1), (x2, y2) in [
|
||||
*rect(0.0, 0.0, 4400.0, 5400.0),
|
||||
*rect(200.0, 200.0, 4200.0, 5200.0),
|
||||
]:
|
||||
block.add_line((x1, y1), (x2, y2))
|
||||
insert = backend._doc.modelspace().add_blockref(
|
||||
"ROOMBLK", (20000.0, 0.0), dxfattribs={"layer": "WALLS"}
|
||||
)
|
||||
await backend.entity_create_text("W1", 100.0, 100.0, 100.0, layer="WALLS")
|
||||
result = await rooms_detect(backend)
|
||||
assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
|
||||
assert [(s["handle"], s["type"]) for s in result["skipped"]] == [(insert.dxf.handle, "INSERT")]
|
||||
assert "walls inside a block are not read" in result["skipped"][0]["reason"]
|
||||
|
||||
|
||||
class LiveRows:
|
||||
"""A backend that answers with the EntityInfo rows a live seat produced.
|
||||
|
||||
The rows are the ones `backends.com_backend._entity_info` returned on
|
||||
AutoCAD 2026 (2026-09-23) for three closed ``ModelSpace.AddPolyline``
|
||||
outlines on WALLS - outer 0..8600 x 0..5400, rooms 200..4200 and
|
||||
4400..8400 x 200..5200 - whose ``Coordinates`` came back as x, y, z
|
||||
triples and were read two at a time; plus an ``AddLightWeightPolyline``
|
||||
pair (AcDbPolyline, reported POLYLINE) and a block reference. Read
|
||||
through, the old-style rows gave five faces of 2.6 to 6.9 m².
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
from backends.base import EntityInfo, LayerInfo
|
||||
|
||||
def row(handle, kind, points=None):
|
||||
props = {} if points is None else {"points": points, "closed": True}
|
||||
if points is not None:
|
||||
props["bulges"] = [0.0] * len(points)
|
||||
return EntityInfo(handle, kind, "WALLS", 256, "ByLayer", True, props)
|
||||
|
||||
self.rows = [
|
||||
row(
|
||||
"2A1",
|
||||
"2DPOLYLINE",
|
||||
[
|
||||
[0.0, 0.0],
|
||||
[0.0, 8600.0],
|
||||
[0.0, 0.0],
|
||||
[8600.0, 5400.0],
|
||||
[0.0, 0.0],
|
||||
[5400.0, 0.0],
|
||||
],
|
||||
),
|
||||
row(
|
||||
"2A2",
|
||||
"2DPOLYLINE",
|
||||
[
|
||||
[200.0, 200.0],
|
||||
[0.0, 4200.0],
|
||||
[200.0, 0.0],
|
||||
[4200.0, 5200.0],
|
||||
[0.0, 200.0],
|
||||
[5200.0, 0.0],
|
||||
],
|
||||
),
|
||||
row(
|
||||
"2A3",
|
||||
"2DPOLYLINE",
|
||||
[
|
||||
[4400.0, 200.0],
|
||||
[0.0, 8400.0],
|
||||
[200.0, 0.0],
|
||||
[8400.0, 5200.0],
|
||||
[0.0, 4400.0],
|
||||
[5200.0, 0.0],
|
||||
],
|
||||
),
|
||||
row("2A4", "POLYLINE", [list(p) for p in outline(10000.0, 0.0, 14400.0, 5400.0)]),
|
||||
row("2A5", "POLYLINE", [list(p) for p in outline(10200.0, 200.0, 14200.0, 5200.0)]),
|
||||
row("2A6", "BLOCKREFERENCE"),
|
||||
]
|
||||
self.layers = [
|
||||
LayerInfo(name, 7, "Continuous", 0.25, True, False, False, name == "0")
|
||||
for name in ("0", "WALLS")
|
||||
]
|
||||
|
||||
async def layer_list(self):
|
||||
return self.layers
|
||||
|
||||
async def entity_list(self, type_filter=None, layer_filter=None, limit=200, offset=0):
|
||||
rows = [r for r in self.rows if layer_filter is None or r.layer == layer_filter]
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
async def entity_get_xdata(self, handle, app_id):
|
||||
return {"xdata": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_old_style_polylines_are_skipped_not_read_as_their_garbled_points():
|
||||
result = await rooms_detect(LiveRows())
|
||||
# only the lightweight pair is a room; the garbled rows make no faces at all
|
||||
assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
|
||||
assert result["confidence_min"] == CONFIDENCE_FOREIGN
|
||||
assert [(s["handle"], s["type"]) for s in result["skipped"]] == [
|
||||
("2A1", "2DPOLYLINE"),
|
||||
("2A2", "2DPOLYLINE"),
|
||||
("2A3", "2DPOLYLINE"),
|
||||
("2A6", "BLOCKREFERENCE"),
|
||||
]
|
||||
|
||||
|
||||
# ── one face, one room record ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_second_label_in_a_labelled_room_is_refused_and_draws_nothing(backend):
|
||||
await draw_lines(
|
||||
backend, [*rect(0.0, 0.0, 4400.0, 5400.0), *rect(200.0, 200.0, 4200.0, 5200.0)], WALL
|
||||
)
|
||||
first = await label_room(backend, {"name": "LIVING", "at": [1000.0, 3000.0]})
|
||||
before = await backend.entity_count()
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
f"room.at: (2000, 3000) lies in a room that is already labelled - "
|
||||
f"R1 'LIVING' (label {first['handle']})"
|
||||
),
|
||||
):
|
||||
await label_room(backend, {"name": "LIVING2", "at": [2000.0, 3000.0]})
|
||||
assert await backend.entity_count() == before
|
||||
result = await rooms_detect(backend)
|
||||
assert [r["labels"] for r in result["rooms"]] == [
|
||||
[{"id": "R1", "name": "LIVING", "number": None, "area": 20_000_000.0}]
|
||||
]
|
||||
assert result["label_conflicts"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_other_room_can_still_be_labelled(backend):
|
||||
await draw_lines(backend, two_rooms(), WALL)
|
||||
await label_room(backend, {"name": "A", "at": [2200.0, 2700.0]})
|
||||
second = await label_room(backend, {"name": "B", "at": [6400.0, 2700.0]})
|
||||
assert second["room"]["id"] == "R2"
|
||||
assert second["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rooms_detect_reports_every_record_in_a_face_and_the_conflict(backend):
|
||||
# two records already on one face (written by other means than arch_room)
|
||||
await draw_lines(backend, two_rooms(), WALL)
|
||||
for rid in ("R1", "R2"):
|
||||
room = Room(id=rid, name=rid, number=None, at=(2200.0, 2700.0), area=20_000_000.0)
|
||||
await write_record(backend, room, ROOM)
|
||||
result = await rooms_detect(backend)
|
||||
first, second = result["rooms"]
|
||||
assert [label["id"] for label in first["labels"]] == ["R1", "R2"]
|
||||
assert first["label"]["id"] == "R1"
|
||||
assert second["labels"] == [] and second["label"] is None
|
||||
assert result["label_conflicts"] == [
|
||||
{
|
||||
"labels": ["R1", "R2"],
|
||||
"centroid": [2200.0, 2700.0],
|
||||
"reason": "one face carries 2 room records; a schedule would count its "
|
||||
"20.00 m2 2 times",
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Door, window and room schedules: rows from the plan model, a TABLE from the drawing.
|
||||
|
||||
Records are written with `model.to_values` directly - in production the wall
|
||||
engine (Task 4, another branch) and `arch_room` write them; the record shape is
|
||||
pinned by the skeleton.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.arch.lang import vocab
|
||||
from engineering.arch.layers import ARCH_ROLE_LAYER
|
||||
from engineering.arch.model import APP_ID, Opening, Room, Wall, to_payload, to_values
|
||||
from engineering.arch.schedule import SCHEDULE_COLUMNS, draw_schedule, schedule_rows
|
||||
|
||||
WALLS = [
|
||||
Wall(id="w1", axis=((0.0, 0.0), (6000.0, 0.0)), thickness=200.0),
|
||||
Wall(id="w2", axis=((6000.0, 0.0), (6000.0, 4000.0)), thickness=200.0),
|
||||
]
|
||||
|
||||
|
||||
def plan():
|
||||
return {
|
||||
"walls": list(WALLS),
|
||||
"openings": [
|
||||
Opening(
|
||||
id="da",
|
||||
wall="w2",
|
||||
kind="door",
|
||||
offset=500.0,
|
||||
width=800.0,
|
||||
swing="out",
|
||||
hand="right",
|
||||
),
|
||||
Opening(id="db", wall="w1", kind="door", offset=3000.0, width=912.5, height=2100.0),
|
||||
Opening(
|
||||
id="dc", wall="w1", kind="door", offset=1000.0, width=900.0, height=2100.0, tag="D2"
|
||||
),
|
||||
Opening(
|
||||
id="wa",
|
||||
wall="w1",
|
||||
kind="window",
|
||||
offset=4000.0,
|
||||
width=1200.0,
|
||||
height=1200.0,
|
||||
sill=900.0,
|
||||
),
|
||||
],
|
||||
"rooms": [
|
||||
Room(id="R1", name="KITCHEN", number="2", at=(0.0, 0.0), area=15_000_000.0),
|
||||
Room(id="R2", name="LIVING", number="10", at=(0.0, 0.0), area=20_000_000.0),
|
||||
Room(id="R3", name="STORE", number=None, at=(0.0, 0.0), area=10_000_000.0),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_door_rows_generate_missing_tags_in_wall_order_and_sort_by_tag():
|
||||
rows = schedule_rows("doors", plan(), lang="en")
|
||||
en = vocab("en")
|
||||
# wall order: w1 at 1000 (tagged D2), w1 at 3000 -> D1, w2 at 500 -> D3 (D2 is taken)
|
||||
assert [(r["id"], r["tag"], r["generated"]) for r in rows] == [
|
||||
("db", "D1", True),
|
||||
("dc", "D2", False),
|
||||
("da", "D3", True),
|
||||
]
|
||||
assert rows[0] == {
|
||||
"id": "db",
|
||||
"tag": "D1",
|
||||
"generated": True,
|
||||
"width": "912.5",
|
||||
"height": "2100",
|
||||
"swing": en["in"],
|
||||
"hand": en["left"],
|
||||
"wall": "w1",
|
||||
}
|
||||
# no height on the model: an empty cell, never a default
|
||||
assert rows[2]["height"] == ""
|
||||
assert (rows[2]["swing"], rows[2]["hand"]) == (en["out"], en["right"])
|
||||
assert set(SCHEDULE_COLUMNS["doors"]) <= set(rows[0])
|
||||
|
||||
|
||||
def test_turkish_door_rows_use_k_tags_decimal_commas_and_turkish_words():
|
||||
rows = schedule_rows("doors", plan(), lang="tr")
|
||||
tr = vocab("tr")
|
||||
# explicit "D2" is never renamed; generated tags take the Turkish prefix
|
||||
assert [r["tag"] for r in rows] == ["D2", "K1", "K2"]
|
||||
k1 = next(r for r in rows if r["tag"] == "K1")
|
||||
assert k1["width"] == "912,5"
|
||||
assert (k1["swing"], k1["hand"]) == (tr["in"], tr["left"])
|
||||
|
||||
|
||||
def test_window_rows_carry_sill_and_no_swing():
|
||||
(row,) = schedule_rows("windows", plan(), lang="en")
|
||||
assert row == {
|
||||
"id": "wa",
|
||||
"tag": "W1",
|
||||
"generated": True,
|
||||
"width": "1200",
|
||||
"height": "1200",
|
||||
"sill": "900",
|
||||
"wall": "w1",
|
||||
}
|
||||
assert [r["tag"] for r in schedule_rows("windows", plan(), lang="tr")] == ["P1"]
|
||||
|
||||
|
||||
def test_room_rows_sort_by_number_with_the_unnumbered_last_and_end_in_a_total():
|
||||
rows = schedule_rows("rooms", plan(), lang="en")
|
||||
assert [(r["number"], r["name"], r["area"]) for r in rows] == [
|
||||
("2", "KITCHEN", "15.00 m²"),
|
||||
("10", "LIVING", "20.00 m²"),
|
||||
("", "STORE", "10.00 m²"),
|
||||
("", vocab("en")["total"], "45.00 m²"), # 15 + 20 + 10
|
||||
]
|
||||
assert rows[-1]["total"] is True
|
||||
assert rows[-1]["area_mm2"] == 45_000_000.0
|
||||
|
||||
|
||||
def test_turkish_room_rows_write_the_total_with_a_decimal_comma():
|
||||
rows = schedule_rows("rooms", plan(), lang="tr")
|
||||
assert (rows[-1]["name"], rows[-1]["area"]) == (vocab("tr")["total"], "45,00 m²")
|
||||
assert rows[0]["area"] == "15,00 m²"
|
||||
|
||||
|
||||
def test_an_empty_plan_has_no_rows_and_no_total():
|
||||
assert schedule_rows("rooms", {"walls": [], "openings": [], "rooms": []}) == ()
|
||||
assert schedule_rows("doors", {"walls": [], "openings": [], "rooms": []}) == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "lang", "message"),
|
||||
[
|
||||
("stairs", "en", "kind: 'stairs' is not a schedule; schedules are doors, windows, rooms"),
|
||||
("doors", "de", "lang: 'de' is not a schedule language; languages are en, tr"),
|
||||
],
|
||||
)
|
||||
def test_an_unknown_kind_or_language_is_refused_with_the_list(kind, lang, message):
|
||||
with pytest.raises(ValueError, match=re.escape(message)):
|
||||
schedule_rows(kind, plan(), lang=lang)
|
||||
|
||||
|
||||
# ── async: the TABLE ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def write_record(backend, obj, role):
|
||||
carrier = await backend.entity_create_line(0.0, 0.0, 1.0, 0.0, layer=ARCH_ROLE_LAYER[role])
|
||||
await backend.entity_set_xdata(carrier.handle, APP_ID, to_values(to_payload(obj)))
|
||||
|
||||
|
||||
async def write_plan(backend):
|
||||
data = plan()
|
||||
for wall in data["walls"]:
|
||||
await write_record(backend, wall, "wall")
|
||||
for opening in data["openings"]:
|
||||
await write_record(backend, opening, opening.kind)
|
||||
for room in data["rooms"]:
|
||||
await write_record(backend, room, "room")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_door_schedule_is_a_table_with_title_header_and_one_row_per_door(backend):
|
||||
await write_plan(backend)
|
||||
result = await draw_schedule(backend, "doors", at=(10_000.0, 5_000.0), lang="en", scale=50)
|
||||
assert result["row_count"] == 3
|
||||
assert result["table_rows"] == 5 # title + header + 3 doors
|
||||
assert result["representation"] == "composite"
|
||||
assert result["layer"] == ARCH_ROLE_LAYER["symbol"]
|
||||
assert result["title"] == vocab("en")["door_schedule"]
|
||||
# 1:50: widths 15+20+20+20+20+20 = 115 mm -> 5750; rows 5 x 7 mm x 50 = 1750
|
||||
assert result["bbox"] == {"min": [10_000.0, 3_250.0], "max": [15_750.0, 5_000.0]}
|
||||
cells = {
|
||||
e.properties["text"]
|
||||
for e in await backend.entity_list(
|
||||
type_filter="MTEXT", layer_filter=ARCH_ROLE_LAYER["symbol"], limit=1000
|
||||
)
|
||||
}
|
||||
assert {"D1", "D2", "D3", "912.5", "w2"} <= cells
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_room_schedule_carries_the_total_row(backend):
|
||||
await write_plan(backend)
|
||||
result = await draw_schedule(backend, "rooms", at=(0.0, 0.0), lang="tr")
|
||||
assert result["row_count"] == 4 # three rooms + the total
|
||||
assert result["table_rows"] == 6
|
||||
assert result["rows"][-1]["area"] == "45,00 m²"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nothing_to_schedule_is_refused_before_anything_is_drawn(backend):
|
||||
before = await backend.entity_count()
|
||||
with pytest.raises(ValueError, match=re.escape("kind: this drawing carries no window records")):
|
||||
await draw_schedule(backend, "windows", at=(0.0, 0.0))
|
||||
with pytest.raises(
|
||||
ValueError, match=re.escape("scale: must be a finite number greater than zero")
|
||||
):
|
||||
await draw_schedule(backend, "doors", at=(0.0, 0.0), scale=-1)
|
||||
assert await backend.entity_count() == before
|
||||
@@ -0,0 +1,65 @@
|
||||
"""arch_schedule over the MCP wire: rooms labelled by arch_room, scheduled as a TABLE."""
|
||||
|
||||
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_two_rooms(client):
|
||||
"""Rooms (200,200)-(4200,5200) and (4400,200)-(8400,5200) between 200 mm walls."""
|
||||
for (x1, y1), (x2, y2) in [
|
||||
*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)),
|
||||
]:
|
||||
await client.call_tool(
|
||||
"entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": WALL}
|
||||
)
|
||||
|
||||
|
||||
async def test_a_room_schedule_reads_the_labels_arch_room_wrote(client):
|
||||
await draw_two_rooms(client)
|
||||
await client.call_tool("arch_room", {"name": "LIVING", "number": "1", "x": 2200.0, "y": 2700.0})
|
||||
await client.call_tool("arch_room", {"name": "BED", "number": "2", "x": 6400.0, "y": 2700.0})
|
||||
result = (
|
||||
await client.call_tool("arch_schedule", {"kind": "rooms", "x": 9000.0, "y": 5400.0})
|
||||
).structured_content
|
||||
assert result["table_rows"] == 5 # title + header + two rooms + total
|
||||
assert [(r["number"], r["area"]) for r in result["rows"]] == [
|
||||
("1", "20.00 m²"),
|
||||
("2", "20.00 m²"),
|
||||
("", "40.00 m²"),
|
||||
]
|
||||
|
||||
|
||||
async def test_nothing_to_schedule_is_refused_over_the_wire(client):
|
||||
result = await client.call_tool(
|
||||
"arch_schedule", {"kind": "doors", "x": 0.0, "y": 0.0}, raise_on_error=False
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "kind: this drawing carries no door records" in result.content[0].text
|
||||
@@ -135,3 +135,77 @@ async def test_server_registers_table_and_mleader_tools():
|
||||
names = {tool.name for tool in await server._registered_tools()}
|
||||
assert "entity_create_table" in names
|
||||
assert "leader_create_mleader" in names
|
||||
|
||||
|
||||
class _MeasuredTable:
|
||||
"""The IAcadTable members a live AutoCAD 2026 seat exposes (measured).
|
||||
|
||||
MEASURED on AutoCAD 2026 (track F task 6): the table has no ``TextHeight``
|
||||
property - pywin32 answers ``table.TextHeight = h`` with AttributeError -
|
||||
and a table left alone keeps the Standard style's heights (title 6.0, header
|
||||
and data 4.5). ``SetCellTextHeight(row, col, h)`` is the member that sets it.
|
||||
A fake that accepted ``TextHeight`` would hide exactly that defect.
|
||||
"""
|
||||
|
||||
def __init__(self, rows, columns):
|
||||
self.Handle = "A1"
|
||||
self.Rows, self.Columns = rows, columns
|
||||
self._heights = {
|
||||
(r, c): (6.0 if r == 0 else 4.5) for r in range(rows) for c in range(columns)
|
||||
}
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name == "TextHeight":
|
||||
raise AttributeError(f"object has no attribute {name!r}")
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def SetColumnWidth(self, column, width):
|
||||
pass
|
||||
|
||||
def SetRowHeight(self, row, height):
|
||||
pass
|
||||
|
||||
def SetText(self, row, column, value):
|
||||
pass
|
||||
|
||||
def SetCellTextHeight(self, row, column, height):
|
||||
self._heights[(row, column)] = float(height)
|
||||
|
||||
def GetCellTextHeight(self, row, column):
|
||||
return self._heights[(row, column)]
|
||||
|
||||
|
||||
async def test_com_table_sets_every_cell_text_height_through_the_measured_member():
|
||||
backend = ComBackend()
|
||||
|
||||
async def run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
backend._run = run_inline
|
||||
mspace = MagicMock()
|
||||
made: list[_MeasuredTable] = []
|
||||
|
||||
def add_table(_point, rows, columns, _row_height, _column_width):
|
||||
made.append(_MeasuredTable(rows, columns))
|
||||
return made[-1]
|
||||
|
||||
mspace.AddTable.side_effect = add_table
|
||||
info = EntityInfo("A1", "ACAD_TABLE", "TEXT", 256, "ByLayer", True, {})
|
||||
|
||||
with (
|
||||
patch("backends.com_backend._msp", return_value=mspace),
|
||||
patch("backends.com_backend._apoint", side_effect=lambda *p: tuple(p)),
|
||||
patch("backends.com_backend._entity_info", return_value=info),
|
||||
patch("backends.com_backend._apply_entity_attrs"),
|
||||
patch("backends.com_backend._regen"),
|
||||
):
|
||||
await backend.entity_create_table(
|
||||
0, 50, [["A", "1"]], headers=["Name", "Qty"], title="BOM", text_height=125.0
|
||||
)
|
||||
|
||||
(table,) = made
|
||||
assert {
|
||||
(row, column): table.GetCellTextHeight(row, column)
|
||||
for row in range(table.Rows)
|
||||
for column in range(table.Columns)
|
||||
} == {(row, column): 125.0 for row in range(3) for column in range(2)}
|
||||
|
||||
@@ -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 (233 tools, 26 groups).
|
||||
"""Frozen snapshot of the surface (236 tools, 26 groups).
|
||||
|
||||
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
||||
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
||||
@@ -587,11 +587,18 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
`arch_dimension_chains`); the `arch` tag is ranked directly after `mech` in
|
||||
`_GROUP_TAG_PRIORITY`, so those tools file under `architecture` rather
|
||||
than under their secondary `create` / `dimension` tags.
|
||||
|
||||
Track F group R opened SECTION 25 with the rooms: `arch_room` and
|
||||
`arch_schedule` are tagged `arch` and `create`, `arch_rooms_detect` `arch`
|
||||
and `query`. `arch` is not in `_GROUP_TAG_PRIORITY` on this branch, so they
|
||||
file under their secondary tags: `entity_creation` 18 -> 20,
|
||||
`entity_query` 9 -> 10. This snapshot is branch-local: the track F merge
|
||||
task recomputes it once the walls, rooms and catalogue branches have landed.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
"analysis": 12,
|
||||
"architecture": 4,
|
||||
"architecture": 7,
|
||||
"batch": 3,
|
||||
"blocks": 9,
|
||||
"corner_ops": 4,
|
||||
@@ -617,4 +624,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 233
|
||||
assert sum(sizes.values()) == 236
|
||||
|
||||
Reference in New Issue
Block a user