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,
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)