geom: port sb-join, the junction and envelope strategies

Face lines, structural butt joints, hull caps, concave fillets, the
derived bore, ring fit, ring envelope and section assembly. N-generic
throughout, as the reference is.

Junctions are structural, not cosmetic: one sleeve runs through its
neighbour and is cut flush against that member the far surface, so the
two share a full-width overlap whether or not a fillet is applied on
top. The bore is derived from the members own inside-wall lines rather
than a separately scaled shape, which is what makes the declared inside
wall exactly what remains beside each cavity.

43 tests. Mutation testing found two of the reference own warnings to be
load-bearing and untested by me. A bore that has turned inside out can
carry over a square millimetre of area, so the area guard alone accepts
it and only the interior-side test rejects it. And the ring fit really
does have a spurious lower branch: a thin triangle meets a 1.2 mm web at
relative scale 0.425, where members overhang their own corners and the
solve looks converged. Both now covered.

A third mutation was malformed on my part rather than a gap -- cutting
the cavities twice is idempotent -- and was replaced with one that does
change behaviour. Nine mutations caught.

Oracle acceptance still skips; 236 unchanged.
This commit is contained in:
2026-08-19 05:28:23 -05:00
parent 38ea024fdc
commit ebf02d6573
3 changed files with 915 additions and 0 deletions
+490
View File
@@ -0,0 +1,490 @@
"""
Unit tests for the junction, bore and ring-fit strategies.
Two things are checked here that the primitive suites cannot reach: that the
solvers converge on the quantity they claim to solve, and that the structural
claims in the reference's own comments actually hold. Where a comment says a
unit square and a 200 mm square must fit to the same result, that is a testable
assertion and it is tested.
The rejection paths matter as much as the acceptance paths. Ten of the oracle's
123 cases are rejections, and a port that produces good geometry while quietly
dropping the constraints that made it trustworthy is a failed port.
"""
from __future__ import annotations
import math
import pytest
from mechcomp.geom.join import (
bore_from_members,
bore_valid,
cavity_region,
centering_shift,
end_face,
face_line,
far_face_line,
fillet_concave,
fillet_junctions,
fit_ring,
hull_cap,
ring_fit_scale,
ring_max_corner_r,
ring_members,
ring_shell,
ring_web,
scale_about_centroid,
section,
sleeve_butt,
sleeve_shell,
strap_region,
fillet_pair,
)
from mechcomp.geom.primitives import (
SB_FACE_BOTH_OUT,
SB_FACE_PLUS_IN,
sb_dist,
sb_path_gap,
sb_signed_area,
)
from mechcomp.geom.records import (Geo, Member, cavity_path, sleeve_path,
strap_path)
from mechcomp.geom.region import area, is_region_simple, nparts, pointlist_bounds
def geo(**kw) -> Geo:
base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.2,
wall_inside=1.0, wall_outside=1.6, wall_edge=1.2, min_wall=0.8)
base.update(kw)
return Geo(**base)
def ngon(n: int, r: float = 20.0, start: float = 90.0):
return [(r * math.cos(math.radians(start + i * 360.0 / n)),
r * math.sin(math.radians(start + i * 360.0 / n)))
for i in range(n)]
TRI = ngon(3)
QUAD = ngon(4)
# ----------------------------------------------------------------------------
# Face lines
# ----------------------------------------------------------------------------
def test_face_lines_sit_at_the_declared_reaches():
g = geo()
m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN)
up, _ = face_line(m, g, 1)
dn, _ = face_line(m, g, -1)
assert up[1] == pytest.approx(g.reach_plus(SB_FACE_PLUS_IN))
assert dn[1] == pytest.approx(-g.reach_minus(SB_FACE_PLUS_IN))
def test_face_line_direction_is_the_member_axis():
g = geo()
m = Member(0.0, 0.0, 37.0)
assert face_line(m, g, 1)[1] == pytest.approx(m.axis)
def test_far_face_line_picks_the_farther_surface():
"""The point of this is not needing to know which way either member faces."""
g = geo()
m = Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT)
from_below = far_face_line(m, g, (0.0, -100.0))
from_above = far_face_line(m, g, (0.0, 100.0))
assert from_below[0][1] > 0
assert from_above[0][1] < 0
# ----------------------------------------------------------------------------
# Junctions
# ----------------------------------------------------------------------------
def test_sleeve_butt_runs_through_to_the_far_surface():
"""
The butted sleeve must reach past its neighbour's centreline and stop on the
far face, not at the near one. That full-width overlap is what carries load.
The arriving member sits at 0 degrees, so its trailing end -- the end
sleeve_butt cuts -- faces the origin and its leading end faces away.
"""
g = geo()
target = Member(0.0, 0.0, 90.0, SB_FACE_BOTH_OUT)
arriving = Member(20.0, 0.0, 0.0, SB_FACE_BOTH_OUT)
butted = sleeve_butt(arriving, g, target)
far_face = -g.reach_minus(SB_FACE_BOTH_OUT)
assert min(p[0] for p in butted) == pytest.approx(far_face)
assert min(p[0] for p in butted) < 0.0
def test_sleeve_butt_leaves_the_free_end_alone():
"""Only the trailing end is cut; the leading end keeps its edge wall."""
g = geo()
target = Member(0.0, 0.0, 90.0)
arriving = Member(20.0, 0.0, 0.0)
plain = sleeve_path(arriving, g)
butted = sleeve_butt(arriving, g, target)
assert max(p[0] for p in butted) == pytest.approx(max(p[0] for p in plain))
def test_end_face_spans_the_full_sleeve_thickness():
g = geo()
m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN)
a, b = end_face(m, g, 1)
expected = g.reach_plus(SB_FACE_PLUS_IN) + g.reach_minus(SB_FACE_PLUS_IN)
assert sb_dist(a, b) == pytest.approx(expected)
def test_end_face_ends_are_opposite():
g = geo()
m = Member(0.0, 0.0, 0.0)
lead = end_face(m, g, 1)
trail = end_face(m, g, -1)
assert lead[0][0] > 0 and trail[0][0] < 0
def test_hull_cap_needs_three_points():
assert hull_cap([[(0.0, 0.0), (1.0, 0.0)]]) == []
def test_hull_cap_plugs_the_gap_between_end_faces():
g = geo()
faces = [end_face(Member(0.0, 0.0, a, SB_FACE_BOTH_OUT), g, 1)
for a in (0.0, 120.0, 240.0)]
cap = hull_cap(faces)
assert len(cap) >= 3
assert area([cap]) > 0
# ----------------------------------------------------------------------------
# Fillets -- cosmetic, never structural
# ----------------------------------------------------------------------------
def test_fillet_concave_leaves_a_convex_path_bit_exact():
square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
assert fillet_concave(square, 2.0) == square
def test_fillet_concave_rounds_only_the_reflex_corner():
ell = [(0.0, 0.0), (10.0, 0.0), (10.0, 4.0),
(4.0, 4.0), (4.0, 10.0), (0.0, 10.0)]
rounded = fillet_concave(ell, 1.0)
assert len(rounded) > len(ell)
for v in (0.0, 0.0), (10.0, 0.0), (10.0, 4.0), (0.0, 10.0):
assert v in rounded
def test_fillet_pair_with_no_radius_returns_both_untouched():
a = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)]
b = [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)]
assert fillet_pair(a, b, 0.0) == [a, b]
def test_fillet_pair_leaves_non_merging_solids_alone():
"""Nothing sane to round, so the pair comes back rather than guessed at."""
a = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)]
b = [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)]
assert len(fillet_pair(a, b, 1.0)) == 2
def test_fillet_pair_rounds_a_merged_junction():
a = [(0.0, 0.0), (10.0, 0.0), (10.0, 3.0), (0.0, 3.0)]
b = [(0.0, 0.0), (3.0, 0.0), (3.0, 10.0), (0.0, 10.0)]
merged = fillet_pair(a, b, 1.0)
assert len(merged) == 1
assert len(merged[0]) > 6
# ----------------------------------------------------------------------------
# Bore
# ----------------------------------------------------------------------------
def test_bore_needs_every_member_to_have_an_interior_face():
g = geo()
ms = ring_members(TRI, g)
ms[0] = Member(ms[0].cx, ms[0].cy, ms[0].angle, SB_FACE_BOTH_OUT)
assert bore_from_members(ms, g) == []
def test_bore_has_one_vertex_per_member():
g = geo()
for poly in (TRI, QUAD, ngon(5)):
ms = ring_members(poly, g)
assert len(bore_from_members(ms, g)) == len(poly)
def test_bore_is_inset_from_the_centreline_polygon():
"""The bore is bounded by inside-wall surfaces, so it sits inside."""
g = geo()
ms = ring_members(TRI, g)
bore = bore_from_members(ms, g)
assert abs(sb_signed_area(bore)) < abs(sb_signed_area(TRI))
def test_bore_valid_rejects_a_degenerate_interior():
"""
A collapsed interior turns itself inside out rather than vanishing, so area
alone proves nothing -- the centre must lie inside every inside wall.
"""
g = geo()
ms = ring_members(TRI, g)
assert not bore_valid([(0.0, 0.0), (1e-3, 0.0), (0.0, 1e-3)], ms, g)
def test_bore_valid_accepts_a_real_interior():
g = geo()
fitted = fit_ring(TRI, geo(), 1.2)
ms = ring_members(fitted, g)
assert bore_valid(bore_from_members(ms, g), ms, g)
# ----------------------------------------------------------------------------
# Ring members and the fit solver
# ----------------------------------------------------------------------------
def test_ring_has_one_member_per_edge_all_facing_inward():
g = geo()
ms = ring_members(TRI, g)
assert len(ms) == 3
assert all(m.face != SB_FACE_BOTH_OUT for m in ms)
def test_ring_members_are_centred_on_their_edges():
"""Centring is what keeps the profile mirror-symmetric."""
g = geo()
ms = ring_members(QUAD, g)
for i, m in enumerate(ms):
a, b = QUAD[i], QUAD[(i + 1) % 4]
assert (m.cx, m.cy) == pytest.approx(((a[0] + b[0]) / 2,
(a[1] + b[1]) / 2))
def test_fit_ring_hits_the_requested_web():
for poly in (TRI, QUAD):
fitted = fit_ring(poly, geo(), 1.2)
assert fitted is not None
assert ring_web(fitted, geo()) == pytest.approx(1.2, abs=1e-6)
def test_fit_is_shape_only_not_size():
"""
The reference states this outright: a unit polygon and a 200 mm polygon must
fit to the same result, because the outline carries shape and the solver
supplies scale.
"""
small = fit_ring(ngon(3, 1.0), geo(), 1.2)
large = fit_ring(ngon(3, 200.0), geo(), 1.2)
assert ring_web(small, geo()) == pytest.approx(ring_web(large, geo()),
abs=1e-6)
assert abs(sb_signed_area(small)) == pytest.approx(abs(sb_signed_area(large)),
rel=1e-6)
def test_fit_returns_none_when_the_web_is_unreachable():
"""An infeasible request must be reported, not approximated."""
assert fit_ring(TRI, geo(), 1e6) is None
def test_a_larger_web_needs_a_larger_polygon():
a = fit_ring(TRI, geo(), 1.0)
b = fit_ring(TRI, geo(), 3.0)
assert abs(sb_signed_area(b)) > abs(sb_signed_area(a))
def test_scale_about_centroid_preserves_shape():
scaled = scale_about_centroid(TRI, 3.0)
assert abs(sb_signed_area(scaled)) == pytest.approx(
9.0 * abs(sb_signed_area(TRI)))
# ----------------------------------------------------------------------------
# Ring envelope
# ----------------------------------------------------------------------------
def test_ring_shell_encloses_the_centreline_polygon():
g = geo()
shell = ring_shell(TRI, g, 0.0)
assert abs(sb_signed_area(shell)) > abs(sb_signed_area(TRI))
def test_ring_shell_corner_radius_removes_material():
g = geo()
sharp = ring_shell(TRI, g, 0.0)
rounded = ring_shell(TRI, g, 2.0)
assert abs(sb_signed_area(rounded)) < abs(sb_signed_area(sharp))
def test_max_corner_r_leaves_the_minimum_wall_intact():
"""
Corner rounding eats material exactly where a cavity approaches the corner,
so the derived radius has to be checked against min_wall, not assumed safe.
"""
g = geo()
fitted = fit_ring(TRI, g, 1.2)
r = ring_max_corner_r(fitted, g)
shell = ring_shell(fitted, g, r)
cav = [cavity_path(m, g) for m in ring_members(fitted, g)]
assert min(sb_path_gap(shell, c) for c in cav) >= g.min_wall - 1e-4
def test_exceeding_max_corner_r_breaches_the_minimum_wall():
"""The derived limit is a real boundary, not a conservative guess."""
g = geo()
fitted = fit_ring(TRI, g, 1.2)
r = ring_max_corner_r(fitted, g)
shell = ring_shell(fitted, g, r * 1.5)
cav = [cavity_path(m, g) for m in ring_members(fitted, g)]
assert min(sb_path_gap(shell, c) for c in cav) < g.min_wall
# ----------------------------------------------------------------------------
# Assembly
# ----------------------------------------------------------------------------
def _ring_section(poly, web=1.2, g=None):
g = g or geo()
fitted = fit_ring(poly, g, web)
ms = ring_members(fitted, g)
bore = bore_from_members(ms, g)
r = ring_max_corner_r(fitted, g)
shell = [ring_shell(fitted, g, r)]
return section(shell, ms, g, bore), ms, g
def test_a_fitted_ring_builds_one_connected_solid():
sec, ms, g = _ring_section(TRI)
assert nparts(sec) == 1
def test_channel_count_matches_the_member_count():
for poly in (TRI, QUAD):
sec, ms, g = _ring_section(poly)
assert nparts(cavity_region(ms, g)) == len(poly)
def test_the_finished_section_is_tessellatable():
"""The manifold precondition, checked on a real profile rather than a fixture."""
sec, _, _ = _ring_section(TRI)
assert is_region_simple(sec)
def test_cavities_are_removed_from_the_solid():
sec, ms, g = _ring_section(TRI)
shell_area = area(sec) + area(cavity_region(ms, g))
assert area(sec) < shell_area
def test_no_member_pla_intrudes_into_another_channel():
"""
Every sleeve is unioned before any cavity is cut, which is what guarantees
this. Each strap must sit in clear space.
"""
sec, ms, g = _ring_section(TRI)
for m in ms:
strap = strap_path(m, g)
for path in sec:
assert sb_path_gap(strap, path) > 0.0
def test_bore_is_absent_when_not_supplied():
"""Without a bore the interior stays solid, so the section is heavier."""
g = geo()
fitted = fit_ring(TRI, g, 1.2)
ms = ring_members(fitted, g)
shell = [ring_shell(fitted, g, 0.0)]
assert area(section(shell, ms, g)) > \
area(section(shell, ms, g, bore_from_members(ms, g)))
def test_centering_shift_puts_the_envelope_on_the_origin():
g = geo()
shell = [ring_shell(fit_ring(TRI, g, 1.2), g, 0.0)]
dx, dy = centering_shift(shell)
moved = [[(p[0] + dx, p[1] + dy) for p in path] for path in shell]
lo, hi = pointlist_bounds([p for path in moved for p in path])
assert lo[0] + hi[0] == pytest.approx(0.0, abs=1e-9)
assert lo[1] + hi[1] == pytest.approx(0.0, abs=1e-9)
def test_sleeve_shell_merges_overlapping_sleeves():
g = geo()
a = sleeve_path(Member(0.0, 0.0, 0.0), g)
b = sleeve_path(Member(0.0, 0.0, 90.0), g)
assert nparts(sleeve_shell([a, b])) == 1
def test_fillet_junctions_with_no_radius_is_a_no_op():
g = geo()
paths = [sleeve_path(Member(0.0, 0.0, 0.0), g),
sleeve_path(Member(0.0, 0.0, 90.0), g)]
shell = sleeve_shell(paths)
assert fillet_junctions(shell, [(0, 1)], paths, 0.0) == shell
def test_fillet_junctions_adds_material_at_the_junction():
g = geo()
paths = [sleeve_path(Member(0.0, 0.0, 0.0), g),
sleeve_path(Member(0.0, 0.0, 90.0), g)]
shell = sleeve_shell(paths)
filleted = fillet_junctions(shell, [(0, 1)], paths, 1.0)
assert area(filleted) > area(shell)
# ----------------------------------------------------------------------------
# Cases found by mutation testing
# ----------------------------------------------------------------------------
THIN_TRI = [(0.0, 40.0), (-3.0, -5.0), (3.0, -5.0)]
def test_bore_valid_rejects_an_inverted_interior_with_real_area():
"""
A ring too small for its straps produces a bore that has turned itself
inside out: at R = 2 its area is over 1 mm2, far past the 0.01 guard, yet
its centre lies on the wrong side of every inside wall.
Area alone would accept this. The interior-side test is what rejects it.
"""
g = geo()
ms = ring_members(ngon(3, 2.0), g)
bore = bore_from_members(ms, g)
assert abs(sb_signed_area(bore)) > 0.01
assert not bore_valid(bore, ms, g)
def test_fit_never_settles_on_the_spurious_lower_branch():
"""
A thin triangle measures a web of 1.385 at relative scale 0.425 -- above the
1.2 target and below 1. There the shortest edge is under one strap width, so
that member overhangs both of its own corners and the corner-setback model
no longer describes the geometry.
The search starts at 1 for exactly this reason. Starting at 0 finds the
lower root, and the result looks like a converged solve.
"""
g = geo()
fitted = fit_ring(THIN_TRI, g, 1.2)
assert fitted is not None
n = len(fitted)
shortest = min(sb_dist(fitted[i], fitted[(i + 1) % n]) for i in range(n))
assert shortest >= g.width - 1e-6
def test_section_cuts_the_bore_when_one_is_supplied():
"""
The bore is unioned with the cavities before the cut, so a supplied bore
must actually open the interior rather than being carried along unused.
"""
g = geo()
fitted = fit_ring(TRI, g, 1.2)
ms = ring_members(fitted, g)
bore = bore_from_members(ms, g)
shell = [ring_shell(fitted, g, 0.0)]
with_bore = section(shell, ms, g, bore)
without = section(shell, ms, g)
assert area(without) - area(with_bore) == pytest.approx(
abs(sb_signed_area(bore)), rel=1e-6)