fix(task-2): order-independent T cuts and bounded near-straight joins

A T recorded its cut on the crossing segment as a distance from that
segment's start; when the start was carried onto a third wall later in the
same pass the cut went stale, so [c,b,a] drew a free-end jamb inside wall a,
an uncut face and a double-hatched overlap where [c,a,b] was right. Cuts are
now stored as points and measured against the final axis.

A near-straight join whose faces are offset (a thickness or justification
change, or a rounding kink between such walls) mitred at offset/sin(kink):
faces 1e8 mm long and a clockwise region for a 0.01 mm rounding. A
straight-through pair whose mitre lies more than _MITRE_REACH (2) thicknesses
from the node now steps on the line bisecting the join - both sides of a
two-end node together, so the pieces tile without a sliver - and every piece
and hub ring is refused unless counter-clockwise, not only when it crosses
itself.

Pure geometry: no ActiveX member is touched, so no live probe applies.

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:46:39 +03:00
co-authored by Claude Opus 5.5
parent dae48d9037
commit 9e9138f22d
2 changed files with 218 additions and 16 deletions
+63 -16
View File
@@ -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 "
+155
View File
@@ -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 -----------------------------------------------------------------------------