// ============================================================================ // CAPSULE FILLING TRAY -- parametric, default size "1", 5 x 5 = 25 // ============================================================================ // // FOUR PRINTED PARTS // TRAY fill / scrape / lock in one flat plate (stepped bores) // CAP piston that carries every cap and presses them on at once // TAMPER piston with one pin per cavity; hard stop = repeatable depth. // Flip it over and the flat back is the LOCK PRESS. // SCRAPER card sized to the powder well // COUPON 20-minute calibration print -- run this FIRST // // GRID // N sets a square N x N grid: 5 -> 25, 6 -> 36, 7 -> 49, 8 -> 64. // Only the footprint grows; height stays the same at every N. Square means // the pistons drop in at any of four rotations, so there is no orientation // to get wrong. Set COLS and ROWS to override with a rectangle. // // HOW THE LOCK WORKS // Each tray bore is stepped: // lower section = body OD + FIT_BODY holds the capsule body // upper section = cap OD + FIT_CAP swallows the cap on closing // The body sits with its mouth RIM_DROP below the fill floor, so the floor // is a continuous flat surface you can scrape a card across. The cap then // descends into the counterbore rather than needing the body to stand proud // -- no moving spacers, no lifting mechanism. // // Closing is deliberately two strokes: // 1. CAP piston -> pre-lock (caps seated ~4 mm onto the bodies) // 2. TAMPER flipped, flat side down -> final lock, hard-stops on the // capsules themselves at exactly LOCK_LEN. Cannot over-crush. // // ============================================================================ /* [What to render] */ PART = "all"; // "all" "tray" "cap" "tamper" "scraper" "coupon" /* [Capsule + grid] */ SIZE = "1"; // "000" "00" "0" "1" "2" "3" "4" "5" N = 10; // square grid: N x N cavities. 5, 6, 7, 8 ... one knob. COLS = 0; // 0 = use N. Set BOTH to force a rectangular grid instead. ROWS = 0; BED = 220; // build plate size; the tray asserts it will fit /* [Printer fit -- set these from the COUPON before the long print] */ FIT_BODY = 0.25; // added to body OD -> tray lower bore FIT_CAP = 0.35; // added to cap OD -> tray counterbore FIT_POCKET = 0.20; // added to cap OD -> cap-piston pocket (guiding fit) FIT_SLIDE = 0.30; // total piston-to-well clearance NUB_GRIP = 0.12; // radial interference of the 3 cap retention nubs /* [Behaviour] */ RIM_DROP = 0.30; // capsule mouth sits this far below the fill floor TAMP_DEPTH = 8.00; // pin tip depth below fill floor at full stroke CAP_GRAB = 0; // depth a cap sits in the cap piston; 0 = auto-derive WELL_DEPTH = 8.00; // powder well depth = piston alignment length /* [Structure] */ WALL = 2.80; // minimum wall between cavities MARGIN = 5.00; // flat floor around the cavity field (scraper run-off) LEDGE = 5.00; // wall thickness around the well FLOOR = 2.50; // material under the cavities EJECT_D = 3.20; // push-out / cleaning hole PIST_H_SET = 0; // piston thickness; 0 = auto, scales with tray span GRIP = true; // finger relief on all four walls GRIP_D = 4.50; // how far down from the rim the relief cuts CHAM = 0.80; TRAY_PAD_X = 0; // pad the footprint if you want to dock it to a rack TRAY_PAD_Y = 0; LABELS = true; // set false if your OpenSCAD has no fonts $fa = 3; $fs = 0.4; // --------------------------------------------------------------------------- // Capsule reference table (mm, industry standard hard-shell dimensions) // size, cap length, body length, locked length, cap OD, body OD, volume mL // --------------------------------------------------------------------------- CAPSULES = [ ["000", 12.95, 22.20, 26.14, 9.91, 9.55, 1.37], ["00", 11.74, 20.22, 23.30, 8.53, 8.18, 0.91], ["0", 10.72, 18.44, 21.70, 7.65, 7.34, 0.68], ["1", 9.78, 16.61, 19.40, 6.91, 6.63, 0.50], ["2", 8.94, 15.27, 17.60, 6.35, 6.07, 0.37], ["3", 8.08, 13.59, 15.90, 5.82, 5.57, 0.30], ["4", 7.21, 12.19, 14.30, 5.31, 5.05, 0.21], ["5", 6.20, 9.30, 11.10, 4.91, 4.68, 0.13] ]; function cidx(s, i = 0) = (CAPSULES[i][0] == s) ? i : cidx(s, i + 1); C = CAPSULES[cidx(SIZE)]; CAP_LEN = C[1]; BODY_LEN = C[2]; LOCK_LEN = C[3]; CAP_OD = C[4]; BODY_OD = C[5]; VOL_ML = C[6]; // --------------------------------------------------------------------------- // Derived geometry // --------------------------------------------------------------------------- OVERLAP = CAP_LEN + BODY_LEN - LOCK_LEN; // cap travel past body rim BORE_B = BODY_OD + FIT_BODY; BORE_C = CAP_OD + FIT_CAP; POCK_C = CAP_OD + FIT_POCKET; PITCH = BORE_C + WALL; // how far the body's dome sinks into the ejection hole SINK = BODY_OD/2 - sqrt(pow(BODY_OD/2, 2) - pow(EJECT_D/2, 2)); POCKET_D = BODY_LEN - SINK + RIM_DROP; // bore depth below fill floor CB_DEPTH = OVERLAP + RIM_DROP + 0.30; // counterbore depth NX = (COLS > 0) ? COLS : N; NY = (ROWS > 0) ? ROWS : N; NCAV = NX*NY; FIELD_X = (NX - 1)*PITCH + BORE_C; FIELD_Y = (NY - 1)*PITCH + BORE_C; WELL_X = FIELD_X + 2*MARGIN; WELL_Y = FIELD_Y + 2*MARGIN; WELL_R = 3; TRAY_X = WELL_X + 2*LEDGE + TRAY_PAD_X; TRAY_Y = WELL_Y + 2*LEDGE + TRAY_PAD_Y; TRAY_H = WELL_DEPTH + POCKET_D + FLOOR; FLOOR_Z = TRAY_H - WELL_DEPTH; // z of the fill floor // A wider plate needs a thicker piston or it bows in the middle and the // centre capsules close before the corner ones do. PIST_H = (PIST_H_SET > 0) ? PIST_H_SET : max(WELL_DEPTH + 2, max(WELL_X, WELL_Y)/6); PIST_X = WELL_X - FIT_SLIDE; PIST_Y = WELL_Y - FIT_SLIDE; PIST_R = WELL_R - FIT_SLIDE/2; VENT_D = 2.50; NUB_R = 0.80; NUB_Z = 1.60; // below the pocket mouth NUB_POS = CAP_OD/2 - NUB_GRIP + NUB_R; PIN_D = BODY_OD - 1.00; // The nubs must land on the cap's straight wall, not on its dome, or they // cannot hold a cap against gravity when the piston is turned over. The dome // is CAP_OD/2 long, so the pocket has to be at least that deep plus NUB_Z. GRAB = (CAP_GRAB > 0) ? CAP_GRAB : CAP_OD/2 + NUB_Z + 0.60; PROTRUDE = CAP_LEN - GRAB; // cap sticking out of piston PRELOCK = PROTRUDE - RIM_DROP; // engagement after stroke 1 LOCKPUSH = OVERLAP - PRELOCK; // travel left for stroke 2 echo(str("size ", SIZE, " grid ", NX, "x", NY, " = ", NCAV, " cavities", " pitch ", PITCH)); echo(str("tray ", TRAY_X, " x ", TRAY_Y, " x ", TRAY_H, " piston ", PIST_H, " thick")); echo(str("capacity ", NCAV*VOL_ML, " mL untamped (", VOL_ML, " mL each)")); echo(str("cap grip ", GRAB, " pre-lock engagement ", PRELOCK, " lock stroke ", LOCKPUSH)); assert(TRAY_X <= BED && TRAY_Y <= BED, "tray is larger than BED: lower N or raise BED if you know it fits"); assert(PIST_H > WELL_DEPTH, "PIST_H must exceed WELL_DEPTH or the piston sinks into the well"); assert(WELL_DEPTH > PROTRUDE + 1.5, "WELL_DEPTH too shallow: piston is not guided before the caps land"); assert(CB_DEPTH >= OVERLAP + RIM_DROP, "counterbore too shallow for the cap to reach the locked position"); assert(PRELOCK > 1.2, "pre-lock engagement too small: caps may lift back off with the piston"); assert(TAMP_DEPTH < BODY_LEN - 2, "TAMP_DEPTH would drive the pins into the bottom of the capsule"); // --------------------------------------------------------------------------- // Primitives // --------------------------------------------------------------------------- module rr2(x, y, r) offset(r = r) square([x - 2*r, y - 2*r], center = true); module rrect(x, y, h, r) linear_extrude(h) rr2(x, y, r); module rr_frustum(x1, y1, r1, x2, y2, r2, h) hull() { linear_extrude(0.01) rr2(x1, y1, r1); translate([0, 0, h - 0.01]) linear_extrude(0.01) rr2(x2, y2, r2); } module grid() { for (i = [0 : NX - 1], j = [0 : NY - 1]) translate([(i - (NX - 1)/2)*PITCH, (j - (NY - 1)/2)*PITCH, 0]) children(); } // Finger relief, one per wall, so a square piston can be lifted out at any // of its four orientations. Cut only the top GRIP_D so the powder stays in. module grip_slots() { for (s = [-1, 1]) { translate([0, s*WELL_Y/2, TRAY_H - GRIP_D]) linear_extrude(GRIP_D + 1) offset(r = 2) square([min(24, WELL_X*0.5) - 4, 2*LEDGE], center = true); translate([s*WELL_X/2, 0, TRAY_H - GRIP_D]) linear_extrude(GRIP_D + 1) offset(r = 2) square([2*LEDGE, min(24, WELL_Y*0.5) - 4], center = true); } } module engrave(txt, size = 3.5, depth = 0.5) translate([0, 0, -depth]) linear_extrude(depth + 0.01) text(txt, size = size, halign = "center", valign = "center"); // --------------------------------------------------------------------------- // TRAY // --------------------------------------------------------------------------- module tray_pocket() { // cuts downward from the fill floor at z = 0 translate([0, 0, -CB_DEPTH]) cylinder(h = CB_DEPTH + 0.01, d = BORE_C); translate([0, 0, -CHAM]) cylinder(h = CHAM + 0.01, d1 = BORE_C, d2 = BORE_C + 2*CHAM); translate([0, 0, -POCKET_D]) cylinder(h = POCKET_D + 0.01, d = BORE_B); translate([0, 0, -POCKET_D - FLOOR - 1]) cylinder(h = FLOOR + 2, d = EJECT_D); } module tray() { difference() { rrect(TRAY_X, TRAY_Y, TRAY_H, 3); // powder well translate([0, 0, FLOOR_Z]) rrect(WELL_X, WELL_Y, WELL_DEPTH + 1, WELL_R); // lead-in chamfer so the pistons self-centre translate([0, 0, TRAY_H - CHAM]) rr_frustum(WELL_X, WELL_Y, WELL_R, WELL_X + 2*CHAM, WELL_Y + 2*CHAM, WELL_R + CHAM, CHAM + 0.01); // outer top edge break translate([0, 0, TRAY_H - CHAM]) difference() { rrect(TRAY_X + 1, TRAY_Y + 1, CHAM + 0.01, 3); rr_frustum(TRAY_X - 2*CHAM, TRAY_Y - 2*CHAM, 3, TRAY_X, TRAY_Y, 3, CHAM + 0.01); } translate([0, 0, FLOOR_Z]) grid() tray_pocket(); if (GRIP) grip_slots(); if (LABELS) translate([0, -TRAY_Y/2 + LEDGE/2 + 0.2, TRAY_H]) engrave(str("SIZE ", SIZE, " ", NX, "x", NY), 3.0, 0.5); } } // --------------------------------------------------------------------------- // CAP PISTON (print pocket-side UP, flat on the bed) // --------------------------------------------------------------------------- module cap_pocket() { // cuts downward from the pocket face at z = 0 translate([0, 0, -GRAB]) cylinder(h = GRAB + 0.01, d = POCK_C); translate([0, 0, -CHAM]) cylinder(h = CHAM + 0.01, d1 = POCK_C, d2 = POCK_C + 2*CHAM); translate([0, 0, -PIST_H - 1]) cylinder(h = PIST_H + 2, d = VENT_D); } module cap_nubs() for (a = [0 : 120 : 359]) rotate([0, 0, a]) translate([NUB_POS, 0, -NUB_Z]) sphere(r = NUB_R); module cap_piston() { union() { difference() { rrect(PIST_X, PIST_Y, PIST_H, PIST_R); translate([0, 0, PIST_H]) grid() cap_pocket(); if (LABELS) translate([0, -PIST_Y/2 + MARGIN/2 + 0.3, PIST_H]) engrave("CAPS DOME DOWN", 3.0, 0.5); } translate([0, 0, PIST_H]) grid() cap_nubs(); } } // --------------------------------------------------------------------------- // TAMPER / LOCK PISTON (print flat back on the bed, pins UP) // --------------------------------------------------------------------------- module tamp_pin() { cylinder(h = TAMP_DEPTH - 0.8, d = PIN_D); translate([0, 0, TAMP_DEPTH - 0.8]) cylinder(h = 0.8, d1 = PIN_D, d2 = PIN_D - 1.2); } module tamper() { union() { difference() { rrect(PIST_X, PIST_Y, PIST_H, PIST_R); if (LABELS) translate([0, -PIST_Y/2 + MARGIN/2 + 0.3, PIST_H]) engrave(str("TAMP ", TAMP_DEPTH, "mm"), 3.0, 0.5); } translate([0, 0, PIST_H]) grid() tamp_pin(); } } // --------------------------------------------------------------------------- // SCRAPER // --------------------------------------------------------------------------- SCR_X = WELL_X - 0.60; SCR_Y = 26; SCR_T = 3.0; module scraper() { union() { translate([0, 0, SCR_T/2]) cube([SCR_X, SCR_Y, SCR_T], center = true); translate([0, SCR_Y/2 - 4, 0]) difference() { translate([0, 0, 6]) cube([SCR_X - 6, 4, 12], center = true); translate([0, 0, 12]) rotate([0, 90, 0]) cylinder(h = SCR_X, d = 3.2, center = true); } } } // --------------------------------------------------------------------------- // FIT COUPON -- print this first, ~20 minutes // row B : body bore, fits 0.15 0.20 0.25 0.30 0.35 (marks 1..5) // row C : counterbore, fits 0.20 0.25 0.30 0.35 0.40 (marks 1..5) // row N : cap pocket + nubs, grips 0.06 0.12 0.18 (marks 1..3) // --------------------------------------------------------------------------- CPN_FIT_B = [0.15, 0.20, 0.25, 0.30, 0.35]; CPN_FIT_C = [0.20, 0.25, 0.30, 0.35, 0.40]; CPN_NUB = [0.06, 0.12, 0.18]; CPN_H = 14; module coupon() { cx = 5*PITCH + 2*MARGIN; cy = 3*PITCH + 2*MARGIN; union() { difference() { rrect(cx, cy, CPN_H, 3); for (i = [0 : 4]) { x = (i - 2)*PITCH; // body bores, 12 mm deep translate([x, PITCH, CPN_H - 12]) cylinder(h = 12.1, d = BODY_OD + CPN_FIT_B[i]); // counterbores, 8 mm deep translate([x, 0, CPN_H - 8]) cylinder(h = 8.1, d = CAP_OD + CPN_FIT_C[i]); if (LABELS) translate([x, PITCH + BORE_C/2 + 2.4, CPN_H]) engrave(str(i + 1), 3.0, 0.5); } for (i = [0 : 2]) { x = (i - 1)*PITCH; translate([x, -PITCH, CPN_H - GRAB]) cylinder(h = GRAB + 0.1, d = POCK_C); } if (LABELS) { translate([-cx/2 + MARGIN/2 + 0.3, PITCH, CPN_H]) engrave("B", 3.2, 0.5); translate([-cx/2 + MARGIN/2 + 0.3, 0, CPN_H]) engrave("C", 3.2, 0.5); translate([-cx/2 + MARGIN/2 + 0.3, -PITCH, CPN_H]) engrave("N", 3.2, 0.5); } } for (i = [0 : 2]) { x = (i - 1)*PITCH; for (a = [0 : 120 : 359]) translate([x, -PITCH, CPN_H]) rotate([0, 0, a]) translate([CAP_OD/2 - CPN_NUB[i] + NUB_R, 0, -NUB_Z]) sphere(r = NUB_R); } } } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- if (PART == "tray") tray(); else if (PART == "cap") cap_piston(); else if (PART == "tamper") tamper(); else if (PART == "scraper") scraper(); else if (PART == "coupon") coupon(); else { tray(); translate([TRAY_X + 8, 0, 0]) cap_piston(); translate([2*TRAY_X + 16, 0, 0]) tamper(); // translate([0, TRAY_Y + 30, 0]) scraper(); // translate([TRAY_X + 8, TRAY_Y + 30, 0]) coupon(); }