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
+133
View File
@@ -0,0 +1,133 @@
/*
sb-core.scad — Strap-Beam library, umbrella include
===================================================
A "strap beam" is N pallet-strap bundles running parallel to a common
longitudinal axis (Z), 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.
include <lib/sb-core.scad>
brings in BOSL2 and the three library files. Generators for a particular
member count (strap-beam-3x.scad, strap-beam-4x.scad, ...) include this
and supply only their own profile catalogue.
---------------------------------------------------------------------
What lives where
---------------------------------------------------------------------
sb-geom.scad GEO and MEMBER records; strap, cavity and sleeve paths;
exact polyline distance; the monotone solver.
sb-join.scad Butt joints, hull caps, fillets; ring envelopes and
bores; polygon fitting. All written for N members.
sb-report.scad Value-based checks, section metrics, the SB_KEY=value
report, and the 2D/3D output modules.
sb-profiles.scad Three complete N-generic arrangements - ring, spokes,
fins - each returning a finished PROFILE record.
---------------------------------------------------------------------
Contract for a profile builder
---------------------------------------------------------------------
A generator supplies one function per profile that takes a GEO record and
returns a PROFILE record built with sb_profile():
members list of MEMBER records, one per strap bundle
shell region: all PLA+ before any void is removed
bore path: the enclosed central void, or [] if there is none
checks list from sb_check(), covering only this profile's own
parameters — never another profile's
info list of [key, value] pairs 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. Everything
a builder needs to construct `shell` is in sb-join.scad; a builder should
not be doing its own boolean algebra.
---------------------------------------------------------------------
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.
*/
include <BOSL2/std.scad>
include <sb-geom.scad>
include <sb-join.scad>
include <sb-report.scad>
// ---------------------------------------------------------------------------
// PROFILE record
// ---------------------------------------------------------------------------
SB_P_MEMBERS = 0;
SB_P_SHELL = 1;
SB_P_BORE = 2;
SB_P_CHECKS = 3;
SB_P_INFO = 4;
function sb_profile(members, shell, bore = [], checks = [], info = []) =
[members, shell, bore, checks, info];
function sb_p_members(p) = p[SB_P_MEMBERS];
function sb_p_shell(p) = p[SB_P_SHELL];
function sb_p_bore(p) = p[SB_P_BORE];
function sb_p_checks(p) = p[SB_P_CHECKS];
function sb_p_info(p) = p[SB_P_INFO];
// A builder that could not produce a usable arrangement returns this instead
// of guessing. The message reaches the user through the normal check list.
function sb_profile_failed(message) =
sb_profile([], [], [], [sb_check(false, message)], []);
function sb_profile_ok(p) = len(sb_p_members(p)) > 0;
// Shared arrangements. Included last because they build PROFILE records.
include <sb-profiles.scad>
// ---------------------------------------------------------------------------
// Centred results
// ---------------------------------------------------------------------------
/*
Everything is generated about whatever origin the profile found natural,
then shifted once so the finished envelope's bounding box is centred. The
shift is applied to the section, the straps and the cavities together, so
they stay registered with each other.
*/
function sb_centred(p, g) =
let(
shell = sb_p_shell(p),
shift = sb_centering_shift(shell)
)
[
move(shift, p = sb_clean_region(sb_section(shell, sb_p_members(p), g, sb_p_bore(p)))),
move(shift, p = sb_strap_region(sb_p_members(p), g)),
move(shift, p = sb_cavity_region(sb_p_members(p), g)),
move(shift, p = shell),
shift
];
SB_C_SECTION = 0;
SB_C_STRAPS = 1;
SB_C_CAVITY = 2;
SB_C_SHELL = 3;
SB_C_SHIFT = 4;
// Members translated by the same shift, for drawing individual laminae.
function sb_centred_members(p, shift) =
[for (m = sb_p_members(p))
sb_member(m[SB_M_CX] + shift.x, m[SB_M_CY] + shift.y,
sb_mang(m), sb_mface(m))];
+488
View File
@@ -0,0 +1,488 @@
/*
sb-geom.scad — Strap-Beam shared geometry primitives
====================================================
Part of the Strap-Beam library. Nothing in this file knows how many
straps a profile has, so it is reused unchanged by the 3x, 4x and any
later N-strap generator.
Two record types are defined here.
GEO record
Everything about a single strap bundle and the PLA+ that wraps it.
Built once per render by sb_geo() and threaded through every call.
MEMBER record
One strap bundle's cross-section placement: centre, angle, and which
broad face (if either) looks into an enclosed interior.
Coordinate convention for a member
local +X = along the strap's WIDTH (the 15.875 mm direction)
local +Y = along the strap's THICKNESS (the 0.508 mm direction)
The member's angle rotates local +X onto the global direction given.
"normal+" is local +Y expressed globally.
Requires BOSL2 (std.scad) to be included by the caller.
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
SB_SQRT3 = sqrt(3);
SB_EPS = 1e-7;
// Face modes — how a member's two broad faces are walled.
SB_FACE_PLUS_IN = 1; // local +Y faces an enclosed interior -> inside wall
SB_FACE_MINUS_IN = -1; // local -Y faces an enclosed interior -> inside wall
SB_FACE_BOTH_OUT = 0; // neither face encloses anything -> outside wall both
// GEO field indices.
SB_G_WIDTH = 0; // nominal strap width
SB_G_STRAP_T = 1; // one strap's thickness
SB_G_COUNT = 2; // straps per bundle
SB_G_CLEAR = 3; // fit clearance, applied to every cavity face
SB_G_WIN = 4; // inside wall
SB_G_WOUT = 5; // outside wall
SB_G_WEDGE = 6; // edge wall (caps the strap's narrow edges)
SB_G_MINWALL = 7; // minimum acceptable PLA thickness anywhere
// MEMBER field indices.
SB_M_CX = 0;
SB_M_CY = 1;
SB_M_ANG = 2;
SB_M_FACE = 3;
// ---------------------------------------------------------------------------
// GEO record
// ---------------------------------------------------------------------------
function sb_geo(
strap_width,
strap_thickness,
bundle_count,
clearance,
wall_inside,
wall_outside,
wall_edge,
min_wall
) = [
strap_width, strap_thickness, bundle_count, clearance,
wall_inside, wall_outside, wall_edge, min_wall
];
function sb_width(g) = g[SB_G_WIDTH];
function sb_strap_t(g) = g[SB_G_STRAP_T];
function sb_count(g) = g[SB_G_COUNT];
function sb_clear(g) = g[SB_G_CLEAR];
function sb_wall_in(g) = g[SB_G_WIN];
function sb_wall_out(g) = g[SB_G_WOUT];
function sb_wall_edge(g) = g[SB_G_WEDGE];
function sb_min_wall(g) = g[SB_G_MINWALL];
function sb_bundle_t(g) = sb_count(g) * sb_strap_t(g);
// Cavity = strap bundle grown by the fit clearance on all four faces.
function sb_cavity_w(g) = sb_width(g) + 2 * sb_clear(g);
function sb_cavity_t(g) = sb_bundle_t(g) + 2 * sb_clear(g);
/*
A declared "web" is the PLA+ that must survive between two neighbouring
strap CAVITIES. Because every cavity is inflated by the clearance, the
corresponding gap between the physical STRAPS is larger. Callers state
the web they want; this converts to the strap-to-strap spacing that
produces it, so a declared 1.2 mm web really is 1.2 mm of plastic.
*/
function sb_web_to_strap_gap(g, web) = web + 2 * sb_clear(g);
// Distance from a member centreline out to each of its four sleeve faces.
function sb_reach_plus(g, face) =
sb_cavity_t(g) / 2 + (face > 0 ? sb_wall_in(g) : sb_wall_out(g));
function sb_reach_minus(g, face) =
sb_cavity_t(g) / 2 + (face < 0 ? sb_wall_in(g) : sb_wall_out(g));
// Distance from centreline to the enclosed-interior side of the sleeve.
// Only meaningful when the member actually has an interior face.
function sb_reach_inside(g) = sb_cavity_t(g) / 2 + sb_wall_in(g);
// ---------------------------------------------------------------------------
// Small vector helpers
// ---------------------------------------------------------------------------
function sb_rot2(p, a) = [
p.x * cos(a) - p.y * sin(a),
p.x * sin(a) + p.y * cos(a)
];
function sb_mid(a, b) = [(a.x + b.x) / 2, (a.y + b.y) / 2];
function sb_dist(a, b) = norm([b.x - a.x, b.y - a.y]);
function sb_cross2(a, b) = a.x * b.y - a.y * b.x;
function sb_centroid(pts) = [
sum([for (p = pts) p.x]) / len(pts),
sum([for (p = pts) p.y]) / len(pts)
];
// Signed area; positive means counter-clockwise.
function sb_signed_area(path) =
sum([for (i = [0 : len(path) - 1])
let(a = path[i], b = path[(i + 1) % len(path)])
(a.x * b.y - b.x * a.y)]) / 2;
function sb_ccw(path) = sb_signed_area(path) >= 0 ? path : reverse(path);
// Intersection of line (p1,d1) with line (p2,d2). Returns undef if parallel.
function sb_line_isect(p1, d1, p2, d2) =
let(denom = sb_cross2(d1, d2))
abs(denom) < SB_EPS
? undef
: let(
delta = [p2.x - p1.x, p2.y - p1.y],
t = sb_cross2(delta, d2) / denom
)
[p1.x + t * d1.x, p1.y + t * d1.y];
// ---------------------------------------------------------------------------
// MEMBER record
// ---------------------------------------------------------------------------
function sb_member(cx, cy, angle, face = SB_FACE_BOTH_OUT) = [cx, cy, angle, face];
function sb_mc(m) = [m[SB_M_CX], m[SB_M_CY]];
function sb_mang(m) = m[SB_M_ANG];
function sb_mface(m) = m[SB_M_FACE];
function sb_maxis(m) = [cos(sb_mang(m)), sin(sb_mang(m))]; // along width
function sb_mnormal(m) = [-sin(sb_mang(m)), cos(sb_mang(m))]; // local +Y
// Unit vector pointing from the member towards the profile interior.
// Returns undef for SB_FACE_BOTH_OUT, which has no interior.
function sb_minside_dir(m) =
let(n = sb_mnormal(m), f = sb_mface(m))
f == 0 ? undef : [f * n.x, f * n.y];
// A point on the interior-facing surface of the member's sleeve.
function sb_minside_wall_pt(m, g) =
let(d = sb_minside_dir(m), c = sb_mc(m))
is_undef(d) ? undef
: [c.x + d.x * sb_reach_inside(g),
c.y + d.y * sb_reach_inside(g)];
/*
Decide the face mode from a target point that lies inside the profile.
Pass interior_target = undef for members with no enclosed side, which
keeps the member symmetric and stops the walls from becoming chiral.
*/
function sb_face_toward(centre, angle, interior_target) =
is_undef(interior_target) ? SB_FACE_BOTH_OUT
: let(
n = [-sin(angle), cos(angle)],
v = [interior_target.x - centre.x, interior_target.y - centre.y]
)
(n.x * v.x + n.y * v.y) >= 0 ? SB_FACE_PLUS_IN : SB_FACE_MINUS_IN;
// Member lying on the segment a->b, optionally slid along its own axis.
function sb_member_on_edge(a, b, interior_target, shift = 0) =
let(
mid = sb_mid(a, b),
angle = atan2(b.y - a.y, b.x - a.x),
c = [mid.x + shift * cos(angle), mid.y + shift * sin(angle)],
face = sb_face_toward(c, angle, interior_target)
)
sb_member(c.x, c.y, angle, face);
// Member placed radially: centre sits at distance r from origin along angle a,
// with its width axis pointing outward. Used by spoke profiles.
function sb_member_radial(r, angle, face = SB_FACE_BOTH_OUT) =
sb_member(r * cos(angle), r * sin(angle), angle, face);
// Translate a member along its outward normal (away from the interior).
function sb_member_offset_out(m, d) =
let(
n = sb_mnormal(m),
s = sb_mface(m) == 0 ? 1 : -sb_mface(m)
)
sb_member(m[SB_M_CX] + s * d * n.x,
m[SB_M_CY] + s * d * n.y,
sb_mang(m), sb_mface(m));
// ---------------------------------------------------------------------------
// Cross-section paths for one member
// ---------------------------------------------------------------------------
// Place a locally-defined path into the member's frame.
function sb_place(m, path) =
move(sb_mc(m), p = zrot(sb_mang(m), p = path));
/*
Rectangle in member-local coordinates.
half_w_lead : extent along +X (towards the member's leading end)
half_w_trail : extent along -X
up / down : extents along +Y / -Y
*/
function sb_local_rect(half_w_lead, half_w_trail, up, down) = [
[ half_w_lead, -down],
[ half_w_lead, up ],
[-half_w_trail, up ],
[-half_w_trail, -down]
];
// The physical strap bundle, as one rectangle.
function sb_strap_path(m, g) =
sb_place(m, sb_local_rect(sb_width(g) / 2, sb_width(g) / 2,
sb_bundle_t(g) / 2, sb_bundle_t(g) / 2));
// Individual strap laminae, for display when bundle_count > 1.
function sb_strap_layer_paths(m, g) = [
for (i = [0 : sb_count(g) - 1])
let(
y = (i - (sb_count(g) - 1) / 2) * sb_strap_t(g),
t = sb_strap_t(g) / 2
)
sb_place(m, move([0, y], p = sb_local_rect(sb_width(g) / 2,
sb_width(g) / 2, t, t)))
];
// The void the strap slides through.
function sb_cavity_path(m, g) =
sb_place(m, sb_local_rect(sb_cavity_w(g) / 2, sb_cavity_w(g) / 2,
sb_cavity_t(g) / 2, sb_cavity_t(g) / 2));
/*
The PLA+ sleeve around one member.
ext_lead / ext_trail extend the sleeve along its own axis beyond the
default edge wall. Junction construction uses this to make neighbouring
sleeves genuinely overlap instead of merely touching at a corner.
*/
function sb_sleeve_path(m, g, ext_lead = 0, ext_trail = 0) =
let(
half = sb_cavity_w(g) / 2 + sb_wall_edge(g),
f = sb_mface(m)
)
sb_place(m, sb_local_rect(half + ext_lead, half + ext_trail,
sb_reach_plus(g, f), sb_reach_minus(g, f)));
/*
Sleeve whose trailing end is cut by an arbitrary line rather than by a
face perpendicular to the axis. This produces a butt joint flush against
a neighbouring member's outer face, which is how junctions are made
structural rather than decorative.
line_pt / line_dir describe the cutting line. The trailing end face is
placed on that line; the leading end stays perpendicular as usual.
Falls back to a plain sleeve if the line is parallel to the axis.
*/
function sb_sleeve_to_line(m, g, line_pt, line_dir, ext_lead = 0) =
let(
c = sb_mc(m),
u = sb_maxis(m),
n = sb_mnormal(m),
f = sb_mface(m),
up = sb_reach_plus(g, f),
dn = sb_reach_minus(g, f),
half = sb_cavity_w(g) / 2 + sb_wall_edge(g),
// The two long edges of the sleeve, as lines.
p_up = [c.x + n.x * up, c.y + n.y * up],
p_dn = [c.x - n.x * dn, c.y - n.y * dn],
t_up = sb_line_isect(p_up, u, line_pt, line_dir),
t_dn = sb_line_isect(p_dn, u, line_pt, line_dir),
lead_up = [p_up.x + u.x * (half + ext_lead),
p_up.y + u.y * (half + ext_lead)],
lead_dn = [p_dn.x + u.x * (half + ext_lead),
p_dn.y + u.y * (half + ext_lead)]
)
(is_undef(t_up) || is_undef(t_dn))
? sb_sleeve_path(m, g, ext_lead, 0)
: sb_ccw([lead_dn, lead_up, t_up, t_dn]);
/*
Sleeve cut by a line at BOTH ends. A member that spans between two
neighbours - a gable crossbar, a chord across a polygon - butts flush
against each of them instead of stopping short or poking through.
*/
function sb_sleeve_span(m, g, pt_a, dir_a, pt_b, dir_b) =
let(
c = sb_mc(m),
u = sb_maxis(m),
n = sb_mnormal(m),
f = sb_mface(m),
p_up = [c.x + n.x * sb_reach_plus(g, f), c.y + n.y * sb_reach_plus(g, f)],
p_dn = [c.x - n.x * sb_reach_minus(g, f), c.y - n.y * sb_reach_minus(g, f)],
a_up = sb_line_isect(p_up, u, pt_a, dir_a),
a_dn = sb_line_isect(p_dn, u, pt_a, dir_a),
b_up = sb_line_isect(p_up, u, pt_b, dir_b),
b_dn = sb_line_isect(p_dn, u, pt_b, dir_b)
)
(is_undef(a_up) || is_undef(a_dn) || is_undef(b_up) || is_undef(b_dn))
? sb_sleeve_path(m, g)
: sb_ccw([a_dn, a_up, b_up, b_dn]);
// ---------------------------------------------------------------------------
// Measurement — exact distance between two closed polylines
// ---------------------------------------------------------------------------
/*
The minimum distance between two disjoint polygons is always attained at
a vertex of one of them, so sampling every vertex against every segment of
the other (both ways round) is exact, not an approximation.
*/
function sb_pt_seg_dist(p, a, b) =
let(
ab = [b.x - a.x, b.y - a.y],
L2 = ab.x * ab.x + ab.y * ab.y
)
L2 < SB_EPS
? sb_dist(p, a)
: let(
t = max(0, min(1, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / L2))
)
sb_dist(p, [a.x + t * ab.x, a.y + t * ab.y]);
function sb_pt_path_dist(p, path) =
min([for (i = [0 : len(path) - 1])
sb_pt_seg_dist(p, path[i], path[(i + 1) % len(path)])]);
// Do two segments properly cross or touch?
function sb_segs_cross(a1, a2, b1, b2) =
let(
d1 = [a2.x - a1.x, a2.y - a1.y],
d2 = [b2.x - b1.x, b2.y - b1.y],
den = sb_cross2(d1, d2),
w = [b1.x - a1.x, b1.y - a1.y]
)
abs(den) < SB_EPS
? false
: let(t = sb_cross2(w, d2) / den, u = sb_cross2(w, d1) / den)
t >= 0 && t <= 1 && u >= 0 && u <= 1;
function sb_paths_cross(p, q) =
len([for (i = [0 : len(p) - 1], j = [0 : len(q) - 1])
if (sb_segs_cross(p[i], p[(i + 1) % len(p)],
q[j], q[(j + 1) % len(q)])) 1]) > 0;
/*
Minimum distance between two closed paths.
Two disjoint polygons always attain their minimum at a vertex of one of
them, so vertex-against-segment both ways round is exact. Two polygons
that CROSS may have no vertex near the other's boundary at all, and the
naive vertex test then reports a comfortable clearance across an outright
overlap - which is exactly the kind of false pass that lets a solver
settle on a degenerate arrangement. Crossing is therefore tested first
and reported as zero.
Nesting is deliberately not treated as overlap: a hole inside an outer
boundary is the normal case, and the distance between them is the wall
thickness that this whole library exists to measure.
*/
function sb_path_gap(p, q) =
sb_paths_cross(p, q)
? 0
: min(min([for (v = p) sb_pt_path_dist(v, q)]),
min([for (v = q) sb_pt_path_dist(v, p)]));
// Minimum distance between any two paths in a region. For a finished
// cross-section this is the thinnest surviving piece of PLA+.
function sb_region_min_gap(rgn) =
len(rgn) < 2
? 1e9
: min([for (i = [0 : len(rgn) - 2], j = [i + 1 : len(rgn) - 1])
sb_path_gap(rgn[i], rgn[j])]);
/*
Largest corner radius a path can physically accept: at every vertex the
roundover's tangent points must stay on their own edges. Probing this by
trial is not an option because the rounding routine raises a library-level
error rather than returning a flag, so it is derived up front.
*/
function sb_corner_radii(path, r) =
let(n = len(path), cw = sb_signed_area(path) < 0)
[for (i = [0 : n - 1])
let(
prev = path[(i + n - 1) % n],
here = path[i],
next = path[(i + 1) % n],
turn = sb_cross2([here.x - prev.x, here.y - prev.y],
[next.x - here.x, next.y - here.y]),
reflex = cw ? (turn > SB_EPS) : (turn < -SB_EPS),
ang = vector_angle(prev, here, next),
fits = (ang <= 0.05 || ang >= 179.95)
? 0
: 0.98 * min(sb_dist(prev, here), sb_dist(here, next))
/ 2 * tan(ang / 2)
)
reflex ? min(r, fits) : 0];
function sb_path_max_round(path) =
let(n = len(path))
n < 3 ? 0 :
0.999 * min([for (i = [0 : n - 1])
let(
prev = path[(i + n - 1) % n],
here = path[i],
next = path[(i + 1) % n],
l1 = sb_dist(prev, here),
l2 = sb_dist(here, next),
ang = vector_angle(prev, here, next)
)
(ang <= 0.05 || ang >= 179.95) ? 1e9 : min(l1, l2) / 2 * tan(ang / 2)
]);
// ---------------------------------------------------------------------------
// Empty-safe wrappers
// ---------------------------------------------------------------------------
// BOSL2's boolean functions return a bare [] when a result is empty, which is
// not a valid region. Every measurement goes through these so a legitimately
// empty result reads as zero instead of raising a library error.
/*
Remove duplicate and collinear vertices from every path in a region.
Exact butt joints and zero-radius fillets produce coincident or perfectly
collinear vertices. They are harmless in 2D but leave zero-area triangles
that the tessellator cannot resolve, so a section that measures perfectly
can still fail to extrude. Cleaning once, at the end, removes that entire
class of failure.
*/
function sb_clean_region(rgn) = [
for (path = rgn)
let(d = deduplicate(path, closed = true))
if (len(d) >= 3)
let(m = path_merge_collinear(d, closed = true))
if (len(m) >= 3) m
];
function sb_area(rgn) = len(rgn) == 0 ? 0 : region_area(rgn);
function sb_nparts(rgn) = len(rgn) == 0 ? 0 : len(region_parts(rgn));
function sb_as_region(x) = is_path(x) ? [x] : x;
// ---------------------------------------------------------------------------
// Monotone solver
// ---------------------------------------------------------------------------
/*
Several profiles need "place this member so that the resulting web is
exactly W". Rather than deriving a closed form per profile — the source
of most of the wrong-by-a-cosine errors in earlier revisions — solve the
real measured quantity numerically. f must be non-decreasing on [lo,hi].
*/
function sb_solve(f, lo, hi, target, iters = 44) =
iters <= 0
? (lo + hi) / 2
: let(mid = (lo + hi) / 2)
f(mid) < target ? sb_solve(f, mid, hi, target, iters - 1)
: sb_solve(f, lo, mid, target, iters - 1);
// True when f(hi) actually reaches the target, i.e. the solve is feasible.
function sb_solvable(f, hi, target) = f(hi) >= target;
+311
View File
@@ -0,0 +1,311 @@
/*
sb-join.scad — Strap-Beam shared junction and envelope strategies
=================================================================
Part of the Strap-Beam library. Everything here is written for N members
and is shared verbatim by the 3x, 4x and later generators.
Three families of strategy live here.
ENVELOPE how the outer PLA+ solid is generated:
* sleeve style — union of per-member sleeves (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
FIT placement solved against a measured web, so a declared wall
thickness is the wall thickness you get
Requires sb-geom.scad and BOSL2.
*/
// ---------------------------------------------------------------------------
// Face lines
// ---------------------------------------------------------------------------
/*
A member's sleeve has two long surfaces. Junction construction needs to
talk about them as infinite lines. side = +1 selects the local +Y
surface, side = -1 the local -Y surface. Returns [point, direction].
*/
function sb_face_line(m, g, side) =
let(
c = sb_mc(m),
n = sb_mnormal(m),
d = side > 0 ? sb_reach_plus(g, sb_mface(m))
: sb_reach_minus(g, sb_mface(m))
)
[[c.x + side * d * n.x, c.y + side * d * n.y], sb_maxis(m)];
// The surface of m that lies farther from the given point. Used to butt one
// member flush against the far side of another without needing to know which
// way either of them is facing.
function sb_far_face_line(m, g, from_pt) =
let(
a = sb_face_line(m, g, 1),
b = sb_face_line(m, g, -1)
)
sb_dist(a[0], from_pt) >= sb_dist(b[0], from_pt) ? a : b;
// ---------------------------------------------------------------------------
// Structural junctions
// ---------------------------------------------------------------------------
/*
sb_sleeve_butt
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. The two sleeves then share
a full-width overlap, so the joint carries load whether or not a fillet is
applied afterwards.
ext_lead extends the opposite (free) end, which is left untouched.
*/
function sb_sleeve_butt(m, g, into, ext_lead = 0) =
let(
line = sb_far_face_line(into, g, sb_mc(m))
)
sb_sleeve_to_line(m, g, line[0], line[1], ext_lead);
/*
sb_hull_cap
Plugs the space enclosed by a set of member end faces with their convex
hull. Deterministic, cheap, and free of the spikes and V-notches that a
bare union of crossing rectangles leaves behind. Used for spoke-style
centres and for gable apexes.
Pass the end-face segments (two points each); the hull of all of them is
the plug.
*/
function sb_hull_cap(segments) =
let(pts = [for (s = segments) each s])
len(pts) < 3 ? [] : hull_region([pts]);
// 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.
function sb_end_face(m, g, end = 1, extra = 0) =
let(
c = sb_mc(m),
u = sb_maxis(m),
n = sb_mnormal(m),
f = sb_mface(m),
half = sb_cavity_w(g) / 2 + sb_wall_edge(g) + extra,
p = [c.x + end * half * u.x, c.y + end * half * u.y],
up = sb_reach_plus(g, f),
dn = sb_reach_minus(g, f)
)
[[p.x + n.x * up, p.y + n.y * up],
[p.x - n.x * dn, p.y - n.y * dn]];
/*
sb_fillet_concave
Rounds 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 (grow by r, shrink by r) 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; the arc
discretisation it introduces 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. Rounding named corners
has none of those failure modes and is considerably faster.
*/
function sb_fillet_concave(path, r) =
len(path) < 3 ? path
: let(radii = sb_corner_radii(path, r))
max(radii) <= 1e-6 ? path
: round_corners(path, radius = radii, closed = true);
// Fillet the junction between two sleeves. Cosmetic only: it is applied on
// top of a structural butt joint, never in place of one. 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.
function sb_fillet_pair(path_a, path_b, r) =
r <= 0 ? [path_a, path_b]
: let(u = sb_as_region(union([[path_a], [path_b]])))
len(u) == 1 ? [sb_fillet_concave(u[0], r)] : u;
// ---------------------------------------------------------------------------
// Bore — the enclosed central void
// ---------------------------------------------------------------------------
/*
sb_bore_from_members
Members must be supplied in cyclic order around the interior, each with a
real interior face. The bore is the polygon bounded by their actual
inside-wall surfaces, so 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 the profile has no closed interior.
*/
function sb_bore_from_members(ms, g) =
len([for (m = ms) if (sb_mface(m) == 0) 1]) > 0 ? []
: let(
n = len(ms),
pts = [for (m = ms) sb_minside_wall_pt(m, g)],
dirs = [for (m = ms) sb_maxis(m)],
verts = [for (i = [0 : n - 1])
sb_line_isect(pts[(i + n - 1) % n], dirs[(i + n - 1) % n],
pts[i], dirs[i])]
)
(len([for (v = verts) if (is_undef(v)) 1]) > 0) ? [] : verts;
/*
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.
*/
function sb_bore_valid(bore, ms, g) =
len(bore) < 3 ? false
: abs(sb_signed_area(bore)) <= 0.01 ? false
: let(c = sb_centroid(bore))
len([for (m = ms)
let(d = sb_minside_dir(m), p = sb_minside_wall_pt(m, g))
if (is_undef(d) ||
(d.x * (c.x - p.x) + d.y * (c.y - p.y)) <= 0.01) 1]) == 0;
// ---------------------------------------------------------------------------
// Ring profiles — members laid along the edges of a closed polygon
// ---------------------------------------------------------------------------
// 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.
function sb_ring_members(path, g) =
let(c = sb_centroid(path), n = len(path))
[for (i = [0 : n - 1]) sb_member_on_edge(path[i], path[(i + 1) % n], c)];
// Smallest PLA+ web between any two neighbouring strap cavities on the ring.
function sb_ring_web(path, g) =
let(
ms = sb_ring_members(path, g),
n = len(ms),
cv = [for (m = ms) sb_cavity_path(m, g)]
)
min([for (i = [0 : n - 1]) sb_path_gap(cv[i], cv[(i + 1) % n])]);
function sb_scale_about_centroid(path, k) =
let(c = sb_centroid(path))
[for (p = path) [c.x + k * (p.x - c.x), c.y + k * (p.y - c.y)]];
/*
sb_fit_ring
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.
This grows the caller's polygon about its centroid, preserving its shape
and proportions exactly, until the tightest corner web reaches `web`.
The same call fits a triangle, a quadrilateral, or any N-gon.
*/
function sb_ring_fit_scale(path, g, web, max_scale = 12) =
let(
n = len(path),
edges = [for (i = [0 : n - 1]) sb_dist(path[i], path[(i + 1) % n])],
// Normalise first, so the caller's outline 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.
k0 = sb_width(g) / min(edges),
f = function(k) sb_ring_web(sb_scale_about_centroid(path, k0 * k), g)
)
!sb_solvable(f, max_scale, web)
? undef
// 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.
: k0 * sb_solve(f, 1, max_scale, web);
function sb_fit_ring(path, g, web, max_scale = 12) =
let(k = sb_ring_fit_scale(path, g, web, max_scale))
is_undef(k) ? undef : sb_scale_about_centroid(path, k);
/*
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.
*/
function sb_ring_shell(path, g, corner_r = 0) =
let(
sharp = offset(sb_ccw(path),
delta = sb_cavity_t(g) / 2 + sb_wall_out(g),
closed = true)
)
corner_r > 0 ? round_corners(sharp, radius = corner_r, closed = true)
: sharp;
// 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.
function sb_ring_max_corner_r(path, g) =
let(
sharp = sb_ring_shell(path, g, 0),
hi = sb_path_max_round(sharp),
ms = sb_ring_members(path, g),
cav = [for (m = ms) sb_cavity_path(m, g)],
f = function(r)
let(sh = sb_ring_shell(path, g, max(0, hi - r)))
len(sh) < 3 ? 0 : min([for (c = cav) sb_path_gap(sh, c)])
)
hi <= 0 ? 0 : max(0, hi - sb_solve(f, 0, hi, sb_min_wall(g) - 1e-6));
// ---------------------------------------------------------------------------
// Sleeve profiles — union of per-member sleeves
// ---------------------------------------------------------------------------
function sb_sleeve_shell(paths) = sb_as_region(union([for (p = paths) [p]]));
// 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.
function sb_fillet_junctions(shell, pairs, paths, r) =
r <= 0 ? shell
: sb_as_region(union(concat([shell],
[for (p = pairs) sb_fillet_pair(paths[p[0]], paths[p[1]], r)])));
// ---------------------------------------------------------------------------
// Assembly
// ---------------------------------------------------------------------------
/*
sb_section
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.
*/
function sb_section(shell, members, g, bore = []) =
let(
cavities = sb_as_region(union([for (m = members) [sb_cavity_path(m, g)]])),
cut = sb_as_region(len(bore) >= 3 ? union([[bore], cavities]) : cavities)
)
sb_as_region(difference(shell, cut));
function sb_strap_region(members, g) =
sb_as_region(union([for (m = members) [sb_strap_path(m, g)]]));
function sb_cavity_region(members, g) =
sb_as_region(union([for (m = members) [sb_cavity_path(m, g)]]));
// Translation that puts the finished envelope's bounding box on the origin.
function sb_centering_shift(shell) =
let(b = pointlist_bounds(hull_region(shell)))
[-(b[0].x + b[1].x) / 2, -(b[0].y + b[1].y) / 2];
+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))
]);
+175
View File
@@ -0,0 +1,175 @@
/*
sb-report.scad — Strap-Beam shared validation and reporting
===========================================================
Part of the Strap-Beam library.
VALIDATION
Checks are values, not statements. A profile builder returns a list
of [condition, message] pairs and the core asserts over that list.
Because the list is built inside the selected profile's own function,
no other profile's parameters are ever touched — a slider that belongs
to one catalogue entry cannot break a different one.
REPORTING
Every render emits a block of SB_KEY=value lines on stderr. These are
stable, flat, and trivially scraped by the catalogue front end, which
needs dimensions and a pass/fail without parsing geometry.
Requires sb-geom.scad, sb-join.scad and BOSL2.
*/
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
function sb_check(condition, message) = [condition ? true : false, message];
function sb_first_failure(checks, i = 0) =
i >= len(checks) ? undef
: checks[i][0] ? sb_first_failure(checks, i + 1)
: checks[i][1];
/*
Assert the whole list and return a status string. Assign the result to a
variable at file scope so the assertion runs before any geometry does.
*/
function sb_require(checks) =
let(fail = sb_first_failure(checks))
assert(is_undef(fail), str("\n[strap-beam] ", fail, "\n"))
"ok";
// ---------------------------------------------------------------------------
// Metrics
// ---------------------------------------------------------------------------
SB_R_AREA = 0;
SB_R_PARTS = 1;
SB_R_SLOTS = 2;
SB_R_MINW = 3;
SB_R_SIZE = 4;
SB_R_LEAK = 5;
function sb_metrics(section, shell, members, g) =
let(
cav = sb_cavity_region(members, g),
hull = hull_region(shell),
b = pointlist_bounds(hull)
)
[
sb_area(section), // PLA+ per unit length
sb_nparts(section), // connected solids
sb_nparts(cav), // separate strap channels
sb_region_min_gap(section), // thinnest surviving wall
[b[1].x - b[0].x, b[1].y - b[0].y], // envelope size
sb_area(difference(cav, shell)) // cavity outside envelope
];
/*
Checks every profile must pass, whatever its shape or member count.
The connectivity test alone is not enough: a cross-section joined by a
0.14 mm knife edge is topologically connected and physically useless. The
minimum-wall test is what actually catches over-large corner radii,
swallowed junction gaps and fillets that have stopped bridging.
*/
function sb_universal_checks(section, metrics, expected_members, g) = [
sb_check(metrics[SB_R_PARTS] == 1,
str("Cross-section is not one connected solid (", metrics[SB_R_PARTS],
" separate pieces). Widen the junctions or thicken the walls.")),
sb_check(metrics[SB_R_SLOTS] == expected_members,
str("Expected ", expected_members, " separate strap channels but found ",
metrics[SB_R_SLOTS],
". Neighbouring channels have merged, so those straps share one slot and are not retained. Increase the relevant web.")),
sb_check(metrics[SB_R_MINW] >= sb_min_wall(g) - 1e-4,
str("Thinnest PLA+ wall is ", metrics[SB_R_MINW],
" mm, below the required minimum of ", sb_min_wall(g),
" mm. Reduce the corner radius, increase the web, or lower min_wall_mm if this really is acceptable.")),
sb_check(is_region_simple(section),
str("The cross-section touches itself at a point rather than ",
"crossing cleanly. Such an outline is valid but cannot be ",
"tessellated, so it would fail on extrusion. Nudge the junction ",
"fillet radius away from zero, or change the web slightly.")),
sb_check(metrics[SB_R_LEAK] < 1e-4,
str("A strap cavity breaks out of the outer envelope (",
metrics[SB_R_LEAK], " mm^2 outside). The straps would not be enclosed."))
];
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
function sb_kv(key, value) = [key, value];
module sb_emit(key, value) { echo(str("SB_", key, "=", value)); }
module sb_emit_all(pairs) { for (p = pairs) sb_emit(p[0], p[1]); }
/*
Standard report. `extra` carries whatever the individual profile wants to
publish — solved sizes, effective projections, headroom on a radius — as
a list of [key, value] pairs.
*/
module sb_report(
family, profile, status, g, metrics, length_mm, density_g_cm3, extra = []
) {
section_area = metrics[SB_R_AREA];
volume_mm3 = section_area * length_mm;
sb_emit("STATUS", status);
sb_emit("FAMILY", family);
sb_emit("PROFILE", profile);
sb_emit("STRAP_WIDTH_MM", sb_width(g));
sb_emit("STRAP_THICK_MM", sb_strap_t(g));
sb_emit("BUNDLE_COUNT", sb_count(g));
sb_emit("BUNDLE_THICK_MM", sb_bundle_t(g));
sb_emit("CLEARANCE_MM", sb_clear(g));
sb_emit("WALL_INSIDE_MM", sb_wall_in(g));
sb_emit("WALL_OUTSIDE_MM", sb_wall_out(g));
sb_emit("WALL_EDGE_MM", sb_wall_edge(g));
sb_emit("MIN_WALL_SPEC_MM", sb_min_wall(g));
sb_emit("MIN_WALL_ACTUAL_MM", metrics[SB_R_MINW]);
sb_emit("SECTION_AREA_MM2", section_area);
sb_emit("SECTION_PARTS", metrics[SB_R_PARTS]);
sb_emit("STRAP_CHANNELS", metrics[SB_R_SLOTS]);
sb_emit("ENVELOPE_X_MM", metrics[SB_R_SIZE].x);
sb_emit("ENVELOPE_Y_MM", metrics[SB_R_SIZE].y);
sb_emit("LENGTH_MM", length_mm);
sb_emit("VOLUME_MM3", volume_mm3);
sb_emit("MASS_G", volume_mm3 * density_g_cm3 / 1000);
sb_emit_all(extra);
sb_emit("END", 1);
}
// ---------------------------------------------------------------------------
// 3D output
// ---------------------------------------------------------------------------
/*
A profile is a cross-section swept along Z. The section is what every
consumer actually cares about — the catalogue renderer projects it, the
slicer extrudes it — so it is generated once and reused for both.
*/
module sb_extrude_section(rgn, length) {
linear_sweep(rgn, height = length, center = true);
}
module sb_extrude_straps(members, g, length) {
for (m = members)
for (p = sb_strap_layer_paths(m, g))
linear_sweep([p], height = length, center = true);
}
// Flat 2D output, for the catalogue's SVG pipeline.
module sb_draw_section(rgn) { region(rgn); }