feat(understand): room frames, the nearest panel and possible packages — what the field test asked for

The takeoffs were run again on a real P&ID pair (kept outside the repository)
and compared with the hand-made workbooks. The user chose how three gaps
should close:

- Rooms drawn as frames. A room label that no wall face holds takes the
  smallest closed, straight-sided polyline around it, provided it holds no
  other room number and is at least ten label heights a side (label+frame,
  0.8). A P&ID draws its rooms this way, so P&ID lengths are now cut at the
  frames rather than sent to the nearest label. An unnumbered mention inside
  a frame ('hot room') does not deny it.
- The nearest panel, flagged. cable_takeoff(panel_rule="nearest") is the
  default: a load that no callout places takes the nearest panel on the
  layout, among names with a panel prefix (is_panel_name: CP, JB, DB,
  MCC ...) or names a callout gives. The row is flagged panel_inferred, and
  an inferred panel never makes a package. panel_rule="stated" keeps the old
  behaviour. The takeoff_roundtrip gate and the cable correctness check read
  stated panels only: a guess is no evidence.
- Possible packages and detail copies. pipe_takeoff reports detail_copies:
  compact groups of tags drawn twice (repeated), and compact groups of one
  equipment's own parts, such as M150B, M150C and M150D of M150 (family). A
  vendor package or an equipment detail shows up this way. Nothing is
  removed until exclude_regions names a region. The runs left out go to the
  control rows as excluded_region and to excluded_m. The workbook's method
  sheet names the regions.

Each change has a synthetic test that failed first. Suite: 4986 passed, 1
xfailed. The inventory and the document numbers match (README: 4987
collected).

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-24 17:18:18 +03:00
co-authored by Claude Opus 5.5
parent db5b4d3ef8
commit 554daff8d0
14 changed files with 589 additions and 18 deletions
+91 -8
View File
@@ -52,6 +52,11 @@ ATTRIBUTE_TAG_CONFIDENCE = 0.95
TEXT_TAG_CONFIDENCE = 0.8
ROOM_FACE_CONFIDENCE = 0.9
ROOM_LABEL_CONFIDENCE = 0.6
#: A room read from a closed frame around its label: weaker than wall faces.
ROOM_FRAME_CONFIDENCE = 0.8
#: A frame is a room only when each side is at least this many label text
#: heights long: a box drawn tight around the label is no room.
FRAME_MIN_TEXT_HEIGHTS = 10.0
TITLE_NAME_CONFIDENCE = 0.9
TITLE_ATTRIBUTE_CONFIDENCE = 0.6
#: The face finder pairs every wall segment with every other; above this many
@@ -178,8 +183,12 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
A label is any text `vocab.room_label` recognises. Wall layers are the
ones `vocab.classify_layer` files under ``architecture``; their straight
segments go through `engineering.arch.faces.planar_faces` (arcs are left
out and counted). ``labelled`` pairs each room row with its `Face` for a
caller that needs the geometry; ``rooms`` is JSON-ready.
out and counted). A label no wall face holds takes the smallest closed,
straight-sided polyline around it that holds no other room label and is at
least ``FRAME_MIN_TEXT_HEIGHTS`` label heights a side - how a P&ID draws
its rooms - at ``ROOM_FRAME_CONFIDENCE``. ``labelled`` pairs each room row
with its `Face` for a caller that needs the geometry; ``rooms`` is
JSON-ready.
"""
labels = []
for rec in records:
@@ -205,8 +214,12 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
rooms: list[dict] = []
labelled: list[tuple[dict, Face]] = []
used: set[int] = set()
frames = _frames(records)
for rec, parsed, at in labels:
face = face_containing(faces, at) if faces else None
from_frame = None
if face is None and frames:
from_frame = _frame_around(rec, parsed, at, frames, labels)
row = {
"number": parsed.get("number"),
"name": parsed.get("name"),
@@ -221,14 +234,16 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
}
if face is not None:
used.add(id(face))
chosen = face or from_frame
if chosen is not None:
row["face"] = {
"area": face.area,
"polygon": [[p[0], p[1]] for p in face.loop],
"centroid": [face.centroid[0], face.centroid[1]],
"area": chosen.area,
"polygon": [[p[0], p[1]] for p in chosen.loop],
"centroid": [chosen.centroid[0], chosen.centroid[1]],
}
row["source"] = "label+face"
row["confidence"] = ROOM_FACE_CONFIDENCE
labelled.append((row, face))
row["source"] = "label+face" if face is not None else "label+frame"
row["confidence"] = ROOM_FACE_CONFIDENCE if face is not None else ROOM_FRAME_CONFIDENCE
labelled.append((row, chosen))
rooms.append(row)
rooms.sort(key=lambda r: (r["cluster"] or "", r["number"] or "", r["name"] or "", r["at"]))
return {
@@ -242,6 +257,74 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
}
def _frames(records: Sequence[EntityRecord]) -> list[tuple[EntityRecord, Face]]:
"""Closed, straight-sided polylines as faces: the frames a room may be drawn as."""
out = []
for rec in records:
if rec.type not in ("LWPOLYLINE", "POLYLINE") or not rec.closed or len(rec.points) < 3:
continue
if any(rec.bulges):
continue
face = _polygon_face(rec.points)
if face is not None:
out.append((rec, face))
return out
def _polygon_face(points) -> Face | None:
pts = [(float(x), float(y)) for x, y in points]
signed = 0.0
cx = cy = 0.0
for (x0, y0), (x1, y1) in zip(pts, pts[1:] + pts[:1], strict=True):
cross = x0 * y1 - x1 * y0
signed += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
signed /= 2.0
if abs(signed) <= 0.0:
return None
centroid = (cx / (6.0 * signed), cy / (6.0 * signed))
if signed < 0.0:
pts.reverse()
start = min(range(len(pts)), key=lambda i: (pts[i][1], pts[i][0]))
loop = tuple(pts[start:] + pts[:start])
return Face(loop=loop, area=abs(signed), centroid=centroid)
def _frame_around(rec, parsed, at, frames, labels) -> Face | None:
"""The smallest frame around ``at`` that is a room: no label of another room
number in it (an unnumbered mention - 'hot room' - does not count), each side
at least FRAME_MIN_TEXT_HEIGHTS label heights."""
least = FRAME_MIN_TEXT_HEIGHTS * float(rec.height or 0.0)
best = None
for frame_rec, face in frames:
box = frame_rec.bbox or (
min(p[0] for p in face.loop),
min(p[1] for p in face.loop),
max(p[0] for p in face.loop),
max(p[1] for p in face.loop),
)
if not (box[0] <= at[0] <= box[2] and box[1] <= at[1] <= box[3]):
continue
if min(box[2] - box[0], box[3] - box[1]) < least:
continue
if face_containing([face], at) is None:
continue
others = [
other_at
for other, other_parsed, other_at in labels
if other is not rec
and other_parsed["number"]
and other_parsed["number"] != parsed["number"]
and face_containing([face], other_at) is not None
]
if others:
continue
if best is None or face.area < best.area:
best = face
return best
def drawing_units(snap: Snapshot, *, prep: dict | None = None, rooms: dict | None = None) -> dict:
"""`units.infer_units` the one way every reader calls it: on the records kept
after the outliers, with the labelled room faces as evidence.