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:
@@ -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))];
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -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))
|
||||
]);
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user