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.
489 lines
18 KiB
OpenSCAD
489 lines
18 KiB
OpenSCAD
/*
|
|
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;
|