engineering/arch/faces.py finds the closed faces a set of straight segments bounds: endpoints within tol merge, every segment splits where another crosses or touches it (the T-junction a partition makes), dangling edges are pruned, and the half-edge walk turns to the next edge clockwise so every bounded face comes out counter-clockwise. A piece nested inside a face of another piece is a hole in it, so a ring of walls measures its body and a room with a column measures its floor. Areas come from engineering.measure.polygon_area_perimeter, the arithmetic analysis_measure_entity reports. engineering/arch/rooms.py labels a room with the area of the face its point lies in - a 4000 x 5000 room between 200 mm walls reads 20.00 m2, never the axis area - and writes the room record onto the name text. Opening records close door and window gaps along the wall faces, so two rooms either side of a door stay two rooms. rooms_detect reads any plan's lines, drops faces under min_area and thin wall bodies (mean width under 600 mm, a declared heuristic), reports confidence 1.0 for faces enclosed only by our wall layer and 0.6 otherwise, lists arcs as skipped rather than flattening them, and never modifies the drawing. Two hardenings beyond the plan text, each with a test: read_records pages the current space once and filters to the arch layers locally (the plan paged it once per arch layer - twelve full scans, each a cross-process call per entity on a live seat), keeping the same layer-then-drawing order; and an opening record whose offset falls outside its wall (a stale record) is listed in omitted instead of raising out of the read-only detector. No new ActiveX member is called: both tools compose entity_list, entity_get/set_xdata, layer_list and draw_prims, whose COM paths are already live-verified. SECTION 25 opens on this branch with arch_room and arch_rooms_detect under the rooms marker; until Task 9 gives arch its group they file under entity_creation and entity_query. Alias records, a regenerated inventory, the README snapshot at 231 tools in 25 groups and the frozen group-size snapshot follow. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""The planar-face finder: straight segments in, the closed faces they bound out.
|
|
|
|
A room is the floor the walls enclose, so its area is a *face* of the planar
|
|
graph the wall outlines draw - never the axis rectangle and never a number the
|
|
caller types. This module finds those faces from plain segments, which is what
|
|
lets `arch_room` measure a room and `arch_rooms_detect` read a foreign plan made
|
|
of LINEs on both engines: the live engine has no `boundary_trace`, and it does
|
|
not need one.
|
|
|
|
The algorithm, in the order it runs:
|
|
|
|
1. **Merge.** Endpoints closer than ``tol`` become one vertex (the first one
|
|
seen keeps its coordinates), so a corner drawn 0.4 mm short still closes.
|
|
2. **Split.** Every segment is split at every point where another one crosses
|
|
it and at every vertex that lies on it within ``tol`` - the T-touch, where a
|
|
partition wall stops on the face of the wall it meets. Collinear overlaps
|
|
fall out of the same rule: each overlapping end splits the other segment and
|
|
the coincident pieces become one edge.
|
|
3. **Prune.** An edge that ends in a vertex of degree one bounds nothing - a
|
|
dangling line, an overshoot past a crossing - and is removed, repeatedly,
|
|
until no such edge is left.
|
|
4. **Walk.** Each undirected edge is two half-edges. From the half-edge
|
|
``u -> v`` the walk continues at ``v`` along the next edge *clockwise* from
|
|
``v -> u``, which keeps the face on the left. A bounded face comes out
|
|
counter-clockwise (positive signed area); the outer boundary of each
|
|
connected piece comes out clockwise.
|
|
5. **Nest.** A clockwise boundary that lies inside a bounded face of *another*
|
|
piece is a hole in the smallest such face: the island a column makes in a
|
|
room, or the whole inner outline of a ring of walls inside its outer outline.
|
|
A face's ``area`` is its loop's area minus its holes'.
|
|
|
|
Areas come from `engineering.measure.polygon_area_perimeter`, the same
|
|
arithmetic `analysis_measure_entity` reports; this module computes the *sign* of
|
|
the shoelace itself only to tell a face from a boundary, because the shared
|
|
function returns the magnitude by design.
|
|
|
|
Pairs of segments are tested against each other, which is quadratic: a floor
|
|
plan of a few thousand wall segments is well inside it, a city block is not.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections.abc import Iterable, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
from engineering.measure import polygon_area_perimeter
|
|
|
|
Pt = tuple[float, float]
|
|
|
|
__all__ = ["DEFAULT_TOL", "Face", "face_containing", "planar_faces", "tagged_faces"]
|
|
|
|
#: Endpoints closer than this (drawing units, mm) are one vertex.
|
|
DEFAULT_TOL = 1.0
|
|
|
|
_EPS = 1e-12
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Face:
|
|
"""One bounded face.
|
|
|
|
``loop`` is counter-clockwise and starts at its lowest, then leftmost,
|
|
vertex. ``holes`` are the clockwise outer boundaries of the pieces nested
|
|
inside it. ``area`` (mm^2) is the loop's area minus the holes'; ``centroid``
|
|
is the centroid of that net region.
|
|
"""
|
|
|
|
loop: tuple[Pt, ...]
|
|
area: float
|
|
centroid: Pt
|
|
holes: tuple[tuple[Pt, ...], ...] = ()
|
|
|
|
|
|
class _Vertices:
|
|
"""Snap points to vertices: a point within ``tol`` of a vertex *is* that vertex."""
|
|
|
|
def __init__(self, tol: float) -> None:
|
|
self.tol = tol
|
|
self.points: list[Pt] = []
|
|
self._grid: dict[tuple[int, int], list[int]] = {}
|
|
|
|
def _cell(self, p: Pt) -> tuple[int, int]:
|
|
return (math.floor(p[0] / self.tol), math.floor(p[1] / self.tol))
|
|
|
|
def index(self, p: Pt) -> int:
|
|
cx, cy = self._cell(p)
|
|
best: int | None = None
|
|
best_d = self.tol
|
|
for dx in (-1, 0, 1):
|
|
for dy in (-1, 0, 1):
|
|
for i in self._grid.get((cx + dx, cy + dy), ()):
|
|
d = math.dist(self.points[i], p)
|
|
if d <= best_d:
|
|
best, best_d = i, d
|
|
if best is not None:
|
|
return best
|
|
i = len(self.points)
|
|
self.points.append((float(p[0]), float(p[1])))
|
|
self._grid.setdefault((cx, cy), []).append(i)
|
|
return i
|
|
|
|
|
|
def _number(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):
|
|
raise ValueError(f"{where}: must be finite, got {value!r}")
|
|
return number
|
|
|
|
|
|
def _point(value, where: str) -> Pt:
|
|
try:
|
|
x, y = value[0], value[1]
|
|
except (TypeError, IndexError, KeyError):
|
|
raise ValueError(f"{where}: expected [x, y], got {value!r}") from None
|
|
return (_number(x, f"{where}[0]"), _number(y, f"{where}[1]"))
|
|
|
|
|
|
def _segments(segments: Iterable) -> list[tuple[Pt, Pt, object]]:
|
|
"""``(p1, p2)`` or ``(p1, p2, tag)``; the tag rides along to the edges."""
|
|
out: list[tuple[Pt, Pt, object]] = []
|
|
for i, seg in enumerate(segments):
|
|
try:
|
|
count = len(seg)
|
|
except TypeError:
|
|
raise ValueError(f"segments[{i}]: expected (p1, p2) or (p1, p2, tag)") from None
|
|
if count not in (2, 3):
|
|
raise ValueError(
|
|
f"segments[{i}]: expected (p1, p2) or (p1, p2, tag), got {count} items"
|
|
)
|
|
tag = seg[2] if count == 3 else None
|
|
out.append((_point(seg[0], f"segments[{i}][0]"), _point(seg[1], f"segments[{i}][1]"), tag))
|
|
return out
|
|
|
|
|
|
def _cross(ax: float, ay: float, bx: float, by: float) -> float:
|
|
return ax * by - ay * bx
|
|
|
|
|
|
def _crossing(p1: Pt, p2: Pt, q1: Pt, q2: Pt) -> Pt | None:
|
|
"""Where two segments meet, ends included; None for parallel or apart."""
|
|
rx, ry = p2[0] - p1[0], p2[1] - p1[1]
|
|
sx, sy = q2[0] - q1[0], q2[1] - q1[1]
|
|
denom = _cross(rx, ry, sx, sy)
|
|
if abs(denom) <= _EPS * math.hypot(rx, ry) * math.hypot(sx, sy):
|
|
return None
|
|
qx, qy = q1[0] - p1[0], q1[1] - p1[1]
|
|
t = _cross(qx, qy, sx, sy) / denom
|
|
u = _cross(qx, qy, rx, ry) / denom
|
|
if -_EPS <= t <= 1.0 + _EPS and -_EPS <= u <= 1.0 + _EPS:
|
|
return (p1[0] + t * rx, p1[1] + t * ry)
|
|
return None
|
|
|
|
|
|
def _along(p: Pt, a: Pt, b: Pt) -> tuple[float, float]:
|
|
"""(distance along a->b of p's foot, distance of p from the line)."""
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
length = math.hypot(dx, dy)
|
|
ux, uy = dx / length, dy / length
|
|
px, py = p[0] - a[0], p[1] - a[1]
|
|
return (px * ux + py * uy, abs(_cross(ux, uy, px, py)))
|
|
|
|
|
|
def _signed_area(points: Sequence[Pt]) -> float:
|
|
total = 0.0
|
|
for i in range(len(points)):
|
|
x1, y1 = points[i]
|
|
x2, y2 = points[(i + 1) % len(points)]
|
|
total += x1 * y2 - x2 * y1
|
|
return total / 2.0
|
|
|
|
|
|
def _area(points: Sequence[Pt]) -> float:
|
|
return polygon_area_perimeter([(x, y, 0.0) for x, y in points], True)[0]
|
|
|
|
|
|
def _centroid(points: Sequence[Pt]) -> tuple[Pt, float]:
|
|
"""(centroid, signed area) of a simple loop; a zero-area loop answers its vertex mean."""
|
|
signed = _signed_area(points)
|
|
if abs(signed) < _EPS:
|
|
n = len(points)
|
|
return (sum(p[0] for p in points) / n, sum(p[1] for p in points) / n), 0.0
|
|
cx = cy = 0.0
|
|
for i in range(len(points)):
|
|
x1, y1 = points[i]
|
|
x2, y2 = points[(i + 1) % len(points)]
|
|
w = x1 * y2 - x2 * y1
|
|
cx += (x1 + x2) * w
|
|
cy += (y1 + y2) * w
|
|
return (cx / (6.0 * signed), cy / (6.0 * signed)), signed
|
|
|
|
|
|
def _inside(point: Pt, loop: Sequence[Pt]) -> bool:
|
|
"""Even-odd ray cast; a point exactly on an edge may answer either way."""
|
|
x, y = point
|
|
inside = False
|
|
n = len(loop)
|
|
for i in range(n):
|
|
x1, y1 = loop[i]
|
|
x2, y2 = loop[(i + 1) % n]
|
|
if (y1 > y) != (y2 > y):
|
|
xs = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
|
|
if xs > x:
|
|
inside = not inside
|
|
return inside
|
|
|
|
|
|
def _start_low_left(loop: list[int], points: list[Pt]) -> list[int]:
|
|
k = min(range(len(loop)), key=lambda i: (points[loop[i]][1], points[loop[i]][0]))
|
|
return loop[k:] + loop[:k]
|
|
|
|
|
|
def tagged_faces(
|
|
segments, *, tol: float = DEFAULT_TOL
|
|
) -> tuple[tuple[Face, tuple[frozenset, ...]], ...]:
|
|
"""`planar_faces`, plus, per face, the tag set of every edge it is bounded by.
|
|
|
|
An edge carries the tags of every input segment that covers it, so a reader
|
|
can ask whether a face is enclosed *only* by segments of a kind - which is
|
|
what `detect_rooms` turns into a confidence.
|
|
"""
|
|
tol = _number(tol, "tol")
|
|
if tol <= 0.0:
|
|
raise ValueError(f"tol: must be greater than zero, got {tol:g}")
|
|
raw = _segments(segments)
|
|
verts = _Vertices(tol)
|
|
|
|
# 1. merge endpoints
|
|
snapped: list[tuple[int, int, object]] = []
|
|
for p1, p2, tag in raw:
|
|
a, b = verts.index(p1), verts.index(p2)
|
|
if a != b:
|
|
snapped.append((a, b, tag))
|
|
|
|
# 2. split at crossings and at vertices lying on a segment
|
|
splits: list[set[int]] = [set() for _ in snapped]
|
|
boxes = []
|
|
for a, b, _tag in snapped:
|
|
(ax, ay), (bx, by) = verts.points[a], verts.points[b]
|
|
boxes.append((min(ax, bx) - tol, min(ay, by) - tol, max(ax, bx) + tol, max(ay, by) + tol))
|
|
for i in range(len(snapped)):
|
|
ai, bi, _ = snapped[i]
|
|
for j in range(i + 1, len(snapped)):
|
|
bx0, by0, bx1, by1 = boxes[j]
|
|
if boxes[i][0] > bx1 or bx0 > boxes[i][2] or boxes[i][1] > by1 or by0 > boxes[i][3]:
|
|
continue
|
|
aj, bj, _ = snapped[j]
|
|
hit = _crossing(verts.points[ai], verts.points[bi], verts.points[aj], verts.points[bj])
|
|
if hit is not None:
|
|
v = verts.index(hit)
|
|
splits[i].add(v)
|
|
splits[j].add(v)
|
|
for i, (a, b, _tag) in enumerate(snapped):
|
|
pa, pb = verts.points[a], verts.points[b]
|
|
length = math.dist(pa, pb)
|
|
x0, y0, x1, y1 = boxes[i]
|
|
for v, p in enumerate(verts.points):
|
|
if v in (a, b) or not (x0 <= p[0] <= x1 and y0 <= p[1] <= y1):
|
|
continue
|
|
s, off = _along(p, pa, pb)
|
|
if off <= tol and tol < s < length - tol:
|
|
splits[i].add(v)
|
|
|
|
edges: dict[tuple[int, int], set] = {}
|
|
for i, (a, b, tag) in enumerate(snapped):
|
|
pa, pb = verts.points[a], verts.points[b]
|
|
chain = (
|
|
[a]
|
|
+ sorted(
|
|
(v for v in splits[i] if v not in (a, b)),
|
|
key=lambda v: _along(verts.points[v], pa, pb)[0],
|
|
)
|
|
+ [b]
|
|
)
|
|
for u, v in zip(chain, chain[1:], strict=False):
|
|
if u != v:
|
|
edges.setdefault((min(u, v), max(u, v)), set()).add(tag)
|
|
|
|
# 3. prune dangling edges
|
|
adjacent: dict[int, set[int]] = {}
|
|
for u, v in edges:
|
|
adjacent.setdefault(u, set()).add(v)
|
|
adjacent.setdefault(v, set()).add(u)
|
|
loose = [v for v, ns in adjacent.items() if len(ns) == 1]
|
|
while loose:
|
|
v = loose.pop()
|
|
for w in list(adjacent.get(v, ())):
|
|
adjacent[w].discard(v)
|
|
adjacent[v].discard(w)
|
|
edges.pop((min(v, w), max(v, w)), None)
|
|
if len(adjacent[w]) == 1:
|
|
loose.append(w)
|
|
adjacent = {v: ns for v, ns in adjacent.items() if ns}
|
|
|
|
# 4. walk: the next half-edge is the next one clockwise at the far vertex
|
|
pts = verts.points
|
|
order: dict[int, list[int]] = {
|
|
v: sorted(ns, key=lambda w, v=v: math.atan2(pts[w][1] - pts[v][1], pts[w][0] - pts[v][0]))
|
|
for v, ns in adjacent.items()
|
|
}
|
|
seen: set[tuple[int, int]] = set()
|
|
cycles: list[list[int]] = []
|
|
for u, ns in adjacent.items():
|
|
for v in ns:
|
|
if (u, v) in seen:
|
|
continue
|
|
cycle: list[int] = []
|
|
a, b = u, v
|
|
while (a, b) not in seen:
|
|
seen.add((a, b))
|
|
cycle.append(a)
|
|
around = order[b]
|
|
a, b = b, around[around.index(a) - 1]
|
|
cycles.append(cycle)
|
|
|
|
component: dict[int, int] = {}
|
|
for start in adjacent:
|
|
if start in component:
|
|
continue
|
|
stack = [start]
|
|
component[start] = start
|
|
while stack:
|
|
v = stack.pop()
|
|
for w in adjacent[v]:
|
|
if w not in component:
|
|
component[w] = start
|
|
stack.append(w)
|
|
|
|
bounded: list[list[int]] = []
|
|
boundaries: list[list[int]] = []
|
|
for cycle in cycles:
|
|
signed = _signed_area([pts[v] for v in cycle])
|
|
if signed > _EPS:
|
|
bounded.append(_start_low_left(cycle, pts))
|
|
elif signed < -_EPS:
|
|
boundaries.append(cycle)
|
|
|
|
# 5. nest each piece's outer boundary in the smallest face of another piece
|
|
gross = [_area([pts[v] for v in loop]) for loop in bounded]
|
|
holes: list[list[list[int]]] = [[] for _ in bounded]
|
|
for boundary in boundaries:
|
|
probe = pts[boundary[0]]
|
|
home = None
|
|
for k, loop in enumerate(bounded):
|
|
if component[loop[0]] == component[boundary[0]]:
|
|
continue
|
|
if _inside(probe, [pts[v] for v in loop]) and (home is None or gross[k] < gross[home]):
|
|
home = k
|
|
if home is not None:
|
|
holes[home].append(_start_low_left(boundary, pts))
|
|
|
|
out: list[tuple[Face, tuple[frozenset, ...]]] = []
|
|
for k, loop in enumerate(bounded):
|
|
outer = [pts[v] for v in loop]
|
|
(gx, gy), g_signed = _centroid(outer)
|
|
net = gross[k]
|
|
mx, my, weight = gx * g_signed, gy * g_signed, g_signed
|
|
hole_loops = []
|
|
for hole in holes[k]:
|
|
ring = [pts[v] for v in hole]
|
|
(hx, hy), h_signed = _centroid(ring) # clockwise, so negative: it subtracts
|
|
net -= _area(ring)
|
|
mx, my, weight = mx + hx * h_signed, my + hy * h_signed, weight + h_signed
|
|
hole_loops.append(tuple(ring))
|
|
centroid = (mx / weight, my / weight) if abs(weight) > _EPS else (gx, gy)
|
|
tags = []
|
|
for ring in [loop, *holes[k]]:
|
|
for i in range(len(ring)):
|
|
u, v = ring[i], ring[(i + 1) % len(ring)]
|
|
tags.append(frozenset(edges[(min(u, v), max(u, v))]))
|
|
face = Face(loop=tuple(outer), area=net, centroid=centroid, holes=tuple(hole_loops))
|
|
out.append((face, tuple(tags)))
|
|
out.sort(key=lambda item: (item[0].loop[0][1], item[0].loop[0][0], item[0].area))
|
|
return tuple(out)
|
|
|
|
|
|
def planar_faces(segments, *, tol: float = DEFAULT_TOL) -> tuple[Face, ...]:
|
|
"""Every bounded face the segments enclose, CCW, net of nested pieces, in mm^2.
|
|
|
|
Refused by path, before anything is computed: a segment that is not
|
|
``(p1, p2)`` or ``(p1, p2, tag)``, a coordinate that is not a finite
|
|
number, a ``tol`` that is not positive.
|
|
"""
|
|
return tuple(face for face, _tags in tagged_faces(segments, tol=tol))
|
|
|
|
|
|
def face_containing(faces: Iterable[Face], point) -> Face | None:
|
|
"""The smallest face whose loop holds ``point`` and none of whose holes do."""
|
|
p = _point(point, "point")
|
|
found = [
|
|
face
|
|
for face in faces
|
|
if _inside(p, face.loop) and not any(_inside(p, hole) for hole in face.holes)
|
|
]
|
|
return min(found, key=lambda face: face.area) if found else None
|