Files
mechanical-compiler/legacy/openscad/lib/sb-geom.scad
T
civicus-build c7e32d8e07 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.
2026-08-18 07:30:17 -05:00

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;