diff --git a/engineering/arch/walls.py b/engineering/arch/walls.py index 6a8aedb..1627608 100644 --- a/engineering/arch/walls.py +++ b/engineering/arch/walls.py @@ -7,7 +7,11 @@ 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. + intersection, so the corner is a clean mitre. A near-straight join whose + faces are offset from each other (a thickness or justification change, or a + rounding kink between two such walls) would mitre kilometres away; past + ``_MITRE_REACH`` thicknesses it steps instead: both faces stop on the line + bisecting the join and a short face closes the step. * **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 @@ -54,6 +58,14 @@ from engineering.mech.primitives import HatchArea, Line, Pt #: this engine, not a building rule. MIN_JOIN_ANGLE_DEG = 5.0 +#: How far from its node, in thicknesses of the thicker wall, the face mitre of +#: a near-straight join may lie. Two faces at equal offsets meet within 1.42 +#: thicknesses of the node at any kink up to 90 degrees; faces at different +#: offsets (a thickness or justification change) meet at offset / sin(kink), +#: kilometres away for a rounding kink, so past this reach the join steps +#: instead of flaring. +_MITRE_REACH = 2.0 + _EPS = 1e-9 #: Decimals a point is rounded to when face lines are matched end to end. _KEY_DECIMALS = 6 @@ -123,7 +135,9 @@ def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None: 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. + not turned here; a ring that comes out clockwise is inside out, and the + callers refuse it (`_counter_clockwise`). The area is + `engineering.measure`'s, not a second shoelace. """ kept: list[Pt] = [] for p in points: @@ -137,6 +151,12 @@ def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None: return tuple(kept) if area > _EPS else None +def _counter_clockwise(ring: Sequence[Pt]) -> bool: + """The ring's turning sense - the sign `polygon_area_perimeter` folds away.""" + twice = sum(_cross(p, q) for p, q in zip(ring, (*ring[1:], ring[0]), strict=True)) + return twice > 0.0 + + def _key(p: Pt) -> tuple[float, float]: return (round(p[0], _KEY_DECIMALS) + 0.0, round(p[1], _KEY_DECIMALS) + 0.0) @@ -179,7 +199,10 @@ class _Seg: vb: tuple[int, int] left: float right: float - cuts: list[float] = field(default_factory=list) + #: Where other axes stop on or cross this one, as points, never as distances: + #: a distance is measured from the segment's start, and that start may still + #: be carried onto a third wall later in the same pass. + cuts: list[Pt] = field(default_factory=list) @dataclass @@ -296,7 +319,7 @@ def _resolve_tees(walls, pos, segs, tol: float) -> None: pos[vertex] = b else: pos[vertex] = x - other.cuts.append(sx) + other.cuts.append(x) break @@ -319,8 +342,9 @@ def _resolve_crossings(walls, pos, segs, tol: float) -> None: 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) + crossing = _add(a1, _mul(r, t)) + one.cuts.append(crossing) + other.cuts.append(crossing) def _pieces(walls, pos, segs, tol: float) -> list[_Piece]: @@ -335,7 +359,7 @@ def _pieces(walls, pos, segs, tol: float) -> list[_Piece]: ) u = _unit(a, b) stops: list[float] = [] - for cut in sorted(seg.cuts): + for cut in sorted(_dot(_sub(point, a), u) for point in seg.cuts): if tol < cut < length - tol and (not stops or cut - stops[-1] > tol): stops.append(cut) bounds = [0.0, *stops, length] @@ -362,8 +386,8 @@ def _canonical(end: int, side: str) -> str: 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 _thickness(piece: _Piece) -> float: + return piece.seg.left - piece.seg.right def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> dict: @@ -405,7 +429,7 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) 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] = [] + pairs: list[list] = [] for idx, (pi, ei) in enumerate(ordered): pj, ej = ordered[(idx + 1) % len(ordered)] oi, ui, ni, li, _ri = _outgoing(pi, ei) @@ -419,15 +443,33 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) ) 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) + mitre = None + if abs(_cross(ui, uj)) > 1e-12: + mitre = _meet(face_i, ui, face_j, uj) + reach = _MITRE_REACH * max(_thickness(pi), _thickness(pj)) + if _dot(ui, uj) < 0.0 and math.dist(mitre, node) > reach: + mitre = None # a near-straight join whose faces are offset: a step + pairs.append([pi, ei, pj, ej, face_i, ui, face_j, uj, mitre]) + if len(pairs) == 2 and any(pair[8] is None for pair in pairs): + # both sides of one straight-through join step on the same cut line, + # or the two pieces would leave a sliver between their ends + for pair in pairs: + pair[8] = None + hub: list[Pt] = [] + for pi, ei, pj, ej, face_i, ui, face_j, uj, mitre in pairs: + if mitre is not None: + ci = cj = mitre + else: + # the step: both faces stop on the line through the node that + # bisects the join - square across a straight join, and the line + # every equal-offset mitre of this node lies on anyway + cut = _left(_sub(ui, uj)) + ci = _meet(face_i, ui, node, cut) + cj = _meet(face_j, uj, node, cut) 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) @@ -435,6 +477,11 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) hub.append(cj) if len(ordered) >= 3: polygon = _polygon(hub) + if polygon and not _counter_clockwise(polygon): + raise ValueError( + f"the junction at ({node[0]:g}, {node[1]:g}) cannot be resolved into a " + "clean outline - its middle turns inside out; move the walls' ends apart." + ) if polygon: owner = min(piece.seg.wall for piece, _end in ordered) regions.append((polygon, walls[owner].id)) @@ -522,7 +569,7 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) 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): + if polygon and (is_self_intersecting(polygon) or not _counter_clockwise(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 " diff --git a/tests/test_arch_walls.py b/tests/test_arch_walls.py index 0ab3a0b..eb2ee3c 100644 --- a/tests/test_arch_walls.py +++ b/tests/test_arch_walls.py @@ -9,6 +9,9 @@ the stem's axis. from __future__ import annotations +import itertools +import math + import pytest from engineering.arch.model import Opening, Wall @@ -132,6 +135,117 @@ def test_every_region_is_counter_clockwise(): assert all(signed_area(r) > 0 for r in geo.regions) +# -- straight joins whose faces do not line up --------------------------------------- + + +def test_a_justification_change_on_a_straight_axis_steps_across_the_node(): + geo = wall_geometry( + [ + wall("a", (0, 0), (3000, 0), justification="left"), + wall("b", (3000, 0), (6000, 0), justification="right"), + ] + ) + assert segs(geo.faces) == { + S(0, 0, 3000, 0), + S(0, 200, 3000, 200), + S(3000, 200, 3000, 0), # the step: each face of a stops on the square cut + S(3000, 0, 3000, -200), # through the node, and so does b's + S(3000, 0, 6000, 0), + S(3000, -200, 6000, -200), + } + assert {ring(r) for r in geo.regions} == { + ring([(0, 0), (3000, 0), (3000, 200), (0, 200)]), + ring([(3000, -200), (6000, -200), (6000, 0), (3000, 0)]), + } + + +def test_a_rounding_kink_steps_like_the_straight_join_instead_of_mitring_kilometres_away(): + # 0.5 mm over 3 m: the faces 200 mm apart would meet at x = 1 203 000 + geo = wall_geometry( + [ + wall("a", (0, 0), (3000, 0), justification="left"), + wall("b", (3000, 0), (6000, 0.5), justification="right"), + ] + ) + points = [p for line in geo.faces for p in (line.p1, line.p2)] + # b's free end is square to its own axis: 200 * sin(0.5 / 3000) past x = 6000 + assert max(abs(x) for x, _y in points) <= 6000.0 + 0.04 + assert max(abs(y) for _x, y in points) <= 200.5 + 1e-6 + assert all(signed_area(r) > 0 for r in geo.regions) + assert sorted(signed_area(r) for r in geo.regions) == pytest.approx([600000, 600000], abs=50) + + +def test_a_thickness_change_at_a_small_kink_steps_at_the_node_and_does_not_flare(): + kink = math.radians(2.0) + geo = wall_geometry( + [ + wall("a", (0, 0), (3000, 0), thickness=100.0), + wall( + "b", + (3000, 0), + (3000 + 3000 * math.cos(kink), 3000 * math.sin(kink)), + thickness=300.0, + ), + ] + ) + a_faces = [line for line in geo.faces if abs(line.p1[1] - line.p2[1]) < 1e-9] + # a's two 100 mm faces stop at the node (the bisector of a 2 degree join), not + # 2865 mm into b where they would meet b's 300 mm faces + assert {round(y, 6) for line in a_faces for y in (line.p1[1], line.p2[1])} == {50.0, -50.0} + assert max(x for line in a_faces for x in (line.p1[0], line.p2[0])) < 3002.0 + assert all(signed_area(r) > 0 for r in geo.regions) + assert sorted(signed_area(r) for r in geo.regions) == pytest.approx([300000, 900000], abs=1) + + +def test_a_rounded_rotated_thickness_change_draws_the_same_step_as_the_exact_one(): + c, s = math.cos(math.radians(30)), math.sin(math.radians(30)) + + def along(d, digits=None): + x, y = d * c, d * s + return (round(x, digits), round(y, digits)) if digits is not None else (x, y) + + exact = wall_geometry( + [ + wall("a", along(0), along(3000), thickness=100.0), + wall("b", along(3000), along(6000), thickness=300.0), + ] + ) + rounded = wall_geometry( + [ + wall("a", along(0, 2), along(3000, 2), thickness=100.0), + wall("b", along(3000, 2), along(6000, 2), thickness=300.0), + ] + ) + assert len(rounded.faces) == len(exact.faces) == 6 + for mine, theirs in zip( + sorted(seg(line) for line in rounded.faces), + sorted(seg(line) for line in exact.faces), + strict=True, + ): + assert math.dist(mine[0], theirs[0]) < 0.05 and math.dist(mine[1], theirs[1]) < 0.05 + + +@pytest.mark.parametrize("kink", [0.0, 0.01, 0.5, 2.0, 8.0, 20.0, 45.0, -2.0, -30.0]) +@pytest.mark.parametrize("justs", list(itertools.product(("center", "left", "right"), repeat=2))) +@pytest.mark.parametrize("thicknesses", [(200.0, 200.0), (100.0, 300.0)]) +def test_a_straight_or_kinked_join_stays_near_its_node_and_every_region_is_ccw( + kink, justs, thicknesses +): + turn = math.radians(30.0 + kink) + p1 = (round(3000 * math.cos(math.radians(30)), 2), round(3000 * math.sin(math.radians(30)), 2)) + p2 = (round(p1[0] + 3000 * math.cos(turn), 2), round(p1[1] + 3000 * math.sin(turn), 2)) + geo = wall_geometry( + [ + wall("a", (0, 0), p1, thickness=thicknesses[0], justification=justs[0]), + wall("b", p1, p2, thickness=thicknesses[1], justification=justs[1]), + ] + ) + reach = 6000 + 2 * max(thicknesses) + assert all(math.hypot(*p) < reach for line in geo.faces for p in (line.p1, line.p2)) + assert len(geo.regions) == 2 + assert all(signed_area(r) > 0 for r in geo.regions) + + # -- T ----------------------------------------------------------------------------- @@ -163,6 +277,47 @@ def test_a_stem_drawn_to_the_face_is_carried_to_the_axis(): assert segs(touching.faces) == segs(on_axis.faces) +# c runs along y = 0; a is drawn to c's face (its start is carried from y = 100 +# down to c's axis) and b is a T into a's middle at y = 2000. The T on a must not +# depend on whether a's start has been carried yet when b is resolved. +T_ON_A_CARRIED_WALL = { + S(-1000, -100, 5000, -100), # c's far face, whole + S(-1000, 100, 1900, 100), # c's near face, cut by a + S(2100, 100, 5000, 100), + S(1900, 100, 1900, 1900), # a's near face to b, cut by b at 1900 / 2100 + S(1900, 2100, 1900, 4000), + S(2100, 100, 2100, 4000), # a's far face to b, whole + S(0, 1900, 1900, 1900), # b stops on a's near face + S(0, 2100, 1900, 2100), +} + + +@pytest.mark.parametrize("order", list(itertools.permutations("cab"))) +def test_a_t_into_a_carried_wall_does_not_depend_on_the_order_of_the_walls(order): + walls = { + "c": wall("c", (-1000, 0), (5000, 0)), + "a": wall("a", (2000, 100), (2000, 4000)), + "b": wall("b", (0, 2000), (2000, 2000)), + } + geo = wall_geometry([walls[name] for name in order]) + assert segs(geo.faces) == T_ON_A_CARRIED_WALL + assert segs(geo.jambs) == { + S(-1000, -100, -1000, 100), + S(5000, -100, 5000, 100), + S(1900, 4000, 2100, 4000), + S(0, 1900, 0, 2100), # b's free end - and no jamb inside wall a + } + assert {ring(r) for r in geo.regions} == { + ring([(-1000, -100), (2000, -100), (1900, 100), (-1000, 100)]), + ring([(2000, -100), (5000, -100), (5000, 100), (2100, 100)]), + ring([(2100, 100), (1900, 100), (2000, -100)]), # the hub on c + ring([(2100, 100), (2100, 2000), (1900, 1900), (1900, 100)]), # a, split at y = 2000 + ring([(2100, 2000), (2100, 4000), (1900, 4000), (1900, 2100)]), + ring([(1900, 2100), (1900, 1900), (2100, 2000)]), # the hub on a + ring([(0, 1900), (1900, 1900), (1900, 2100), (0, 2100)]), + } + + # -- X -----------------------------------------------------------------------------