A drawing somebody else made, read back as one report in which every claim carries its evidence and a confidence; the drawing is never modified. - units.py: $INSUNITS against the unit the geometry implies (text heights, wall thickness, door swings, labelled room areas, overall size). The header is overruled only when the evidence outweighs it, and then with a warning that quotes the numbers. Robust extents grow from the quartile box of entity centres; what never joins is an outlier, named by handle. - clusters.py: separate bodies of model-space content by raster, 8-connectivity and box absorption - two copies of one plan are two clusters. - describe.py: layers with drawn length and the vocabulary's meaning (name keyword, else the texts on the layer), equipment tags (one tag lettered twice beside its symbol merged, repeats flagged ambiguous), room labels in the wall-layer faces of engineering.arch.faces, text languages by character class, blocks, layouts, the title block. - server.py opens SECTION 26 (Understanding & QA) with drawing_understand: core, read-only, tagged analysis/query. Alias record, inventory, README release snapshot 242, frozen group sizes (analysis 13). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
195 lines
7.1 KiB
Python
195 lines
7.1 KiB
Python
"""Separate bodies of model-space content: sheets, plan copies, details.
|
|
|
|
A foreign model space is often several drawings side by side - two copies of
|
|
one plan, a detail beside it, a legend. Every reader after this one must know
|
|
which body a tag or a room belongs to, or it matches a tag in one copy against
|
|
the same tag in the other.
|
|
|
|
The algorithm, in the order it runs:
|
|
|
|
1. **Raster.** The box of the records is divided into square cells of
|
|
``cell`` drawing units (default :data:`CELL_FRACTION` of its diagonal, so
|
|
the grid never exceeds about 50 x 50 cells). Every record marks the cells
|
|
its box covers.
|
|
2. **Connect.** Occupied cells that touch, corners included, are one
|
|
component; a record belongs to the component of its cells. Two bodies stay
|
|
apart when an empty cell separates them - a gap of two cells is always
|
|
enough.
|
|
3. **Absorb.** Components whose boxes intersect are merged, repeatedly: a tag
|
|
floating in the middle of a room lies inside the plan's box and belongs to
|
|
the plan, not to a cluster of its own.
|
|
|
|
Ids are ``C1``, ``C2``, ... by entity count, largest first (ties left to right,
|
|
then bottom to top). Outliers are the caller's to exclude: one entity thousands of
|
|
kilometres away would otherwise set the cell size. Pure; the drawing is never touched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections.abc import Iterable, Sequence
|
|
|
|
from engineering.understand.snapshot import EntityRecord
|
|
from engineering.understand.units import entity_box
|
|
|
|
__all__ = ["CELL_FRACTION", "find_clusters"]
|
|
|
|
#: Cell side as a fraction of the content's diagonal.
|
|
CELL_FRACTION = 0.02
|
|
#: Labels listed per cluster.
|
|
LABELS_PER_CLUSTER = 5
|
|
|
|
_TEXT_TYPES = frozenset({"TEXT", "MTEXT", "MULTILEADER"})
|
|
|
|
|
|
def _intersect(a, b) -> bool:
|
|
return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
|
|
|
|
|
|
def _merge(a, b):
|
|
return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
|
|
|
|
|
|
def _root(parent: list[int], i: int) -> int:
|
|
"""Union-find root with path halving."""
|
|
while parent[i] != i:
|
|
parent[i] = parent[parent[i]]
|
|
i = parent[i]
|
|
return i
|
|
|
|
|
|
def _absorb(bodies: list[tuple[tuple, list[int]]]) -> list[tuple[tuple, list[int]]]:
|
|
"""Merge bodies whose boxes intersect until none do.
|
|
|
|
One pass is a sweep over boxes sorted by their left edge (a pair can only
|
|
intersect while the next left edge is inside the current box) feeding a
|
|
union-find; a merged box can reach a body it did not touch before, so
|
|
passes repeat until one merges nothing.
|
|
"""
|
|
while True:
|
|
order = sorted(range(len(bodies)), key=lambda i: bodies[i][0][0])
|
|
parent = list(range(len(bodies)))
|
|
joined = False
|
|
for n, i in enumerate(order):
|
|
box = bodies[i][0]
|
|
for j in order[n + 1 :]:
|
|
if bodies[j][0][0] > box[2]:
|
|
break
|
|
ri, rj = _root(parent, i), _root(parent, j)
|
|
if ri != rj and _intersect(box, bodies[j][0]):
|
|
parent[rj] = ri
|
|
joined = True
|
|
if not joined:
|
|
return bodies
|
|
merged: dict[int, tuple[tuple, list[int]]] = {}
|
|
for i, (box, members) in enumerate(bodies):
|
|
r = _root(parent, i)
|
|
if r in merged:
|
|
merged[r] = (_merge(merged[r][0], box), merged[r][1] + members)
|
|
else:
|
|
merged[r] = (box, list(members))
|
|
bodies = list(merged.values())
|
|
|
|
|
|
def find_clusters(
|
|
records: Sequence[EntityRecord],
|
|
*,
|
|
exclude: Iterable[str] = (),
|
|
cell: float | None = None,
|
|
) -> tuple[list[dict], dict[str, str]]:
|
|
"""``(clusters, membership)``: one row per body of content, and handle -> id.
|
|
|
|
A row is ``{"id", "box": [x0, y0, x1, y1], "size": [w, h], "entities",
|
|
"layers": [the three commonest], "labels": [the tallest texts]}``.
|
|
``exclude`` names handles to leave out (the outliers). A record without a
|
|
box (no geometry the snapshot could read) belongs to no cluster.
|
|
"""
|
|
skip = set(exclude)
|
|
items = [(rec, box) for rec in records if rec.handle not in skip and (box := entity_box(rec))]
|
|
if not items:
|
|
return [], {}
|
|
x0 = min(b[0] for _r, b in items)
|
|
y0 = min(b[1] for _r, b in items)
|
|
x1 = max(b[2] for _r, b in items)
|
|
y1 = max(b[3] for _r, b in items)
|
|
if cell is None:
|
|
diag = math.hypot(x1 - x0, y1 - y0)
|
|
cell = diag * CELL_FRACTION if diag > 0.0 else 1.0
|
|
if not cell > 0.0:
|
|
raise ValueError(f"cell: must be greater than zero, got {cell!r}")
|
|
|
|
def span(lo: float, hi: float, origin: float) -> range:
|
|
return range(
|
|
int(math.floor((lo - origin) / cell)), int(math.floor((hi - origin) / cell)) + 1
|
|
)
|
|
|
|
owner: dict[tuple[int, int], list[int]] = {}
|
|
first_cell: list[tuple[int, int]] = []
|
|
for k, (_rec, b) in enumerate(items):
|
|
cols, rows = span(b[0], b[2], x0), span(b[1], b[3], y0)
|
|
first_cell.append((cols[0], rows[0]))
|
|
for i in cols:
|
|
for j in rows:
|
|
owner.setdefault((i, j), []).append(k)
|
|
|
|
component: dict[tuple[int, int], int] = {}
|
|
count = 0
|
|
for start in owner:
|
|
if start in component:
|
|
continue
|
|
component[start] = count
|
|
stack = [start]
|
|
while stack:
|
|
ci, cj = stack.pop()
|
|
for di in (-1, 0, 1):
|
|
for dj in (-1, 0, 1):
|
|
nxt = (ci + di, cj + dj)
|
|
if nxt in owner and nxt not in component:
|
|
component[nxt] = count
|
|
stack.append(nxt)
|
|
count += 1
|
|
|
|
groups: dict[int, list[int]] = {}
|
|
for k in range(len(items)):
|
|
groups.setdefault(component[first_cell[k]], []).append(k)
|
|
bodies = []
|
|
for members in groups.values():
|
|
box = items[members[0]][1]
|
|
for k in members[1:]:
|
|
box = _merge(box, items[k][1])
|
|
bodies.append((box, members))
|
|
bodies = _absorb(bodies)
|
|
|
|
bodies.sort(key=lambda body: (-len(body[1]), body[0][0], body[0][1]))
|
|
clusters: list[dict] = []
|
|
membership: dict[str, str] = {}
|
|
for n, (box, members) in enumerate(bodies, start=1):
|
|
cid = f"C{n}"
|
|
layers: dict[str, int] = {}
|
|
texts = []
|
|
for k in members:
|
|
rec = items[k][0]
|
|
membership[rec.handle] = cid
|
|
layers[rec.layer] = layers.get(rec.layer, 0) + 1
|
|
if rec.type in _TEXT_TYPES and rec.text and rec.text.strip():
|
|
texts.append((-(rec.height or 0.0), rec.text.strip()))
|
|
labels: list[str] = []
|
|
for _h, text in sorted(texts):
|
|
if text not in labels:
|
|
labels.append(text)
|
|
if len(labels) == LABELS_PER_CLUSTER:
|
|
break
|
|
clusters.append(
|
|
{
|
|
"id": cid,
|
|
"box": list(box),
|
|
"size": [box[2] - box[0], box[3] - box[1]],
|
|
"entities": len(members),
|
|
"layers": [
|
|
name for name, _c in sorted(layers.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
|
|
],
|
|
"labels": labels,
|
|
}
|
|
)
|
|
return clusters, membership
|