diff --git a/models/disney_ear_hanger.invariants.py b/models/disney_ear_hanger.invariants.py index 38ab8a7..fb0d255 100644 --- a/models/disney_ear_hanger.invariants.py +++ b/models/disney_ear_hanger.invariants.py @@ -1,22 +1,28 @@ -"""Invariants for the vendored Disney ear hanger (st-w2g / pst-3tum). +"""Invariants for the vendored Disney ear hanger (st-w2g / pst-3tum / pst-j3ej). The saddle is externally-authored (MakerWorld, CC BY-NC-SA 4.0); we don't claim its geometry, so the DEFAULT (mount_type=tab) variant is pinned only for single-solid topology plus the built-ins (watertight, orphan-fragment, triangle ceiling, PRINT_ANCHOR_BBOX drift). The house -contribution this file guards is the mount_type fan-out (pst-3tum): the -openGrid and Multiconnect backers that replace the tab must each fuse to -the saddle as one printable solid, sit grid-aligned on the panel plane, -and — for openGrid — carry their directional snaps' strong nub UP the -wall so the saddle's cantilever lever-out bears on the rigid hook, not -the flexy click side. - -FRAMES. The .scad prints every variant with `translate([seat_x,0,seat_z]) -rotate([0,-90,0])`, i.e. body (X out of wall, +Y across, +Z up the wall) --> print (build +Z up). `_body_frame()` undoes that so the mount probes -read in the body frame the backer is placed in. The default-variant mesh -in `ctx` is mount_type=tab; the two backer variants are loaded from their -own filename-grid exports. +contribution this file guards is the mount_type fan-out: the openGrid +backer that replaces the tab, and the TWO-PIECE Multiconnect mount +(pst-j3ej) — a slim slot plate with a dovetail rail +(multiconnect_plate) plus the saddle with a matching dovetail socket +(multiconnect_saddle). Each fanned STL must be one printable watertight +solid that seats min-Z on the bed for its own derived print orientation +(no default-only seating), keep the saddle it is built from intact, and +carry its half of the joint (rail undercut / socket undercut) so the two +parts actually mate. + +FRAMES. The saddle-bearing variants (tab, opengrid, multiconnect_saddle) +print with `translate([seat_x,0,seat_z]) rotate([0,-90,0])`, i.e. body +(X out of wall, +Y across, +Z up the wall) -> print (build +Z up). +`_body_frame()` undoes that so the mount probes read in the body frame. +multiconnect_plate is a STANDALONE part authored directly in its own +print frame (slab flat, slot face down, dovetail rail up), so it is read +in build coordinates, not body-framed. The default-variant mesh in `ctx` +is mount_type=tab; the other variants load from their filename-grid +exports. """ from __future__ import annotations @@ -36,53 +42,54 @@ _SNAP_PITCH = 28.0 _SNAP_W = 24.8 _WELD = 0.02 -_MC_THICKNESS = 6.5 -_MC_WELD = 0.4 _PLATE_T = 4.0 _MIN_WALL = 2.4 _SADDLE_UP = 19.0 _WELD_EMBED = 2.0 _SADDLE_UP_CENTER = 12.6 -# Directional nub probe: radius at which the strong front nub is solid but -# the other three nubs are air, at the nub band's mid-height above the -# panel (adapted from apple_tv_4th_gen_holder's check #5). +# Slim Multiconnect slot plate + dovetail joint (pst-j3ej). +_MC_THICKNESS = 6.0 +_DT_DEPTH = 4.0 +_DT_NECK = 8.0 +_DT_TIP = 12.0 +_DT_LEN = 24.0 +_DOVETAIL_Z = 11.0 +_DT_CLEAR = 0.25 +# Directional nub probe (opengrid): radius at which the strong front nub is +# solid but the other three nubs are air, at the nub band's mid-height. _NUB_PROBE_R = 13.0 _NUB_PROBE_Z = 4.2 _CONTACT_EPS = 0.6 -# Corner-rounding probe (pst-4g1u). The backer footprint must carry a -# uniform rounded corner (plate cap AND the Multiconnect slab under it) like -# the tab's offset(2) — no square slab corner poking past the rounded plate -# as a stray post. Probe the slot-free plate layer: a point _CORNER_AIR mm -# diagonally inside each true rectangle corner must be AIR (the corner is -# rounded), while a point _CORNER_SOLID mm in must be SOLID. With rounding -# r, a corner point at diagonal inset d is air iff d < r*(1 - 1/sqrt2) ~= -# 0.29*r; at r=2 that is 0.59mm, so 0.4mm is comfortably air, while a square -# corner (r=0) keeps it solid. +# Corner-rounding probe (pst-4g1u): a point _CORNER_AIR mm diagonally inside +# each true rectangle corner must be AIR (rounded), a point _CORNER_SOLID mm +# in must be SOLID. _CORNER_AIR = 0.4 _CORNER_SOLID = 3.0 -def _variant_geom(hanger_length, width_units, height_units, mount_type): - """Mirror the .scad's mount derivation for one variant.""" - snap_h = 6.8 # full snaps only (invariants render defaults, snap_lite=False) +def _units(width_units, height_units): units_w = max(int(width_units), 1) # no saddle-spanning floor (pst-gmg0) units_h = max(int(height_units), math.ceil((_SADDLE_UP + 2 * _MIN_WALL) / _SNAP_PITCH)) + return units_w, units_h + + +def _opengrid_geom(hanger_length, width_units, height_units): + """Mirror the .scad's opengrid mount derivation (body frame).""" + snap_h = 6.8 # full snaps only (invariants render defaults, snap_lite=False) + units_w, units_h = _units(width_units, height_units) W = units_w * _SNAP_PITCH H = units_h * _SNAP_PITCH - plate_z0 = (_MC_THICKNESS - _MC_WELD) if mount_type == "multiconnect" \ - else (snap_h - _WELD) + plate_z0 = snap_h - _WELD plate_top = plate_z0 + _PLATE_T wall_face_x = -(hanger_length / 2 + 5) plate_front_x = wall_face_x + _WELD_EMBED mount_tx = plate_front_x - plate_top mount_tz = _SADDLE_UP_CENTER - H / 2 - seat_z = -mount_tx - seat_x = _SADDLE_UP_CENTER return dict(units_w=units_w, units_h=units_h, W=W, H=H, plate_z0=plate_z0, plate_top=plate_top, mount_tx=mount_tx, mount_tz=mount_tz, - seat_x=seat_x, seat_z=seat_z, + seat_x=_SADDLE_UP_CENTER, seat_z=-mount_tx, wall_face_x=wall_face_x, saddle_front_x=hanger_length / 2 + 5) @@ -97,7 +104,7 @@ def _body_frame(mesh, seat_x, seat_z): def _inside(mesh, points): - """Majority-voted containment — trimesh.contains flakes near snap geometry.""" + """Majority-voted containment — trimesh.contains flakes near thin geometry.""" offsets = [(0, 0, 0), (0.15, 0, 0), (-0.15, 0, 0), (0, 0.15, 0), (0, -0.15, 0)] base = np.array(points, dtype=float) @@ -107,13 +114,16 @@ def _inside(mesh, points): return votes * 2 > len(offsets) -def _components(mesh) -> int: - """Connected components via union-find over face adjacency. +def _air_span_z(mesh, x, y, z0, z1, step=0.2): + """Span of the AIR interval in z at (x,y) over [z0,z1] — 0 if none.""" + zs = np.arange(z0, z1 + 1e-9, step) + air = ~_inside(mesh, [[x, y, z] for z in zs]) + zin = zs[air] + return (zin.max() - zin.min()) if len(zin) else 0.0 + - trimesh.split needs scipy/networkx which CI doesn't have; this - mirrors scripts/check-invariants.py's built-in approach (and the - sibling mount sidecars, e.g. apple_tv_4th_gen_holder). - """ +def _components(mesh) -> int: + """Connected components via union-find over face adjacency.""" n = len(mesh.faces) if n == 0: return 0 @@ -132,6 +142,266 @@ def find(i: int) -> int: return len({find(i) for i in range(n)}) +def _load(stem, suffix, key, failures): + path = EXPORTS_DIR / f"{stem}-{suffix}.stl" + if not path.exists(): + failures.append(Failure( + f"{key}-export", + f"{path.name} missing — run scripts/export-all.py " + "(the mount_type filename grid should produce it)", + )) + return None + mesh = trimesh.load(str(path), force="mesh") + if not bool(mesh.is_watertight): + failures.append(Failure(f"{key}-watertight", f"{path.name} is not watertight")) + n = _components(mesh) + if n != 1: + failures.append(Failure( + f"{key}-topology", + f"{path.name} has {n} connected components, expected 1")) + if abs(mesh.bounds[0][2]) > 0.05: + failures.append(Failure( + f"{key}-seat", + f"{path.name} min build-Z = {mesh.bounds[0][2]:.2f} != 0 — the part " + "must seat flat on the bed (pst-t9ri: no default-only seating)")) + return mesh + + +def _check_opengrid(stem, hanger_length, width_units, height_units, failures): + g = _opengrid_geom(hanger_length, width_units, height_units) + mesh = _load(stem, "opengrid", "opengrid", failures) + if mesh is None: + return + bm = _body_frame(mesh, g["seat_x"], g["seat_z"]) + bext = bm.bounds + + # Footprint = whole openGrid tiles, measured on the panel side of the weld. + panel = bm.vertices[bm.vertices[:, 0] < g["wall_face_x"] - 0.5] + if len(panel): + w_meas = panel[:, 1].max() - panel[:, 1].min() + h_meas = panel[:, 2].max() - panel[:, 2].min() + if abs(w_meas - g["W"]) > 0.5 or abs(h_meas - g["H"]) > 0.5: + failures.append(Failure( + "opengrid-footprint", + f"backer plate reads {w_meas:.1f} x {h_meas:.1f}mm != " + f"{g['units_w']}x{g['units_h']} openGrid tiles " + f"({g['W']:.1f} x {g['H']:.1f}mm) — off the 28mm grid")) + else: + failures.append(Failure( + "opengrid-footprint", + "no plate vertices on the panel side of the saddle weld")) + + # Uniform rounded corners (pst-4g1u), plate cap layer. + corners = [(sy * g["W"] / 2, g["mount_tz"] + sz * g["H"]) + for sy in (1, -1) for sz in (0, 1)] + x_probe = g["mount_tx"] + (g["plate_z0"] + g["plate_top"]) / 2 + near_pts, far_pts = [], [] + for cy, cz in corners: + iy = -1.0 if cy > 0 else 1.0 + iz = -1.0 if cz > g["mount_tz"] + g["H"] / 2 else 1.0 + near_pts.append([x_probe, cy + iy * _CORNER_AIR, cz + iz * _CORNER_AIR]) + far_pts.append([x_probe, cy + iy * _CORNER_SOLID, cz + iz * _CORNER_SOLID]) + near_solid = _inside(bm, near_pts) + far_solid = _inside(bm, far_pts) + sharp = [corners[i] for i in range(4) if near_solid[i] or not far_solid[i]] + if sharp: + failures.append(Failure( + "opengrid-corner-rounding", + f"{len(sharp)} of 4 plate corners are not rounded (pst-4g1u stray " + f"post or rounding regressed). First offender (body y,z)={sharp[0]}")) + + # Panel plane at the most -X face = mount_tx. + if abs(bext[0][0] - g["mount_tx"]) > 0.6: + failures.append(Failure( + "opengrid-panel-plane", + f"panel face at body x={bext[0][0]:.1f} != mount_tx=" + f"{g['mount_tx']:.1f} — print-frame rotation or backer moved")) + return + + # Saddle preserved: arch still reaches its front. + if abs(bext[1][0] - g["saddle_front_x"]) > 1.0: + failures.append(Failure( + "opengrid-saddle", + f"saddle front at body x={bext[1][0]:.1f} != " + f"{g['saddle_front_x']:.1f} — the saddle was altered or dropped")) + + centres = [((cx - (g["units_w"] - 1) / 2) * _SNAP_PITCH, + (ry + 0.5) * _SNAP_PITCH + g["mount_tz"]) + for cx in range(g["units_w"]) for ry in range(g["units_h"])] + + # Panel-contact patch spans exactly the corner-snap grid. + v = bm.vertices + contact = v[v[:, 0] < g["mount_tx"] + _CONTACT_EPS] + if len(contact): + span_y = contact[:, 1].max() - contact[:, 1].min() + span_z = contact[:, 2].max() - contact[:, 2].min() + want_y = (g["units_w"] - 1) * _SNAP_PITCH + _SNAP_W + want_z = (g["units_h"] - 1) * _SNAP_PITCH + _SNAP_W + if abs(span_y - want_y) > 0.6 or abs(span_z - want_z) > 0.6: + failures.append(Failure( + "opengrid-snapgrid", + f"panel contact spans {span_y:.1f} x {span_z:.1f}mm but a " + f"{g['units_w']}x{g['units_h']} snap grid should span " + f"{want_y:.1f} x {want_z:.1f}mm — pitch or snap count drifted")) + else: + failures.append(Failure( + "opengrid-snapgrid", "no vertices on the panel plane")) + + # Strong nub UP the wall (+Z body). + xr = g["mount_tx"] + _NUB_PROBE_Z + strong = _inside(bm, [[xr, cy, cz + _NUB_PROBE_R] for cy, cz in centres]) + click = _inside(bm, [[xr, cy, cz - _NUB_PROBE_R] for cy, cz in centres]) + bad = [centres[i] for i in range(len(centres)) + if not (strong[i] and not click[i])] + if bad: + failures.append(Failure( + "opengrid-snap-direction", + f"{len(bad)} of {len(centres)} snaps are not directional-with-" + f"strong-nub-up. First offender {bad[0]}")) + + +def _check_mc_plate(stem, width_units, height_units, failures): + """multiconnect_plate: standalone slim slot slab + dovetail rail, read in + its STANDING print frame — the slab is a wall in the X-Z plane at + y[-mc_thickness, 0] (slot openings on the -Y board face, solid back at + y=0), standing on its z=0 bottom edge; the dovetail rail protrudes +Y.""" + units_w, units_h = _units(width_units, height_units) + W, H = units_w * _SNAP_PITCH, units_h * _SNAP_PITCH + mesh = _load(stem, "multiconnect_plate", "mc_plate", failures) + if mesh is None: + return + ext = mesh.bounds[1] - mesh.bounds[0] + + # Footprint = the W(x) x H(z) tile slab; total depth = slab + rail (Y). + if abs(ext[0] - W) > 0.6 or abs(ext[2] - H) > 0.6: + failures.append(Failure( + "mc_plate-footprint", + f"slot slab reads {ext[0]:.1f} x {ext[2]:.1f}mm != {units_w}x" + f"{units_h} tiles ({W:.1f} x {H:.1f}mm) — off the 28mm grid")) + want_y = _MC_THICKNESS + _DT_DEPTH + if abs(ext[1] - want_y) > 0.6: + failures.append(Failure( + "mc_plate-thickness", + f"plate depth {ext[1]:.2f}mm != slim slab + rail " + f"({_MC_THICKNESS:.1f}+{_DT_DEPTH:.1f}={want_y:.1f}mm) — the plate is " + "not the slimmed ~6mm slab or the rail depth drifted (pst-j3ej A1)")) + + # Slab really is only mc_thickness thick where there is no rail: probe a + # near-corner column in Y, clear of the central rail (x~W/2,z~H/2) — + # solid inside the slab, air past the y=0 back face (no rail there). + cx, cz = W * 0.12, H * 0.12 + if not _inside(mesh, [[cx, -_MC_THICKNESS / 2, cz]])[0]: + failures.append(Failure( + "mc_plate-thickness", + "no slab where a corner column was probed — frame/footprint mismatch")) + elif _inside(mesh, [[cx, 1.0, cz]])[0]: + failures.append(Failure( + "mc_plate-thickness", + f"slab is solid past the y=0 back face away from the rail — the " + "slot plate is thicker than the slim ~6mm target (pst-j3ej A1)")) + + # SLOT DIRECTION (pst-j3ej): the slots MUST open on the -Y board face, so + # the plate engages a Multiconnect board — not a solid face. An earlier + # print had the rotation inverted, sealing the board face solid (the part + # met the rails with a blank wall). Probe the central slot: AIR just + # inside the -Y face (the open slot mouth), SOLID at the back behind it. + board_air = _inside(mesh, [[W / 2, -_MC_THICKNESS + 0.4, H / 2]])[0] + back_solid = _inside(mesh, [[W / 2, -0.5, H / 2]])[0] + if board_air: + failures.append(Failure( + "mc_plate-slot-direction", + "the -Y board face is SOLID at the central slot — the slot " + "openings face the wrong way, so the plate cannot engage a " + "Multiconnect board (pst-j3ej: inverted slot rotation)")) + if not back_solid: + failures.append(Failure( + "mc_plate-slot-direction", + "no solid back wall behind the central slot — the slab is open " + "through, so there is nothing to host the retention undercut")) + + # Dovetail rail is a real undercut: protruding +Y off the back, it is + # WIDER in Z near its tip than at its base (self-supporting flare that + # captures pull-out). Measure the solid Z-span at two rail depths. + def rail_width(yoff): + zs = np.arange(H / 2 - 10, H / 2 + 10, 0.2) + solid = _inside(mesh, [[W / 2, yoff, z] for z in zs]) + zin = zs[solid] + return (zin.max() - zin.min()) if len(zin) else 0.0 + w_base = rail_width(0.5) + w_tip = rail_width(_DT_DEPTH - 0.5) + if not (w_base > 1 and w_tip > w_base + 1.0): + failures.append(Failure( + "mc_plate-rail", + f"dovetail rail is not an undercut: width at base={w_base:.1f}mm, " + f"near tip={w_tip:.1f}mm (tip must be clearly wider). The rail " + "must flare so it captures the socket against pull-out (pst-j3ej)")) + # Rail runs a finite length along X (the slide axis) ~ dt_len, not the + # whole plate — so the joint is a fixed interface. + xs = np.arange(W / 2 - 15, W / 2 + 15, 0.25) + solid = _inside(mesh, [[x, _DT_DEPTH / 2, H / 2] for x in xs]) + xin = xs[solid] + rail_len = (xin.max() - xin.min()) if len(xin) else 0.0 + if abs(rail_len - _DT_LEN) > 2.0: + failures.append(Failure( + "mc_plate-rail", + f"dovetail rail length {rail_len:.1f}mm != {_DT_LEN:.0f}mm interface " + "— the fixed-size joint drifted")) + + +def _check_mc_saddle(stem, hanger_length, failures): + """multiconnect_saddle: the saddle with a solid wall-end backing and a + dovetail socket relieved into it, read in the body frame.""" + seat_x, seat_z = _SADDLE_UP_CENTER, hanger_length / 2 + 5.05 + wall_face_x = -(hanger_length / 2 + 5) + saddle_front_x = hanger_length / 2 + 5 + mesh = _load(stem, "multiconnect_saddle", "mc_saddle", failures) + if mesh is None: + return + bm = _body_frame(mesh, seat_x, seat_z) + bext = bm.bounds + + # Saddle preserved: the arch still reaches its front at x = hL/2+5. + if abs(bext[1][0] - saddle_front_x) > 1.0: + failures.append(Failure( + "mc_saddle-saddle", + f"saddle front at body x={bext[1][0]:.1f} != {saddle_front_x:.1f} — " + "the saddle was altered or dropped (the socket must only RELIEVE it)")) + + # Socket void present in solid backing: air inside the socket, solid on + # the back wall behind it and in the roof/floor around it. + x_in = wall_face_x + _DT_DEPTH / 2 # mid-depth, inside the socket + x_back = wall_face_x + _DT_DEPTH + 1.5 # behind the blind end + air = _inside(bm, [[x_in, 0, _DOVETAIL_Z]])[0] + back = _inside(bm, [[x_back, 0, _DOVETAIL_Z]])[0] + roof = _inside(bm, [[x_in, 0, _DOVETAIL_Z + _DT_TIP / 2 + 2.5]])[0] + floor = _inside(bm, [[x_in, 0, _DOVETAIL_Z - _DT_TIP / 2 - 2.5]])[0] + if air: + failures.append(Failure( + "mc_saddle-socket", + "no socket void at the wall-end centre — the dovetail socket is " + "missing or the wall-end backing was not relieved (pst-j3ej)")) + if not (back and roof and floor): + failures.append(Failure( + "mc_saddle-backing", + f"the wall-end socket is not embedded in solid backing " + f"(back={bool(back)} roof={bool(roof)} floor={bool(floor)}) — the " + "arch is hollow there, so the socket would have nothing to grip " + "(pst-j3ej: the backing must refill the wall-end channel)")) + + # Socket is a real dovetail undercut: the air channel is WIDER in body-z + # deeper in (near the blind end) than at the mouth. + span_mouth = _air_span_z(bm, wall_face_x + 0.6, 0, + _DOVETAIL_Z - 9, _DOVETAIL_Z + 9) + span_deep = _air_span_z(bm, wall_face_x + _DT_DEPTH - 0.4, 0, + _DOVETAIL_Z - 9, _DOVETAIL_Z + 9) + if not (span_mouth > 3 and span_deep > span_mouth + 1.5): + failures.append(Failure( + "mc_saddle-socket", + f"socket is not an undercut: air-span at the mouth={span_mouth:.1f}mm, " + f"deep={span_deep:.1f}mm (deep must be clearly wider to trap the " + "rail against pull-out, pst-j3ej)")) + + def check(ctx): failures = [] p = as_default_params(ctx["params"]) @@ -144,194 +414,21 @@ def check(ctx): height_units = int(p.get("height_units", 2)) stem = ctx["stem"] - # --- Width control must be live across its whole declared range AND - # reach the minimal single-tile backer (pst-gmg0, reversing pst-51es). - # The hung headband is trivially light, so the backer has NO - # saddle-spanning floor: units_w = max(width_units, 1), a single whole - # tile. Two failure modes this guards: - # * @param min < 1 -> sub-tile / no whole snap tile. - # * @param min > 1 -> the width control cannot reach the minimal - # single-snap backer the operator requires (and, if a caller ever - # re-adds a saddle-spanning floor above the min, the low end of the - # slider becomes a dead zone again — the original pst-51es bug). - # Pin the declared @param min at the 1-tile structural floor so - # units_w == width_units 1:1 across the range and width=1 is reachable. - # (The mount-footprint check below is the second, independent source of - # truth: it re-derives W from width_units and fails if the export width - # does not track it, catching a re-added floor at the default.) + # --- Width control must reach the minimal single-tile backer (pst-gmg0). --- floor_w = 1 declared_min_w = ctx["params"].get("width_units", {}).get("min") if declared_min_w is not None and int(declared_min_w) != floor_w: failures.append(Failure( "width-range-min", f"width_units @param min={int(declared_min_w)} != the {floor_w}-" - f"tile structural floor. The minimal backer has no saddle-" - f"spanning floor (pst-gmg0): min<{floor_w} is sub-tile, and " - f"min>{floor_w} forbids the minimal single-snap backer (or masks " - f"a re-added floor that would collapse the low end of the slider " - f"into a dead zone, the pst-51es bug). Pin the @param min to " - f"{floor_w} so units_w == width_units 1:1 and width=1 is reachable.", + f"tile structural floor. The minimal backer has no saddle-spanning " + f"floor (pst-gmg0): pin the @param min to {floor_w} so units_w == " + f"width_units 1:1 and width=1 is reachable.", )) - # --- Backer variants: openGrid + Multiconnect, each its own export. --- - for mount_type in ("opengrid", "multiconnect"): - g = _variant_geom(hanger_length, width_units, height_units, mount_type) - path = EXPORTS_DIR / f"{stem}-{mount_type}.stl" - if not path.exists(): - failures.append(Failure( - f"{mount_type}-export", - f"{path.name} missing — run scripts/export-all.py " - "(the mount_type filename grid should produce it)", - )) - continue - mesh = trimesh.load(str(path), force="mesh") - - if not bool(mesh.is_watertight): - failures.append(Failure( - f"{mount_type}-watertight", f"{path.name} is not watertight")) - n = _components(mesh) - if n != 1: - failures.append(Failure( - f"{mount_type}-topology", - f"{path.name} has {n} connected components, expected 1 — the " - "backer is not welded to the saddle (a smaller weld_embed or a " - "moved plate breaks the fusion first)", - )) - - # Into the body frame for the wall-end probes. - bm = _body_frame(mesh, g["seat_x"], g["seat_z"]) - bext = bm.bounds - - # Footprint = whole openGrid tiles (W across x H up-wall). The plate - # may now be NARROWER than the 65mm saddle (pst-gmg0 minimal backer), - # so the whole-mesh bbox no longer reads the plate width — the saddle - # dominates. Measure the plate on the PANEL SIDE of the saddle weld: - # the saddle's nearest-wall face is at body x = wall_face_x, and the - # plate/snaps live below it, so vertices with x < wall_face_x are - # plate + snaps only (saddle-free). The plate corners are the widest - # and tallest of those, giving W (body Y) and H (body Z) directly. - panel = bm.vertices[bm.vertices[:, 0] < g["wall_face_x"] - 0.5] - if len(panel): - w_meas = panel[:, 1].max() - panel[:, 1].min() - h_meas = panel[:, 2].max() - panel[:, 2].min() - if abs(w_meas - g["W"]) > 0.5 or abs(h_meas - g["H"]) > 0.5: - failures.append(Failure( - f"{mount_type}-footprint", - f"backer plate reads {w_meas:.1f} x {h_meas:.1f}mm != " - f"{g['units_w']}x{g['units_h']} openGrid tiles " - f"({g['W']:.1f} x {g['H']:.1f}mm) — off the 28mm grid", - )) - else: - failures.append(Failure( - f"{mount_type}-footprint", - "no plate vertices on the panel side of the saddle weld — " - "the backer moved or the frame no longer matches _body_frame()", - )) - - # Uniform rounded corners (pst-4g1u). At each of the 4 footprint - # corners a point just inside the true rectangle corner must be air - # (the corner is rounded) and a point well inside must be solid. - # Probe every solid, slot-free layer of the backer at that corner: - # * the plate cap (both mounts) — a plain rounded rect, no slots. - # * the Multiconnect slab (multiconnect only) — where the original - # square corner poked past the plate as the stray post; the slots - # sit inboard so the corners are solid slab. The openGrid snaps - # are inboard of the footprint, so there is no snap layer to probe - # (a corner probe there would be air by design). - corners = [(sy * g["W"] / 2, g["mount_tz"] + sz * g["H"]) - for sy in (1, -1) for sz in (0, 1)] - layers = {"plate": g["mount_tx"] + (g["plate_z0"] + g["plate_top"]) / 2} - if mount_type == "multiconnect": - layers["slab"] = g["mount_tx"] + _MC_THICKNESS / 2 - for layer, x_probe in layers.items(): - near_pts, far_pts = [], [] - for cy, cz in corners: - iy = -1.0 if cy > 0 else 1.0 - iz = -1.0 if cz > g["mount_tz"] + g["H"] / 2 else 1.0 - near_pts.append([x_probe, cy + iy * _CORNER_AIR, - cz + iz * _CORNER_AIR]) - far_pts.append([x_probe, cy + iy * _CORNER_SOLID, - cz + iz * _CORNER_SOLID]) - near_solid = _inside(bm, near_pts) - far_solid = _inside(bm, far_pts) - sharp = [corners[i] for i in range(4) - if near_solid[i] or not far_solid[i]] - if sharp: - failures.append(Failure( - f"{mount_type}-corner-rounding", - f"{len(sharp)} of 4 {layer} corners are not rounded: a " - f"probe {_CORNER_AIR}mm inside the true rectangle corner " - "reads solid (a square corner poking past the rounded " - "plate — the pst-4g1u stray post — or the rounding " - f"regressed), expected air. First offender (body y,z)=" - f"{sharp[0]}", - )) - - # Panel plane (snap/slot faces) sits at the most -X face = mount_tx. - if abs(bext[0][0] - g["mount_tx"]) > 0.6: - failures.append(Failure( - f"{mount_type}-panel-plane", - f"panel face at body x={bext[0][0]:.1f} != mount_tx=" - f"{g['mount_tx']:.1f} — the print-frame rotation in the .scad " - "no longer matches _body_frame(), or the backer moved", - )) - continue - - # Saddle preserved: the arch still reaches its front at x = hL/2+5. - if abs(bext[1][0] - g["saddle_front_x"]) > 1.0: - failures.append(Failure( - f"{mount_type}-saddle", - f"saddle front at body x={bext[1][0]:.1f} != " - f"{g['saddle_front_x']:.1f} — the saddle was altered or " - "dropped (the backer must ADD to it, not replace it)", - )) - - # Full grid — a snap in every tile (see grid_snaps() in the .scad). - centres = [((cx - (g["units_w"] - 1) / 2) * _SNAP_PITCH, - (ry + 0.5) * _SNAP_PITCH + g["mount_tz"]) - for cx in range(g["units_w"]) for ry in range(g["units_h"])] - - if mount_type == "opengrid": - # Panel-contact patch spans exactly the corner-snap grid. - v = bm.vertices - contact = v[v[:, 0] < g["mount_tx"] + _CONTACT_EPS] - if len(contact): - span_y = contact[:, 1].max() - contact[:, 1].min() - span_z = contact[:, 2].max() - contact[:, 2].min() - want_y = (g["units_w"] - 1) * _SNAP_PITCH + _SNAP_W - want_z = (g["units_h"] - 1) * _SNAP_PITCH + _SNAP_W - if abs(span_y - want_y) > 0.6 or abs(span_z - want_z) > 0.6: - failures.append(Failure( - "opengrid-snapgrid", - f"panel contact spans {span_y:.1f} x {span_z:.1f}mm " - f"but a {g['units_w']}x{g['units_h']} snap grid should " - f"span {want_y:.1f} x {want_z:.1f}mm — pitch or snap " - "count drifted", - )) - else: - failures.append(Failure( - "opengrid-snapgrid", - "no vertices on the panel plane — snaps missing or frame " - "mismatch", - )) - - # Strong nub UP the wall (+Z body): solid at +Z of each snap - # centre, air at -Z (the flexy click side). - xr = g["mount_tx"] + _NUB_PROBE_Z - strong = _inside(bm, [[xr, cy, cz + _NUB_PROBE_R] - for cy, cz in centres]) - click = _inside(bm, [[xr, cy, cz - _NUB_PROBE_R] - for cy, cz in centres]) - bad = [centres[i] for i in range(len(centres)) - if not (strong[i] and not click[i])] - if bad: - failures.append(Failure( - "opengrid-snap-direction", - f"{len(bad)} of {len(centres)} snaps are not directional-" - f"with-strong-nub-up: a probe {_NUB_PROBE_R}mm from a snap " - "centre must be solid at +Z (the 0.8mm front hook, up the " - "wall) and air at -Z (the 0.4mm click side). The saddle's " - f"cantilever levers the top row out. First offender {bad[0]}", - )) + # --- The fanned mount variants, each its own export. --- + _check_opengrid(stem, hanger_length, width_units, height_units, failures) + _check_mc_plate(stem, width_units, height_units, failures) + _check_mc_saddle(stem, hanger_length, failures) return failures diff --git a/models/disney_ear_hanger.scad b/models/disney_ear_hanger.scad index 67f498f..c71edca 100644 --- a/models/disney_ear_hanger.scad +++ b/models/disney_ear_hanger.scad @@ -23,26 +23,63 @@ // NON-COMMERCIAL. Same mount hardware the sibling models // (apple_tv_4th_gen_holder, ryobi_p2860_strap_saddle) carry. // -// === Mount options (mount_type, pst-3tum) === +// === Mount options (mount_type, pst-3tum / pst-j3ej) === // -// The saddle geometry is IDENTICAL across all three mounts — only the -// wall attachment at the wall end changes, and it is the export-grid -// filename axis: -// tab (default) — the original rounded-square adhesive fin. Geometry -// is BYTE-IDENTICAL to the pre-mount model; tape / 3M -// Command strip to a smooth wall (pst-15du/pst-wjte). -// opengrid — an openGrid snap plate that REPLACES the tab, so the -// hanger clicks onto a 28mm openGrid panel. -// multiconnect — a Multiconnect slot backer (Multiboard / openGrid MC -// studs) that replaces the tab. -// The backer is a whole-tile plate behind the saddle's wall end. There is -// NO saddle-spanning floor: the hung headband is trivially light, so a -// MINIMAL plate is by design — in the spirit of the original small -// adhesive tab (operator correction, pst-gmg0). It defaults to a single -// tile (one openGridSnap / one Multiconnect slot behind the saddle) and -// widens 1:1 with width_units for anyone who wants a broader couple; -// height_units floats up from 1 like the sibling mounts. width_units maps -// 1:1 to a distinct plate width across its whole 1..6 range. +// The saddle geometry is IDENTICAL across the mounts — only the wall +// attachment at the wall end changes, and it is the export-grid filename +// axis. The Multiconnect mount is a TWO-PIECE, dovetail-jointed pair +// (pst-j3ej), so the enum carries four fanned STLs: +// tab (default) — the original rounded-square adhesive fin. +// Geometry is BYTE-IDENTICAL to the pre-mount model; +// tape / 3M Command strip to a smooth wall. +// opengrid — an openGrid snap plate that REPLACES the tab, so +// the hanger clicks onto a 28mm openGrid panel. +// multiconnect_plate — PART A of the Multiconnect mount: a slim (~6mm) +// Multiconnect slot plate with a dovetail RAIL on its +// accessory face. Prints standing, on its own. +// multiconnect_saddle— PART B: the ear-hanger saddle with a dovetail +// SOCKET cut into its wall end (no backer welded on). +// Prints in the original supportless orientation. +// The two Multiconnect parts print SEPARATELY (each its own zero-support +// flat print) and slide together at the dovetail — see the assembly note +// below. This replaces the old one-piece Multiconnect variant, which welded +// the plate perpendicular to the saddle and so could not print without +// heavy supports in any orientation (operator, pst-j3ej). +// +// The plate is a whole-tile Multiconnect back behind the saddle's wall +// end. There is NO saddle-spanning floor: the hung headband is trivially +// light, so a MINIMAL plate is by design — in the spirit of the original +// small adhesive tab (operator correction, pst-gmg0). It defaults to a +// single tile and widens 1:1 with width_units for anyone who wants a +// broader couple; height_units floats up from 1 like the sibling mounts. +// +// === Two-piece dovetail joint (pst-j3ej) === +// +// The plate carries a male dovetail RAIL; the saddle a female SOCKET. The +// rail runs ACROSS the wall (body Y) — the slide axis is horizontal — so +// BOTH the saddle's cantilever lever-out (body +X, pull off the wall) AND +// gravity (body -Z) bear on the dovetail's flared shoulders, not along the +// slide (operator's load rule). You mount the plate on the board, then +// slide the saddle sideways onto the rail; a snug fit +// (dovetail_clearance ~0.25mm, @param) plus an optional glue dot at the +// joint holds it against sliding back out. The joint dims are fixed +// (independent of plate size) so a future tab/opengrid back could grow the +// same rail and become interchangeable (pst-j3ej "don't preclude it"). +// +// === Print: the Multiconnect plate prints STANDING, slots horizontal === +// +// The plate prints in multiconnectBack's NATIVE frame: the slab is a wall +// in the X-Z plane, thin in Y, standing on its z=0 bottom edge. The slot +// openings face horizontal (-Y) and print as vertical walls with their +// undercuts and retention domes self-supporting and the on-ramp bridging — +// the standard Multiconnect ZERO-SUPPORT orientation the sibling backers +// (apple_tv_4th_gen_holder etc.) already print in. The dovetail rail +// protrudes horizontally (+Y) off the SOLID back face, opposite the slot +// openings, and adds no overhang of its own. (pst-j3ej: the operator chose +// the standing print. It is taller than the saddle — brim it if tippy — +// but it is the only orientation that is both support-free AND presents the +// slot openings to the board. An earlier flat print had the slots inverted +// so the plate met the board with a solid face — see the invariant probe.) // // === Up-the-wall axis / load (pst-3tum, JUDGMENT CALL) === // @@ -57,15 +94,19 @@ // // === Print orientation: wall face on the bed, arch up === // -// Every variant prints with the flat mounting side on the bed and the -// arch rising straight up (rotate([0,-90,0]); the upstream orientation, -// supportless — the saddle is a constant vertical cross-section). tab -// reproduces the upstream transform byte-for-byte. For the backer -// variants the plate + snaps/slots print snaps-DOWN on the bed (first -// layers are the panel-mating face) with the saddle rising off the plate -// front; the min-Z bed seat is parameter-derived per variant (pst-t9ri -// rule — no default-only seating). Print the panel-mating face against a -// smooth plate. +// The saddle-bearing variants (tab, opengrid, multiconnect_saddle) print +// with the flat mounting side on the bed and the arch rising straight up +// (rotate([0,-90,0]); the upstream orientation, supportless — the saddle +// is a constant vertical cross-section). tab reproduces the upstream +// transform byte-for-byte. multiconnect_saddle is that same saddle with a +// dovetail socket relieved into its wall end (a small ~12mm bridge on the +// bed-facing wall block). The opengrid backer prints snaps-DOWN on the bed +// (first layers are the panel-mating face) with the saddle rising off the +// plate front. The multiconnect_plate prints on its OWN — STANDING, slots +// facing horizontal / rail out (see the two-piece print note above). Every +// part's min-Z bed seat is parameter-derived per variant (pst-t9ri rule — +// no default-only seating). Print the panel-mating face against a smooth +// plate. include // `use` not `include`: opengrid-snap.scad ends with a top-level demo @@ -98,12 +139,14 @@ tabThickness = 3; // @param number min=2 max=6 step=0.5 unit=mm group=tab lab tabRounding = 2; // @param number min=0 max=5 step=0.5 unit=mm group=tab label="Tab corner rounding" // ----- Wall mount ----- -// Three interchangeable back mounts, exported as one STL each (the -// 'filename' flag fans the export grid over the enum). Default is the -// original adhesive tab so the shipped hanger is unchanged; 'opengrid' -// and 'multiconnect' are the panel-mount alternatives. Same @param set -// the sibling mounts (apple_tv, ryobi, opengrid_bin) copy. -mount_type = "tab"; // @param enum choices=tab|opengrid|multiconnect group=mount label="Wall mount type" filename +// Interchangeable back mounts, exported as one STL each (the 'filename' +// flag fans the export grid over the enum). Default is the original +// adhesive tab so the shipped hanger is unchanged. 'opengrid' is a snap +// panel mount. The Multiconnect mount is TWO fanned parts that print +// separately and slide together at a dovetail (pst-j3ej): +// 'multiconnect_plate' (the slot plate + rail) and 'multiconnect_saddle' +// (the saddle + socket). Print ONE of each and join them. +mount_type = "tab"; // @param enum choices=tab|opengrid|multiconnect_plate|multiconnect_saddle group=mount label="Wall mount type" filename // Backer plate size in whole openGrid tiles. NO saddle-spanning floor: the // hung headband is trivially light, so a minimal plate behind the saddle is @@ -117,19 +160,26 @@ width_units = 1; // @param integer min=1 max=6 group=mount label="Backer wi height_units = 2; // @param integer min=1 max=6 group=mount label="Backer height (openGrid units, min)" snap_lite = false; // @param boolean group=mount label="Lite openGrid snaps (3.4mm instead of 6.8mm)" -// Standard Multiconnect slot tuning (only affects mount_type = -// "multiconnect"; ignored otherwise). Defaults reproduce the shipped -// sibling backer exactly, threaded through the multiconnectBack() call. +// Standard Multiconnect slot tuning (only affects the multiconnect_plate +// part; ignored otherwise). Defaults reproduce the shipped sibling backer +// slot exactly, threaded through the multiconnectBack() call. slot_tolerance = 1.0; // @param number min=0.925 max=1.075 step=0.005 group=mount label="Slot fit tolerance" slot_retention = true; // @param boolean group=mount label="Slot retention (v2 snap)" dimple_scale = 1.0; // @param number min=0.5 max=1.5 step=0.05 group=mount label="Dimple scale (v1 only)" on_ramp = true; // @param boolean group=mount label="Slot on-ramp lead-in" +// Dovetail joint fit (multiconnect_plate rail <-> multiconnect_saddle +// socket). The socket is cut this much wider than the rail (total across +// both flanks) for a friction slide fit; bump it if the parts bind, drop +// it for a tighter hold. A glue dot at the joint is the belt-and-braces. +dovetail_clearance = 0.25; // @param number min=0.0 max=0.6 step=0.05 unit=mm group=mount label="Dovetail slide clearance" + // @preset id="standard" label="Standard tab (28mm projection)" mount_type=tab hangerLength=28 // @preset id="deep" label="Deep tab (45mm projection)" mount_type=tab hangerLength=45 // @preset id="chunky-tab" label="Chunky mounting tab" mount_type=tab tabSize=22 tabThickness=4 tabRounding=3 // @preset id="opengrid" label="openGrid snap backer" mount_type=opengrid width_units=1 height_units=2 -// @preset id="multiconnect" label="Multiconnect slot backer" mount_type=multiconnect width_units=1 height_units=2 +// @preset id="mc-plate" label="Multiconnect slot plate (part A)" mount_type=multiconnect_plate width_units=1 height_units=2 +// @preset id="mc-saddle" label="Multiconnect saddle (part B)" mount_type=multiconnect_saddle // === Derived === @@ -154,20 +204,24 @@ snap_w = 24.8; // snap footprint snap_h = snap_lite ? 3.4 : 6.8; weld = 0.02; // embed of snap tops into the plate (st-v7k) -// Multiconnect backer. The 6.5mm slab depth and 25mm pitch are held fixed -// by choice (exposing them invites board-incompatible prints), same -// reasoning as the sibling mounts. +// Multiconnect slot plate (multiconnect_plate part). 25mm pitch is fixed +// by choice (exposing it invites board-incompatible prints). The slab is +// SLIM at 6.0mm (pst-j3ej A1): the multiconnect slot needs only ~4.15mm of +// engaging recess to its blind face, plus a ~1.85mm back wall — the old +// 6.5mm slab + 4mm plate cap (~10mm) was far thicker than a light headband +// hook warrants. 6.0 is threaded through as backThickness via patch 0002, +// which keeps the 4.15mm engaging profile identical and adds/removes the +// extra meat behind it. slot_spacing = 25; // Multiconnect standard pitch -mc_thickness = 6.5; // backer slab depth (= the module's fixed backThickness) -mc_weld = 0.4; // backer top sink into the plate (real overlap) +mc_thickness = 6.0; // slim slot-plate slab depth (patched backThickness) // Parts sink this far into the solid below them; cut tools overshoot this // far past every face they pass through (st-n4v / st-v7k). bury = 0.6; ov = 2; -plate_t = 4; // backer plate thickness (fixed — sturdy enough for a - // light headband hook; not worth a sweep axis). +plate_t = 4; // openGrid backer plate thickness (fixed — sturdy enough + // for a light headband hook; not worth a sweep axis). // Plate is a whole number of openGrid tiles on each axis. width: NO // saddle-spanning floor (pst-gmg0, reversing pst-51es) — just a >=1 @@ -184,19 +238,37 @@ units_h = max(height_units, ceil((saddle_up + 2 * min_wall) / snap_pitch)); W = units_w * snap_pitch; H = units_h * snap_pitch; -// Plate bottom = thickness of whichever back mount is selected, minus its -// weld into the plate. openGrid: snap tops weld 0.02 up. Multiconnect: the -// 6.5mm slab welds mc_weld up. (Used only by the backer variants.) -plate_z0 = mount_type == "multiconnect" ? mc_thickness - mc_weld - : snap_h - weld; +// openGrid plate bottom = snap height minus its 0.02 weld into the plate. +// (Used only by the opengrid backer; the multiconnect_plate part builds its +// own slab in mc_plate_part() below.) +plate_z0 = snap_h - weld; plate_top = plate_z0 + plate_t; // plate front face (mount-frame z) // Uniform backer corner rounding = the tab's upstream offset(2) (pst-4g1u). -// The plate AND the Multiconnect slab under it are clipped to ONE rounded -// outline (backer_outline below), so their corners round together and no -// square slab corner pokes past the rounded plate as a stray post. The +// The openGrid plate and the multiconnect slab are each clipped to this +// rounded outline so no square slab corner pokes past as a stray post. The // snap grid is inboard of this radius, so it is untouched. plate_corner_r = 2; +// ----- Dovetail joint (multiconnect_plate rail <-> multiconnect_saddle +// socket), pst-j3ej. Fixed dims (independent of plate size) so the joint is +// a stable interface a future tab/opengrid back could reuse. The rail runs +// along body Y (across the wall = the horizontal slide axis); its +// cross-section flares in body Z from a dt_neck neck at the plate face to a +// wider dt_tip tip at depth dt_depth, so it captures body +X pull-out (the +// cantilever lever-out) and body Z (gravity) on the flared shoulders. +// Flank angle atan((dt_tip-dt_neck)/2 / dt_depth) ~= 26.6deg — a short +// self-supporting ridge on the standing plate (rail out, +Y) and a +// ~dt_tip-wide bridge on +// the saddle wall block (socket relieved into the bed-facing face). +dt_depth = 4; // rail height / socket depth (body X) +dt_neck = 8; // rail width at the plate face (body Z) +dt_tip = 12; // rail width at the tip (body Z), > dt_neck -> undercut +dt_len = 24; // rail length along the slide axis (body Y) +// Socket centre up-the-wall (body Z). The saddle's solid wall-end block +// spans body z ~ [3, 20]; 11 centres the socket in it with margin, and the +// tip half-width (dt_tip/2 + clearance) stays clear of the block top. +dovetail_z = 11; + // ----- Backer placement in the saddle body frame (pst-3tum) ----- // The mount is authored in a local MOUNT frame (Xm across the wall, +Ym up // the wall, +Zm out of the wall / toward the saddle, panel face at Zm=0), @@ -218,11 +290,13 @@ saddle_up_center = 12.6; // dome up-wall centre (measured mount_tz = saddle_up_center - H / 2; // centre the plate on it // Bed seat (min build-Z = 0) after rotate([0,-90,0]) (which maps body X -> -// build Z). tab: min body X is the wall cap at -(hangerLength/2+5.05). -// backer: min body X is the snap/slot panel face at mount_tx. Derived per -// variant so every mount_type + hangerLength seats on the bed (pst-t9ri). -seat_z = mount_type == "tab" ? hangerLength / 2 + 5.05 : -mount_tx; -// Centre the backer variants in build-X (cosmetic; the anchor check only +// build Z). tab / multiconnect_saddle: min body X is the wall cap at +// -(hangerLength/2+5.05) (the socket only relieves material, so it does not +// move the seat). opengrid: min body X is the snap panel face at mount_tx. +// Derived per variant so every mount_type + hangerLength seats (pst-t9ri). +// (multiconnect_plate seats itself in mc_plate_part(), below.) +seat_z = mount_type == "opengrid" ? -mount_tx : hangerLength / 2 + 5.05; +// Centre the saddle variants in build-X (cosmetic; the anchor check only // covers the tab default). tab keeps its shipped 18.95 (pst-t9ri). seat_x = mount_type == "tab" ? 18.95 : saddle_up_center; @@ -234,16 +308,28 @@ seat_x = mount_type == "tab" ? 18.95 : saddle_up_center; // Y (65.0): saddle width across the two ±32.5 side clips — arch-only. // Z (38.1): vertical print height = hangerLength(28) + the two end flares // (each +5); hangerLength-dependent. -// The opengrid / multiconnect variants have their own bbox — the backer -// plate is W x H tiles (28 x 56 at defaults, one tile wide) and is -// narrower than the 65mm saddle, so their across-wall extent is still the -// saddle's 65mm — measured on their own STLs, not pinned here. +// The other variants have their own bbox — measured on their own STLs, not +// pinned here. opengrid/multiconnect_saddle keep the 65mm saddle width; +// multiconnect_plate is a standalone W x H tile slab (28 x 56 at defaults). PRINT_ANCHOR_BBOX = [31.1, 65, 38.1]; // === Assembly === -translate([ seat_x, 0, seat_z ]) rotate([ 0, -90, 0 ]) { - earHanger(); - if (mount_type != "tab") wall_mount_placed(); +// multiconnect_plate is a standalone part in its OWN print frame; every +// other variant is the saddle in the shared wall-face-down print frame. +if (mount_type == "multiconnect_plate") { + mc_plate_part(); +} else { + translate([ seat_x, 0, seat_z ]) rotate([ 0, -90, 0 ]) { + if (mount_type == "multiconnect_saddle") + difference() { + union() { earHanger(); dovetail_backing(); } + dovetail_socket(); + } + else { + earHanger(); + if (mount_type == "opengrid") wall_mount_placed(); + } + } } module earHanger() @@ -306,12 +392,13 @@ module baseShape(height) } // =================================================================== -// Wall mount (openGrid / Multiconnect) — pst-3tum +// openGrid wall mount — pst-3tum // =================================================================== // Authored in the MOUNT frame (Xm across, +Ym up-wall, +Zm out-of-wall), // then rotate([90,0,90]) + translate carry it to the saddle wall end. The -// snap wrapper, grid layout and Multiconnect transform are verbatim from -// apple_tv_4th_gen_holder (st-0of / pst-qdje) — do not re-derive them. +// snap wrapper and grid layout are verbatim from apple_tv_4th_gen_holder +// (st-0of / pst-qdje) — do not re-derive them. (The Multiconnect mount is +// now the standalone two-piece plate + saddle-socket further below.) // One openGrid snap in its own frame (front/strong nub toward +X), welded // into a single solid. Each click-nub root gets a 0.3mm shim straddling @@ -348,60 +435,28 @@ module grid_snaps() { zrot(90) welded_directional_snap(); } -// QuackWorks' BOSL2-free master slot back — a plain difference() (slab -// minus slot tools). multiconnectBack's local frame L is a cube x[0,W] -// y[-6.5,0] z[0,H] with the slot channels recessed from the y=-6.5 face, -// closed retention domes at high z and entry mouths + on-ramps at low z. -// rotate(180,[0,1,1]) maps (lx,ly,lz) -> (-lx, lz, ly): L.z -> +Ym (domes -// point UP the wall — retention takes the cantilever load), L.y -> +Zm -// (openings face -Zm, the panel/bed face the snaps engage), L.x -> -Xm -// (re-centred on x=0). The outer translate lands the slab at z[0,6.5] with -// the openings at z=0; the plate bottom sits mc_weld lower (plate_z0) so -// the backer top overlaps into the plate as a real weld. Verbatim -// transform from the sibling mounts (front-anchored y[0,H] frame). -module multiconnect_backer() { - // Clip the vendored slab's square corners to the shared rounded outline - // so it matches the plate cap welded on top of it — kills the stray - // square-corner post that poked past the rounded plate (pst-4g1u). The - // clip prism spans the slab's full depth (z 0..mc_thickness); the slots - // sit inboard of the corners, so only the 4 corners are trimmed. - intersection() { - translate([W / 2, 0, mc_thickness]) - rotate(180, [0, 1, 1]) - multiconnectBack(backWidth = W, backHeight = H, - distanceBetweenSlots = slot_spacing, - quickRelease = !slot_retention, - tolerance = slot_tolerance, - dimple = dimple_scale, - onRamp = on_ramp); - translate([0, 0, -padding]) - linear_extrude(height = mc_thickness + 2 * padding) - backer_outline(); - } -} - -// The shared backer footprint: a rounded rect, front-anchored y[0,H], -// X-centred. Both the plate cap and the Multiconnect slab are built on this -// one outline so their corners round uniformly (pst-4g1u). +// The openGrid backer footprint: a rounded rect, front-anchored y[0,H], +// X-centred — the tab's offset(2) rounding, so no square corner pokes past +// the plate as a stray post (pst-4g1u). module backer_outline() { rect([W, H], rounding = plate_corner_r, anchor = FRONT); } -// The backer plate: a plain rounded slab, front-anchored y[0,H], X-centred, -// z[plate_z0, plate_top]. Its front face (plate_top) welds into the saddle. +// The openGrid backer plate: a plain rounded slab, front-anchored y[0,H], +// X-centred, z[plate_z0, plate_top]. Its front (plate_top) welds into the +// saddle; the snaps hang off its back. module plate() { translate([0, 0, plate_z0]) linear_extrude(height = plate_t) backer_outline(); } -// Whole mount in the MOUNT frame: plate + (snaps or Multiconnect backer), -// at the sibling resolution ($fn = 64). +// openGrid mount in the MOUNT frame: plate + snap grid, at the sibling +// resolution ($fn = 64). module wall_mount() { $fn = 64; plate(); - if (mount_type == "multiconnect") multiconnect_backer(); - else grid_snaps(); + grid_snaps(); } // Mount placed at the saddle wall end (see the placement derivation above). @@ -410,3 +465,118 @@ module wall_mount_placed() { rotate([90, 0, 90]) wall_mount(); } + +// =================================================================== +// Multiconnect two-piece: slot plate (part A) + saddle socket (part B) +// — pst-j3ej +// =================================================================== + +// PART A — the standalone slim Multiconnect slot plate, in its OWN print +// frame: the slab lies flat with the slot/panel face DOWN on the bed +// (build z=0) and the dovetail rail rising UP (+build z). The vendored +// multiconnectBack L-frame is a cube x[0,W] y[-t,0] z[0,H] with the slots +// recessed from the y=0 mating face; rotate([-90,0,0]) maps L(x,y,z) -> +// (x, z, -y), so the slab sits at build x[0,W] y[0,H] z[0,t] with the +// mating face on the bed (L.y=0 -> build z=0). See the print note in the +// header for why slot-DOWN / rail-UP (the rail cannot point below the bed). +// The standalone Multiconnect slot plate, in its own STANDING print frame. +// The slab sits in multiconnectBack's NATIVE frame — a wall in the X-Z +// plane, thin in Y, standing on its z=0 bottom edge — so the slot openings +// face horizontal (-Y) and print as vertical walls with self-supporting +// undercuts and an on-ramp bridge: the native Multiconnect ZERO-SUPPORT +// orientation the sibling backers use once stood up (pst-j3ej, operator- +// picked standing plate). The dovetail rail protrudes horizontally (+Y) +// off the SOLID back face (y=0), opposite the slot openings. +module mc_plate_part() { + $fn = 64; + union() { + mc_slab_standing(); + plate_rail_standing(); + } +} + +// The slim slot slab in the native frame, clipped to the rounded outline +// (in the X-Z face plane, through the full Y depth) so its corners match +// the sibling backers (pst-4g1u). multiconnectBack's cube is x[0,W] +// y[-mc_thickness,0] z[0,H] with the slot channels recessed from the +// y=-mc_thickness face (openings face -Y) and the solid back at y=0. +module mc_slab_standing() { + intersection() { + multiconnectBack(backWidth = W, backHeight = H, + distanceBetweenSlots = slot_spacing, + backThickness = mc_thickness, + quickRelease = !slot_retention, + tolerance = slot_tolerance, + dimple = dimple_scale, + onRamp = on_ramp); + translate([0, padding, 0]) + rotate([90, 0, 0]) + linear_extrude(height = mc_thickness + 2 * padding) + translate([W / 2, H / 2]) + rect([W, H], rounding = plate_corner_r); + } +} + +// The dovetail rail: a shallow trapezoidal ridge on the SOLID back face +// (y=0), centred on the plate, protruding +Y (horizontal, toward the +// saddle) and running along build X (= the across-wall slide axis). Hull +// of a narrow neck band (dt_neck, buried bury into the slab) and a wider +// tip band (dt_tip) at depth dt_depth -> an undercut ridge (flanks +// ~26.6deg). Same trapezoidal shape (24 long x 4 deep x 8->12 flare) the +// saddle socket receives — the two parts print separately and slide +// together, so the plate's print frame is independent of the joint. +module plate_rail_standing() { + translate([W / 2, 0, H / 2]) + hull() { + translate([0, -bury / 2, 0]) + cube([dt_len, bury, dt_neck], center = true); + translate([0, dt_depth, 0]) + cube([dt_len, 0.2, dt_tip], center = true); + } +} + +// PART B backing — the saddle's wall end is a hollow arch the whole way +// through (the inner cut's scale([1,2,2]) doubles its length past the wall +// cap), so there is no solid there to host a socket. This refills the arch +// channel over just the wall-cap depth (x in [wall_face_x-ov, +// wall_face_x+dt_depth+2]) with the arch's OWN outer silhouette (baseShape +// + the wall-end hull, un-hollowed), clipped to that slab — a solid +// mounting back flush inside the arch outline (bbox unchanged). Prints flat +// on the bed with the rest of the wall face. The socket is then cut into it. +module dovetail_backing() { + intersection() { + translate([wall_face_x - ov, -40, -25]) + cube([ov + dt_depth + 2, 80, 60]); + union() { + baseShape(hangerLength); + hull() { + translate([-hangerLength / 2, 0, 0]) baseShape(0.1); + translate([-hangerLength / 2 - 5, 0, 1]) scale([1, 1, 1.2]) baseShape(0.1); + } + } + } +} + +// PART B — the dovetail SOCKET tool, in the saddle BODY frame (X out of +// wall, +Y across, +Z up). Cut from earHanger() at the wall end. The socket +// is the rail's trapezoid, dovetail_clearance wider across the flanks, +// running through the saddle along Y (open both ends so the rail slides in +// from either side). Its narrow mouth is at the wall face (body X = +// wall_face_x) and it widens to the tip at +dt_depth, so it grips the rail +// against +X pull-out. Centred body z = dovetail_z, inside the solid wall +// block. A mouth extension past the wall face keeps the entry open. +module dovetail_socket() { + c = dovetail_clearance; + through = 80; // > 65mm saddle width -> open both ends + hull() { + translate([wall_face_x, 0, dovetail_z]) + cube([0.1, through, dt_neck + c], center = true); + // 0.4mm deeper than the rail so the flanks + the flush plate/wall + // faces seat, not the rail tip bottoming in the socket. + translate([wall_face_x + dt_depth + 0.4, 0, dovetail_z]) + cube([0.1, through, dt_tip + c], center = true); + } + // open the mouth: extend the neck outward past the wall face + translate([wall_face_x - ov, 0, dovetail_z]) + cube([2 * ov, through, dt_neck + c], center = true); +} diff --git a/renders/disney_ear_hanger/front_multiconnect.png b/renders/disney_ear_hanger/front_multiconnect.png deleted file mode 100644 index ce2ee8e..0000000 Binary files a/renders/disney_ear_hanger/front_multiconnect.png and /dev/null differ diff --git a/renders/disney_ear_hanger/front_multiconnect_plate.png b/renders/disney_ear_hanger/front_multiconnect_plate.png new file mode 100644 index 0000000..72512f2 Binary files /dev/null and b/renders/disney_ear_hanger/front_multiconnect_plate.png differ diff --git a/renders/disney_ear_hanger/front_multiconnect_saddle.png b/renders/disney_ear_hanger/front_multiconnect_saddle.png new file mode 100644 index 0000000..e551ae0 Binary files /dev/null and b/renders/disney_ear_hanger/front_multiconnect_saddle.png differ diff --git a/renders/disney_ear_hanger/iso_multiconnect.png b/renders/disney_ear_hanger/iso_multiconnect.png deleted file mode 100644 index 2f38711..0000000 Binary files a/renders/disney_ear_hanger/iso_multiconnect.png and /dev/null differ diff --git a/renders/disney_ear_hanger/iso_multiconnect_plate.png b/renders/disney_ear_hanger/iso_multiconnect_plate.png new file mode 100644 index 0000000..e0fb981 Binary files /dev/null and b/renders/disney_ear_hanger/iso_multiconnect_plate.png differ diff --git a/renders/disney_ear_hanger/iso_multiconnect_saddle.png b/renders/disney_ear_hanger/iso_multiconnect_saddle.png new file mode 100644 index 0000000..4905175 Binary files /dev/null and b/renders/disney_ear_hanger/iso_multiconnect_saddle.png differ diff --git a/renders/disney_ear_hanger/side_multiconnect.png b/renders/disney_ear_hanger/side_multiconnect.png deleted file mode 100644 index 90a5c9d..0000000 Binary files a/renders/disney_ear_hanger/side_multiconnect.png and /dev/null differ diff --git a/renders/disney_ear_hanger/side_multiconnect_plate.png b/renders/disney_ear_hanger/side_multiconnect_plate.png new file mode 100644 index 0000000..221e9c0 Binary files /dev/null and b/renders/disney_ear_hanger/side_multiconnect_plate.png differ diff --git a/renders/disney_ear_hanger/side_multiconnect_saddle.png b/renders/disney_ear_hanger/side_multiconnect_saddle.png new file mode 100644 index 0000000..23a97b8 Binary files /dev/null and b/renders/disney_ear_hanger/side_multiconnect_saddle.png differ diff --git a/renders/disney_ear_hanger/top_multiconnect.png b/renders/disney_ear_hanger/top_multiconnect.png deleted file mode 100644 index 5148770..0000000 Binary files a/renders/disney_ear_hanger/top_multiconnect.png and /dev/null differ diff --git a/renders/disney_ear_hanger/top_multiconnect_plate.png b/renders/disney_ear_hanger/top_multiconnect_plate.png new file mode 100644 index 0000000..d7a37f1 Binary files /dev/null and b/renders/disney_ear_hanger/top_multiconnect_plate.png differ diff --git a/renders/disney_ear_hanger/top_multiconnect_saddle.png b/renders/disney_ear_hanger/top_multiconnect_saddle.png new file mode 100644 index 0000000..9763700 Binary files /dev/null and b/renders/disney_ear_hanger/top_multiconnect_saddle.png differ