Files
Umut CANandClaude Opus 5.5 a2996b0c95 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
2026-09-23 22:48:36 +03:00

599 lines
25 KiB
Python

"""Rooms: labels that carry a measured area, and a reader for plans that are not ours.
A room's area is the floor its walls enclose. `label_room` never takes it from
the caller: it reads the wall faces off the drawing, finds the planar face the
label point lies in (`engineering.arch.faces`) and writes *that* area into the
label and into the ``room`` record of ``ACADMCP_ARCH`` - so a room schedule is
a read of the drawing, and the number on the sheet is the number the geometry
gives.
A door or a window cuts its wall, so the two rooms either side of a door would
read as one face through the gap. The opening records on the drawing close it:
for each opening, the wall's two face lines are continued across the gap - in
memory only, never drawn - which is the convention a net floor area is measured
to (the wall face, not the reveal). A plan without our records has no such
lines, and `rooms_detect` says so rather than guessing where a door was.
`rooms_detect` runs the same finder on any lines and lightweight polylines -
a foreign plan - and reports each face with a confidence, the way `pid_graph`
does: 1.0 when every edge of the face lies on our wall layer, 0.6 when any
edge is a plain line from somewhere else. It never modifies the drawing. 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:
* The label text heights (3.5 mm name, 2.5 mm number and area, on paper) are
two values of the ISO 3098-1 series; which line gets which is this module's
choice. The line pitch of 1.6 x the text height is this module's choice too.
* ``MIN_ROOM_WIDTH``: a face whose mean width (2 x area / perimeter) is under
600 mm is taken for a wall body or an opening reveal, not a room. That is
this reader's heuristic, not a building rule - a 200 mm wall ring measures
200, a 900 mm corridor 5 m long measures 763.
"""
from __future__ import annotations
import math
from dataclasses import replace
from typing import TYPE_CHECKING
from engineering.arch.faces import DEFAULT_TOL, Face, face_containing, planar_faces, tagged_faces
from engineering.arch.lang import LANGS, fmt_area_m2
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.arch.model import (
APP_ID,
Opening,
Room,
Stair,
Wall,
decode,
from_payload,
room_from_dict,
segment_of,
to_payload,
to_values,
)
from engineering.measure import polygon_area_perimeter
from engineering.mech.primitives import Pt, Text
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"CONFIDENCE_FOREIGN",
"CONFIDENCE_OURS",
"DEFAULT_MIN_AREA",
"LINE_PITCH",
"MIN_ROOM_WIDTH",
"NAME_HEIGHT",
"TEXT_HEIGHT",
"WALL_LAYER_KEYWORDS",
"detect_rooms",
"label_room",
"mean_width",
"opening_closures",
"read_records",
"read_segments",
"room_label_prims",
"rooms_detect",
]
NAME_HEIGHT = 3.5 # mm on paper (ISO 3098-1 series value)
TEXT_HEIGHT = 2.5 # mm on paper (ISO 3098-1 series value)
LINE_PITCH = 1.6 # baseline step, x the height of the line being placed
MIN_ROOM_WIDTH = 600.0 # mm; the wall-body heuristic above
DEFAULT_MIN_AREA = 1.0e6 # mm^2 = 1 m^2
CONFIDENCE_OURS = 1.0
CONFIDENCE_FOREIGN = 0.6
#: A layer whose name contains one of these (any case) is read by default.
WALL_LAYER_KEYWORDS = ("WALL", "DUVAR")
#: Entity types that bound a region with a curve; the finder reads straight
#: segments only, so these are reported as skipped, never approximated.
CURVE_TYPES = frozenset({"ARC", "CIRCLE", "ELLIPSE", "SPLINE"})
#: 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:
if lang not in LANGS:
raise ValueError(
f"lang: {lang!r} is not a label language; languages are {', '.join(LANGS)}"
)
return lang
def _positive(value, where: str) -> float:
try:
number = float(value)
except (TypeError, ValueError):
raise ValueError(f"{where}: expected a number, got {value!r}") from None
if not math.isfinite(number) or number <= 0.0:
raise ValueError(f"{where}: must be a finite number greater than zero, got {value!r}")
return number
def room_label_prims(room: Room, *, lang: str = "en", scale=50) -> tuple[Text, ...]:
"""Name, number (when there is one) and measured area, stacked down from ``room.at``.
Heights are paper millimetres times the plot-scale denominator: at 1:50 the
3.5 mm name is 175 drawing units high. The first baseline is ``room.at``;
each next one is 1.6 x its own height lower.
"""
_check_lang(lang)
factor = _positive(scale, "scale")
x, y = float(room.at[0]), float(room.at[1])
lines = [(room.name, NAME_HEIGHT * factor)]
if room.number:
lines.append((str(room.number), TEXT_HEIGHT * factor))
lines.append((fmt_area_m2(room.area, lang), TEXT_HEIGHT * factor))
prims: list[Text] = []
baseline = y
for index, (text, height) in enumerate(lines):
if index:
baseline -= LINE_PITCH * height
prims.append(Text(at=(x, baseline), text=text, height=height, rotation=0.0, role="room"))
return tuple(prims)
def _face_offsets(wall: Wall) -> tuple[float, float]:
"""Signed distances of the wall's two faces from its axis, + to the left."""
t = float(wall.thickness)
if wall.justification == "left":
return (0.0, t)
if wall.justification == "right":
return (-t, 0.0)
return (-t / 2.0, t / 2.0)
def opening_closures(walls, openings) -> tuple[tuple[tuple[Pt, Pt], ...], tuple[dict, ...]]:
"""The wall-face lines continued across every opening, and what could not be closed.
For an opening at ``offset`` of ``width`` on its wall, the segment of the
axis it sits on gives the direction ``u`` and the left normal ``n``; each
face line runs from ``start + n*d`` to ``start + u*width + n*d`` for the
face's signed offset ``d``. An opening whose wall has no record is returned
in ``omitted`` - its gap stays open - never guessed.
"""
by_id = {wall.id: wall for wall in walls}
closures: list[tuple[Pt, Pt]] = []
omitted: list[dict] = []
for opening in openings:
wall = by_id.get(opening.wall)
if wall is None:
omitted.append(
{
"element": opening.id,
"reason": f"host wall {opening.wall!r} has no record on this drawing; "
"its gap is left open",
}
)
continue
try:
index, along = segment_of(wall, float(opening.offset))
except ValueError as exc:
# a stale record (its wall was shortened since): the reader is
# read-only and answers for every other room, so it says so here
omitted.append({"element": opening.id, "reason": f"{exc}; its gap is left open"})
continue
axis = [(float(p[0]), float(p[1])) for p in wall.axis]
p0, p1 = axis[index], axis[(index + 1) % len(axis)]
length = math.dist(p0, p1)
ux, uy = (p1[0] - p0[0]) / length, (p1[1] - p0[1]) / length
nx, ny = -uy, ux
sx, sy = p0[0] + ux * along, p0[1] + uy * along
ex, ey = sx + ux * float(opening.width), sy + uy * float(opening.width)
for d in _face_offsets(wall):
closures.append(((sx + nx * d, sy + ny * d), (ex + nx * d, ey + ny * d)))
return tuple(closures), tuple(omitted)
def mean_width(face: Face) -> float:
"""2 x area / perimeter: the thickness of a long thin face, holes included."""
perimeter = sum(
polygon_area_perimeter([(x, y, 0.0) for x, y in ring], True)[1]
for ring in (face.loop, *face.holes)
)
return 2.0 * face.area / perimeter if perimeter > 0.0 else 0.0
def detect_rooms(
segments,
*,
min_area: float = DEFAULT_MIN_AREA,
tol: float = DEFAULT_TOL,
min_width: float = MIN_ROOM_WIDTH,
) -> tuple[dict, ...]:
"""Faces of at least ``min_area`` mm^2 and ``min_width`` mean width, with a confidence.
``segments`` are ``(p1, p2)`` or ``(p1, p2, layer)``. A face scores 1.0
when every edge of it (holes included) is covered by a segment on
``ARCH_ROLE_LAYER["wall"]``, and 0.6 otherwise - an untagged segment is a
plain line from somewhere else. The minimum of the inputs, never raised by
agreement: one foreign edge is enough to make the face 0.6.
"""
min_area = _positive(min_area, "min_area")
min_width = _positive(min_width, "min_width")
wall = ARCH_ROLE_LAYER["wall"].casefold()
rooms: list[dict] = []
for face, tags in tagged_faces(segments, tol=tol):
if face.area < min_area or mean_width(face) < min_width:
continue
ours = all(any(isinstance(t, str) and t.casefold() == wall for t in edge) for edge in tags)
rooms.append(
{
"loop": face.loop,
"holes": face.holes,
"area": face.area,
"centroid": face.centroid,
"confidence": CONFIDENCE_OURS if ours else CONFIDENCE_FOREIGN,
}
)
return tuple(rooms)
async def read_records(backend: AutoCADBackend) -> dict:
"""Every ``ACADMCP_ARCH`` record on the drawing's architectural layers.
The shape `read_plan` (Task 4) returns - ``walls``, ``openings``, ``stairs``,
``rooms`` as model dataclasses - plus ``room_handles`` (room id -> the
handle of the label that carries it). The current space is paged through
once, not sampled and not once per layer: a record past the 200th entity is
still a record, and a live seat, which pays a cross-process call per entity
per scan, scans once rather than once for each of the twelve arch layers.
Entities on the ``ARCH_ROLE_LAYER`` layers (any case) are read in layer-name
order, drawing order within a layer. XDATA that is not ours or does not
decode is skipped - this reader never guesses a record - and counted in
``unreadable``. A record that appears on several entities (a wall on each
of its outlines) is kept once, by kind and id.
"""
from engineering.sheet.bom import all_entities # lazy: sheet imports stay out of arch
found: dict[str, list] = {"walls": [], "openings": [], "stairs": [], "rooms": []}
room_handles: dict[str, str] = {}
seen: set[tuple[str, str]] = set()
unreadable = 0
buckets = {Wall: "walls", Opening: "openings", Stair: "stairs", Room: "rooms"}
arch_layers = {name.casefold(): name for name in ARCH_ROLE_LAYER.values()}
ours = [
entity
for entity in await all_entities(backend)
if str(entity.layer or "").casefold() in arch_layers
]
ours.sort(key=lambda entity: arch_layers[str(entity.layer).casefold()]) # stable
for entity in ours:
raw = await backend.entity_get_xdata(entity.handle, APP_ID)
values = (raw.get("xdata") or {}).get(APP_ID) or []
if not values:
continue
try:
obj = from_payload(decode(values))
except (ValueError, KeyError, TypeError):
obj = None
bucket = buckets.get(type(obj))
if bucket is None:
unreadable += 1
continue
key = (bucket, obj.id)
if key in seen:
continue
seen.add(key)
found[bucket].append(obj)
if bucket == "rooms":
room_handles[obj.id] = entity.handle
return {**found, "room_handles": room_handles, "unreadable": unreadable}
async def read_segments(backend: AutoCADBackend, layers) -> tuple[list, list[dict]]:
"""Straight segments on ``layers`` as ``(p1, p2, layer)``, and what was skipped.
Read: LINE, and the lightweight polyline by its ``points`` (the live engine
names it POLYLINE, the headless one LWPOLYLINE). Everything else that can
bound a room is listed in the second return value with its handle, type and
reason - never dropped, never guessed:
* 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
segments: list = []
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 entity in await all_entities(backend, layer=layer):
props = entity.properties or {}
kind = str(entity.type).upper()
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))
elif kind in LIGHT_POLYLINE_TYPES and props.get("points"):
points = [tuple(p[:2]) for p in props["points"]]
bulges = list(props.get("bulges") or [])
count = len(points)
arcs = 0
for i in range(count if props.get("closed") else count - 1):
if i < len(bulges) and abs(float(bulges[i])) > 1e-12:
arcs += 1
continue
segments.append((points[i], points[(i + 1) % count], entity.layer))
if arcs:
skip(entity, kind, f"{arcs} arc segment(s): {_ARC_REASON}")
elif kind in CURVE_TYPES:
skip(entity, kind, f"a curve: {_ARC_REASON}")
elif kind in HEAVY_POLYLINE_TYPES or kind == "POLYLINE":
skip(
entity,
kind,
"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
def _wcs_loop(loop) -> list[list[float]]:
return [[float(x), float(y)] for x, y in loop]
async def label_room(
backend: AutoCADBackend, room_dict: dict, *, lang: str = "en", scale=50
) -> dict:
"""Label the room around ``room_dict["at"]`` with its name, number and measured area.
Refused before anything is drawn: an ``area`` in the request (areas are
measured, never typed), an unknown ``lang``, a non-positive ``scale``, an
``id`` already labelled on the drawing, a point that lies in no closed face
of the wall layer, a point inside a wall body, and a point in a face that
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):
raise ValueError(
f"room: expected an object with name and at, got {type(room_dict).__name__}"
)
if "area" in room_dict:
raise ValueError(
"room.area: a room's area is measured from its walls, never typed; remove it"
)
_check_lang(lang)
_positive(scale, "scale")
records = await read_records(backend)
taken = {room.id for room in records["rooms"]}
data = dict(room_dict)
data.setdefault("number", None)
if not data.get("id"):
number = len(taken) + 1
while f"R{number}" in taken:
number += 1
data["id"] = f"R{number}"
room = room_from_dict(data, "room")
if room.id in taken:
raise ValueError(
f"room.id: {room.id!r} is already labelled on this drawing "
f"(label {records['room_handles'][room.id]}); choose another id"
)
wall_layer = ARCH_ROLE_LAYER["wall"]
segments, skipped = await read_segments(backend, [wall_layer])
closures, omitted = opening_closures(records["walls"], records["openings"])
faces = planar_faces(
[*segments, *((p1, p2, wall_layer) for p1, p2 in closures)], tol=DEFAULT_TOL
)
x, y = float(room.at[0]), float(room.at[1])
face = face_containing(faces, (x, y))
if face is None:
raise ValueError(
f"room.at: ({x:g}, {y:g}) lies in no closed face of the {len(segments)} wall "
f"segment(s) on {wall_layer}; close the walls around it first (arch_wall), or read "
"a plan drawn by other means with arch_rooms_detect"
)
width = mean_width(face)
if width < MIN_ROOM_WIDTH:
raise ValueError(
f"room.at: ({x:g}, {y:g}) lies inside a wall body (a face {width:.0f} mm wide on "
f"average, under the {MIN_ROOM_WIDTH:.0f} mm a room is taken to need), not in a room"
)
# 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)
from engineering.mech.draw import draw_prims # lazy: mech.draw imports the arch layer set
drawn = await draw_prims(
backend, room_label_prims(measured, lang=lang, scale=scale), role_layer=ARCH_ROLE_LAYER
)
handle = drawn["handles"][0]
await backend.entity_set_xdata(handle, APP_ID, to_values(to_payload(measured)))
return {
"ok": True,
"room": {
"id": measured.id,
"name": measured.name,
"number": measured.number,
"at": [x, y],
"area": measured.area,
"area_m2": measured.area / 1.0e6,
"area_text": fmt_area_m2(measured.area, lang),
},
"handle": handle,
"handles": list(drawn["handles"]),
"layer": ARCH_ROLE_LAYER["room"],
"loop": _wcs_loop(face.loop),
"holes": [_wcs_loop(hole) for hole in face.holes],
"wall_segments": len(segments),
"closures": len(closures),
"skipped": skipped,
"omitted": list(omitted),
}
async def rooms_detect(
backend: AutoCADBackend,
*,
layers=None,
min_area: float = DEFAULT_MIN_AREA,
tol: float = DEFAULT_TOL,
) -> dict:
"""Read the rooms of any plan: faces of the lines on ``layers``, measured, never drawn.
``layers=None`` reads every layer whose name contains WALL or DUVAR (any
case). A named layer the drawing does not have is refused with the list of
the ones it does. Our own opening records, when there are any, close door
and window gaps exactly as `label_room` does; a foreign plan's gaps stay
open, and two rooms joined by an open doorway read as one face.
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")
tol = _positive(tol, "tol")
available = [layer.name for layer in await backend.layer_list()]
if layers is None:
chosen = [
name for name in available if any(key in name.upper() for key in WALL_LAYER_KEYWORDS)
]
if not chosen:
raise ValueError(
"layers: no layer name contains "
f"{' or '.join(WALL_LAYER_KEYWORDS)}; name the layers the walls are on "
f"(layers are {', '.join(sorted(available))})"
)
else:
lookup = {name.casefold(): name for name in available}
missing = [str(name) for name in layers if str(name).casefold() not in lookup]
if missing:
raise ValueError(
f"layers: {', '.join(missing)} not in this drawing; "
f"layers are {', '.join(sorted(available))}"
)
chosen = [lookup[str(name).casefold()] for name in layers]
segments, skipped = await read_segments(backend, chosen)
records = await read_records(backend)
closures, omitted = opening_closures(records["walls"], records["openings"])
wall_layer = ARCH_ROLE_LAYER["wall"]
extra = [(p1, p2, wall_layer) for p1, p2 in closures]
rooms = detect_rooms([*segments, *extra], min_area=min_area, tol=tol)
out: list[dict] = []
conflicts: list[dict] = []
for room in rooms:
face = Face(
loop=room["loop"], area=room["area"], centroid=room["centroid"], holes=room["holes"]
)
labels = [
{"id": r.id, "name": r.name, "number": r.number, "area": r.area}
for r in records["rooms"]
if face_containing((face,), r.at) is not None
]
if len(labels) > 1:
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(
{
"loop": _wcs_loop(room["loop"]),
"holes": [_wcs_loop(hole) for hole in room["holes"]],
"area": room["area"],
"area_m2": room["area"] / 1.0e6,
"centroid": [room["centroid"][0], room["centroid"][1]],
"confidence": room["confidence"],
"label": labels[0] if labels else None,
"labels": labels,
}
)
return {
"ok": True,
"modified": False,
"layers": chosen,
"segments": len(segments),
"closures": len(extra),
"count": len(out),
"rooms": out,
"confidence_min": min((room["confidence"] for room in out), default=None),
"label_conflicts": conflicts,
"skipped": skipped,
"omitted": list(omitted),
"min_area": min_area,
"tol": tol,
}