#!/usr/bin/env python3 """ Crayon Leash holder -- parametric generator (collet body + tightening nut). Units: millimetres. Requires: numpy, trimesh, manifold3d (pip install manifold3d trimesh) Usage: python3 crayon_leash.py --out ./stl # print-quality STLs python3 crayon_leash.py --out ./stl --ridged # adds 4 tiny gripping ridges inside the fingers python3 crayon_leash.py --out ./stl --check # also run fit / printability checks Coordinate convention: both parts are modelled in PRINT ORIENTATION (axis = Z, flat face on the bed). * Collet body : flange / leash ear on the bed, crayon opening points up. * Nut : front (crayon-hole) face on the bed, threaded opening points up. """ import argparse import math import os import sys import numpy as np import trimesh from manifold3d import CrossSection, Manifold # ---------------------------------------------------------------------------------------- # DESIGN PARAMETERS (tweak these, re-run) # ---------------------------------------------------------------------------------------- # --- crayon (Crayola "regular" size: 3.63 in x 0.31 in = 92.2 mm x 7.9 mm, paper-wrapped) CRAYON_D = 8.0 # design diameter used for fit maths (real range ~7.8 - 8.2 mm) CRAYON_L = 92.2 # --- collet body 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 = 3.5 # top of the solid floor (crayon butt rests here) PUSH_HOLE_D = 3.0 # axial hole through floor so a stub can be poked out FLANGE_R = 11.0 FLANGE_H = 3.0 EAR_X = 16.0 # leash-ear hole centre distance from axis EAR_R = 5.0 EAR_HOLE_D = 3.5 # cord hole (knot is larger than this, so it can't pull through) SLOTS = 6 # number of fingers SLOT_W = 0.9 SLOT_BOTTOM_Z = 7.0 # long fingers = low strain at the root BORE_CHAMFER = 0.5 # --- thread (single start, right-hand, 50 deg flanks => 40 deg overhang, prints without support) THREAD_P = 3.0 THREAD_H = 1.0 # radial thread depth THREAD_ANGLE = 50.0 # flank angle measured from the horizontal (=> 40 deg overhang from vertical, prints without support) R_ROOT = 7.5 # male thread root radius (major dia = 2*(R_ROOT+THREAD_H) = 17.0) THREAD_CLR = 0.25 # radial clearance, nut thread vs. body thread # --- collet cone (the wedge that closes the fingers) Z_CONE0 = 17.0 CONE_L = 10.0 CONE_ANGLE = 10.0 # half angle, degrees TOP_Z = Z_CONE0 + CONE_L THREAD_TOP = Z_CONE0 - 1.0 # body thread stops 1 mm short of the cone so the nut's thread cavity always covers it # --- nut R_OUT = 12.0 PLATE_T = 2.0 HOLE_R = 5.25 # crayon pass-through hole radius (dia 10.5) CONE_CLR = 0.10 # radial gap between nut cone and body cone when the nut first touches DELTA_MAX = 0.35 # max radial finger closing allowed by the nut's hard stop on the flange FLUTES = 10 FLUTE_R = 1.8 FLUTE_CENTER_R = 12.4 # --- optional grip ridges inside the fingers RIDGE_ZS = (19.5, 21.5, 23.5, 25.5) 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 # cone radius at the finger tips R_THR = R_ROOT + 0.03 # thread root radius: 0.03 proud of the plain cylinder (clean boolean) Z_F_TOUCH = (R_ROOT + CONE_CLR - HOLE_R) / T + Z_CONE0 + PLATE_T # nut front-face z when cones first touch DELTA_STOP = (CONE_CLR + DELTA_MAX) / T # axial travel from "touch" to the hard stop NUT_L = Z_F_TOUCH - DELTA_STOP - FLANGE_H # nut length so its rear face meets the flange at the stop Z_F_STOP = Z_F_TOUCH - DELTA_STOP # The nut's cone stops where the body's cone starts *at the hard-stop position*, so the nut never pinches the # plain (un-coned) part of the body. Beyond that depth the nut opens out to its thread bore. NUT_D_K = Z_F_STOP - Z_CONE0 # depth (from nut front face) where the nut cone ends R_K = HOLE_R + (NUT_D_K - PLATE_T) * T # nut cone radius at that depth _WF = THREAD_H / math.tan(math.radians(THREAD_ANGLE)) # axial width of one flank _WR = (THREAD_P - 2 * _WF) / 2.0 # root flat == crest flat THETA_SYM_DEG = 360.0 * (_WR + _WF + _WR / 2.0) / THREAD_P # profile symmetry axis (crest centre) RES = { "print": dict(seg=192, ang=3.0, twist=4.0), "preview": dict(seg=72, ang=6.0, twist=9.0), } # ---------------------------------------------------------------------------------------- # GEOMETRY HELPERS # ---------------------------------------------------------------------------------------- def revolve(profile, seg): """Revolve an (r, z) polygon around Z.""" cs = CrossSection([np.array(profile, dtype=float)]) return Manifold.revolve(cs, seg) def thread_outline(r_root, ang_step): """One-lobe 'thread' outline r(theta): axial profile mapped onto theta (theta = 360*z/P).""" 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): """Twisted extrusion = helical thread solid whose phase is r(theta - 360*z/P) in the part's own frame.""" 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)) # ---------------------------------------------------------------------------------------- # PARTS # ---------------------------------------------------------------------------------------- def build_collet(res="print", ridged=False): q = RES[res] ch = 0.5 core = revolve([ (0, 0), (FLANGE_R - ch, 0), (FLANGE_R, ch), (FLANGE_R, FLANGE_H), (R_ROOT, FLANGE_H), (R_ROOT, Z_CONE0), (R_TIP, TOP_Z), (0, TOP_Z), ], q["seg"]) z_a = FLANGE_H - 0.5 thr = helical(thread_outline(R_THR, q["ang"]), z_a, THREAD_TOP - z_a, q["twist"]) ear = (Manifold.cube((EAR_X, 2 * EAR_R, FLANGE_H)).translate((0, -EAR_R, 0)) + Manifold.cylinder(FLANGE_H, EAR_R, EAR_R, 64).translate((EAR_X, 0, 0))) body = core + thr + ear # crayon bore (+ entry chamfer) bore = Manifold.cylinder(TOP_Z - FLOOR_Z + 1, BORE_R, BORE_R, q["seg"]).translate((0, 0, FLOOR_Z)) ch_cone = Manifold.cylinder(BORE_CHAMFER + 0.01, BORE_R, BORE_R + BORE_CHAMFER + 0.01, q["seg"]).translate( (0, 0, TOP_Z - BORE_CHAMFER)) body = body - bore - ch_cone 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"]) # leash-ear hole, push-out hole body = body - Manifold.cylinder(FLANGE_H + 2, EAR_HOLE_D / 2, EAR_HOLE_D / 2, 48).translate((EAR_X, 0, -1)) body = body - Manifold.cylinder(FLOOR_Z + 1, PUSH_HOLE_D / 2, PUSH_HOLE_D / 2, 48).translate((0, 0, -0.5)) # finger slots (rounded bottom so there is no sharp stress riser) w = SLOT_W z0 = SLOT_BOTTOM_Z slot = (Manifold.cube((10.0, w, TOP_Z + 1 - (z0 + w / 2))).translate((0, -w / 2, z0 + w / 2)) + Manifold.cylinder(10.0, w / 2, w / 2, 32).rotate((0, 90, 0)).translate((0, 0, z0 + w / 2))) 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_b, ch_t = 0.8, 0.6 outer = revolve([ (0, 0), (R_OUT - ch_b, 0), (R_OUT, ch_b), (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, 32).translate( (FLUTE_CENTER_R * math.cos(a), FLUTE_CENTER_R * math.sin(a), 1.0)) 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 # start just below the cone end so the boolean has no edge-on-plane coincidence female = helical(thread_outline(R_THR + THREAD_CLR, q["ang"]), z_a, NUT_L + 1.0 - z_a, q["twist"]) return outer - cavity - female def place_nut(nut, z_front, psi_deg=None): """Put the print-orientation nut onto the collet (collet frame): flip, phase-lock thread, translate.""" 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 overhang_area(mesh, thresh=-0.73): n = mesh.face_normals zmin = mesh.triangles[:, :, 2].min(axis=1) sel = (n[:, 2] < thresh) & (zmin > 0.02) return float(mesh.area_faces[sel].sum()), float(mesh.area_faces.sum()) def finger_estimate(E_mpa=1200.0): """Euler-Bernoulli estimate of one finger (cantilever from slot root to mid-cone).""" zs = np.linspace(SLOT_BOTTOM_Z, TOP_Z, 400) r_out = np.where(zs < Z_CONE0, R_ROOT + 0.5 * THREAD_H * 0.0, R_ROOT - (zs - Z_CONE0) * T) r_in = BORE_R half = math.pi / SLOTS - (SLOT_W / 2) / ((r_in + r_out) / 2) # half-angle of the finger sector I = np.zeros_like(zs) c_out = np.zeros_like(zs) for i, (ro, z) in enumerate(zip(r_out, zs)): 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) # radial coordinate 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) # load point (middle of the cone) mask = zs <= zl zz, II, cc = zs[mask], I[mask], c_out[mask] # tip deflection for unit load: delta = integral (zl - s)^2 / (E I) ds flex = np.trapezoid((zl - zz) ** 2 / (E_mpa * II), zz) k = 1.0 / flex # N/mm per finger delta = DELTA_MAX F = k * delta strain_root = F * (zl - zz[0]) * cc[0] / (E_mpa * II[0]) return k, F, strain_root def run_checks(collet, nut): ok = True for name, m in (("collet", collet), ("nut", nut)): tm = to_trimesh(m) oh, tot = overhang_area(tm) print(f"[{name}] watertight={tm.is_watertight} volume={tm.volume/1000:.2f} cm3 " f"extents={np.round(tm.extents, 2)} overhang>45deg area={oh:.1f} mm2 ({100*oh/tot:.2f}% of surface)") ok &= bool(tm.is_watertight) print(f"\nDesign numbers: cone tip radius {R_TIP:.3f} nut length {NUT_L:.2f} " f"touch z_front={Z_F_TOUCH:.2f} stop z_front={Z_F_STOP:.2f} travel touch->stop {DELTA_STOP:.2f} mm " f"(= {DELTA_STOP/THREAD_P:.2f} turn)") # thread phase / interference vc = collet.volume() print("\nNut vs collet interference (rigid parts):") for label, zf in (("fully open (nut 3 mm back from touch)", 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)): inter = (collet ^ place_nut(nut, zf)).volume() print(f" {label:42s} z_front={zf:6.2f} overlap = {inter: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 wrong overlap = {bad:8.3f} mm3 (must be >> 0)") # radial cone interference at stop vs. what fingers would have to deflect (analytic) print(f"\nFinger closing at the hard stop: {DELTA_MAX:.2f} mm radial " f"(relaxed bore {BORE_D:.2f} -> {BORE_D-2*DELTA_MAX:.2f} mm)") print(f"Crayon {CRAYON_D:.2f} mm needs {(BORE_D-CRAYON_D)/2:.2f} mm closing to touch; " f"7.8 mm needs {(BORE_D-7.8)/2:.2f}; 8.2 mm needs {(BORE_D-8.2)/2:.2f}") k, F, eps = finger_estimate() print(f"Finger stiffness ~{k:.1f} N/mm each x{SLOTS}; at stop: {F:.1f} N/finger, " f"clamp (all fingers, crayon-free) {SLOTS*F:.0f} N; root strain ~{100*eps:.2f}% (PETG E=1.2 GPa)") return ok # ---------------------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", default="./stl") ap.add_argument("--ridged", action="store_true") ap.add_argument("--preview", action="store_true", help="low-res meshes for the web viewer") 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 "" collet = build_collet(res, a.ridged) nut = build_nut(res) suffix = "_preview" if a.preview else "" paths = [os.path.join(a.out, f"crayon_leash_collet{tag}{suffix}.stl")] to_trimesh(collet).export(paths[0]) if not a.ridged: paths.append(os.path.join(a.out, f"crayon_leash_nut{suffix}.stl")) to_trimesh(nut).export(paths[1]) if not a.preview: # both parts side by side on one plate (nut to the -x side of the collet, 8 mm gap) plate = collet + nut.translate((-(R_OUT + FLANGE_R + 8.0), 0, 0)) paths.append(os.path.join(a.out, f"crayon_leash_plate{tag}.stl")) to_trimesh(plate).export(paths[-1]) for p in paths: # round-trip: what a slicer will actually see tm = trimesh.load(p, force="mesh", process=True) print(f"{os.path.basename(p):38s} {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()