/* 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];