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,74 @@
|
||||
# Reference implementation — OpenSCAD, revision 8.0.0
|
||||
|
||||
**This is not a live target.** It is kept so the acceptance oracle in
|
||||
`fixtures/strap-beam-8.0.0/` can be regenerated, and so the port has something
|
||||
to be checked against. The running application has no OpenSCAD dependency, and
|
||||
none of this is installed on an instance host.
|
||||
|
||||
## What it produces
|
||||
|
||||
Eleven profiles across two generators. A profile is a cross-section swept along
|
||||
a longitudinal axis; a "member" is the end view of one strap bundle, so its
|
||||
length in the cross-section is the strap's **width**, never the beam's length.
|
||||
|
||||
| Generator | Profiles |
|
||||
|---|---|
|
||||
| `strap-beam-3x.scad` | Equilateral Triangle, General Triangle, A Frame, Y, T, Three-Fin |
|
||||
| `strap-beam-4x.scad` | Square, Rectangle, Diamond, Cross, Four-Fin |
|
||||
|
||||
4x is the end of the N-strap family. Higher polygon counts compose from
|
||||
triangles and squares rather than getting their own generators.
|
||||
|
||||
## The shared library
|
||||
|
||||
Everything reusable is in `lib/`, written for N members and used unchanged by
|
||||
both generators:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `sb-geom.scad` | GEO and MEMBER records, path construction, exact polyline distance, the monotone solver |
|
||||
| `sb-join.scad` | butt joints, hull caps, concave-corner fillets, ring envelopes and bores, polygon fitting |
|
||||
| `sb-report.scad` | value-based checks, section metrics, the `SB_KEY=value` report |
|
||||
| `sb-profiles.scad` | three complete N-generic arrangements: ring, spokes, fins |
|
||||
| `sb-core.scad` | umbrella include and the PROFILE record |
|
||||
|
||||
`strap-beam-4x.scad` is 210 lines and contains **no geometry** — five one-line
|
||||
calls into the shared library. The same three calls with N=3 produce the
|
||||
triangles, the Y and the Three-Fin. That is the evidence the split is real.
|
||||
|
||||
## Design rules the library enforces
|
||||
|
||||
1. Declared webs are cavity-to-cavity. A stated 1.2 mm web is 1.2 mm of plastic.
|
||||
2. Placement that cannot be derived exactly is solved numerically against the
|
||||
measured quantity, not approximated with a closed form.
|
||||
3. Junctions are structural before they are decorative: members butt through
|
||||
their neighbours, and fillets are applied on top of that overlap.
|
||||
4. A member with no enclosed side takes 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 and
|
||||
never sufficient — revision 7 passed cross-sections joined by 0.13 mm.
|
||||
|
||||
## Running it
|
||||
|
||||
Requires OpenSCAD 2021.01 and BOSL2 at `92d697c2`. Use the pinned image:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD:/repo" mechcomp/reference-toolchain:8.0.0 \
|
||||
openscad -o /dev/null --export-format=asciistl \
|
||||
-Dprofile_type='"Three-Fin"' /repo/legacy/openscad/strap-beam-3x.scad
|
||||
```
|
||||
|
||||
Every render emits a flat `SB_KEY=value` block on stderr, terminated by
|
||||
`SB_END=1`. `render_mode="Section"` emits the 2D region instead of the swept
|
||||
solid, which is what `--export-format=svg` consumes.
|
||||
|
||||
## Three BOSL2 behaviours worth knowing
|
||||
|
||||
Found the hard way; the library works around all three:
|
||||
|
||||
- Boolean functions return a bare `[]` rather than an empty region.
|
||||
- `offset()` on a single-part region returns a bare path rather than a region.
|
||||
- `round_corners()` raises rather than reporting when a radius will not fit.
|
||||
|
||||
`sb_as_region()`, `sb_area()`/`sb_nparts()` and `sb_path_max_round()` exist for
|
||||
exactly these.
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,471 @@
|
||||
/*
|
||||
strap-beam-3x.scad — Three-strap longitudinal beam enclosures
|
||||
=============================================================
|
||||
Revision 8. Replaces triangle-strap-enclosure_v7.scad.
|
||||
|
||||
Three pallet-strap bundles run parallel to one longitudinal axis (Z).
|
||||
A profile decides only how the three cross-sections are arranged in XY;
|
||||
that arrangement is swept along Z. A "member" here is the end view of
|
||||
one 10-100 ft strap bundle, so its length in the cross-section is the
|
||||
strap's WIDTH.
|
||||
|
||||
Profiles
|
||||
1. Equilateral Triangle ring, three equal sides
|
||||
2. General Triangle ring, caller-shaped triangle
|
||||
3. A Frame two legs meeting at a gable apex, plus a
|
||||
crossbar butted between them
|
||||
4. Y three radial spokes about a plugged centre
|
||||
5. T two-strap flange with a perpendicular stem
|
||||
6. Three-Fin tangential triangle whose members are slid
|
||||
cyclically so each projects one fin
|
||||
|
||||
Everything reusable lives in lib/sb-core.scad and is shared unchanged
|
||||
with strap-beam-4x.scad. This file contains only the six arrangements
|
||||
and their parameters.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
Changes from v7 that alter dimensions
|
||||
---------------------------------------------------------------------
|
||||
* Every gap parameter is now a WEB: the PLA+ that survives between two
|
||||
strap cavities. v7 stated gaps strap-to-strap and then inflated the
|
||||
cavities by the fit clearance afterwards, which quietly removed
|
||||
2 x clearance from every junction. A declared 1.2 mm web is now
|
||||
1.2 mm of plastic.
|
||||
* Ring profiles size themselves. 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 three at once because each
|
||||
edge shares its budget with two corners. The polygon is therefore
|
||||
grown about its centroid until the tightest corner reaches the
|
||||
declared web. Caller-supplied triangle dimensions set the SHAPE; the
|
||||
size follows from the strap width.
|
||||
* Ring members are centred on their edges rather than shifted
|
||||
cyclically, so the triangle profiles are now mirror-symmetric.
|
||||
* Junctions are structural. Members butt through their neighbours and
|
||||
are cut flush on the far face; fillets are applied on top of that
|
||||
overlap rather than in place of it. In v7 the Three-Fin was held
|
||||
together by a 0.4 mm corner touch that the fillet happened to bridge.
|
||||
* Members with no enclosed side (Y spokes, T stem and flange) take the
|
||||
outside wall on both faces. v7 hard-coded an interior direction for
|
||||
these, which made the Y a pinwheel whenever the two wall thicknesses
|
||||
differed.
|
||||
* Validation measures the finished section. Connectivity is necessary
|
||||
but not sufficient - v7 passed cross-sections joined by 0.13 mm.
|
||||
* Checks are scoped to the selected profile. In v7 every assert was
|
||||
unconditional, so an A Frame slider could kill the Y profile.
|
||||
*/
|
||||
|
||||
include <lib/sb-core.scad>
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/* [Profile] */
|
||||
profile_type = "Equilateral Triangle"; // ["Equilateral Triangle","General Triangle","A Frame","Y","T","Three-Fin"]
|
||||
|
||||
/* [Pallet strap] */
|
||||
strap_width_mm = 15.875; // ULINE 5/8 in nominal width
|
||||
strap_thickness_mm = 0.508; // ULINE .020 in nominal thickness
|
||||
bundle_count = 1; // [1:1:3]
|
||||
|
||||
/* [Longitudinal axis] */
|
||||
member_length_ft = 10; // [10:1:100]
|
||||
length_view = "Preview"; // ["Preview","Full Length"]
|
||||
preview_length_mm = 100;
|
||||
|
||||
/* [PLA+ enclosure] */
|
||||
// Clearance is added to every cavity face. Declared webs are unaffected by
|
||||
// it; the library compensates internally.
|
||||
fit_clearance_mm = 0.25;
|
||||
inside_wall_thickness_mm = 1.20; // faces that look into an enclosed bore
|
||||
outside_wall_thickness_mm = 1.20; // faces exposed to the outside world
|
||||
edge_wall_thickness_mm = 1.20; // caps over a strap's narrow edges
|
||||
// Nothing anywhere in the section is allowed to be thinner than this.
|
||||
min_wall_mm = 1.20;
|
||||
|
||||
/* [Ring profiles] */
|
||||
// Applies to both triangle profiles. The polygon grows until its tightest
|
||||
// corner reaches this web.
|
||||
ring_corner_web_mm = 1.20;
|
||||
// Rounding of the complete outer envelope, never of individual sleeves.
|
||||
ring_corner_radius_mm = 2.00;
|
||||
|
||||
/* [General Triangle shape] */
|
||||
// These set proportions only; absolute size is solved from the strap width.
|
||||
general_triangle_base = 17.50;
|
||||
general_triangle_height = 15.50;
|
||||
general_triangle_apex_offset = 1.50;
|
||||
|
||||
/* [A Frame] */
|
||||
a_frame_leg_angle_deg = 45; // leg angle measured from horizontal
|
||||
a_frame_apex_web_mm = 1.20; // PLA+ between the two leg cavities
|
||||
a_frame_crossbar_web_mm = 1.20; // PLA+ between crossbar and leg cavities
|
||||
a_frame_crossbar_offset_mm = 0;
|
||||
a_frame_junction_round_mm = 1.50;
|
||||
|
||||
/* [Y Profile] */
|
||||
y_rotation_deg = 90;
|
||||
y_junction_web_mm = 1.20; // PLA+ between neighbouring spoke cavities
|
||||
y_junction_round_mm = 1.50;
|
||||
|
||||
/* [T Profile] */
|
||||
t_stem_web_mm = 1.20; // PLA+ between stem and flange cavities
|
||||
t_flange_web_mm = 1.20; // PLA+ between the two flange cavities
|
||||
t_junction_round_mm = 1.50;
|
||||
|
||||
/* [Three-Fin Profile] */
|
||||
// Fin projection past the corner it overhangs. The core triangle is solved
|
||||
// from this plus the web and bore minimums below.
|
||||
three_fin_fin_mm = 6.25;
|
||||
three_fin_web_mm = 1.20; // PLA+ at each cyclic junction
|
||||
three_fin_bore_side_mm = 7.50; // minimum central opening
|
||||
three_fin_rotation_deg = 0;
|
||||
three_fin_junction_round_mm = 2.00;
|
||||
|
||||
/* [Display] */
|
||||
render_mode = "3D"; // ["3D","Section"]
|
||||
show_straps = true;
|
||||
show_enclosure = true;
|
||||
|
||||
/* [Quality] */
|
||||
facets = 48; // [12:4:128]
|
||||
|
||||
/* [Reporting] */
|
||||
material_density_g_cm3 = 1.24; // PLA+ nominal
|
||||
|
||||
$fn = facets;
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
SB_FAMILY = "3x";
|
||||
// Generator revision. Qualification attestations bind to this, so any change
|
||||
// that alters emitted geometry MUST bump it. Cosmetic or comment-only edits
|
||||
// must not. See sb-report.scad for what is published.
|
||||
SB_REVISION = "8.0.0";
|
||||
SB_MEMBER_COUNT = 3;
|
||||
|
||||
geo = sb_geo(
|
||||
strap_width_mm, strap_thickness_mm, bundle_count, fit_clearance_mm,
|
||||
inside_wall_thickness_mm, outside_wall_thickness_mm,
|
||||
edge_wall_thickness_mm, min_wall_mm
|
||||
);
|
||||
|
||||
model_length_mm = length_view == "Full Length"
|
||||
? member_length_ft * 304.8
|
||||
: preview_length_mm;
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1-2. Ring profiles
|
||||
// ---------------------------------------------------------------------------
|
||||
/*
|
||||
Both triangle profiles are the same construction: a centreline polygon,
|
||||
grown until its corners are legal, wrapped in one envelope with solid
|
||||
rounded corners, with the bore taken from the members' real inside walls.
|
||||
|
||||
The 4x generator gets these for free by handing sb_ring_profile() a
|
||||
quadrilateral instead.
|
||||
*/
|
||||
|
||||
function sb3_equilateral(g) =
|
||||
let(s = sb_width(g), a = s / (2 * SB_SQRT3))
|
||||
sb_ring_polygon_profile([[-s / 2, -a], [s / 2, -a], [0, 2 * a]],
|
||||
g, ring_corner_web_mm, ring_corner_radius_mm,
|
||||
"Equilateral Triangle");
|
||||
|
||||
function sb3_general_triangle(g) =
|
||||
sb_ring_polygon_profile(
|
||||
[[-general_triangle_base / 2, -general_triangle_height / 2],
|
||||
[ general_triangle_base / 2, -general_triangle_height / 2],
|
||||
[ general_triangle_apex_offset, general_triangle_height / 2]],
|
||||
g, ring_corner_web_mm, ring_corner_radius_mm, "General Triangle");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. A Frame
|
||||
// ---------------------------------------------------------------------------
|
||||
/*
|
||||
The apex is a real meeting point: both leg centrelines pass through it and
|
||||
each leg is set back along its own axis until the two cavities are exactly
|
||||
a_frame_apex_web_mm apart. v7 instead offset both legs from the origin by
|
||||
a fixed radius, which delivered apex_gap x cos(leg_angle) and collapsed to
|
||||
nothing as the legs approached vertical.
|
||||
|
||||
The crossbar is likewise solved by depth against the measured web and then
|
||||
butted flush between the two legs' outer faces. v7 measured to the leg
|
||||
CENTRELINE, so its declared 1.40 mm arrived as 0.16 mm.
|
||||
*/
|
||||
|
||||
function sb3_a_frame_legs(g, setback) =
|
||||
let(
|
||||
a = a_frame_leg_angle_deg,
|
||||
target = [0, -sb_width(g)], // deep inside the counter
|
||||
d = sb_cavity_w(g) / 2 + setback,
|
||||
angL = 180 + a,
|
||||
angR = -a,
|
||||
cL = [d * cos(angL), d * sin(angL)],
|
||||
cR = [d * cos(angR), d * sin(angR)]
|
||||
)
|
||||
[
|
||||
sb_member(cL.x, cL.y, angL, sb_face_toward(cL, angL, target)),
|
||||
sb_member(cR.x, cR.y, angR, sb_face_toward(cR, angR, target))
|
||||
];
|
||||
|
||||
function sb3_a_frame_apex_web(g, setback) =
|
||||
let(l = sb3_a_frame_legs(g, setback))
|
||||
sb_path_gap(sb_cavity_path(l[0], g), sb_cavity_path(l[1], g));
|
||||
|
||||
function sb3_a_frame_crossbar(g, depth) =
|
||||
let(c = [a_frame_crossbar_offset_mm, -depth])
|
||||
sb_member(c.x, c.y, 0, sb_face_toward(c, 0, [0, 0]));
|
||||
|
||||
function sb3_a_frame_bar_web(g, legs, depth) =
|
||||
let(cb = sb_cavity_path(sb3_a_frame_crossbar(g, depth), g))
|
||||
min(sb_path_gap(cb, sb_cavity_path(legs[0], g)),
|
||||
sb_path_gap(cb, sb_cavity_path(legs[1], g)));
|
||||
|
||||
function sb3_a_frame(g) =
|
||||
let(
|
||||
W = sb_width(g),
|
||||
f_apex = function(s) sb3_a_frame_apex_web(g, s),
|
||||
feasible = sb_solvable(f_apex, 6 * W, a_frame_apex_web_mm)
|
||||
)
|
||||
!feasible
|
||||
? sb_profile_failed(str(
|
||||
"A Frame: at ", a_frame_leg_angle_deg,
|
||||
" degrees the legs are too close to parallel to open a ",
|
||||
a_frame_apex_web_mm,
|
||||
" mm apex web. Reduce a_frame_leg_angle_deg."))
|
||||
: let(
|
||||
setback = sb_solve(f_apex, 0, 6 * W, a_frame_apex_web_mm),
|
||||
legs = sb3_a_frame_legs(g, setback),
|
||||
f_bar = function(d) sb3_a_frame_bar_web(g, legs, d),
|
||||
bar_ok = sb_solvable(f_bar, 8 * W, a_frame_crossbar_web_mm)
|
||||
)
|
||||
!bar_ok
|
||||
? sb_profile_failed(
|
||||
"A Frame: the crossbar cannot reach a legal web against the legs. Reduce a_frame_crossbar_web_mm or the crossbar offset.")
|
||||
: let(
|
||||
depth = sb_solve(f_bar, 0, 8 * W, a_frame_crossbar_web_mm),
|
||||
bar = sb3_a_frame_crossbar(g, depth),
|
||||
ms = [legs[0], bar, legs[1]],
|
||||
|
||||
// Legs run up to the apex; the wedge between their end faces
|
||||
// is plugged with their convex hull, which leaves no spike
|
||||
// and no V-notch.
|
||||
ext = max(0, setback - sb_wall_edge(g)),
|
||||
legL = sb_sleeve_path(legs[0], g, 0, ext),
|
||||
legR = sb_sleeve_path(legs[1], g, 0, ext),
|
||||
cap = sb_hull_cap([sb_end_face(legs[0], g, -1, ext),
|
||||
sb_end_face(legs[1], g, -1, ext)]),
|
||||
|
||||
// Crossbar spans flush between the legs' outer faces.
|
||||
lineL = sb_far_face_line(legs[0], g, sb_mc(bar)),
|
||||
lineR = sb_far_face_line(legs[1], g, sb_mc(bar)),
|
||||
barP = sb_sleeve_span(bar, g, lineL[0], lineL[1],
|
||||
lineR[0], lineR[1]),
|
||||
|
||||
paths = [legL, barP, legR],
|
||||
raw = union(concat([[legL], [legR], [barP]],
|
||||
len(cap) > 0 ? [cap] : [])),
|
||||
shell = sb_fillet_junctions(raw, [[0, 1], [1, 2], [0, 2]],
|
||||
paths, a_frame_junction_round_mm),
|
||||
bore = sb_bore_from_members(ms, g),
|
||||
leg_end_depth = (setback + sb_cavity_w(g)) * sin(a_frame_leg_angle_deg)
|
||||
)
|
||||
sb_profile(
|
||||
ms, shell, bore,
|
||||
[
|
||||
sb_check(depth < leg_end_depth,
|
||||
str("A Frame: the crossbar sits at ", depth,
|
||||
" mm below the apex but the legs only reach ",
|
||||
leg_end_depth,
|
||||
" mm. Reduce a_frame_crossbar_web_mm or the leg angle.")),
|
||||
sb_check(sb_bore_valid(bore, ms, g),
|
||||
"A Frame: the enclosed counter has collapsed. Reduce inside_wall_thickness_mm or open the frame out."),
|
||||
sb_check(a_frame_junction_round_mm > 0,
|
||||
"A Frame: a_frame_junction_round_mm 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_check(abs(a_frame_crossbar_offset_mm) < sb_width(g) / 2,
|
||||
"A Frame: a_frame_crossbar_offset_mm has pushed the crossbar off the frame.")
|
||||
],
|
||||
[
|
||||
sb_kv("AF_APEX_SETBACK_MM", setback),
|
||||
sb_kv("AF_CROSSBAR_DEPTH_MM", depth),
|
||||
sb_kv("AF_APEX_WEB_MM", sb3_a_frame_apex_web(g, setback)),
|
||||
sb_kv("AF_CROSSBAR_WEB_MM", sb3_a_frame_bar_web(g, legs, depth))
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Y
|
||||
// ---------------------------------------------------------------------------
|
||||
/*
|
||||
Three spokes pointing away from a common centre. A spoke's broad faces
|
||||
both look at open air, so both get the outside wall - the profile stays
|
||||
three-fold symmetric and non-chiral under any wall settings. The centre
|
||||
is plugged with the convex hull of the three inner end faces rather than
|
||||
left to whatever the crossing rectangles happened to produce.
|
||||
*/
|
||||
|
||||
function sb3_y(g) =
|
||||
sb_spoke_profile(3, y_rotation_deg, y_junction_web_mm,
|
||||
y_junction_round_mm, g, "Y");
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. T
|
||||
// ---------------------------------------------------------------------------
|
||||
/*
|
||||
Two straps form the flange, separated by their own web so each keeps a
|
||||
private channel - in v7 they touched edge to edge and shared one slot.
|
||||
The stem is placed so its cavity clears the flange cavities by exactly
|
||||
t_stem_web_mm and is then butted flush into the flange's far face.
|
||||
*/
|
||||
|
||||
function sb3_t_members(g) =
|
||||
let(
|
||||
half = t_flange_web_mm / 2 + sb_cavity_w(g) / 2,
|
||||
top = -sb_cavity_t(g) / 2 - t_stem_web_mm,
|
||||
stemy = top - sb_cavity_w(g) / 2
|
||||
)
|
||||
[
|
||||
sb_member(-half, 0, 0, SB_FACE_BOTH_OUT),
|
||||
sb_member( half, 0, 0, SB_FACE_BOTH_OUT),
|
||||
sb_member(0, stemy, -90, SB_FACE_BOTH_OUT) // leading end points down
|
||||
];
|
||||
|
||||
function sb3_t(g) =
|
||||
let(
|
||||
ms = sb3_t_members(g),
|
||||
flL = sb_sleeve_path(ms[0], g),
|
||||
flR = sb_sleeve_path(ms[1], g),
|
||||
stem = sb_sleeve_butt(ms[2], g, ms[0]),
|
||||
paths = [flL, flR, stem],
|
||||
raw = union([[flL], [flR], [stem]]),
|
||||
shell = sb_fillet_junctions(raw, [[0, 2], [1, 2]], paths,
|
||||
t_junction_round_mm),
|
||||
flange_overlap = 2 * sb_wall_edge(g) - t_flange_web_mm
|
||||
)
|
||||
sb_profile(ms, shell, [], [
|
||||
sb_check(flange_overlap > 0,
|
||||
str("T: t_flange_web_mm of ", t_flange_web_mm,
|
||||
" mm exceeds twice the edge wall, so the two flange sleeves no longer meet. Reduce it or raise edge_wall_thickness_mm.")),
|
||||
sb_check(t_stem_web_mm >= sb_min_wall(g) - 1e-9,
|
||||
str("T: t_stem_web_mm of ", t_stem_web_mm, " mm is below min_wall_mm.")),
|
||||
sb_check(t_flange_web_mm >= sb_min_wall(g) - 1e-9,
|
||||
str("T: t_flange_web_mm of ", t_flange_web_mm, " mm is below min_wall_mm.")),
|
||||
sb_check(t_junction_round_mm > 0,
|
||||
"T: t_junction_round_mm must be greater than zero. A butt joint with no fillet meets the flange along an exactly tangent boundary, which is a valid outline but cannot be tessellated.")
|
||||
], [
|
||||
sb_kv("T_FLANGE_SPAN_MM", 2 * sb_cavity_w(g) + t_flange_web_mm
|
||||
+ 2 * sb_wall_edge(g)),
|
||||
sb_kv("T_NOTE", "inside_wall unused: no enclosed bore")
|
||||
]);
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Three-Fin
|
||||
// ---------------------------------------------------------------------------
|
||||
/*
|
||||
Tangential, not radial. Three straight members lie on the sides of a core
|
||||
triangle and are slid cyclically along those sides, so each stops short of
|
||||
the corner behind it and overhangs the corner ahead of it. The three
|
||||
overhangs are the fins.
|
||||
|
||||
Y three radial arms leaving a common centre
|
||||
Three-Fin three tangential sides around a core, one fin each
|
||||
|
||||
The core triangle is not a free parameter. It is solved so that the
|
||||
cyclic junctions carry the declared web AND the bore reaches its declared
|
||||
minimum, whichever demands more. The fin projection stays exactly as
|
||||
asked, because it is measured against the same solved triangle.
|
||||
|
||||
Each member's trailing end is run through the member behind it and cut off
|
||||
flush on its far face. That gives every junction a full-width overlap;
|
||||
the fillet that follows is cosmetic. In v7 the junctions were a 0.4 mm
|
||||
corner touch and the fillet was the only thing holding the part together.
|
||||
*/
|
||||
|
||||
function sb3_three_fin(g) =
|
||||
sb_fin_profile(3, three_fin_fin_mm, three_fin_web_mm,
|
||||
three_fin_bore_side_mm, three_fin_rotation_deg,
|
||||
three_fin_junction_round_mm, g, "Three-Fin");
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
/*
|
||||
Only the selected builder runs, so a parameter belonging to one entry can
|
||||
never invalidate another. A 4x generator supplies its own table here and
|
||||
nothing else changes.
|
||||
*/
|
||||
|
||||
function sb3_build(name, g) =
|
||||
name == "Equilateral Triangle" ? sb3_equilateral(g)
|
||||
: name == "General Triangle" ? sb3_general_triangle(g)
|
||||
: name == "A Frame" ? sb3_a_frame(g)
|
||||
: name == "Y" ? sb3_y(g)
|
||||
: name == "T" ? sb3_t(g)
|
||||
: name == "Three-Fin" ? sb3_three_fin(g)
|
||||
: sb_profile_failed(str("Unknown profile_type: ", name));
|
||||
|
||||
profile = sb3_build(profile_type, geo);
|
||||
|
||||
// Parameters that apply to every profile, so they are checked once here.
|
||||
base_checks = [
|
||||
sb_check(strap_width_mm > 0, "strap_width_mm must be greater than zero."),
|
||||
sb_check(strap_thickness_mm > 0, "strap_thickness_mm must be greater than zero."),
|
||||
sb_check(bundle_count >= 1 && bundle_count == floor(bundle_count),
|
||||
"bundle_count must be a whole number of at least 1."),
|
||||
sb_check(fit_clearance_mm >= 0, "fit_clearance_mm cannot be negative."),
|
||||
sb_check(inside_wall_thickness_mm > 0, "inside_wall_thickness_mm must be positive."),
|
||||
sb_check(outside_wall_thickness_mm > 0, "outside_wall_thickness_mm must be positive."),
|
||||
sb_check(edge_wall_thickness_mm > 0, "edge_wall_thickness_mm must be positive."),
|
||||
sb_check(min_wall_mm > 0, "min_wall_mm must be positive."),
|
||||
sb_check(preview_length_mm > 0, "preview_length_mm must be greater than zero.")
|
||||
];
|
||||
|
||||
// Fail on the profile's own checks before touching geometry, so an
|
||||
// unbuildable arrangement reports its cause instead of a library error.
|
||||
build_status = sb_require(concat(base_checks, sb_p_checks(profile)));
|
||||
|
||||
centred = sb_centred(profile, geo);
|
||||
section = centred[SB_C_SECTION];
|
||||
members = sb_centred_members(profile, centred[SB_C_SHIFT]);
|
||||
metrics = sb_metrics(section, centred[SB_C_SHELL], members, geo);
|
||||
|
||||
status = sb_require(sb_universal_checks(section, metrics, SB_MEMBER_COUNT, geo));
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sb_report(SB_FAMILY, profile_type, status, geo, metrics,
|
||||
model_length_mm, material_density_g_cm3,
|
||||
concat([sb_kv("REVISION", SB_REVISION),
|
||||
sb_kv("LENGTH_FT", member_length_ft),
|
||||
sb_kv("LENGTH_VIEW", length_view)],
|
||||
sb_p_info(profile)));
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (render_mode == "Section") {
|
||||
if (show_enclosure)
|
||||
color([0.20, 0.55, 0.95]) sb_draw_section(section);
|
||||
if (show_straps)
|
||||
color([0.96, 0.72, 0.05]) sb_draw_section(centred[SB_C_STRAPS]);
|
||||
} else {
|
||||
if (show_enclosure)
|
||||
color([0.20, 0.55, 0.95, 0.55]) sb_extrude_section(section, model_length_mm);
|
||||
if (show_straps)
|
||||
color([0.96, 0.72, 0.05, 0.82]) sb_extrude_straps(members, geo, model_length_mm);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
strap-beam-4x.scad — Four-strap longitudinal beam enclosures
|
||||
============================================================
|
||||
Companion to strap-beam-3x.scad. Four pallet-strap bundles run parallel
|
||||
to one longitudinal axis (Z); a profile decides only how the four
|
||||
cross-sections are arranged in XY.
|
||||
|
||||
Profiles
|
||||
1. Square ring, four equal sides
|
||||
2. Rectangle ring, caller-shaped by aspect ratio
|
||||
3. Diamond ring, square rotated 45 degrees
|
||||
4. Cross four spokes about a plugged centre
|
||||
5. Four-Fin tangential square, one cyclic fin per side
|
||||
|
||||
---------------------------------------------------------------------
|
||||
How much of this file is new
|
||||
---------------------------------------------------------------------
|
||||
None of the geometry. All five profiles are one call each into
|
||||
lib/sb-profiles.scad with N = 4:
|
||||
|
||||
Square / Rectangle / Diamond -> sb_ring_polygon_profile()
|
||||
Cross -> sb_spoke_profile(4, ...)
|
||||
Four-Fin -> sb_fin_profile(4, ...)
|
||||
|
||||
The same three calls with N = 3 produce the Triangles, the Y and the
|
||||
Three-Fin in strap-beam-3x.scad. The polygon fitting, the bore
|
||||
derivation, the butt joints, the fillets, the validation and the report
|
||||
are all member-count agnostic, so this file contains only parameters, a
|
||||
catalogue table and the outline shapes.
|
||||
|
||||
A profile that genuinely does not generalise - the 3x A Frame and T, which
|
||||
are specific arrangements rather than instances of a family - stays in its
|
||||
own generator. That is the intended split: shapes that are an N-instance
|
||||
live in the library, one-off arrangements live with their family.
|
||||
*/
|
||||
|
||||
include <lib/sb-core.scad>
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/* [Profile] */
|
||||
profile_type = "Square"; // ["Square","Rectangle","Diamond","Cross","Four-Fin"]
|
||||
|
||||
/* [Pallet strap] */
|
||||
strap_width_mm = 15.875;
|
||||
strap_thickness_mm = 0.508;
|
||||
bundle_count = 1; // [1:1:3]
|
||||
|
||||
/* [Longitudinal axis] */
|
||||
member_length_ft = 10; // [10:1:100]
|
||||
length_view = "Preview"; // ["Preview","Full Length"]
|
||||
preview_length_mm = 100;
|
||||
|
||||
/* [PLA+ enclosure] */
|
||||
fit_clearance_mm = 0.25;
|
||||
inside_wall_thickness_mm = 1.20;
|
||||
outside_wall_thickness_mm = 1.20;
|
||||
edge_wall_thickness_mm = 1.20;
|
||||
min_wall_mm = 1.20;
|
||||
|
||||
/* [Ring profiles] */
|
||||
ring_corner_web_mm = 1.20;
|
||||
// 1.25 mm is usable by all three ring outlines. The report publishes
|
||||
// RING_CORNER_R_MAX_MM per profile if you want to push a specific entry.
|
||||
ring_corner_radius_mm = 1.25;
|
||||
// Proportions only; absolute size is solved from the strap width.
|
||||
rectangle_aspect = 1.60;
|
||||
|
||||
/* [Cross Profile] */
|
||||
cross_rotation_deg = 0;
|
||||
cross_junction_web_mm = 1.20;
|
||||
cross_junction_round_mm = 1.50;
|
||||
|
||||
/* [Four-Fin Profile] */
|
||||
four_fin_fin_mm = 6.25;
|
||||
four_fin_web_mm = 1.20;
|
||||
four_fin_bore_side_mm = 7.50;
|
||||
four_fin_rotation_deg = 0;
|
||||
four_fin_junction_round_mm = 2.00;
|
||||
|
||||
/* [Display] */
|
||||
render_mode = "3D"; // ["3D","Section"]
|
||||
show_straps = true;
|
||||
show_enclosure = true;
|
||||
|
||||
/* [Quality] */
|
||||
facets = 48; // [12:4:128]
|
||||
|
||||
/* [Reporting] */
|
||||
material_density_g_cm3 = 1.24;
|
||||
|
||||
$fn = facets;
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
SB_FAMILY = "4x";
|
||||
// Generator revision. Qualification attestations bind to this, so any change
|
||||
// that alters emitted geometry MUST bump it. Cosmetic or comment-only edits
|
||||
// must not. See sb-report.scad for what is published.
|
||||
SB_REVISION = "8.0.0";
|
||||
SB_MEMBER_COUNT = 4;
|
||||
|
||||
geo = sb_geo(
|
||||
strap_width_mm, strap_thickness_mm, bundle_count, fit_clearance_mm,
|
||||
inside_wall_thickness_mm, outside_wall_thickness_mm,
|
||||
edge_wall_thickness_mm, min_wall_mm
|
||||
);
|
||||
|
||||
model_length_mm = length_view == "Full Length"
|
||||
? member_length_ft * 304.8
|
||||
: preview_length_mm;
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profiles
|
||||
// ---------------------------------------------------------------------------
|
||||
// Each is an outline plus a library call. The outlines set shape only; the
|
||||
// ring fitter grows them until every corner carries ring_corner_web_mm.
|
||||
|
||||
function sb4_rect_outline(w, h) =
|
||||
[[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]];
|
||||
|
||||
function sb4_square(g) =
|
||||
sb_ring_polygon_profile(sb4_rect_outline(1, 1), g,
|
||||
ring_corner_web_mm, ring_corner_radius_mm, "Square");
|
||||
|
||||
function sb4_rectangle(g) =
|
||||
sb_ring_polygon_profile(sb4_rect_outline(rectangle_aspect, 1), g,
|
||||
ring_corner_web_mm, ring_corner_radius_mm, "Rectangle");
|
||||
|
||||
function sb4_diamond(g) =
|
||||
sb_ring_polygon_profile([for (p = sb4_rect_outline(1, 1)) sb_rot2(p, 45)], g,
|
||||
ring_corner_web_mm, ring_corner_radius_mm, "Diamond");
|
||||
|
||||
function sb4_cross(g) =
|
||||
sb_spoke_profile(4, cross_rotation_deg, cross_junction_web_mm,
|
||||
cross_junction_round_mm, g, "Cross");
|
||||
|
||||
function sb4_four_fin(g) =
|
||||
sb_fin_profile(4, four_fin_fin_mm, four_fin_web_mm, four_fin_bore_side_mm,
|
||||
four_fin_rotation_deg, four_fin_junction_round_mm, g,
|
||||
"Four-Fin");
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sb4_build(name, g) =
|
||||
name == "Square" ? sb4_square(g)
|
||||
: name == "Rectangle" ? sb4_rectangle(g)
|
||||
: name == "Diamond" ? sb4_diamond(g)
|
||||
: name == "Cross" ? sb4_cross(g)
|
||||
: name == "Four-Fin" ? sb4_four_fin(g)
|
||||
: sb_profile_failed(str("Unknown profile_type: ", name));
|
||||
|
||||
profile = sb4_build(profile_type, geo);
|
||||
|
||||
base_checks = [
|
||||
sb_check(strap_width_mm > 0, "strap_width_mm must be greater than zero."),
|
||||
sb_check(strap_thickness_mm > 0, "strap_thickness_mm must be greater than zero."),
|
||||
sb_check(bundle_count >= 1 && bundle_count == floor(bundle_count),
|
||||
"bundle_count must be a whole number of at least 1."),
|
||||
sb_check(fit_clearance_mm >= 0, "fit_clearance_mm cannot be negative."),
|
||||
sb_check(inside_wall_thickness_mm > 0, "inside_wall_thickness_mm must be positive."),
|
||||
sb_check(outside_wall_thickness_mm > 0, "outside_wall_thickness_mm must be positive."),
|
||||
sb_check(edge_wall_thickness_mm > 0, "edge_wall_thickness_mm must be positive."),
|
||||
sb_check(min_wall_mm > 0, "min_wall_mm must be positive."),
|
||||
sb_check(rectangle_aspect > 0, "rectangle_aspect must be greater than zero."),
|
||||
sb_check(preview_length_mm > 0, "preview_length_mm must be greater than zero.")
|
||||
];
|
||||
|
||||
build_status = sb_require(concat(base_checks, sb_p_checks(profile)));
|
||||
|
||||
centred = sb_centred(profile, geo);
|
||||
section = centred[SB_C_SECTION];
|
||||
members = sb_centred_members(profile, centred[SB_C_SHIFT]);
|
||||
metrics = sb_metrics(section, centred[SB_C_SHELL], members, geo);
|
||||
|
||||
status = sb_require(sb_universal_checks(section, metrics, SB_MEMBER_COUNT, geo));
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sb_report(SB_FAMILY, profile_type, status, geo, metrics,
|
||||
model_length_mm, material_density_g_cm3,
|
||||
concat([sb_kv("REVISION", SB_REVISION),
|
||||
sb_kv("LENGTH_FT", member_length_ft),
|
||||
sb_kv("LENGTH_VIEW", length_view)],
|
||||
sb_p_info(profile)));
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (render_mode == "Section") {
|
||||
if (show_enclosure)
|
||||
color([0.20, 0.55, 0.95]) sb_draw_section(section);
|
||||
if (show_straps)
|
||||
color([0.96, 0.72, 0.05]) sb_draw_section(centred[SB_C_STRAPS]);
|
||||
} else {
|
||||
if (show_enclosure)
|
||||
color([0.20, 0.55, 0.95, 0.55]) sb_extrude_section(section, model_length_mm);
|
||||
if (show_straps)
|
||||
color([0.96, 0.72, 0.05, 0.82]) sb_extrude_straps(members, geo, model_length_mm);
|
||||
}
|
||||
Reference in New Issue
Block a user