From dae48d9037e37119ba1e2cc75e01f02fab55e3b5 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:33:31 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/arch/__init__.py | 16 + engineering/arch/walls.py | 621 +++++++++++++++++++++++++++++++++++ tests/test_arch_walls.py | 359 ++++++++++++++++++++ 3 files changed, 996 insertions(+) create mode 100644 engineering/arch/walls.py create mode 100644 tests/test_arch_walls.py diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 505a99e..2898cff 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -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", ] diff --git a/engineering/arch/walls.py b/engineering/arch/walls.py new file mode 100644 index 0000000..6a8aedb --- /dev/null +++ b/engineering/arch/walls.py @@ -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) + ) diff --git a/tests/test_arch_walls.py b/tests/test_arch_walls.py new file mode 100644 index 0000000..0ab3a0b --- /dev/null +++ b/tests/test_arch_walls.py @@ -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