merge: track F group R
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:
@@ -13,6 +13,7 @@ from .dimension import (
|
||||
STEP_PAPER_MM,
|
||||
exterior_chains,
|
||||
)
|
||||
from .faces import DEFAULT_TOL, Face, face_containing, planar_faces, tagged_faces
|
||||
from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab
|
||||
from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER
|
||||
from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch
|
||||
@@ -38,6 +39,15 @@ from .model import (
|
||||
wall_length,
|
||||
)
|
||||
from .openings import opening_prims
|
||||
from .rooms import (
|
||||
detect_rooms,
|
||||
label_room,
|
||||
opening_closures,
|
||||
read_records,
|
||||
room_label_prims,
|
||||
rooms_detect,
|
||||
)
|
||||
from .schedule import SCHEDULE_KINDS, draw_schedule, schedule_rows
|
||||
from .stairs import (
|
||||
ARROW_PAPER_MM,
|
||||
BLONDEL_RANGE,
|
||||
@@ -65,13 +75,16 @@ __all__ = [
|
||||
"ARCH_ROLE_LAYER",
|
||||
"ARROW_PAPER_MM",
|
||||
"BLONDEL_RANGE",
|
||||
"DEFAULT_TOL",
|
||||
"FIRST_OFFSET_PAPER_MM",
|
||||
"Face",
|
||||
"KINDS",
|
||||
"LANGS",
|
||||
"MIN_JOIN_ANGLE_DEG",
|
||||
"Opening",
|
||||
"ROWS",
|
||||
"Room",
|
||||
"SCHEDULE_KINDS",
|
||||
"SIDES",
|
||||
"STAIR_KINDS",
|
||||
"STEP_PAPER_MM",
|
||||
@@ -85,20 +98,31 @@ __all__ = [
|
||||
"axis_segments",
|
||||
"blondel",
|
||||
"decode",
|
||||
"detect_rooms",
|
||||
"draw_schedule",
|
||||
"encode",
|
||||
"exterior_chains",
|
||||
"face_containing",
|
||||
"face_offsets",
|
||||
"fmt_area_m2",
|
||||
"fmt_number",
|
||||
"from_payload",
|
||||
"label_room",
|
||||
"opening_closures",
|
||||
"opening_from_dict",
|
||||
"opening_prims",
|
||||
"planar_faces",
|
||||
"read_records",
|
||||
"room_from_dict",
|
||||
"room_label_prims",
|
||||
"room_segments",
|
||||
"rooms_detect",
|
||||
"schedule_rows",
|
||||
"segment_of",
|
||||
"stair_from_dict",
|
||||
"stair_prims",
|
||||
"tag_prefix",
|
||||
"tagged_faces",
|
||||
"to_payload",
|
||||
"to_values",
|
||||
"validate_openings",
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""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
|
||||
@@ -0,0 +1,598 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Door, window and room schedules - a read of the drawing, drawn as a real TABLE.
|
||||
|
||||
`schedule_rows` turns plan data (the shape `read_plan` returns: ``walls``,
|
||||
``openings``, ``rooms`` lists of model dataclasses) into rows; `draw_schedule`
|
||||
reads that data off the drawing's ``ACADMCP_ARCH`` records and draws it through
|
||||
the same `entity_create_table` path the ISO 7573 parts list takes
|
||||
(`engineering/sheet/bom.py::draw_bom_table`): insertion at the top-left corner,
|
||||
the table growing downwards, and the engine's representation reported rather
|
||||
than assumed - a real ACAD_TABLE live, a composite of rules and MTEXT headless.
|
||||
|
||||
Rows:
|
||||
|
||||
* doors - tag, width, height, swing, hand, wall; windows - tag, width,
|
||||
height, sill, wall. Sorted by tag, numbers compared as numbers (D2 before
|
||||
D10). An opening without a tag gets the next free ``<prefix><n>`` in wall
|
||||
order (the order of ``walls``, then the offset along the wall); an explicit
|
||||
tag is never renumbered and a generated one never repeats it.
|
||||
* rooms - number, name, area (the measured one the label carries), sorted by
|
||||
number with the unnumbered last, plus a total row.
|
||||
|
||||
A value the model does not carry (a door without a height) is an empty cell,
|
||||
never a default. Headers come from `vocab(lang)`; ``lang="tr"`` also writes
|
||||
decimal commas.
|
||||
|
||||
DECLARED, not standard: the paper column widths below and the reuse of the
|
||||
parts list's 7 mm row and 2.5 mm text (`engineering.sheet.bom.ROW_HEIGHT`,
|
||||
`TEXT_HEIGHT`) - no standard fixes a schedule's layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab
|
||||
from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import AutoCADBackend
|
||||
|
||||
__all__ = [
|
||||
"COLUMN_WIDTHS",
|
||||
"MAX_ROWS",
|
||||
"SCHEDULE_COLUMNS",
|
||||
"SCHEDULE_KINDS",
|
||||
"SCHEDULE_TITLES",
|
||||
"draw_schedule",
|
||||
"schedule_rows",
|
||||
]
|
||||
|
||||
SCHEDULE_KINDS: tuple[str, ...] = ("doors", "windows", "rooms")
|
||||
|
||||
SCHEDULE_COLUMNS: dict[str, tuple[str, ...]] = {
|
||||
"doors": ("tag", "width", "height", "swing", "hand", "wall"),
|
||||
"windows": ("tag", "width", "height", "sill", "wall"),
|
||||
"rooms": ("number", "name", "area"),
|
||||
}
|
||||
|
||||
#: The `vocab` key of each schedule's title row.
|
||||
SCHEDULE_TITLES: dict[str, str] = {
|
||||
"doors": "door_schedule",
|
||||
"windows": "window_schedule",
|
||||
"rooms": "room_schedule",
|
||||
}
|
||||
|
||||
#: Column widths in paper millimetres (x the scale denominator in the drawing).
|
||||
COLUMN_WIDTHS: dict[str, float] = {
|
||||
"tag": 15.0,
|
||||
"width": 20.0,
|
||||
"height": 20.0,
|
||||
"swing": 20.0,
|
||||
"hand": 20.0,
|
||||
"sill": 20.0,
|
||||
"wall": 20.0,
|
||||
"number": 15.0,
|
||||
"name": 45.0,
|
||||
"area": 25.0,
|
||||
}
|
||||
|
||||
#: `entity_create_table` takes at most 200 rows, the title and header included.
|
||||
MAX_ROWS = 198
|
||||
|
||||
_OPENING_KIND = {"doors": "door", "windows": "window"}
|
||||
|
||||
|
||||
def _check(kind: str, lang: str) -> None:
|
||||
if kind not in SCHEDULE_KINDS:
|
||||
raise ValueError(
|
||||
f"kind: {kind!r} is not a schedule; schedules are {', '.join(SCHEDULE_KINDS)}"
|
||||
)
|
||||
if lang not in LANGS:
|
||||
raise ValueError(
|
||||
f"lang: {lang!r} is not a schedule language; languages are {', '.join(LANGS)}"
|
||||
)
|
||||
|
||||
|
||||
def _natural(text: str) -> tuple:
|
||||
"""``D10`` after ``D2``: digit runs compare as numbers."""
|
||||
return tuple(
|
||||
(0, int(part), "") if part.isdigit() else (1, 0, part)
|
||||
for part in re.findall(r"\d+|\D+", str(text))
|
||||
)
|
||||
|
||||
|
||||
def _mm(value, lang: str) -> str:
|
||||
"""A length in mm: whole numbers without decimals, others to 0.1 mm; None is blank."""
|
||||
if value is None:
|
||||
return ""
|
||||
number = float(value)
|
||||
decimals = 0 if abs(number - round(number)) < 1e-9 else 1
|
||||
return fmt_number(number, lang, decimals)
|
||||
|
||||
|
||||
def _opening_rows(kind: str, plan: dict, lang: str) -> tuple[dict, ...]:
|
||||
words = vocab(lang)
|
||||
wanted = _OPENING_KIND[kind]
|
||||
wall_order = {wall.id: index for index, wall in enumerate(plan.get("walls") or ())}
|
||||
openings = [o for o in plan.get("openings") or () if o.kind == wanted]
|
||||
openings.sort(
|
||||
key=lambda o: (wall_order.get(o.wall, len(wall_order)), o.wall, float(o.offset), o.id)
|
||||
)
|
||||
prefix = tag_prefix(wanted, lang)
|
||||
used = {str(o.tag) for o in openings if o.tag}
|
||||
counter = 0
|
||||
rows: list[dict] = []
|
||||
for opening in openings:
|
||||
tag = str(opening.tag) if opening.tag else None
|
||||
generated = tag is None
|
||||
if generated:
|
||||
counter += 1
|
||||
while f"{prefix}{counter}" in used:
|
||||
counter += 1
|
||||
tag = f"{prefix}{counter}"
|
||||
used.add(tag)
|
||||
row = {
|
||||
"id": opening.id,
|
||||
"tag": tag,
|
||||
"generated": generated,
|
||||
"width": _mm(opening.width, lang),
|
||||
"height": _mm(opening.height, lang),
|
||||
"wall": opening.wall,
|
||||
}
|
||||
if kind == "doors":
|
||||
row["swing"] = words[opening.swing]
|
||||
row["hand"] = words[opening.hand]
|
||||
else:
|
||||
row["sill"] = _mm(opening.sill, lang)
|
||||
rows.append(row)
|
||||
rows.sort(key=lambda row: _natural(row["tag"]))
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def _room_rows(plan: dict, lang: str) -> tuple[dict, ...]:
|
||||
rooms = sorted(
|
||||
plan.get("rooms") or (),
|
||||
key=lambda r: (
|
||||
r.number is None or str(r.number) == "",
|
||||
_natural(r.number or ""),
|
||||
r.name,
|
||||
r.id,
|
||||
),
|
||||
)
|
||||
if not rooms:
|
||||
return ()
|
||||
rows = [
|
||||
{
|
||||
"id": room.id,
|
||||
"number": "" if room.number is None else str(room.number),
|
||||
"name": room.name,
|
||||
"area": fmt_area_m2(room.area, lang),
|
||||
"area_mm2": float(room.area),
|
||||
"total": False,
|
||||
}
|
||||
for room in rooms
|
||||
]
|
||||
total = math.fsum(float(room.area) for room in rooms)
|
||||
rows.append(
|
||||
{
|
||||
"id": "",
|
||||
"number": "",
|
||||
"name": vocab(lang)["total"],
|
||||
"area": fmt_area_m2(total, lang),
|
||||
"area_mm2": total,
|
||||
"total": True,
|
||||
}
|
||||
)
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def schedule_rows(kind: str, plan: dict, *, lang: str = "en") -> tuple[dict, ...]:
|
||||
"""The rows of one schedule, each a dict holding every column of ``SCHEDULE_COLUMNS[kind]``
|
||||
as display text, plus ``id`` (and ``generated`` for openings, ``area_mm2`` and
|
||||
``total`` for rooms). Refused: an unknown kind or language, named with the list."""
|
||||
_check(kind, lang)
|
||||
if kind == "rooms":
|
||||
return _room_rows(plan, lang)
|
||||
return _opening_rows(kind, plan, lang)
|
||||
|
||||
|
||||
async def _ensure_layer(backend: AutoCADBackend, name: str) -> None:
|
||||
"""Create the schedule layer from its `ARCH_LAYERS` row when the drawing lacks it.
|
||||
|
||||
Live ActiveX will not create a layer on assignment (the repository rule),
|
||||
so the layer exists before the table does.
|
||||
"""
|
||||
if name.casefold() in {layer.name.casefold() for layer in await backend.layer_list()}:
|
||||
return
|
||||
row = next(r for r in ARCH_LAYERS if r[0] == name)
|
||||
await backend.layer_create(name=row[0], color=row[1], linetype=row[2], lineweight=row[3])
|
||||
|
||||
|
||||
async def draw_schedule(
|
||||
backend: AutoCADBackend, kind: str, *, at, lang: str = "en", scale=50
|
||||
) -> dict:
|
||||
"""Read the drawing's records and draw one schedule as a TABLE whose top-left is ``at``.
|
||||
|
||||
Refused before anything is drawn: an unknown kind or language, a scale that
|
||||
is not a finite positive number, an ``at`` that is not two finite numbers,
|
||||
a drawing with nothing to schedule, and more rows than one TABLE takes.
|
||||
"""
|
||||
from engineering.arch.rooms import read_records # the one ACADMCP_ARCH reader
|
||||
from engineering.sheet.bom import ROW_HEIGHT, TEXT_HEIGHT
|
||||
|
||||
_check(kind, lang)
|
||||
try:
|
||||
factor = float(scale)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"scale: expected a number, got {scale!r}") from None
|
||||
if not math.isfinite(factor) or factor <= 0.0:
|
||||
raise ValueError(f"scale: must be a finite number greater than zero, got {scale!r}")
|
||||
try:
|
||||
x, y = float(at[0]), float(at[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
raise ValueError(f"at: expected [x, y], got {at!r}") from None
|
||||
if not (math.isfinite(x) and math.isfinite(y)):
|
||||
raise ValueError(f"at: must be two finite numbers, got {at!r}")
|
||||
|
||||
plan = await read_records(backend)
|
||||
rows = schedule_rows(kind, plan, lang=lang)
|
||||
if not rows:
|
||||
raise ValueError(
|
||||
f"kind: this drawing carries no {kind[:-1]} records to schedule "
|
||||
"(ACADMCP_ARCH on the architectural layers); draw the plan first"
|
||||
)
|
||||
if len(rows) > MAX_ROWS:
|
||||
raise ValueError(
|
||||
f"kind: {len(rows)} {kind} rows do not fit one TABLE ({MAX_ROWS} data rows at most)"
|
||||
)
|
||||
words = vocab(lang)
|
||||
columns = SCHEDULE_COLUMNS[kind]
|
||||
layer = ARCH_ROLE_LAYER["symbol"]
|
||||
await _ensure_layer(backend, layer)
|
||||
entity = await backend.entity_create_table(
|
||||
x,
|
||||
y,
|
||||
[[row[c] for c in columns] for row in rows],
|
||||
headers=[words[c] for c in columns],
|
||||
column_widths=[COLUMN_WIDTHS[c] * factor for c in columns],
|
||||
row_height=ROW_HEIGHT * factor,
|
||||
text_height=TEXT_HEIGHT * factor,
|
||||
title=words[SCHEDULE_TITLES[kind]],
|
||||
layer=layer,
|
||||
)
|
||||
width = sum(COLUMN_WIDTHS[c] for c in columns) * factor
|
||||
height = (len(rows) + 2) * ROW_HEIGHT * factor
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": kind,
|
||||
"lang": lang,
|
||||
"handle": entity.handle,
|
||||
"entity_type": entity.type,
|
||||
"representation": entity.properties.get("representation", "native"),
|
||||
"child_handles": list(entity.properties.get("child_handles") or []),
|
||||
"layer": layer,
|
||||
"columns": list(columns),
|
||||
"headers": [words[c] for c in columns],
|
||||
"title": words[SCHEDULE_TITLES[kind]],
|
||||
"rows": [dict(row) for row in rows],
|
||||
"row_count": len(rows),
|
||||
"table_rows": entity.properties.get("rows"),
|
||||
"insert": [x, y],
|
||||
"bbox": {"min": [x, y - height], "max": [x + width, y]},
|
||||
}
|
||||
Reference in New Issue
Block a user