Seed repository: rev-8.0.0 reference, frozen oracle, toolchain, test harness

Reference implementation of the strap-beam generators at revision 8.0.0, kept
so the acceptance oracle can be regenerated. Not a live target; the running
application has no OpenSCAD dependency.

The oracle holds 123 frozen cases, 113 accepted and 10 rejected, produced by
OpenSCAD 2021.01 with BOSL2 at 92d697c2. The ten rejections are part of the
contract: a port that accepts them is wrong.

tests/test_oracle.py specifies the port API and was written before the port,
so the interface follows from what must be verified rather than what is
convenient to implement. Proven by adversarial stub: a build() that rejects
everything passes all 10 rejection tests and fails all 226 acceptance tests.
This commit is contained in:
2026-08-18 07:30:17 -05:00
parent eb0d533d86
commit c7e32d8e07
28 changed files with 7822 additions and 1 deletions
+221
View File
@@ -0,0 +1,221 @@
/*
sb-profiles.scad — Strap-Beam shared profile constructions
==========================================================
Part of the Strap-Beam library. Three complete arrangements, each written
for N members and each returning a finished PROFILE record.
sb_ring_polygon_profile members on the edges of a closed polygon,
wrapped in one envelope with solid rounded
corners and an enclosed bore
sb_spoke_profile N members radiating from a plugged centre
sb_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 below changes
between the 3x and 4x files. strap-beam-3x.scad calls all three
(Triangles, Y, Three-Fin) and strap-beam-4x.scad calls the same three
(Quadrilaterals, Cross, Four-Fin) with N = 4.
Requires sb-geom.scad, sb-join.scad and sb-report.scad.
*/
// ---------------------------------------------------------------------------
// Ring: members on the edges of a closed polygon
// ---------------------------------------------------------------------------
/*
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.
*/
function sb_ring_polygon_profile(seed_path, g, web, corner_r, label) =
abs(sb_signed_area(seed_path)) < 1e-6
? sb_profile_failed(str(label, ": the supplied outline is degenerate."))
: let(
k = sb_ring_fit_scale(seed_path, g, web),
path = is_undef(k) ? undef : sb_scale_about_centroid(seed_path, k)
)
is_undef(path)
? sb_profile_failed(str(label, ": no polygon size gives a ", web,
" mm corner web. Reduce the web, the wall thicknesses, or the strap width."))
: let(
ms = sb_ring_members(path, g),
max_r = sb_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 = [sb_ring_shell(path, g, min(corner_r, max_r))],
bore = sb_bore_from_members(ms, g),
edges = [for (i = [0 : len(path) - 1])
sb_dist(path[i], path[(i + 1) % len(path)])]
)
sb_profile(ms, shell, bore,
[
sb_check(len(shell[0]) >= 3,
str(label, ": the outer envelope collapsed.")),
sb_check(sb_bore_valid(bore, ms, g),
str(label, ": the central bore has collapsed. Reduce inside_wall_thickness_mm or the bundle thickness.")),
sb_check(corner_r <= max_r + 1e-6,
str(label, ": corner radius of ", corner_r,
" mm is not usable here - it would cut the outer wall below ",
sb_min_wall(g), " mm at the corners, or exceed what the envelope can accept. Maximum is ", max_r, " mm."))
],
[
sb_kv("RING_SCALE", k),
sb_kv("RING_EDGES_MM", edges),
sb_kv("RING_CORNER_WEB_MM", sb_ring_web(path, g)),
sb_kv("RING_CORNER_R_MAX_MM", max_r)
]);
// ---------------------------------------------------------------------------
// Spokes: N members radiating from a common centre
// ---------------------------------------------------------------------------
/*
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.
*/
function sb_spoke_members(n, radius, rotation) = [
for (i = [0 : n - 1])
sb_member_radial(radius, rotation + 360 * i / n, SB_FACE_BOTH_OUT)
];
function sb_spoke_web(n, radius, rotation, g) =
let(cv = [for (m = sb_spoke_members(n, radius, rotation))
sb_cavity_path(m, g)])
min([for (i = [0 : n - 1]) sb_path_gap(cv[i], cv[(i + 1) % n])]);
function sb_spoke_profile(n, rotation, web, fillet_r, g, label) =
let(
W = sb_width(g),
f = function(r) sb_spoke_web(n, r, rotation, g),
hi = 6 * W
)
!sb_solvable(f, hi, web)
? sb_profile_failed(str(label, ": cannot open a ", web,
" mm web between neighbouring spokes. Reduce the web or the wall thicknesses."))
: let(
radius = sb_solve(f, W / 2, hi, web),
ms = sb_spoke_members(n, radius, rotation),
paths = [for (m = ms) sb_sleeve_path(m, g)],
cap = sb_hull_cap([for (m = ms) sb_end_face(m, g, -1)]),
raw = union(concat([for (p = paths) [p]],
len(cap) > 0 ? [cap] : [])),
pairs = [for (i = [0 : n - 1]) [i, (i + 1) % n]],
shell = sb_fillet_junctions(raw, pairs, paths, fillet_r)
)
sb_profile(ms, shell, [],
[
sb_check(fillet_r > 0,
str(label, ": 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."))
],
[
sb_kv("SPOKE_RADIUS_MM", radius),
sb_kv("SPOKE_WEB_MM", sb_spoke_web(n, radius, rotation, g)),
sb_kv("NOTE", "inside wall unused: no enclosed bore")
]);
// ---------------------------------------------------------------------------
// Fins: N members lying tangentially on a regular core polygon
// ---------------------------------------------------------------------------
/*
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.
spokes N members leaving a common centre, ends pointing outward
fins N members wrapping a core, each with one cyclic overhang
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.
*/
function sb_regular_polygon(n, side, rotation = 0) =
let(R = side / (2 * sin(180 / n)))
[for (k = [0 : n - 1])
let(a = -90 + 180 / n + 360 * k / n + rotation)
[R * cos(a), R * sin(a)]];
function sb_fin_members(n, side, fin, rotation, g) =
let(
path = sb_regular_polygon(n, side, rotation),
c = sb_centroid(path),
shift = fin + (side - sb_width(g)) / 2
)
[for (i = [0 : n - 1])
sb_member_on_edge(path[i], path[(i + 1) % n], c, shift)];
function sb_fin_web(n, side, fin, rotation, g) =
let(cv = [for (m = sb_fin_members(n, side, fin, rotation, g))
sb_cavity_path(m, g)])
min([for (i = [0 : n - 1]) sb_path_gap(cv[i], cv[(i + n - 1) % n])]);
// Side length of the bore left by N inside walls around a regular core.
function sb_fin_bore_side(n, side, g) =
let(t = tan(180 / n))
2 * t * (side / (2 * t) - sb_reach_inside(g));
function sb_fin_profile(n, fin, web, bore_side, rotation, fillet_r, g, label) =
let(
W = sb_width(g),
fweb = function(s) sb_fin_web(n, s, fin, rotation, g),
fbor = function(s) sb_fin_bore_side(n, s, g),
hi = 10 * W
)
!sb_solvable(fweb, hi, web)
? sb_profile_failed(str(label, ": cannot open a ", web,
" mm junction web. Reduce the web or the wall thicknesses."))
: !sb_solvable(fbor, hi, bore_side)
? sb_profile_failed(str(label, ": cannot reach a ", bore_side,
" mm bore. Reduce the requested bore."))
: let(
side = max(sb_solve(fweb, 0.1, hi, web),
sb_solve(fbor, 0.1, hi, bore_side)),
ms = sb_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 = [for (i = [0 : n - 1])
sb_sleeve_butt(ms[i], g, ms[(i + n - 1) % n])],
pairs = [for (i = [0 : n - 1]) [(i + n - 1) % n, i]],
shell = sb_fillet_junctions(sb_sleeve_shell(paths), pairs, paths, fillet_r),
bore = sb_bore_from_members(ms, g),
setback = fin + side - W
)
sb_profile(ms, shell, bore,
[
sb_check(sb_bore_valid(bore, ms, g),
str(label, ": the central bore has collapsed. Raise the requested bore or reduce inside_wall_thickness_mm.")),
sb_check(setback > 0,
str(label, ": the solved trailing setback is ", setback,
" mm, so the members overlap instead of stepping cyclically. Increase the fin projection.")),
sb_check(fillet_r > 0,
str(label, ": 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."))
],
[
sb_kv("FIN_CORE_SIDE_MM", side),
sb_kv("FIN_PROJECTION_MM", fin),
sb_kv("FIN_SETBACK_MM", setback),
sb_kv("FIN_BORE_SIDE_MM", sb_fin_bore_side(n, side, g)),
sb_kv("FIN_JUNCTION_WEB_MM", sb_fin_web(n, side, fin, rotation, g))
]);