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
+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 -----------------------------------------------------------------------------