#!/usr/bin/env python3 """ Crayon Leash holder, TOUGH (v3) -- parametric generator: collet body + tightening nut. Units: millimetres. pip install -r requirements.txt python3 crayon_leash_tough.py --out ./stl_tough # print-quality STLs python3 crayon_leash_tough.py --out ./stl_tough --ridged # + 4 small gripping ridges in the fingers python3 crayon_leash_tough.py --out ./stl_tough --ear # small side tab instead of the rear cord hole python3 crayon_leash_tough.py --out ./stl_tough --check # also run fit / printability checks What changed from v2 (fingers snapped at the slot roots): * Slots start ABOVE the thread (v2 slots cut through the thread groove: tiny slot-end radius + groove in the same plane = ~3x stress concentration exactly where the bending moment peaks). * Round keyhole relief (R1.1) at the bottom of every slot instead of a 0.45 mm radius. * Thread ends in a ramp (lead-in) and the nut has a 45 deg entry chamfer: no snagging / cross-threading. * Looser thread (0.30 mm radial clearance); 50 deg flanks kept (still support-free). * Grip "lands": the bore is relieved 0.25 mm except directly under the cone, so the crayon is pinched where the nut pushes. This removes the bending couple that over-tightening used to put into the finger roots. * Slightly less finger closing allowed by the hard stop (0.25 mm instead of 0.30). Both parts are modelled in PRINT ORIENTATION (axis = Z, flat end on the bed): * Collet body : rear (tail) on the bed, crayon opening up. * Nut : front (crayon hole) on the bed, threaded opening up. """ import argparse import math import os import sys import numpy as np import trimesh from manifold3d import CrossSection, Manifold from shapely.geometry import Point, Polygon from shapely.geometry.polygon import orient # ---------------------------------------------------------------------------------------- # DESIGN PARAMETERS # ---------------------------------------------------------------------------------------- # --- crayon (Crayola "regular": 3.63 in x 0.31 in = 92.2 x 7.9 mm, paper wrapped) CRAYON_D = 8.0 CRAYON_L = 92.2 # --- overall envelope R_OUT = 10.0 # outside radius of nut barrel and body shoulder (O20) # --- collet body (rear) BORE_D = 8.3 # relaxed bore. FDM holes print ~0.1-0.15 small; raise to 8.4/8.5 if too tight FLOOR_Z = 7.0 # crayon butt rests here TAIL_H = 4.0 # rear taper height R_TAIL = 7.4 # radius at the bed FL_H = 8.5 # shoulder top (nut seats here) PUSH_HOLE_D = 2.4 # axial hole through the floor so a stub can be poked out CORD_HOLE_D = 3.4 # teardrop cord hole across the rear CORD_HOLE_Z = 3.3 # optional side tab (--ear) EAR_X = 11.8 EAR_R = 4.0 EAR_T = 3.0 SLOTS = 6 SLOT_W = 0.9 SLOT_BOTTOM_Z = 13.0 # bottom of the keyhole (thread ends below this) KEY_R = 1.1 # keyhole relief radius (round end of each slot) LAND_RELIEF = 0.25 # bore relief outside the grip lands BORE_CHAMFER = 0.5 # --- thread (single start, right hand, 50 deg flanks => 40 deg overhang, no supports) THREAD_P = 2.5 THREAD_H = 0.9 THREAD_ANGLE = 50.0 R_ROOT = 6.6 # thread root radius (major O14.4) THREAD_CLR = 0.30 # radial clearance nut vs body thread # --- cone that closes the fingers Z_CONE0 = 23.0 CONE_L = 6.0 CONE_ANGLE = 10.0 TOP_Z = Z_CONE0 + CONE_L THREAD_TOP = 12.5 # thread stops well below the slots THREAD_LEAD = 1.4 # length of the ramp at the thread end LAND_Z0 = Z_CONE0 - 1.5 # grip lands start here (just under the cone) # --- nut PLATE_T = 2.0 HOLE_R = 4.9 # crayon pass-through O9.8 CONE_CLR = 0.10 DELTA_MAX = 0.25 # max radial finger closing allowed by the hard stop R_NOSE = 7.0 # nut radius at the bed NOSE_H = 6.0 FLUTES = 12 FLUTE_R = 1.1 FLUTE_CENTER_R = R_OUT + 0.5 RIDGE_ZS = (Z_CONE0 + 1.0, Z_CONE0 + 2.8, Z_CONE0 + 4.6) RIDGE_H = 0.30 # ---------------------------------------------------------------------------------------- # DERIVED # ---------------------------------------------------------------------------------------- T = math.tan(math.radians(CONE_ANGLE)) BORE_R = BORE_D / 2.0 R_TIP = R_ROOT - CONE_L * T R_THR = R_ROOT + 0.03 # thread root 0.03 proud of the plain cylinder (clean boolean) Z_F_TOUCH = (R_ROOT + CONE_CLR - HOLE_R) / T + Z_CONE0 + PLATE_T DELTA_STOP = (CONE_CLR + DELTA_MAX) / T Z_F_STOP = Z_F_TOUCH - DELTA_STOP NUT_L = Z_F_STOP - FL_H NUT_D_K = Z_F_STOP - Z_CONE0 R_K = HOLE_R + (NUT_D_K - PLATE_T) * T _WF = THREAD_H / math.tan(math.radians(THREAD_ANGLE)) _WR = (THREAD_P - 2 * _WF) / 2.0 THETA_SYM_DEG = 360.0 * (_WR + _WF + _WR / 2.0) / THREAD_P RES = { "print": dict(seg=192, ang=3.0, twist=4.0), "preview": dict(seg=72, ang=6.0, twist=9.0), } # ---------------------------------------------------------------------------------------- # HELPERS # ---------------------------------------------------------------------------------------- def revolve(profile, seg): return Manifold.revolve(CrossSection([np.array(profile, dtype=float)]), seg) def ogive(r0, r1, h, n=28): zs = np.linspace(0.0, h, n) rs = r0 + (r1 - r0) * np.sin(0.5 * np.pi * zs / h) return [(float(r), float(z)) for r, z in zip(rs, zs)] def thread_outline(r_root, ang_step): bp_u = np.array([0.0, _WR, _WR + _WF, _WR + _WF + _WR, THREAD_P]) rs = np.array([r_root, r_root, r_root + THREAD_H, r_root + THREAD_H, r_root]) bp_deg = bp_u * 360.0 / THREAD_P th = np.unique(np.concatenate([bp_deg[:-1], np.arange(0.0, 360.0, ang_step)])) r = np.interp(th, bp_deg, rs) a = np.radians(th) return np.column_stack([r * np.cos(a), r * np.sin(a)]) def helical(outline, z_a, length, twist_step): twist = 360.0 * length / THREAD_P n = max(1, int(math.ceil(abs(twist) / twist_step))) m = Manifold.extrude(CrossSection([outline]), length, n_divisions=n, twist_degrees=twist) return m.rotate((0, 0, 360.0 * z_a / THREAD_P)).translate((0, 0, z_a)) def teardrop_hole(d, length, z_center): """Horizontal hole along X with a 45-degree pointed top, so it prints without support.""" r = d / 2.0 c = Point(0, 0).buffer(r, resolution=16) tri = Polygon([(-r * math.sqrt(2), 0), (-r / math.sqrt(2), r / math.sqrt(2)), (-r / math.sqrt(2), -r / math.sqrt(2))]) poly = orient(c.union(tri), 1.0) m = Manifold.extrude(CrossSection([np.array(poly.exterior.coords)[:-1]]), length) return m.rotate((0, 90, 0)).translate((-length / 2.0, 0, z_center)) # ---------------------------------------------------------------------------------------- # PARTS # ---------------------------------------------------------------------------------------- def build_collet(res="print", ridged=False, ear=False): q = RES[res] prof = [(0, 0)] + ogive(R_TAIL, R_OUT, TAIL_H) + [ (R_OUT, FL_H), (R_ROOT, FL_H), (R_ROOT, Z_CONE0), (R_TIP, TOP_Z), (0, TOP_Z)] core = revolve(prof, q["seg"]) z_a = FL_H - 0.5 thr = helical(thread_outline(R_THR, q["ang"]), z_a, THREAD_TOP - z_a, q["twist"]) ramp = revolve([(0, z_a - 0.1), (R_THR + THREAD_H + 0.5, z_a - 0.1), (R_THR + THREAD_H + 0.5, THREAD_TOP - THREAD_LEAD), (R_THR + 0.05, THREAD_TOP), (0, THREAD_TOP)], q["seg"]) thr = thr ^ ramp body = core + thr if ear: tab = (Manifold.cube((EAR_X, 2 * EAR_R, EAR_T)).translate((0, -EAR_R, 0)) + Manifold.cylinder(EAR_T, EAR_R, EAR_R, 64).translate((EAR_X, 0, 0))) body = body + tab rr = BORE_R + LAND_RELIEF bore = revolve([(0, FLOOR_Z), (rr, FLOOR_Z), (rr, LAND_Z0 - LAND_RELIEF), (BORE_R, LAND_Z0), (BORE_R, TOP_Z - BORE_CHAMFER), (BORE_R + BORE_CHAMFER + 1.0, TOP_Z + 1.0), (0, TOP_Z + 1.0)], q["seg"]) body = body - bore if ridged: for z0 in RIDGE_ZS: body = body + revolve([(BORE_R + 0.1, z0 - 0.35), (BORE_R + 0.1, z0 + 0.35), (BORE_R - RIDGE_H, z0)], q["seg"]) if ear: body = body - Manifold.cylinder(EAR_T + 2, CORD_HOLE_D / 2, CORD_HOLE_D / 2, 48).translate((EAR_X, 0, -1)) else: body = body - teardrop_hole(CORD_HOLE_D, 2 * R_OUT + 2, CORD_HOLE_Z) body = body - Manifold.cylinder(FLOOR_Z + 1, PUSH_HOLE_D / 2, PUSH_HOLE_D / 2, 48).translate((0, 0, -0.5)) w, z0 = SLOT_W, SLOT_BOTTOM_Z zc = z0 + KEY_R slot = (Manifold.cube((10.0, w, TOP_Z + 1 - zc)).translate((0, -w / 2, zc)) + teardrop_hole(2 * KEY_R, 10.0, zc).translate((5.0, 0, 0))) slots = slot for k in range(1, SLOTS): slots = slots + slot.rotate((0, 0, 360.0 * k / SLOTS)) return body - slots def build_nut(res="print"): q = RES[res] ch_t = 0.5 # (flute floor is at R_OUT-0.6: keep the chamfer edge off it to avoid a tangent boolean) outer = revolve([(0, 0)] + ogive(R_NOSE, R_OUT, NOSE_H) + [ (R_OUT, NUT_L - ch_t), (R_OUT - ch_t, NUT_L), (0, NUT_L)], q["seg"]) for k in range(FLUTES): a = math.radians(360.0 * k / FLUTES + 180.0 / FLUTES) fl = Manifold.cylinder(NUT_L, FLUTE_R, FLUTE_R, 24).translate( (FLUTE_CENTER_R * math.cos(a), FLUTE_CENTER_R * math.sin(a), NOSE_H)) outer = outer - fl cavity = revolve([ (0, -1), (HOLE_R, -1), (HOLE_R, PLATE_T), (R_K, NUT_D_K), (R_K + 0.5 * T, NUT_D_K + 0.5), (0, NUT_D_K + 0.5), ], q["seg"]) z_a = NUT_D_K - 0.05 female = helical(thread_outline(R_THR + THREAD_CLR, q["ang"]), z_a, NUT_L + 1.0 - z_a, q["twist"]) lead = revolve([(0, NUT_L - 1.0), (R_THR + THREAD_CLR + 0.05, NUT_L - 1.0), (R_THR + THREAD_CLR + 1.06, NUT_L + 0.01), (0, NUT_L + 0.01)], q["seg"]) return outer - cavity - female - lead def place_nut(nut, z_front, psi_deg=None): if psi_deg is None: psi_deg = 360.0 * z_front / THREAD_P + 2.0 * THETA_SYM_DEG return nut.rotate((180, 0, 0)).rotate((0, 0, psi_deg)).translate((0, 0, z_front)) def to_trimesh(m): me = m.to_mesh() return trimesh.Trimesh(vertices=np.asarray(me.vert_properties)[:, :3].astype(float), faces=np.asarray(me.tri_verts).astype(int), process=False) # ---------------------------------------------------------------------------------------- # CHECKS # ---------------------------------------------------------------------------------------- def finger_estimate(E_mpa=1200.0): zs = np.linspace(SLOT_BOTTOM_Z + KEY_R, TOP_Z, 400) r_out = np.where(zs < Z_CONE0, R_ROOT, R_ROOT - (zs - Z_CONE0) * T) r_in = BORE_R half = math.pi / SLOTS - (SLOT_W / 2) / ((r_in + r_out) / 2) I = np.zeros_like(zs); c_out = np.zeros_like(zs) for i, ro in enumerate(r_out): rr = np.linspace(r_in, ro, 60); aa = np.linspace(-half[i], half[i], 60) R, A = np.meshgrid(rr, aa) x = R * np.cos(A); dA = R * (rr[1] - rr[0]) * (aa[1] - aa[0]) area = dA.sum(); xc = (x * dA).sum() / area I[i] = (((x - xc) ** 2) * dA).sum(); c_out[i] = ro - xc zl = 0.5 * (Z_CONE0 + TOP_Z) mask = zs <= zl zz, II, cc = zs[mask], I[mask], c_out[mask] k = 1.0 / np.trapezoid((zl - zz) ** 2 / (E_mpa * II), zz) F = k * DELTA_MAX return k, F, F * (zl - zz[0]) * cc[0] / (E_mpa * II[0]) def run_checks(collet, nut): ok = True for name, m in (("collet", collet), ("nut", nut)): tm = to_trimesh(m) print(f"[{name}] watertight={tm.is_watertight} volume={tm.volume/1000:.2f} cm3 extents={np.round(tm.extents, 2)}") ok &= bool(tm.is_watertight) print(f"\nnut length {NUT_L:.2f} cone tip radius {R_TIP:.3f} (finger wall at tip {R_TIP-BORE_R:.2f}) " f"touch z_front={Z_F_TOUCH:.2f} stop z_front={Z_F_STOP:.2f} travel {DELTA_STOP:.2f} mm = {DELTA_STOP/THREAD_P:.2f} turn") print("\nNut vs collet interference (rigid parts):") for label, zf in (("fully open (3 mm back)", Z_F_TOUCH + 3.0), ("touch (cone gap = CONE_CLR)", Z_F_TOUCH), ("midway to stop", Z_F_TOUCH - DELTA_STOP / 2), ("hard stop", Z_F_STOP)): print(f" {label:32s} z_front={zf:6.2f} overlap = {(collet ^ place_nut(nut, zf)).volume():8.3f} mm3") bad = (collet ^ place_nut(nut, Z_F_TOUCH, 360.0 * Z_F_TOUCH / THREAD_P + 2 * THETA_SYM_DEG + 180.0)).volume() print(f" sanity: thread phase 180 deg off overlap = {bad:8.3f} mm3 (must be >> 0)") k, F, eps = finger_estimate() print(f"\nfinger closing at hard stop {DELTA_MAX:.2f} mm radial ({BORE_D:.2f} -> {BORE_D-2*DELTA_MAX:.2f}); crayon {CRAYON_D} needs " f"{(BORE_D-CRAYON_D)/2:.2f}, 7.8 needs {(BORE_D-7.8)/2:.2f}, 8.2 needs {(BORE_D-8.2)/2:.2f}") print(f"finger stiffness ~{k:.1f} N/mm each; root strain at stop ~{100*eps:.2f}% nominal, ~{200*eps:.2f}% local with keyhole Kt~2 (PETG E=1.2 GPa)") return ok def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", default="./stl_tough") ap.add_argument("--ridged", action="store_true") ap.add_argument("--ear", action="store_true") ap.add_argument("--preview", action="store_true") ap.add_argument("--check", action="store_true") a = ap.parse_args() os.makedirs(a.out, exist_ok=True) res = "preview" if a.preview else "print" tag = ("_ridged" if a.ridged else "") + ("_ear" if a.ear else "") suffix = "_preview" if a.preview else "" collet = build_collet(res, a.ridged, a.ear) nut = build_nut(res) paths = [os.path.join(a.out, f"tough_collet{tag}{suffix}.stl")] to_trimesh(collet).export(paths[0]) if not (a.ridged or a.ear): paths.append(os.path.join(a.out, f"tough_nut{suffix}.stl")) to_trimesh(nut).export(paths[-1]) if not a.preview: plate = collet + nut.translate((-(2 * R_OUT + 8.0 + (EAR_X + EAR_R - R_OUT if a.ear else 0)), 0, 0)) paths.append(os.path.join(a.out, f"tough_plate{tag}.stl")) to_trimesh(plate).export(paths[-1]) for p in paths: tm = trimesh.load(p, force="mesh", process=True) print(f"{os.path.basename(p):28s} {len(tm.faces):7d} tris {os.path.getsize(p)/1e6:5.1f} MB " f"watertight={tm.is_watertight} winding_ok={tm.is_winding_consistent} volume={tm.volume/1000:.2f} cm3") if a.check: sys.exit(0 if run_checks(collet, nut) else 1) if __name__ == "__main__": main()