feat(arch): the wall engine - L/T/X junctions, opening gaps and material poche

engineering/arch/walls.py turns a wall network - axis polylines with a
thickness, a justification and a material - into clean outlines. One rule
resolves every junction: at each node the segment ends are sorted by angle and
the left face of each meets the right face of the next one counter-clockwise.
That mitres an L, stops a T's stem on the crossing wall's near face (cut there,
while the far face stays one line) and cuts all four faces of an X. A T is an
axis end lying inside another wall: it is carried onto the crossing axis and
the crossing segment is split. The hub a three- or four-way node leaves is a
region of its own, so the poche has no hole.

Openings remove their width from both faces and are closed by jambs; a free
end is closed by a jamb. Each solid piece is one CCW region and one HatchArea
in the wall's material. The two face lines across each opening are kept as
thresholds - never drawn - so room_segments hands the face finder closed rooms
instead of rooms merged through their doorways.

Refused by name before any geometry: a duplicate id, an unknown material or
justification, a zero-length segment, a junction below 5 degrees, a corner
inside another wall off its axis, an end whose axis meets the other wall past
its end, a segment lying wholly inside the wall it joins, and one too short
for its junctions (engineering.measure.is_self_intersecting on its outline).
An opening that runs into a junction is returned in omitted, never dropped.

Every corner in the tests is hand-computed: two 200 mm walls at the origin
meet at (100, 100) inside and (-100, -100) outside.

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-23 22:33:31 +03:00
co-authored by Claude Opus 5.5
parent d5c5e90406
commit dae48d9037
3 changed files with 996 additions and 0 deletions
+16
View File
@@ -30,6 +30,15 @@ from .model import (
wall_from_dict,
wall_length,
)
from .walls import (
MIN_JOIN_ANGLE_DEG,
WallGeometry,
face_offsets,
room_segments,
wall_geometry,
wall_geometry_by_wall,
wall_segments,
)
__all__ = [
"APP_ID",
@@ -39,20 +48,24 @@ __all__ = [
"ARCH_ROLE_LAYER",
"KINDS",
"LANGS",
"MIN_JOIN_ANGLE_DEG",
"Opening",
"Room",
"Stair",
"WALL_HATCH_TABLE",
"WALL_MATERIALS",
"Wall",
"WallGeometry",
"axis_segments",
"decode",
"encode",
"face_offsets",
"fmt_area_m2",
"fmt_number",
"from_payload",
"opening_from_dict",
"room_from_dict",
"room_segments",
"segment_of",
"stair_from_dict",
"tag_prefix",
@@ -61,6 +74,9 @@ __all__ = [
"validate_openings",
"vocab",
"wall_from_dict",
"wall_geometry",
"wall_geometry_by_wall",
"wall_hatch",
"wall_length",
"wall_segments",
]
+621
View File
@@ -0,0 +1,621 @@
"""The wall engine: axis polylines in, clean wall outlines out.
A wall is an axis polyline with a thickness and a justification - the side of
the axis the wall lies on, looking along it from its first point. This module
offsets every axis segment into its two faces and resolves every place where
segments meet:
* **L and straight joins** - two segment ends at one point. Each face is met by
the face of its neighbour and both are trimmed or extended to their
intersection, so the corner is a clean mitre.
* **T** - a segment ends *inside* another wall. Its axis is carried to the
crossing axis, the crossing segment is split there, and the same rule stops
the stem's faces on the crossing wall's near face. That face is cut where
the stem meets it; the far face stays one line.
* **X** - two axes cross in their interiors. Both are split and all four
faces are cut.
All three are one algorithm: at every node the segment ends are taken as
outgoing directions and sorted by angle, and the left face of each end meets
the right face of the next one counter-clockwise. A node of three or more ends
leaves a *hub* - the square an X crosses in, the triangle a centred T leaves -
that no single segment covers; it is a region of its own, so the poche has no
hole in it.
An opening removes its width from both faces and each edge is closed by a jamb;
a free wall end is closed by a jamb too. Every solid piece - one axis segment's
stretch between junctions, openings and free ends, plus each hub - is one CCW
region and, with ``poche=True``, one ``HatchArea`` in the wall's material.
Refused by name before anything is computed: two walls with one id, an unknown
material or justification, a zero-length segment, two segments that meet at
less than ``MIN_JOIN_ANGLE_DEG`` (the mitre would run away), a wall corner that
lies inside another wall off its axis, an end inside a wall whose axis would
have to run past that wall's end to meet it, a segment that lies wholly
inside the wall it joins, and one too short for its junctions (its outline
would cross itself - `engineering.measure.is_self_intersecting`). An opening that cannot be cut because it runs into a
junction is returned in ``omitted``, never dropped.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass, field
from engineering.arch.materials import wall_hatch
from engineering.arch.model import Opening, Wall, segment_of, validate_openings
from engineering.measure import is_self_intersecting, polygon_area_perimeter
from engineering.mech.primitives import HatchArea, Line, Pt
#: The shallowest angle two wall axes may meet at. Below it the mitre point of
#: the two faces runs more than eleven wall thicknesses away from the node, so
#: the junction is refused rather than drawn as a spike. A drafting limit of
#: this engine, not a building rule.
MIN_JOIN_ANGLE_DEG = 5.0
_EPS = 1e-9
#: Decimals a point is rounded to when face lines are matched end to end.
_KEY_DECIMALS = 6
@dataclass(frozen=True)
class WallGeometry:
faces: tuple[Line, ...]
jambs: tuple[Line, ...]
regions: tuple[tuple[Pt, ...], ...]
hatches: tuple[HatchArea, ...]
omitted: tuple[dict, ...]
#: Each opening's two face lines across its gap. Never drawn: they are what
#: `room_segments` adds so a doorway does not merge two rooms into one face.
thresholds: tuple[Line, ...] = ()
# -- vectors ------------------------------------------------------------------
def _sub(p: Pt, q: Pt) -> Pt:
return (p[0] - q[0], p[1] - q[1])
def _add(p: Pt, q: Pt) -> Pt:
return (p[0] + q[0], p[1] + q[1])
def _mul(v: Pt, k: float) -> Pt:
return (v[0] * k, v[1] * k)
def _dot(u: Pt, v: Pt) -> float:
return u[0] * v[0] + u[1] * v[1]
def _cross(u: Pt, v: Pt) -> float:
return u[0] * v[1] - u[1] * v[0]
def _left(u: Pt) -> Pt:
return (-u[1], u[0])
def _unit(p: Pt, q: Pt) -> Pt:
length = math.dist(p, q)
return ((q[0] - p[0]) / length, (q[1] - p[1]) / length)
def _meet(p: Pt, u: Pt, q: Pt, v: Pt) -> Pt:
"""Where the line through ``p`` along ``u`` meets the line through ``q`` along ``v``."""
t = _cross(_sub(q, p), v) / _cross(u, v)
return _add(p, _mul(u, t))
def _heading(u: Pt) -> float:
return math.degrees(math.atan2(u[1], u[0])) % 360.0
def _line_angle(u: Pt, v: Pt) -> float:
"""The angle between two lines (not directions), 0-90 degrees."""
return math.degrees(math.asin(min(1.0, abs(_cross(u, v)))))
def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None:
"""Consecutive duplicates removed; None if nothing is enclosed.
Every caller builds its ring counter-clockwise - a piece as right face out,
left face back; a hub in the angular order of its corners - so the ring is
not turned here. The area is `engineering.measure`'s, not a second shoelace.
"""
kept: list[Pt] = []
for p in points:
if not kept or math.dist(kept[-1], p) > _EPS:
kept.append(p)
if len(kept) > 1 and math.dist(kept[0], kept[-1]) <= _EPS:
kept.pop()
if len(kept) < 3:
return None
area, _perimeter = polygon_area_perimeter([(x, y, 0.0) for x, y in kept], True)
return tuple(kept) if area > _EPS else None
def _key(p: Pt) -> tuple[float, float]:
return (round(p[0], _KEY_DECIMALS) + 0.0, round(p[1], _KEY_DECIMALS) + 0.0)
# -- the wall's own numbers -----------------------------------------------------
def face_offsets(wall: Wall) -> tuple[float, float]:
"""``(left, right)``: the signed offsets of a wall's two faces along the axis's left normal."""
thickness = float(wall.thickness)
if wall.justification == "center":
return (thickness / 2.0, -thickness / 2.0)
if wall.justification == "left":
return (thickness, 0.0)
if wall.justification == "right":
return (0.0, -thickness)
raise ValueError(
f"wall {wall.id!r}: justification {wall.justification!r}; "
"justifications are center, left, right"
)
def wall_segments(wall: Wall) -> tuple[tuple[Pt, Pt], ...]:
"""The wall's axis segments in order, the closing one last when ``closed``."""
points = [(float(x), float(y)) for x, y in wall.axis]
pairs = list(zip(points, points[1:], strict=False))
if wall.closed:
pairs.append((points[-1], points[0]))
return tuple(pairs)
# -- the network ----------------------------------------------------------------
@dataclass
class _Seg:
wall: int
index: int
va: tuple[int, int]
vb: tuple[int, int]
left: float
right: float
cuts: list[float] = field(default_factory=list)
@dataclass
class _Piece:
seg: _Seg
a: Pt
b: Pt
s0: float
corners: dict = field(default_factory=dict) # (end 0|1, "left"|"right") -> Pt
def _validate(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> None:
if not isinstance(join_tol, (int, float)) or not math.isfinite(join_tol) or join_tol <= 0:
raise ValueError(f"join_tol: must be a finite number above zero, got {join_tol!r}")
seen: dict[str, int] = {}
for i, wall in enumerate(walls):
if not isinstance(wall, Wall):
raise ValueError(f"walls[{i}]: expected a Wall, got {type(wall).__name__}")
if wall.id in seen:
raise ValueError(
f"walls[{i}].id: {wall.id!r} is already used by walls[{seen[wall.id]}]"
)
seen[wall.id] = i
try:
wall_hatch(wall.material)
except ValueError as exc:
# wall_hatch's message already names its field; the path replaces it
reason = str(exc).removeprefix("material: ")
raise ValueError(f"walls[{i}].material: {reason}") from None
try:
face_offsets(wall)
except ValueError as exc:
raise ValueError(f"walls[{i}].justification: {exc}") from None
segments = wall_segments(wall)
if not segments:
raise ValueError(f"walls[{i}].axis: needs at least two points")
for k, (a, b) in enumerate(segments):
if math.dist(a, b) <= _EPS:
raise ValueError(f"walls[{i}].axis[{k}]: zero-length segment at {a}")
validate_openings(list(walls), list(openings))
def _segments(walls: Sequence[Wall]) -> tuple[dict, list[_Seg]]:
pos: dict[tuple[int, int], Pt] = {}
segs: list[_Seg] = []
for wi, wall in enumerate(walls):
left, right = face_offsets(wall)
count = len(wall.axis)
for k, (x, y) in enumerate(wall.axis):
pos[(wi, k)] = (float(x), float(y))
for i in range(count if wall.closed else count - 1):
segs.append(_Seg(wi, i, (wi, i), (wi, (i + 1) % count), left, right))
return pos, segs
def _name(walls: Sequence[Wall], index: int) -> str:
return f"walls[{index}] ({walls[index].id!r})"
def _refuse_shallow(walls, one: _Seg, other: _Seg, u: Pt, v: Pt) -> None:
angle = _line_angle(u, v)
if angle < MIN_JOIN_ANGLE_DEG:
raise ValueError(
f"{_name(walls, one.wall)} meets {_name(walls, other.wall)} at {angle:.1f}°; "
f"a junction below {MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean "
"outline - end the wall at a steeper angle."
)
def _resolve_tees(walls, pos, segs, tol: float) -> None:
"""Carry every axis end that stops inside another wall onto that wall's axis, and split it."""
touching: dict[tuple[int, int], list[_Seg]] = {}
for seg in segs:
touching.setdefault(seg.va, []).append(seg)
touching.setdefault(seg.vb, []).append(seg)
for vertex, mine in touching.items():
for other in segs:
if any(other is seg for seg in mine):
continue
p = pos[vertex]
a, b = pos[other.va], pos[other.vb]
if math.dist(p, a) <= tol or math.dist(p, b) <= tol:
continue
length = math.dist(a, b)
u = _unit(a, b)
s = _dot(_sub(p, a), u)
d = _dot(_sub(p, a), _left(u))
if s < -tol or s > length + tol or d < other.right - tol or d > other.left + tol:
continue
for seg in mine:
_refuse_shallow(walls, seg, other, _unit(pos[seg.va], pos[seg.vb]), u)
if abs(d) <= tol:
x = _add(a, _mul(u, s))
elif len(mine) == 1:
seg = mine[0]
far = pos[seg.vb] if seg.va == vertex else pos[seg.va]
x = _meet(far, _unit(far, p), a, u)
else:
raise ValueError(
f"the corner of {_name(walls, vertex[0])} at ({p[0]:g}, {p[1]:g}) lies inside "
f"{_name(walls, other.wall)} off its axis; end the wall there or move the corner "
"onto the other wall's axis."
)
sx = _dot(_sub(x, a), u)
if sx < -tol or sx > length + tol:
raise ValueError(
f"{_name(walls, vertex[0])} ends inside {_name(walls, other.wall)} at "
f"({p[0]:g}, {p[1]:g}), but its axis meets that wall's axis beyond its end; "
"join the two axes at a common point."
)
if sx <= tol:
pos[vertex] = a
elif sx >= length - tol:
pos[vertex] = b
else:
pos[vertex] = x
other.cuts.append(sx)
break
def _resolve_crossings(walls, pos, segs, tol: float) -> None:
"""Split both segments wherever two axes cross in their interiors (an X)."""
for i, one in enumerate(segs):
for other in segs[i + 1 :]:
if {one.va, one.vb} & {other.va, other.vb}:
continue
a1, b1 = pos[one.va], pos[one.vb]
a2, b2 = pos[other.va], pos[other.vb]
r = _sub(b1, a1)
q = _sub(b2, a2)
denom = _cross(r, q)
if abs(denom) <= _EPS:
continue
t = _cross(_sub(a2, a1), q) / denom
w = _cross(_sub(a2, a1), r) / denom
l1 = math.hypot(*r)
l2 = math.hypot(*q)
if tol < t * l1 < l1 - tol and tol < w * l2 < l2 - tol:
_refuse_shallow(walls, one, other, _unit(a1, b1), _unit(a2, b2))
one.cuts.append(t * l1)
other.cuts.append(w * l2)
def _pieces(walls, pos, segs, tol: float) -> list[_Piece]:
pieces: list[_Piece] = []
for seg in segs:
a, b = pos[seg.va], pos[seg.vb]
length = math.dist(a, b)
if length <= tol:
raise ValueError(
f"{_name(walls, seg.wall)}: segment {seg.index} lies inside the wall it joins - "
"nothing of it stands clear of that wall; lengthen it or leave it out."
)
u = _unit(a, b)
stops: list[float] = []
for cut in sorted(seg.cuts):
if tol < cut < length - tol and (not stops or cut - stops[-1] > tol):
stops.append(cut)
bounds = [0.0, *stops, length]
for s0, s1 in zip(bounds, bounds[1:], strict=False):
start = a if s0 == 0.0 else _add(a, _mul(u, s0))
end = b if s1 == length else _add(a, _mul(u, s1))
pieces.append(_Piece(seg, start, end, s0))
return pieces
def _outgoing(piece: _Piece, end: int):
"""(origin, direction, left normal, left offset, right offset) leaving the node at ``end``."""
u = _unit(piece.a, piece.b)
if end == 0:
return piece.a, u, _left(u), piece.seg.left, piece.seg.right
back = (-u[0], -u[1])
return piece.b, back, _left(back), -piece.seg.right, -piece.seg.left
def _canonical(end: int, side: str) -> str:
"""Leaving through its end, a piece's left face is its canonical right one."""
if end == 0:
return side
return "right" if side == "left" else "left"
def _foot(node: Pt, p: Pt, u: Pt) -> Pt:
return _add(p, _mul(u, _dot(_sub(node, p), u)))
def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> dict:
walls = list(walls)
openings = list(openings)
_validate(walls, openings, join_tol)
pos, segs = _segments(walls)
_resolve_tees(walls, pos, segs, join_tol)
_resolve_crossings(walls, pos, segs, join_tol)
pieces = _pieces(walls, pos, segs, join_tol)
faces: list[tuple[Line, str]] = []
jambs: list[tuple[Line, str]] = []
thresholds: list[tuple[Line, str]] = []
regions: list[tuple[tuple[Pt, ...], str]] = []
omitted: list[tuple[dict, str]] = []
# -- the nodes ------------------------------------------------------------
nodes: list[Pt] = []
ends: list[list[tuple[_Piece, int]]] = []
for piece in pieces:
for end, point in ((0, piece.a), (1, piece.b)):
for k, node in enumerate(nodes):
if math.dist(point, node) <= join_tol:
ends[k].append((piece, end))
break
else:
nodes.append(point)
ends.append([(piece, end)])
for node, members in zip(nodes, ends, strict=True):
if len(members) == 1:
piece, end = members[0]
origin, _u, n, left, right = _outgoing(piece, end)
pl = _add(origin, _mul(n, left))
pr = _add(origin, _mul(n, right))
piece.corners[(end, _canonical(end, "left"))] = pl
piece.corners[(end, _canonical(end, "right"))] = pr
jambs.append((Line(pl, pr, "wall"), walls[piece.seg.wall].id))
continue
ordered = sorted(members, key=lambda m: _heading(_outgoing(*m)[1]))
hub: list[Pt] = []
for idx, (pi, ei) in enumerate(ordered):
pj, ej = ordered[(idx + 1) % len(ordered)]
oi, ui, ni, li, _ri = _outgoing(pi, ei)
oj, uj, nj, _lj, rj = _outgoing(pj, ej)
gap = (_heading(uj) - _heading(ui)) % 360.0
if gap < MIN_JOIN_ANGLE_DEG:
raise ValueError(
f"{_name(walls, pi.seg.wall)} and {_name(walls, pj.seg.wall)} meet at "
f"({node[0]:g}, {node[1]:g}) {gap:.1f}° apart; a junction below "
f"{MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean outline."
)
face_i = _add(oi, _mul(ni, li))
face_j = _add(oj, _mul(nj, rj))
if abs(_cross(ui, uj)) <= 1e-12:
ci = _foot(node, face_i, ui)
cj = _foot(node, face_j, uj)
if math.dist(ci, cj) <= _EPS:
cj = ci
else:
faces.append((Line(ci, cj, "wall"), walls[pi.seg.wall].id))
else:
ci = cj = _meet(face_i, ui, face_j, uj)
pi.corners[(ei, _canonical(ei, "left"))] = ci
pj.corners[(ej, _canonical(ej, "right"))] = cj
hub.append(ci)
if cj is not ci:
hub.append(cj)
if len(ordered) >= 3:
polygon = _polygon(hub)
if polygon:
owner = min(piece.seg.wall for piece, _end in ordered)
regions.append((polygon, walls[owner].id))
# -- the openings -----------------------------------------------------------
by_id = {wall.id: i for i, wall in enumerate(walls)}
by_seg = {(seg.wall, seg.index): seg for seg in segs}
cut_in: dict[int, list[tuple[float, float]]] = {}
for opening in openings:
wi = by_id[opening.wall]
wall = walls[wi]
si, along = segment_of(wall, float(opening.offset))
a0, b0 = wall_segments(wall)[si]
start = _add(a0, _mul(_unit(a0, b0), along))
seg = by_seg[(wi, si)]
u = _unit(pos[seg.va], pos[seg.vb])
s0 = _dot(_sub(start, pos[seg.va]), u)
s1 = s0 + float(opening.width)
host = None
for piece in pieces:
length = math.dist(piece.a, piece.b)
if piece.seg is seg and piece.s0 - _EPS <= s0 and s1 <= piece.s0 + length + _EPS:
host = piece
break
if host is None:
omitted.append(
(
{
"element": opening.id,
"reason": f"it crosses a junction on wall {wall.id!r} segment {si}; "
"an opening must lie inside one stretch of wall",
},
wall.id,
)
)
continue
d0 = s0 - host.s0
d1 = s1 - host.s0
clear_from = max(
_dot(_sub(host.corners[(0, side)], host.a), u) for side in ("left", "right")
)
clear_to = min(_dot(_sub(host.corners[(1, side)], host.a), u) for side in ("left", "right"))
if d0 < clear_from - _EPS or d1 > clear_to + _EPS:
omitted.append(
(
{
"element": opening.id,
"reason": f"it runs into a junction of wall {wall.id!r}: on segment {si} "
f"the solid wall runs from {host.s0 + clear_from:g} to "
f"{host.s0 + clear_to:g} mm along the axis, the opening from "
f"{s0:g} to {s1:g}",
},
wall.id,
)
)
continue
cut_in.setdefault(id(host), []).append((d0, d1))
# -- faces, jambs and regions, piece by piece ---------------------------------
for piece in pieces:
seg = piece.seg
owner = walls[seg.wall].id
u = _unit(piece.a, piece.b)
n = _left(u)
def at(s: float, offset: float, piece=piece, u=u, n=n) -> Pt:
return _add(_add(piece.a, _mul(u, s)), _mul(n, offset))
cur_l = piece.corners[(0, "left")]
cur_r = piece.corners[(0, "right")]
runs: list[tuple[Pt, Pt, Pt, Pt]] = []
for d0, d1 in sorted(cut_in.get(id(piece), [])):
la, ra = at(d0, seg.left), at(d0, seg.right)
lb, rb = at(d1, seg.left), at(d1, seg.right)
runs.append((cur_l, cur_r, la, ra))
jambs.append((Line(la, ra, "wall"), owner))
jambs.append((Line(lb, rb, "wall"), owner))
thresholds.append((Line(la, lb, "wall"), owner))
thresholds.append((Line(ra, rb, "wall"), owner))
cur_l, cur_r = lb, rb
runs.append((cur_l, cur_r, piece.corners[(1, "left")], piece.corners[(1, "right")]))
for sl, sr, el, er in runs:
if math.dist(sl, el) > _EPS:
faces.append((Line(sl, el, "wall"), owner))
if math.dist(sr, er) > _EPS:
faces.append((Line(sr, er, "wall"), owner))
polygon = _polygon((sr, er, el, sl))
if polygon and is_self_intersecting(polygon):
raise ValueError(
f"{_name(walls, seg.wall)}: segment {seg.index} is too short for the "
"junctions at its ends - its outline would cross itself; lengthen it "
"or join it elsewhere."
)
if polygon:
regions.append((polygon, owner))
return {
"walls": walls,
"faces": _merge_collinear(faces, jambs),
"jambs": jambs,
"thresholds": thresholds,
"regions": regions,
"omitted": omitted,
}
def _merge_collinear(faces, jambs):
"""Join two faces of one wall that continue each other in a straight line.
That is what keeps a crossing wall's far face one line past a T, and a
straight axis with an intermediate vertex one face. A point touched by a
third line (a jamb, another face) is a real corner and is left alone.
"""
items = list(faces)
while True:
touching: dict[tuple[float, float], list[int]] = {}
for idx, (line, _owner) in enumerate(items):
for p in (line.p1, line.p2):
touching.setdefault(_key(p), []).append(idx)
for line, _owner in jambs:
for p in (line.p1, line.p2):
touching.setdefault(_key(p), []).append(-1)
for key, idxs in touching.items():
if len(idxs) != 2 or -1 in idxs or idxs[0] == idxs[1]:
continue
i, j = sorted(idxs)
(li, wi), (lj, wj) = items[i], items[j]
if wi != wj:
continue
near_i, far_i = (li.p2, li.p1) if _key(li.p2) == key else (li.p1, li.p2)
far_j = lj.p2 if _key(lj.p1) == key else lj.p1
d1 = _unit(far_i, near_i)
d2 = _unit(near_i, far_j)
if abs(_cross(d1, d2)) > 1e-9 or _dot(d1, d2) <= 0.0:
continue
items[i] = (Line(far_i, far_j, "wall"), wi)
del items[j]
break
else:
return items
def _assemble(parts: dict, poche: bool, owner: str | None) -> WallGeometry:
def mine(rows):
return [item for item, who in rows if owner is None or who == owner]
material = {wall.id: wall.material for wall in parts["walls"]}
regions = [(polygon, who) for polygon, who in parts["regions"] if owner in (None, who)]
hatches = (
tuple(HatchArea((polygon,), material[who]) for polygon, who in regions) if poche else ()
)
return WallGeometry(
faces=tuple(mine(parts["faces"])),
jambs=tuple(mine(parts["jambs"])),
regions=tuple(polygon for polygon, _who in regions),
hatches=hatches,
omitted=tuple(mine(parts["omitted"])),
thresholds=tuple(mine(parts["thresholds"])),
)
def wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry:
"""The whole network's outlines, jambs, regions and poche - see the module docstring."""
return _assemble(_build(walls, openings, join_tol), poche, None)
def wall_geometry_by_wall(
walls, openings=(), *, poche=True, join_tol=1.0
) -> dict[str, WallGeometry]:
"""The same geometry, split by the wall each element belongs to.
A hub (the middle of a T or an X) belongs to the first of its walls in the
order given. `engineering.arch.draw` uses the split to redraw only the walls
whose outline a new wall or opening changes.
"""
parts = _build(walls, openings, join_tol)
return {wall.id: _assemble(parts, poche, wall.id) for wall in parts["walls"]}
def room_segments(geometry: WallGeometry) -> tuple[tuple[Pt, Pt], ...]:
"""Faces, jambs and thresholds as bare segments - the input of the planar-face finder."""
return tuple(
(line.p1, line.p2) for line in (*geometry.faces, *geometry.jambs, *geometry.thresholds)
)
+359
View File
@@ -0,0 +1,359 @@
"""The wall engine: outlines, L/T/X junctions, opening gaps and poche.
Every corner below is hand-computed and written out: two 200 mm walls centred
on their axes put their faces 100 mm either side, so an L at the origin has
its inner corner at (100, 100) and its outer one at (-100, -100), and a T or
an X leaves the faces of the crossing wall cut exactly 100 mm either side of
the stem's axis.
"""
from __future__ import annotations
import pytest
from engineering.arch.model import Opening, Wall
from engineering.arch.walls import (
MIN_JOIN_ANGLE_DEG,
WallGeometry,
face_offsets,
room_segments,
wall_geometry,
wall_geometry_by_wall,
wall_segments,
)
from engineering.mech.primitives import HatchArea, Line
def seg(line) -> tuple:
"""An undirected, rounded segment: comparable as a set member."""
a = (round(line.p1[0], 9) + 0.0, round(line.p1[1], 9) + 0.0)
b = (round(line.p2[0], 9) + 0.0, round(line.p2[1], 9) + 0.0)
return tuple(sorted((a, b)))
def segs(lines) -> set:
return {seg(line) for line in lines}
def S(x1, y1, x2, y2) -> tuple:
return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2)))))
def ring(points) -> tuple:
"""A polygon, rounded and rotated to start at its smallest vertex (order kept)."""
pts = [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in points]
k = pts.index(min(pts))
return tuple(pts[k:] + pts[:k])
def signed_area(points) -> float:
return (
sum(
x1 * y2 - x2 * y1
for (x1, y1), (x2, y2) in zip(points, list(points[1:]) + [points[0]], strict=True)
)
/ 2.0
)
def wall(id, *axis, thickness=200.0, **kw) -> Wall:
return Wall(id=id, axis=tuple(axis), thickness=thickness, **kw)
# -- the wall's own numbers ------------------------------------------------------
def test_face_offsets_follow_the_justification():
assert face_offsets(wall("c", (0, 0), (1, 0))) == (100.0, -100.0)
assert face_offsets(wall("l", (0, 0), (1, 0), justification="left")) == (200.0, 0.0)
assert face_offsets(wall("r", (0, 0), (1, 0), justification="right")) == (0.0, -200.0)
def test_wall_segments_close_a_closed_axis():
square = wall("s", (0, 0), (4000, 0), (4000, 3000), (0, 3000), closed=True)
assert wall_segments(square) == (
((0.0, 0.0), (4000.0, 0.0)),
((4000.0, 0.0), (4000.0, 3000.0)),
((4000.0, 3000.0), (0.0, 3000.0)),
((0.0, 3000.0), (0.0, 0.0)),
)
# -- a free end ------------------------------------------------------------------
def test_a_single_wall_is_two_faces_closed_by_a_jamb_at_each_free_end():
geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="left")])
assert isinstance(geo, WallGeometry)
assert segs(geo.faces) == {S(0, 100, 3000, 100), S(0, 0, 3000, 0)}
assert segs(geo.jambs) == {S(0, 0, 0, 100), S(3000, 0, 3000, 100)}
assert [ring(r) for r in geo.regions] == [ring([(0, 0), (3000, 0), (3000, 100), (0, 100)])]
assert all(line.role == "wall" for line in (*geo.faces, *geo.jambs))
def test_right_justification_puts_the_wall_on_the_other_side_of_the_axis():
geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="right")])
assert segs(geo.faces) == {S(0, 0, 3000, 0), S(0, -100, 3000, -100)}
# -- L -----------------------------------------------------------------------------
L_FACES = {
S(100, 100, 4000, 100), # wall a, inner face
S(-100, -100, 4000, -100), # wall a, outer face
S(100, 100, 100, 3000), # wall b, inner face
S(-100, -100, -100, 3000), # wall b, outer face
}
L_JAMBS = {S(4000, -100, 4000, 100), S(-100, 3000, 100, 3000)}
L_REGIONS = {
ring([(-100, -100), (4000, -100), (4000, 100), (100, 100)]),
ring([(100, 100), (100, 3000), (-100, 3000), (-100, -100)]),
}
def test_an_l_between_two_walls_mitres_both_faces_to_their_intersections():
geo = wall_geometry([wall("a", (0, 0), (4000, 0)), wall("b", (0, 0), (0, 3000))])
assert segs(geo.faces) == L_FACES
assert segs(geo.jambs) == L_JAMBS
assert {ring(r) for r in geo.regions} == L_REGIONS
assert len(geo.hatches) == 2
def test_an_l_inside_one_wall_is_the_same_corner():
geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))])
assert segs(geo.faces) == L_FACES
assert segs(geo.jambs) == L_JAMBS
assert {ring(r) for r in geo.regions} == L_REGIONS
def test_every_region_is_counter_clockwise():
geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))])
assert all(signed_area(r) > 0 for r in geo.regions)
# -- T -----------------------------------------------------------------------------
def test_a_t_stops_the_stem_on_the_near_face_and_keeps_the_far_face_whole():
geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))])
assert segs(geo.faces) == {
S(-3000, -100, 3000, -100), # the far face: one line past the stem
S(-3000, 100, -100, 100), # the near face, cut where the stem meets it
S(100, 100, 3000, 100),
S(-100, 100, -100, 2500), # the stem, stopped on the near face
S(100, 100, 100, 2500),
}
assert segs(geo.jambs) == {
S(-3000, -100, -3000, 100),
S(3000, -100, 3000, 100),
S(-100, 2500, 100, 2500),
}
assert {ring(r) for r in geo.regions} == {
ring([(-3000, -100), (0, -100), (-100, 100), (-3000, 100)]),
ring([(0, -100), (3000, -100), (3000, 100), (100, 100)]),
ring([(100, 100), (100, 2500), (-100, 2500), (-100, 100)]),
ring([(100, 100), (-100, 100), (0, -100)]), # the hub the three pieces leave
}
def test_a_stem_drawn_to_the_face_is_carried_to_the_axis():
touching = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 100), (0, 2500))])
on_axis = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))])
assert segs(touching.faces) == segs(on_axis.faces)
# -- X -----------------------------------------------------------------------------
def test_an_x_cuts_all_four_faces():
geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, -2500), (0, 2500))])
assert segs(geo.faces) == {
S(-3000, 100, -100, 100),
S(100, 100, 3000, 100),
S(-3000, -100, -100, -100),
S(100, -100, 3000, -100),
S(-100, -2500, -100, -100),
S(-100, 100, -100, 2500),
S(100, -2500, 100, -100),
S(100, 100, 100, 2500),
}
assert len(geo.jambs) == 4
assert ring([(100, 100), (-100, 100), (-100, -100), (100, -100)]) in {
ring(r) for r in geo.regions
}
assert len(geo.regions) == 5 # four arms and the hub
# -- openings ------------------------------------------------------------------------
def test_a_door_removes_its_width_from_both_faces_and_is_closed_by_two_jambs():
geo = wall_geometry(
[wall("a", (0, 0), (5000, 0))],
[Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)],
)
assert segs(geo.faces) == {
S(0, 100, 1000, 100),
S(1900, 100, 5000, 100),
S(0, -100, 1000, -100),
S(1900, -100, 5000, -100),
}
assert segs(geo.jambs) == {
S(0, -100, 0, 100),
S(5000, -100, 5000, 100),
S(1000, -100, 1000, 100),
S(1900, -100, 1900, 100),
}
assert segs(geo.thresholds) == {S(1000, 100, 1900, 100), S(1000, -100, 1900, -100)}
assert {ring(r) for r in geo.regions} == {
ring([(0, -100), (1000, -100), (1000, 100), (0, 100)]),
ring([(1900, -100), (5000, -100), (5000, 100), (1900, 100)]),
}
assert [h.material for h in geo.hatches] == ["brick", "brick"]
assert geo.omitted == ()
def test_an_opening_is_placed_along_the_whole_axis_not_its_first_segment():
geo = wall_geometry(
[wall("a", (0, 0), (4000, 0), (4000, 3000))],
[Opening(id="w1", wall="a", kind="window", offset=4500.0, width=1200.0)],
)
assert {S(3900, 100, 3900, 500), S(3900, 1700, 3900, 3000)} <= segs(geo.faces)
assert {S(3900, 500, 4100, 500), S(3900, 1700, 4100, 1700)} <= segs(geo.jambs)
def test_an_opening_that_runs_into_a_junction_is_omitted_by_name():
geo = wall_geometry(
[wall("a", (0, 0), (4000, 0), (4000, 3000))],
[Opening(id="d9", wall="a", kind="door", offset=3500.0, width=450.0)],
)
((entry,),) = [geo.omitted]
assert entry["element"] == "d9"
assert "junction" in entry["reason"]
assert segs(geo.faces) == segs(
wall_geometry([wall("a", (0, 0), (4000, 0), (4000, 3000))]).faces
)
# -- poche -----------------------------------------------------------------------------
def test_one_hatch_per_region_in_the_wall_material_and_none_without_poche():
walls = [wall("a", (-3000, 0), (3000, 0), material="concrete"), wall("b", (0, 0), (0, 2500))]
geo = wall_geometry(walls)
assert len(geo.hatches) == len(geo.regions) == 4
assert all(isinstance(h, HatchArea) and len(h.loops) == 1 for h in geo.hatches)
assert sorted(h.material for h in geo.hatches) == ["brick", "concrete", "concrete", "concrete"]
bare = wall_geometry(walls, poche=False)
assert bare.hatches == ()
assert len(bare.regions) == 4
def test_by_wall_splits_every_element_to_its_owner():
walls = [wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))]
split = wall_geometry_by_wall(walls)
whole = wall_geometry(walls)
assert set(split) == {"a", "b"}
assert segs(split["a"].faces) | segs(split["b"].faces) == segs(whole.faces)
assert segs(split["b"].faces) == {S(-100, 100, -100, 2500), S(100, 100, 100, 2500)}
assert len(split["a"].regions) == 3 # two arms and the hub (a is first)
assert len(split["b"].regions) == 1
def test_room_segments_are_faces_jambs_and_thresholds():
geo = wall_geometry(
[wall("a", (0, 0), (5000, 0))],
[Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)],
)
segments = room_segments(geo)
assert len(segments) == len(geo.faces) + len(geo.jambs) + len(geo.thresholds) == 10
assert {S(*p1, *p2) for p1, p2 in segments} == segs(geo.faces) | segs(geo.jambs) | segs(
geo.thresholds
)
# -- refusals -------------------------------------------------------------------------
def test_a_stem_meeting_a_wall_below_five_degrees_is_refused_by_name():
with pytest.raises(ValueError) as excinfo:
wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (-2000, -150), (2000, 0))])
message = str(excinfo.value)
assert "'a'" in message and "'b'" in message
assert f"{MIN_JOIN_ANGLE_DEG:g}°" in message
def test_two_ends_meeting_below_five_degrees_are_refused():
with pytest.raises(ValueError, match="apart"):
wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (0, 0), (5000, 200))])
def test_duplicate_wall_ids_are_refused_with_both_paths():
with pytest.raises(ValueError, match=r"walls\[1\]\.id: 'a' is already used by walls\[0\]"):
wall_geometry([wall("a", (0, 0), (1000, 0)), wall("a", (0, 500), (1000, 500))])
def test_an_unknown_material_is_refused_by_path():
with pytest.raises(ValueError, match=r"walls\[0\]\.material") as excinfo:
wall_geometry([wall("a", (0, 0), (1000, 0), material="cheese")])
assert "material: material" not in str(excinfo.value)
def test_a_corner_inside_another_wall_is_refused():
with pytest.raises(ValueError, match="corner"):
wall_geometry(
[
wall("a", (-3000, 0), (3000, 0)),
wall("b", (-500, 2000), (0, 50), (500, 2000)),
]
)
def test_an_end_whose_axis_misses_the_other_wall_is_refused():
with pytest.raises(ValueError, match="beyond its end"):
wall_geometry([wall("a", (0, 0), (3000, 0)), wall("b", (2990, 80), (2000, 2000))])
def test_a_stub_lying_inside_the_wall_it_joins_is_refused():
with pytest.raises(ValueError, match="lies inside the wall it joins"):
wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 80))])
def test_a_segment_too_short_for_its_corner_is_refused():
# a 30 degree corner puts the inner mitre 100 / tan(15 deg) = 373.2 along the
# second segment, which is only 300 long: its outline would cross itself
with pytest.raises(ValueError, match="too short"):
wall_geometry([wall("a", (1000, 0), (0, 0), (259.8, 150.0))])
def test_a_stub_ending_inside_its_own_corner_is_refused():
# 60 long past a 200 mm L: its end lies inside the first segment's body
with pytest.raises(ValueError, match="lies inside the wall it joins"):
wall_geometry([wall("a", (0, 0), (1000, 0), (1000, 60))])
def test_a_bad_join_tolerance_is_refused():
with pytest.raises(ValueError, match="join_tol"):
wall_geometry([wall("a", (0, 0), (1000, 0))], join_tol=0.0)
def test_line_primitives_are_the_mech_line_type():
geo = wall_geometry([wall("a", (0, 0), (1000, 0))])
assert all(isinstance(line, Line) for line in (*geo.faces, *geo.jambs))
def test_a_closed_axis_is_a_ring_with_no_jamb_and_a_4_by_5_m_inner_face():
geo = wall_geometry([wall("r", (0, 0), (4200, 0), (4200, 5200), (0, 5200), closed=True)])
assert geo.jambs == ()
assert segs(geo.faces) == {
S(100, 100, 4100, 100),
S(4100, 100, 4100, 5100),
S(4100, 5100, 100, 5100),
S(100, 5100, 100, 100),
S(-100, -100, 4300, -100),
S(4300, -100, 4300, 5300),
S(4300, 5300, -100, 5300),
S(-100, 5300, -100, -100),
}
assert len(geo.regions) == 4