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
+24
View File
@@ -80,3 +80,27 @@ from .region import ( # noqa: F401
to_shapely, to_shapely,
union, union,
) )
from .join import ( # noqa: F401
bore_from_members,
bore_valid,
cavity_region,
centering_shift,
end_face,
face_line,
far_face_line,
fillet_concave,
fillet_junctions,
fillet_pair,
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,
)
+401
View File
@@ -0,0 +1,401 @@
"""
Port of ``legacy/openscad/lib/sb-join.scad`` -- junction and envelope strategies.
Everything here is written for N members and is shared verbatim by the 3x, 4x
and any later generator. Three families of strategy live here.
ENVELOPE
How the outer PLA+ solid is generated: *sleeve style*, the union of
per-member sleeves, for open profiles; *ring style*, one closed envelope
offset from a centreline polygon with solid rounded corners.
BORE
The enclosed central void, derived from the members' actual inside-wall
lines rather than from a separately scaled shape. That is what makes the
declared inside wall exactly what remains between each cavity and the void.
FIT
Placement solved against a measured web, so a declared wall thickness is the
wall thickness you get.
The structural idea worth keeping in view while reading: junctions are made by
running one sleeve's trailing end all the way through its neighbour and cutting
it flush against that member's far surface. The two then share a full-width
overlap, so the joint carries load whether or not a fillet is applied
afterwards. Fillets here are cosmetic, applied on top of a structural butt
joint, never in place of one.
"""
from __future__ import annotations
import math
from typing import List, Optional, Sequence, Tuple
from .primitives import (
Path,
Point,
sb_ccw,
sb_centroid,
sb_corner_radii,
sb_dist,
sb_line_isect,
sb_path_gap,
sb_path_max_round,
sb_signed_area,
sb_solvable,
sb_solve,
)
from .records import Geo, Member, cavity_path, member_on_edge, sleeve_path, \
sleeve_to_line, strap_path
from .region import (
Region,
as_region,
difference,
hull_region,
pointlist_bounds,
offset_path,
union,
)
from .rounding import round_corners
Line = Tuple[Point, Point]
# ----------------------------------------------------------------------------
# Face lines
# ----------------------------------------------------------------------------
def face_line(m: Member, g: Geo, side: int) -> Line:
"""
One of a member's two long sleeve surfaces, as an infinite line.
``side = +1`` selects the local +Y surface, ``-1`` the local -Y surface.
Returns ``(point, direction)``.
"""
c = m.centre
n = m.normal
d = g.reach_plus(m.face) if side > 0 else g.reach_minus(m.face)
return ((c[0] + side * d * n[0], c[1] + side * d * n[1]), m.axis)
def far_face_line(m: Member, g: Geo, from_pt: Point) -> Line:
"""
The surface of ``m`` lying farther from ``from_pt``.
Lets one member butt flush against the far side of another without either
needing to know which way the other is facing.
"""
a = face_line(m, g, 1)
b = face_line(m, g, -1)
return a if sb_dist(a[0], from_pt) >= sb_dist(b[0], from_pt) else b
# ----------------------------------------------------------------------------
# Structural junctions
# ----------------------------------------------------------------------------
def sleeve_butt(m: Member, g: Geo, into: Member,
ext_lead: float = 0.0) -> List[Point]:
"""
The core junction primitive.
Rather than letting two sleeves clip each other at a corner and relying on a
cosmetic fillet to hold the result together, the trailing end of ``m`` is
run all the way through ``into`` and cut off flush with that member's far
surface. ``ext_lead`` extends the opposite, free end, which is left
untouched.
"""
pt, direction = far_face_line(into, g, m.centre)
return sleeve_to_line(m, g, pt, direction, ext_lead)
def end_face(m: Member, g: Geo, end: int = 1, extra: float = 0.0) -> List[Point]:
"""
The end-face segment of a member at its leading (+1) or trailing (-1) end,
taken at the sleeve surface. ``extra`` pushes the face further along the
axis.
"""
c = m.centre
u = m.axis
n = m.normal
f = m.face
half = g.cavity_w / 2.0 + g.wall_edge + extra
p = (c[0] + end * half * u[0], c[1] + end * half * u[1])
up = g.reach_plus(f)
dn = g.reach_minus(f)
return [(p[0] + n[0] * up, p[1] + n[1] * up),
(p[0] - n[0] * dn, p[1] - n[1] * dn)]
def hull_cap(segments: Sequence[Sequence[Point]]) -> List[Point]:
"""
Plug the space enclosed by a set of member end faces with their convex hull.
Deterministic, cheap, and free of the spikes and V-notches a bare union of
crossing rectangles leaves behind. Used for spoke-style centres and gable
apexes.
"""
pts = [p for seg in segments for p in seg]
if len(pts) < 3:
return []
return hull_region([pts])
def fillet_concave(path: Path, r: float) -> List[Point]:
"""
Round only the reflex corners of a path, leaving every convex corner
bit-exact. Each radius is clamped to what its own corner can accept, so the
operation cannot fail on a tight junction.
This replaces the morphological closing used in earlier revisions. Closing
had three problems: an inward offset on a many-vertex path is the least
reliable operation in the pipeline and raises a library-level error rather
than reporting one; its arc discretisation is not mirror-symmetric, so it
quietly made symmetric profiles chiral; and it filled every concavity within
reach rather than the junction actually being treated.
"""
pts = list(path)
if len(pts) < 3:
return pts
radii = sb_corner_radii(pts, r)
if max(radii) <= 1e-6:
return pts
return round_corners(pts, radii, closed=True)
def fillet_pair(path_a: Path, path_b: Path, r: float) -> Region:
"""
Fillet the junction between two sleeves. Cosmetic only.
If the two solids do not merge into a single simple outline there is nothing
sane to round, so the pair is returned untouched rather than guessed at.
"""
if r <= 0:
return [list(path_a), list(path_b)]
u = as_region(union([[list(path_a)], [list(path_b)]]))
return [fillet_concave(u[0], r)] if len(u) == 1 else u
# ----------------------------------------------------------------------------
# Bore -- the enclosed central void
# ----------------------------------------------------------------------------
def bore_from_members(ms: Sequence[Member], g: Geo) -> List[Point]:
"""
The polygon bounded by the members' actual inside-wall surfaces.
Members must be supplied in cyclic order around the interior, each with a
real interior face. Because the bore is derived from those surfaces rather
than from a separately scaled shape, the declared inside wall is exactly
what remains between each cavity and the void.
Nothing here is specific to three members; a four-sided profile produces a
quadrilateral bore from the same call. Returns ``[]`` when any pair of
consecutive inside lines is parallel, which means no closed interior.
"""
if any(m.face == 0 for m in ms):
return []
n = len(ms)
pts = [m.inside_wall_pt(g) for m in ms]
dirs = [m.axis for m in ms]
verts: List[Point] = []
for i in range(n):
v = sb_line_isect(pts[(i - 1) % n], dirs[(i - 1) % n], pts[i], dirs[i])
if v is None:
return []
verts.append(v)
return verts
def bore_valid(bore: Sequence[Point], ms: Sequence[Member], g: Geo) -> bool:
"""
Is the derived bore real?
A collapsed interior does not vanish, it turns itself inside out, so area
alone proves nothing. The test that matters is that the bore's own centre
still lies on the interior side of every member's inside wall.
"""
if len(bore) < 3:
return False
if abs(sb_signed_area(bore)) <= 0.01:
return False
c = sb_centroid(bore)
for m in ms:
d = m.inside_dir
p = m.inside_wall_pt(g)
if d is None:
return False
if d[0] * (c[0] - p[0]) + d[1] * (c[1] - p[1]) <= 0.01:
return False
return True
# ----------------------------------------------------------------------------
# Ring profiles -- members along the edges of a closed polygon
# ----------------------------------------------------------------------------
def ring_members(path: Path, g: Geo) -> List[Member]:
"""
One member per edge, each centred on its edge, interior face towards the
polygon centroid.
Centring keeps the profile mirror-symmetric; the corner webs are then set by
the polygon's size, solved for below.
"""
c = sb_centroid(path)
n = len(path)
return [member_on_edge(path[i], path[(i + 1) % n], c) for i in range(n)]
def ring_web(path: Path, g: Geo) -> float:
"""Smallest PLA+ web between any two neighbouring strap cavities on the ring."""
ms = ring_members(path, g)
n = len(ms)
cv = [cavity_path(m, g) for m in ms]
return min(sb_path_gap(cv[i], cv[(i + 1) % n]) for i in range(n))
def scale_about_centroid(path: Path, k: float) -> List[Point]:
c = sb_centroid(path)
return [(c[0] + k * (p[0] - c[0]), c[1] + k * (p[1] - c[1])) for p in path]
def ring_fit_scale(path: Path, g: Geo, web: float,
max_scale: float = 12.0) -> Optional[float]:
"""
Grow the caller's polygon about its centroid until the tightest corner web
reaches ``web``.
Straps have a fixed width, so on a polygon of a given size the corner webs
are whatever they are -- they cannot be dialled in by sliding members along
their edges, because every edge shares its budget with two corners. The only
free variable that raises all N webs at once is the polygon's size.
The outline is normalised first, so the caller's polygon really is shape
only: a unit square and a 200 mm square must fit to the same result. At
relative scale 1 the shortest edge is exactly one strap wide.
The search starts at 1, never below. Once an edge is shorter than a strap,
that member overhangs both of its own corners and the corner-setback model
no longer describes the geometry -- yet the measured web can come back
positive there, which is exactly the kind of spurious lower branch a
bisection will happily settle on.
"""
n = len(path)
edges = [sb_dist(path[i], path[(i + 1) % n]) for i in range(n)]
k0 = g.width / min(edges)
def f(k: float) -> float:
return ring_web(scale_about_centroid(path, k0 * k), g)
if not sb_solvable(f, max_scale, web):
return None
return k0 * sb_solve(f, 1.0, max_scale, web)
def fit_ring(path: Path, g: Geo, web: float,
max_scale: float = 12.0) -> Optional[List[Point]]:
k = ring_fit_scale(path, g, web, max_scale)
return None if k is None else scale_about_centroid(path, k)
def ring_shell(path: Path, g: Geo, corner_r: float = 0.0) -> List[Point]:
"""
Outer envelope of a ring profile: the centreline polygon pushed out to the
outside-wall surface, with its corners rounded.
Corner rounding removes material from precisely the region where a strap
cavity approaches the corner, so the caller must check the result against
the minimum wall rather than assume a radius is safe.
"""
sharp = offset_path(sb_ccw(path), g.cavity_t / 2.0 + g.wall_outside,
closed=True)
if corner_r > 0:
return round_corners(sharp, corner_r, closed=True)
return sharp
def ring_max_corner_r(path: Path, g: Geo) -> float:
"""
Largest corner radius that still leaves ``min_wall`` between the envelope and
every strap cavity, and that the envelope can geometrically accept.
Reported so a catalogue entry can be tuned once and then trusted.
The solve runs on ``hi - r`` rather than on the radius directly, because the
measured clearance falls as the radius grows and the bisection requires a
non-decreasing function.
"""
sharp = ring_shell(path, g, 0.0)
hi = sb_path_max_round(sharp)
ms = ring_members(path, g)
cav = [cavity_path(m, g) for m in ms]
def f(r: float) -> float:
sh = ring_shell(path, g, max(0.0, hi - r))
if len(sh) < 3:
return 0.0
return min(sb_path_gap(sh, c) for c in cav)
if hi <= 0:
return 0.0
return max(0.0, hi - sb_solve(f, 0.0, hi, g.min_wall - 1e-6))
# ----------------------------------------------------------------------------
# Sleeve profiles -- union of per-member sleeves
# ----------------------------------------------------------------------------
def sleeve_shell(paths: Sequence[Path]) -> Region:
return as_region(union([[list(p)] for p in paths]))
def fillet_junctions(shell: Sequence[Path], pairs: Sequence[Tuple[int, int]],
paths: Sequence[Path], r: float) -> Region:
"""
Apply one cosmetic fillet per declared junction, each computed from only the
two members involved, then merge with the untouched shell.
Keeping the closings pairwise stops distant parts of the profile from
bridging to each other through the middle of the section.
"""
if r <= 0:
return as_region(shell)
regions: List[Sequence[Path]] = [list(shell)]
for a, b in pairs:
regions.append(fillet_pair(paths[a], paths[b], r))
return as_region(union(regions))
# ----------------------------------------------------------------------------
# Assembly
# ----------------------------------------------------------------------------
def section(shell: Sequence[Path], members: Sequence[Member], g: Geo,
bore: Sequence[Point] = ()) -> Region:
"""
The one place where solid and void meet.
All sleeve solids are unioned first and every cavity is removed afterwards,
so no member's PLA+ can ever intrude into another member's strap channel.
"""
cavities = as_region(union([[cavity_path(m, g)] for m in members]))
if len(bore) >= 3:
cut = as_region(union([[list(bore)], cavities]))
else:
cut = cavities
return as_region(difference(shell, cut))
def strap_region(members: Sequence[Member], g: Geo) -> Region:
return as_region(union([[strap_path(m, g)] for m in members]))
def cavity_region(members: Sequence[Member], g: Geo) -> Region:
return as_region(union([[cavity_path(m, g)] for m in members]))
def centering_shift(shell: Sequence[Path]) -> Point:
"""Translation putting the finished envelope's bounding box on the origin."""
b = pointlist_bounds(hull_region(shell))
return (-(b[0][0] + b[1][0]) / 2.0, -(b[0][1] + b[1][1]) / 2.0)
+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)