fix(task-5): skip what the room reader cannot read, and one face carries one room

read_segments trusted any row with `points`. On a live seat an old-style
AcDb2dPolyline's Coordinates are x, y, z triples (measured on AutoCAD 2026,
2026-09-23: [200,200,0, 4200,200,0, ...]) which the backend reads two at a
time, so three closed ms.AddPolyline outlines on WALLS reported points like
[[0,0],[0,8600],[0,0],[8600,5400],...] and rooms_detect answered five faces of
2.6-6.9 m2 for two 20 m2 rooms. Headless, the same POLYLINE arrives with no
points and was dropped without a word; so were walls inside a block reference.
The reader now reads LINE and the lightweight polyline only (LWPOLYLINE, and
the live AcDbPolyline reported POLYLINE), and lists every other entity that
could bound a room in `skipped` with its handle, type and reason: arcs and
bulged edges as before, old-style polylines (2DPOLYLINE/3DPOLYLINE live, a
vertex-less POLYLINE headless), block references (INSERT / BLOCKREFERENCE)
and anything else not a line. Text, dimensions, leaders, points and hatches
bound nothing and are passed over. Re-run live on AutoCAD 2026 through the
real com_backend._entity_info rows: count 0 with the three polylines in
skipped (was 5 wrong rooms); with a lightweight pair and a block added, count
1 at 20 000 000 mm2 plus the block reference in skipped. The backend's stride
is left alone (shared file, other readers); this reader no longer trusts it.

label_room refused a reused id but not a reused face: a second label in a
labelled room (a retry after a transport timeout) wrote a second record with
the same area, which a schedule total counts twice. It is now refused before
anything is drawn, naming the record and label already there. rooms_detect
reports `labels` (every record in the face; `label` stays the first) and
`label_conflicts` for a face holding more than one.

No new ActiveX member is called.

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:
Umut CAN
2026-09-23 22:48:36 +03:00
co-authored by Claude Opus 5.5
parent 67d578e09a
commit a2996b0c95
3 changed files with 340 additions and 46 deletions
+127 -37
View File
@@ -14,10 +14,14 @@ 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 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. 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 `rooms_detect` runs the same finder on any lines and lightweight polylines -
plan - and reports each face with a confidence, the way `pid_graph` does: a foreign plan - and reports each face with a confidence, the way `pid_graph`
1.0 when every edge of the face lies on our wall layer, 0.6 when any edge is a does: 1.0 when every edge of the face lies on our wall layer, 0.6 when any
plain line from somewhere else. It never modifies the drawing. edge is a plain line from somewhere else. It never modifies the drawing. What
it cannot read - an arc, an old-style POLYLINE, walls inside a block
reference - is listed in ``skipped`` with its handle, never dropped and never
approximated, so a room it could not close is a room the answer says it
missed, not a wrong area.
DECLARED, not standard: DECLARED, not standard:
@@ -89,6 +93,40 @@ WALL_LAYER_KEYWORDS = ("WALL", "DUVAR")
#: Entity types that bound a region with a curve; the finder reads straight #: Entity types that bound a region with a curve; the finder reads straight
#: segments only, so these are reported as skipped, never approximated. #: segments only, so these are reported as skipped, never approximated.
CURVE_TYPES = frozenset({"ARC", "CIRCLE", "ELLIPSE", "SPLINE"}) CURVE_TYPES = frozenset({"ARC", "CIRCLE", "ELLIPSE", "SPLINE"})
#: Polylines whose ``points`` are the vertices on both engines: the headless
#: LWPOLYLINE, and the live AcDbPolyline / AcDbLWPolyline (reported POLYLINE /
#: LWPOLYLINE). A headless POLYLINE is the old-style one - it arrives with no
#: ``points`` at all, and is skipped by that test.
LIGHT_POLYLINE_TYPES = frozenset({"LWPOLYLINE", "POLYLINE"})
#: Old-style polylines whose ``points`` are NOT to be trusted: the live
#: AcDb2dPolyline hands its Coordinates over as x, y, z triples, which the
#: backend reads two at a time (measured on AutoCAD 2026: a 4000 x 5000 outline
#: came back as [[200, 200], [0, 4200], [200, 0], ...]), so a room read off them
#: is a wrong number, not a missing one. Skipped, by name.
HEAVY_POLYLINE_TYPES = frozenset({"2DPOLYLINE", "3DPOLYLINE", "POLYFACEMESH", "POLYGONMESH"})
#: Block references on both engines (headless INSERT, live AcDbBlockReference).
BLOCK_REFERENCE_TYPES = frozenset({"INSERT", "BLOCKREFERENCE", "MINSERTBLOCK"})
#: Annotation and fills: they sit on wall layers (a poche hatch, a wall tag)
#: without bounding anything, so they are passed over without a skipped entry.
#: Every DIMENSION kind (the live engine names them ROTATEDDIMENSION, ...) is
#: matched by name below.
NON_BOUNDARY_TYPES = frozenset(
{
"TEXT",
"MTEXT",
"ATTDEF",
"ATTRIBUTEDEFINITION",
"ATTRIB",
"POINT",
"HATCH",
"LEADER",
"MLEADER",
"MULTILEADER",
"TOLERANCE",
"FCF",
}
)
_ARC_REASON = "the face finder reads straight segments only and does not approximate an arc"
def _check_lang(lang: str) -> str: def _check_lang(lang: str) -> str:
@@ -283,23 +321,39 @@ async def read_records(backend: AutoCADBackend) -> dict:
async def read_segments(backend: AutoCADBackend, layers) -> tuple[list, list[dict]]: async def read_segments(backend: AutoCADBackend, layers) -> tuple[list, list[dict]]:
"""Straight segments on ``layers`` as ``(p1, p2, layer)``, and what was skipped. """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 Read: LINE, and the lightweight polyline by its ``points`` (the live engine
both engines (the live one names a polyline POLYLINE, the headless one names it POLYLINE, the headless one LWPOLYLINE). Everything else that can
LWPOLYLINE). A bulged polyline edge or an ARC/CIRCLE/ELLIPSE/SPLINE is an bound a room is listed in the second return value with its handle, type and
arc, and the finder reads straight segments only: it is listed in the reason - never dropped, never guessed:
second return value with its handle, never flattened into chords.
* an ARC/CIRCLE/ELLIPSE/SPLINE or a bulged polyline edge is an arc, and the
finder reads straight segments only - it is never flattened into chords;
* an old-style POLYLINE (``HEAVY_POLYLINE_TYPES`` live; a headless POLYLINE
reports no vertices) is not read, because its vertices do not reach this
reader correctly on either engine;
* a block reference is not exploded: walls inside a block are not read;
* any other type is listed as not a line.
Text, dimensions, leaders, points and hatches (``NON_BOUNDARY_TYPES``)
bound nothing and are passed over without an entry.
""" """
from engineering.sheet.bom import all_entities # lazy: sheet imports stay out of arch from engineering.sheet.bom import all_entities # lazy: sheet imports stay out of arch
segments: list = [] segments: list = []
skipped: list[dict] = [] skipped: list[dict] = []
def skip(entity, kind: str, reason: str) -> None:
skipped.append({"handle": entity.handle, "type": kind, "reason": reason})
for layer in layers: for layer in layers:
for entity in await all_entities(backend, layer=layer): for entity in await all_entities(backend, layer=layer):
props = entity.properties or {} props = entity.properties or {}
kind = str(entity.type).upper() kind = str(entity.type).upper()
if "start" in props and "end" in props and "center" not in props: if kind in NON_BOUNDARY_TYPES or "DIMENSION" in kind:
continue
if kind == "LINE" and "start" in props and "end" in props:
segments.append((tuple(props["start"][:2]), tuple(props["end"][:2]), entity.layer)) segments.append((tuple(props["start"][:2]), tuple(props["end"][:2]), entity.layer))
elif "points" in props: elif kind in LIGHT_POLYLINE_TYPES and props.get("points"):
points = [tuple(p[:2]) for p in props["points"]] points = [tuple(p[:2]) for p in props["points"]]
bulges = list(props.get("bulges") or []) bulges = list(props.get("bulges") or [])
count = len(points) count = len(points)
@@ -310,22 +364,29 @@ async def read_segments(backend: AutoCADBackend, layers) -> tuple[list, list[dic
continue continue
segments.append((points[i], points[(i + 1) % count], entity.layer)) segments.append((points[i], points[(i + 1) % count], entity.layer))
if arcs: if arcs:
skipped.append( skip(entity, kind, f"{arcs} arc segment(s): {_ARC_REASON}")
{
"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: elif kind in CURVE_TYPES:
skipped.append( skip(entity, kind, f"a curve: {_ARC_REASON}")
{ elif kind in HEAVY_POLYLINE_TYPES or kind == "POLYLINE":
"handle": entity.handle, skip(
"type": kind, entity,
"reason": "a curve: the face finder reads straight segments only " kind,
"and does not approximate an arc", "an old-style polyline: its vertices are not read reliably by this "
} "reader on either engine, so its edges are not counted; convert it to a "
"lightweight polyline (CONVERTPOLY) or explode it to lines",
)
elif kind in BLOCK_REFERENCE_TYPES:
skip(
entity,
kind,
"a block reference: walls inside a block are not read; explode it "
"(block_explode) to count them",
)
else:
skip(
entity,
kind,
"not a LINE or a lightweight polyline: the face finder does not read it",
) )
return segments, skipped return segments, skipped
@@ -342,8 +403,10 @@ async def label_room(
Refused before anything is drawn: an ``area`` in the request (areas are Refused before anything is drawn: an ``area`` in the request (areas are
measured, never typed), an unknown ``lang``, a non-positive ``scale``, an 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 ``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 of the wall layer, a point inside a wall body, and a point in a face that
first free ``R<n>``. already holds a room record (one face, one room: a second record would be
counted twice by every schedule total). ``id`` defaults to the first free
``R<n>``.
""" """
if not isinstance(room_dict, dict): if not isinstance(room_dict, dict):
raise ValueError( raise ValueError(
@@ -391,6 +454,18 @@ async def label_room(
f"room.at: ({x:g}, {y:g}) lies inside a wall body (a face {width:.0f} mm wide on " 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" f"average, under the {MIN_ROOM_WIDTH:.0f} mm a room is taken to need), not in a room"
) )
# One face is one room: a second record on it would be a second area in
# every schedule total (a retried call after a timeout is the usual way in).
already = [r for r in records["rooms"] if face_containing(faces, r.at) is face]
if already:
held = ", ".join(
f"{r.id} {r.name!r} (label {records['room_handles'][r.id]})" for r in already
)
raise ValueError(
f"room.at: ({x:g}, {y:g}) lies in a room that is already labelled - {held}; "
"one face carries one room record, so its area is counted once. Delete that "
"label to relabel the room, or place this one in another room"
)
measured = replace(room, area=face.area) measured = replace(room, area=face.area)
from engineering.mech.draw import draw_prims # lazy: mech.draw imports the arch layer set from engineering.mech.draw import draw_prims # lazy: mech.draw imports the arch layer set
@@ -437,6 +512,12 @@ async def rooms_detect(
the ones it does. Our own opening records, when there are any, close door 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 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. open, and two rooms joined by an open doorway read as one face.
Each room reports ``labels`` - every room record whose point lies in its
face - and ``label``, the first of them or None. A face carrying more than
one record is listed in ``label_conflicts``, because a schedule reading the
records would count its area once per record. What `read_segments` could
not read is in ``skipped``, with its handle and why.
""" """
min_area = _positive(min_area, "min_area") min_area = _positive(min_area, "min_area")
tol = _positive(tol, "tol") tol = _positive(tol, "tol")
@@ -469,18 +550,25 @@ async def rooms_detect(
rooms = detect_rooms([*segments, *extra], min_area=min_area, tol=tol) rooms = detect_rooms([*segments, *extra], min_area=min_area, tol=tol)
out: list[dict] = [] out: list[dict] = []
conflicts: list[dict] = []
for room in rooms: for room in rooms:
face = Face( face = Face(
loop=room["loop"], area=room["area"], centroid=room["centroid"], holes=room["holes"] loop=room["loop"], area=room["area"], centroid=room["centroid"], holes=room["holes"]
) )
label = next( labels = [
( {"id": r.id, "name": r.name, "number": r.number, "area": r.area}
{"id": r.id, "name": r.name, "number": r.number, "area": r.area} for r in records["rooms"]
for r in records["rooms"] if face_containing((face,), r.at) is not None
if face_containing((face,), r.at) is not None ]
), if len(labels) > 1:
None, conflicts.append(
) {
"labels": [label["id"] for label in labels],
"centroid": [room["centroid"][0], room["centroid"][1]],
"reason": f"one face carries {len(labels)} room records; a schedule "
f"would count its {room['area'] / 1.0e6:.2f} m2 {len(labels)} times",
}
)
out.append( out.append(
{ {
"loop": _wcs_loop(room["loop"]), "loop": _wcs_loop(room["loop"]),
@@ -489,7 +577,8 @@ async def rooms_detect(
"area_m2": room["area"] / 1.0e6, "area_m2": room["area"] / 1.0e6,
"centroid": [room["centroid"][0], room["centroid"][1]], "centroid": [room["centroid"][0], room["centroid"][1]],
"confidence": room["confidence"], "confidence": room["confidence"],
"label": label, "label": labels[0] if labels else None,
"labels": labels,
} }
) )
return { return {
@@ -501,6 +590,7 @@ async def rooms_detect(
"count": len(out), "count": len(out),
"rooms": out, "rooms": out,
"confidence_min": min((room["confidence"] for room in out), default=None), "confidence_min": min((room["confidence"] for room in out), default=None),
"label_conflicts": conflicts,
"skipped": skipped, "skipped": skipped,
"omitted": list(omitted), "omitted": list(omitted),
"min_area": min_area, "min_area": min_area,
+16 -9
View File
@@ -9818,7 +9818,8 @@ async def arch_room(
"""Label the room around (x, y) with its name, number and MEASURED area. """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 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 of the arch set (LINE and lightweight polyline, both engines; anything else
there is listed in `skipped`), finds the closed face
the point lies in and writes that face's area - net of the wall thickness 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` 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 room record on the name text, which `arch_schedule` reads. A door or window
@@ -9828,8 +9829,10 @@ async def arch_room(
Refused before anything is drawn: an unknown `lang` (named with en, tr), a 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 `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, 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 or read a plan drawn by other means with `arch_rooms_detect`), a point
inside a wall body. inside a wall body, and a point in a room that already carries a label (one
face, one room record - a retried call does not add a second area to the
schedule).
""" """
from engineering.arch.rooms import label_room from engineering.arch.rooms import label_room
@@ -9869,17 +9872,21 @@ async def arch_rooms_detect(
) -> dict: ) -> dict:
"""Read the rooms of a plan - ours or a foreign one made of plain lines. """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, Every LINE and lightweight polyline on the layers is split where lines
and the closed faces they bound are measured (mm², net of nested pieces). 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 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 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 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 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 trusting a foreign plan. Room labels already on the drawing are reported
with the face it sits in. in `labels` with the face they sit in; a face with more than one is listed
in `label_conflicts`.
Never modifies the drawing. Arcs and bulged polyline edges are listed in Never modifies the drawing. Arcs and bulged polyline edges, old-style
`skipped`, never flattened into chords. A foreign plan's open doorways are POLYLINEs and block references are listed in `skipped` with their handle
and reason - never flattened into chords, never dropped silently. Read
`skipped` before trusting `count`. A foreign plan's open doorways are
not closed - two rooms joined by one read as one face. Refused: a named 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 the drawing does not have (with the list of the ones it has), and no
layer matching WALL or DUVAR when `layers` is omitted. layer matching WALL or DUVAR when `layers` is omitted.
+197
View File
@@ -347,3 +347,200 @@ async def test_an_arc_on_a_wall_layer_is_reported_skipped_never_approximated(bac
result = await rooms_detect(backend) result = await rooms_detect(backend)
assert [r["area"] for r in result["rooms"]] == [20_000_000.0] assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
assert [s["handle"] for s in result["skipped"]] == [arc.handle] 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",
}
]