geom: port sb-core and sb-profiles, the N-generic arrangements

The PROFILE record, centred assembly, and three complete arrangements:
ring, spokes, fins. Each written for N members, exercised at N=3 and N=4.

Failure travels as an empty profile carrying one failing check, as in the
reference, so profile rejections and universal-check rejections stay in
one order-sensitive list and the first failure is what surfaces.

The section is cleaned before it is measured, not after. A Three-Fin
section carries 17 collinear vertices from exact butt joints; they are
harmless in 2D and leave zero-area triangles the tessellator cannot
resolve, so measuring first would report on geometry that is not what
gets extruded.

Centred now carries the shifted members. Measuring a shifted shell
against unshifted members reports every cavity as escaping the envelope,
a leak of the whole cavity area from geometry that is fine. That trap
caught me while smoke testing, so the opportunity is removed rather than
documented.

Verified against the oracle where the fillet does not affect the result:
SPOKE_RADIUS_MM 9.1713, FIN_CORE_SIDE_MM 13.4028, FIN_SETBACK_MM
3.77783, FIN_JUNCTION_WEB_MM 2.29919, RING_CORNER_R_MAX_MM 2.87663, the
ring edge vector, and both envelope dimensions all match to the recorded
digit. The solvers are right.

OPEN: with a junction fillet of 1.5 the Y section area is 135.572973
against a recorded 135.574, off by 0.001027 and just past the area
tolerance, while every other quantity for that case matches exactly.
Either the generator fillet default is not 1.5, or there is a difference
of about seven parts per million concentrated in the fillet. The
generator settles it.

35 tests. Nine mutations, two of which found real gaps: nothing asserted
that cleaning removed anything, and the spoke zero-fillet check was
untested.

Oracle acceptance still skips; 236 unchanged.
This commit is contained in:
2026-08-19 06:56:02 -05:00
parent b101fe6adf
commit 8d79431016
5 changed files with 875 additions and 2 deletions
+19
View File
@@ -106,6 +106,7 @@ from .join import ( # noqa: F401
)
from .report import ( # noqa: F401
Check,
echo_vec,
Metrics,
ProfileRejected,
Result,
@@ -117,3 +118,21 @@ from .report import ( # noqa: F401
require,
universal_checks,
)
from .core import ( # noqa: F401
Centred,
Profile,
centred,
centred_members,
profile_failed,
)
from .arrangements import ( # noqa: F401
fin_bore_side,
fin_members,
fin_profile,
fin_web,
regular_polygon,
ring_polygon_profile,
spoke_members,
spoke_profile,
spoke_web,
)
+290
View File
@@ -0,0 +1,290 @@
"""
Port of ``legacy/openscad/lib/sb-profiles.scad`` -- three complete arrangements,
each written for N members and each returning a finished Profile.
ring_polygon_profile members on the edges of a closed polygon, wrapped in
one envelope with solid rounded corners and an
enclosed bore
spoke_profile N members radiating from a plugged centre
fin_profile N members lying tangentially on the sides of a
regular core polygon, slid cyclically so each
overhangs one corner
A family generator supplies N and the parameters; nothing here changes between
the 3x and 4x catalogues. The 3x generator calls all three (Triangles, Y,
Three-Fin) and the 4x generator calls the same three with N = 4
(Quadrilaterals, Cross, Four-Fin).
"""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
from .core import Profile, profile_failed
from .join import (
bore_from_members,
bore_valid,
end_face,
fillet_junctions,
hull_cap,
ring_fit_scale,
ring_max_corner_r,
ring_members,
ring_shell,
ring_web,
scale_about_centroid,
sleeve_butt,
sleeve_shell,
)
from .primitives import (
SB_FACE_BOTH_OUT,
Point,
cos_d,
sb_centroid,
sb_dist,
sb_path_gap,
sb_signed_area,
sb_solvable,
sb_solve,
sin_d,
tan_d,
)
from .records import Geo, cavity_path, member_on_edge, member_radial, sleeve_path
from .region import union
from .report import check
# ----------------------------------------------------------------------------
# Ring: members on the edges of a closed polygon
# ----------------------------------------------------------------------------
def ring_polygon_profile(seed_path: Sequence[Point], g: Geo, web: float,
corner_r: float, label: str) -> Profile:
"""
Straps have a fixed width, so on a polygon of a given size the corner webs
are whatever they are -- sliding members along their edges cannot raise all
N at once, because every edge shares its budget with two corners. The one
free variable that lifts them together is the polygon's size, so the
caller's outline is treated as a SHAPE and grown about its centroid until
the tightest corner reaches ``web``.
Members are centred on their edges, which keeps the result mirror-symmetric
whenever the outline is.
"""
if abs(sb_signed_area(seed_path)) < 1e-6:
return profile_failed("%s: the supplied outline is degenerate." % label)
k = ring_fit_scale(seed_path, g, web)
if k is None:
return profile_failed(
"%s: no polygon size gives a %s mm corner web. Reduce the web, the "
"wall thicknesses, or the strap width." % (label, web))
path = scale_about_centroid(seed_path, k)
ms = ring_members(path, g)
max_r = ring_max_corner_r(path, g)
# Build with a radius the envelope can actually accept; if the caller asked
# for more, the check below reports it rather than letting the rounding
# routine fail with a library error.
shell = [ring_shell(path, g, min(corner_r, max_r))]
bore = bore_from_members(ms, g)
n = len(path)
edges = [sb_dist(path[i], path[(i + 1) % n]) for i in range(n)]
return Profile(
members=ms,
shell=shell,
bore=bore,
checks=[
check(len(shell[0]) >= 3,
"%s: the outer envelope collapsed." % label),
check(bore_valid(bore, ms, g),
"%s: the central bore has collapsed. Reduce "
"inside_wall_thickness_mm or the bundle thickness." % label),
check(corner_r <= max_r + 1e-6,
"%s: corner radius of %s mm is not usable here - it would cut "
"the outer wall below %s mm at the corners, or exceed what the "
"envelope can accept. Maximum is %s mm."
% (label, corner_r, g.min_wall, max_r)),
],
info={
"RING_SCALE": k,
"RING_EDGES_MM": edges,
"RING_CORNER_WEB_MM": ring_web(path, g),
"RING_CORNER_R_MAX_MM": max_r,
},
)
# ----------------------------------------------------------------------------
# Spokes: N members radiating from a common centre
# ----------------------------------------------------------------------------
def spoke_members(n: int, radius: float, rotation: float):
return [member_radial(radius, rotation + 360.0 * i / n, SB_FACE_BOTH_OUT)
for i in range(n)]
def spoke_web(n: int, radius: float, rotation: float, g: Geo) -> float:
cv = [cavity_path(m, g) for m in spoke_members(n, radius, rotation)]
return min(sb_path_gap(cv[i], cv[(i + 1) % n]) for i in range(n))
def spoke_profile(n: int, rotation: float, web: float, fillet_r: float,
g: Geo, label: str) -> Profile:
"""
A spoke's two broad faces both look at open air, so both take the outside
wall. There is no interior to face and therefore no bore; forcing an
interior direction on these members is what made earlier revisions chiral
under asymmetric wall settings.
The centre is plugged with the convex hull of the N inner end faces rather
than left as whatever the crossing rectangles happened to produce. The spoke
radius is solved against the measured web between neighbours.
"""
W = g.width
hi = 6.0 * W
def f(r: float) -> float:
return spoke_web(n, r, rotation, g)
if not sb_solvable(f, hi, web):
return profile_failed(
"%s: cannot open a %s mm web between neighbouring spokes. Reduce "
"the web or the wall thicknesses." % (label, web))
radius = sb_solve(f, W / 2.0, hi, web)
ms = spoke_members(n, radius, rotation)
paths = [sleeve_path(m, g) for m in ms]
cap = hull_cap([end_face(m, g, -1) for m in ms])
regions = [[p] for p in paths] + ([[cap]] if len(cap) > 0 else [])
raw = union(regions)
pairs = [(i, (i + 1) % n) for i in range(n)]
shell = fillet_junctions(raw, pairs, paths, fillet_r)
return Profile(
members=ms,
shell=shell,
bore=[],
checks=[
check(fillet_r > 0,
"%s: the junction fillet radius must be greater than zero. A "
"hull-plugged centre with no fillet meets the spokes along an "
"exactly tangent boundary, which is a valid outline but cannot "
"be tessellated." % label),
],
info={
"SPOKE_RADIUS_MM": radius,
"SPOKE_WEB_MM": spoke_web(n, radius, rotation, g),
"NOTE": "inside wall unused: no enclosed bore",
},
)
# ----------------------------------------------------------------------------
# Fins: N members lying tangentially on a regular core polygon
# ----------------------------------------------------------------------------
def regular_polygon(n: int, side: float, rotation: float = 0.0) -> List[Point]:
R = side / (2.0 * sin_d(180.0 / n))
out = []
for k in range(n):
a = -90.0 + 180.0 / n + 360.0 * k / n + rotation
out.append((R * cos_d(a), R * sin_d(a)))
return out
def fin_members(n: int, side: float, fin: float, rotation: float, g: Geo):
"""
Tangential, not radial. Each member lies along one side of a regular core
polygon and is slid cyclically along that side, so it stops short of the
corner behind it and overhangs the corner ahead of it. Those N overhangs are
the fins.
"""
path = regular_polygon(n, side, rotation)
c = sb_centroid(path)
shift = fin + (side - g.width) / 2.0
return [member_on_edge(path[i], path[(i + 1) % n], c, shift)
for i in range(n)]
def fin_web(n: int, side: float, fin: float, rotation: float, g: Geo) -> float:
cv = [cavity_path(m, g) for m in fin_members(n, side, fin, rotation, g)]
return min(sb_path_gap(cv[i], cv[(i + n - 1) % n]) for i in range(n))
def fin_bore_side(n: int, side: float, g: Geo) -> float:
"""Side length of the bore left by N inside walls around a regular core."""
t = tan_d(180.0 / n)
return 2.0 * t * (side / (2.0 * t) - g.reach_inside)
def fin_profile(n: int, fin: float, web: float, bore_side: float,
rotation: float, fillet_r: float, g: Geo,
label: str) -> Profile:
"""
The core size is not a free parameter. It is solved so the cyclic junctions
carry the declared web AND the bore reaches its declared minimum, whichever
demands more; the fin projection is then exact because it is measured
against that same solved polygon.
Each member's trailing end is run through the member behind it and cut off
flush on its far face, so every junction has a full-width overlap and the
fillet that follows is cosmetic. Junctions that merely touch at a corner and
rely on a fillet to bridge them are not load paths.
"""
W = g.width
hi = 10.0 * W
def fweb(s: float) -> float:
return fin_web(n, s, fin, rotation, g)
def fbor(s: float) -> float:
return fin_bore_side(n, s, g)
if not sb_solvable(fweb, hi, web):
return profile_failed(
"%s: cannot open a %s mm junction web. Reduce the web or the wall "
"thicknesses." % (label, web))
if not sb_solvable(fbor, hi, bore_side):
return profile_failed(
"%s: cannot reach a %s mm bore. Reduce the requested bore."
% (label, bore_side))
side = max(sb_solve(fweb, 0.1, hi, web),
sb_solve(fbor, 0.1, hi, bore_side))
ms = fin_members(n, side, fin, rotation, g)
# Member i butts through member i-1, the one whose fin crosses the corner
# that member i stops short of.
paths = [sleeve_butt(ms[i], g, ms[(i + n - 1) % n]) for i in range(n)]
pairs = [((i + n - 1) % n, i) for i in range(n)]
shell = fillet_junctions(sleeve_shell(paths), pairs, paths, fillet_r)
bore = bore_from_members(ms, g)
setback = fin + side - W
return Profile(
members=ms,
shell=shell,
bore=bore,
checks=[
check(bore_valid(bore, ms, g),
"%s: the central bore has collapsed. Raise the requested bore "
"or reduce inside_wall_thickness_mm." % label),
check(setback > 0,
"%s: the solved trailing setback is %s mm, so the members "
"overlap instead of stepping cyclically. Increase the fin "
"projection." % (label, setback)),
check(fillet_r > 0,
"%s: the junction fillet radius must be greater than zero. A "
"butt joint with no fillet meets its neighbour along an "
"exactly tangent boundary, which is a valid outline but cannot "
"be tessellated." % label),
],
info={
"FIN_CORE_SIDE_MM": side,
"FIN_PROJECTION_MM": fin,
"FIN_SETBACK_MM": setback,
"FIN_BORE_SIDE_MM": fin_bore_side(n, side, g),
"FIN_JUNCTION_WEB_MM": fin_web(n, side, fin, rotation, g),
},
)
+141
View File
@@ -0,0 +1,141 @@
"""
Port of ``legacy/openscad/lib/sb-core.scad`` -- the PROFILE record and assembly.
A strap beam is N pallet-strap bundles running parallel to a common
longitudinal axis, held in a printed PLA+ enclosure. A profile only decides how
the N cross-sections are arranged in XY; that arrangement is then swept along Z.
Member length in the cross-section is therefore the strap's WIDTH, never the
beam's length.
CONTRACT FOR A PROFILE BUILDER
Takes a GEO record, returns a Profile:
members one Member per strap bundle
shell region: all PLA+ before any void is removed
bore path: the enclosed central void, or empty if there is none
checks from ``check()``, covering only this profile's own parameters
info extra keys to add to the report
The core removes the bore and all cavities from the shell in one step, so
one member's plastic can never fill another member's channel. A builder
should not be doing its own boolean algebra.
FAILURE
OpenSCAD has no exceptions, so a builder that cannot produce a usable
arrangement returns an empty profile carrying a single failing check. The
message then reaches the caller through the ordinary check list rather than
through a separate path. That is reproduced here rather than raising early,
because it keeps profile-specific rejections and universal-check rejections
in one order-sensitive list -- and the reference reports the first failure.
DESIGN RULES THE LIBRARY ENFORCES
1. Declared webs are cavity-to-cavity. A stated 1.2 mm web is 1.2 mm of
plastic; the library adds the fit clearance internally.
2. Placement that cannot be derived exactly is solved numerically against
the measured web, not approximated with a closed form.
3. Junctions are structural before they are pretty: members butt through
their neighbours, and fillets are applied on top of that overlap.
4. A member with no enclosed side gets the outside wall on both faces, so
asymmetric wall settings never make a symmetric profile chiral.
5. Validation measures the finished section. Connectivity is necessary but
never sufficient; the minimum wall is what is actually checked.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Sequence, Tuple
from .join import cavity_region, centering_shift, section as cut_section, \
strap_region
from .records import Geo, Member
from .region import Region, clean_region
from .report import Check, check
Point = Tuple[float, float]
@dataclass(frozen=True)
class Profile:
members: List[Member]
shell: Region
bore: List[Point] = field(default_factory=list)
checks: List[Check] = field(default_factory=list)
info: Dict[str, object] = field(default_factory=dict)
@property
def ok(self) -> bool:
"""A failed builder returns no members, which is how failure is signalled."""
return len(self.members) > 0
def profile_failed(message: str) -> Profile:
"""
What a builder returns instead of guessing.
The message travels as an ordinary failing check, so it is subject to the
same first-failure ordering as everything else.
"""
return Profile(members=[], shell=[], bore=[],
checks=[check(False, message)], info={})
# ----------------------------------------------------------------------------
# Centred results
# ----------------------------------------------------------------------------
def _move(shift: Point, paths: Sequence[Sequence[Point]]) -> Region:
return [[(p[0] + shift[0], p[1] + shift[1]) for p in path] for path in paths]
@dataclass(frozen=True)
class Centred:
section: Region
straps: Region
cavity: Region
shell: Region
shift: Point
members: List[Member]
"""
Members translated by the same shift.
Carried here rather than left to the caller because measuring a shifted
shell against unshifted members silently reports every cavity as escaping
the envelope -- a leak of the whole cavity area, from geometry that is
perfectly fine. Returning them together removes the opportunity.
"""
def centred(p: Profile, g: Geo) -> Centred:
"""
Cut the section, clean it, and shift everything onto a centred origin.
Two orderings here are load-bearing.
The section is cleaned *before* anything measures it. Exact butt joints and
zero-radius fillets leave coincident and collinear vertices that are
harmless in 2D but leave zero-area triangles the tessellator cannot resolve.
Measuring first and cleaning afterwards would report on geometry that is not
what gets extruded.
The shift is applied to the section, the straps, the cavities and the shell
together, so they stay registered with each other. Everything is generated
about whatever origin the profile found natural and moved once at the end.
"""
shell = p.shell
shift = centering_shift(shell)
sec = clean_region(cut_section(shell, p.members, g, p.bore))
return Centred(
section=_move(shift, sec),
straps=_move(shift, strap_region(p.members, g)),
cavity=_move(shift, cavity_region(p.members, g)),
shell=_move(shift, shell),
shift=shift,
members=centred_members(p, shift),
)
def centred_members(p: Profile, shift: Point) -> List[Member]:
"""Members translated by the same shift, for drawing individual laminae."""
return [Member(m.cx + shift[0], m.cy + shift[1], m.angle, m.face)
for m in p.members]
+20 -2
View File
@@ -179,6 +179,18 @@ def echo_num(value: float) -> float:
return float("%g" % value)
def echo_vec(values) -> str:
"""
Format a sequence as OpenSCAD prints a vector: ``[a, b, c]``, each element
at six significant figures.
The harvester scraped `SB_KEY=value` text lines, so a vector reached the
oracle as the string OpenSCAD printed. RING_EDGES_MM is recorded as
'[20.5209, 20.5209, 20.5209]', not as a list.
"""
return "[%s]" % ", ".join("%g" % v for v in values)
def report(family: str, profile: str, status: str, g: Geo, m: Metrics,
length_mm: float, density_g_cm3: float,
extra: Optional[Dict[str, object]] = None) -> Dict[str, object]:
@@ -219,8 +231,14 @@ def report(family: str, profile: str, status: str, g: Geo, m: Metrics,
}
for key, value in (extra or {}).items():
out[key] = echo_num(value) if isinstance(value, (int, float)) \
and not isinstance(value, bool) else value
if isinstance(value, bool):
out[key] = value
elif isinstance(value, (int, float)):
out[key] = echo_num(value)
elif isinstance(value, (list, tuple)):
out[key] = echo_vec(value)
else:
out[key] = value
return out