// =====================================================================
include <BOSL2/std.scad>
include <BOSL2/threading.scad>      // printed Tr8x8 nut in the carriage (Rev H)
//  SchoolAir "Wilting Flower" indicator — parametric CAD (OpenSCAD)
//
//  A hobby servo turns a 40 mm crank; a Scotch yoke converts that into
//  80 mm of pure vertical travel of a 3 mm rod that stiffens (or lets
//  droop) a silicone "stem".  Rod height = L1 * sin(theta).
//
//  Coordinate frame (everything shares it):
//     origin  = centre of the module floor's top face = the pot's axis
//     z up    = towards the flower; the drive rod runs up the z axis
//     x       = across the crank's swing (crank centre sits at x = cx)
//     y       = depth: yoke plate just behind the rod, servo body further back (-y)
//
//  Render one part from the command line, e.g.
//     openscad -D 'part="carriage"' -o stl/carriage.stl wilting-flower.scad
//  Rev H parts: shell | dish | dock_base | dock_lid | dock_lid_flat | coupler | carriage | rim_band | stop_spacer | ls_bracket | ls_flag | stem_spigot | stem_gland | stem_gland_nut | screen_blank
//  views: assembly | cutaway | noshell | dock_floor · retired servo parts: module | arm | arm_direct | yoke | coupon
// =====================================================================

part  = "assembly";
theta = 0;          // (Rev D-G servo crank) servo angle for the preview, -90 (wilted) .. +90 (healthy)
pos   = 0.5;        // Rev H preview: carriage position, 0 = wilted (on the home stop) .. 1 = erect (car_travel above it)
$fn   = 96;

// ---------------- kinematics ----------------
L1 = 40;            // crank radius  -> stroke = 2*L1 = 80 mm

// ---------------- stem hardware ----------------
rod_d    = 3;       // drive rod: M3 threaded rod is fine here (hidden, clamped by a set screw)
rod_bore_clear = 0.4; // rod bore = rod_d + this: slides in, the M3 set screw holds. 0.2 printed too tight in the carriage (2026-09-24)
rail_bore_clear = 0.6; // yoke sleeve bore = rail_d + this; printed holes come out ~0.3 undersize, so this gives a free slide
rod_len  = 146;     // gives 120 mm above the throat at full extension
rail_d   = 5;       // SMOOTH rod for the yoke's guide rail (never threaded). 5 mm in hand since 2026-09-11
rail_len = 111;     // Rev F: foot in the lid, top in a collar on the rail fin (z 104-110); roof underside is at 111
tube_od  = 6;       // artificial-flower stem: floppy plastic tube, 6 mm OD / 4 mm ID
tube_len = 150;     // bench-tested 2026-09-10: 12 g head (5 g flower + 7 g washers), rod 40..120 in, wilts to horizontal

// ---------------- servo envelope (SpringRC SM-S2309S, measured 2026-09-09) ----------------
sv_body_l        = 24.0;  // along x
sv_body_w        = 12.0;  // along z
sv_body_h        = 22.0;  // along y (shaft direction), body only
sv_shaft_from_end= 7.0;   // shaft centre from the nearer end of the body (x)
sv_gear_h        = 4.0;   // body top -> underside of the horn hub (body bottom to horn = 26)
sv_tab_pitch     = 28;    // screw-hole spacing across the two tabs
sv_tab_t         = 2.5;
sv_tab_drop      = 4.9;   // tab upper face sits this far below the body top (calipers, 2026-09-13). Seen on the assembled unit first:
                          // with the tabs on the wall the arm landed 4.1 mm too far forward (the model had the tabs flush with the top)
sv_clear         = 0.6;   // cut-out clearance around the body (tabs carry the load, not the body)
wall_t           = 5;     // servo wall thickness (3 mm split when the tab screws went in). M2 x 10 screws from the back, nuts on the tab fronts
horn_hub_d = 7.2;         // single-arm horn shipped with the servo (hub measured 7.0)
horn_len   = 14.0;        // hub centre -> tip (13.1 measured; first print was 0.9 short)
horn_tip_d = 4.25;        // narrowed 0.175 per side after the first test print
horn_t     = 2.0;         // pocket depth
// direct-on-spline arm (bench variant, no horn): the bore presses onto the servo's spline. TO CONFIRM with calipers.
spline_d   = 4.8;         // spline outer diameter across the teeth (SG90-class 21T is 4.8; measure the SM-S2309S)
spline_h   = 3.0;         // spline height above the gear housing's top face
spline_fit = -0.1;        // bore = spline_d + this; printed holes come out ~0.2 under, so this is a firm press

// ---------------- mechanism layout ----------------
// The rod is on the pot axis. The servo body is centred behind it in x so that
// its rear corners stay inside the flared wall; that is what fixes cx.
cx      = -6;       // crank centre x. Pin sweeps x in [cx, cx+L1] = [-6, +34]
cz      = 54;       // crank centre height above the module floor
yoke_y  = -7;       // back face of the yoke plate (plate spans y in [-7, -3], rod boss in [-4, +4]). Was -8; moved 1 mm
                    // toward the pot axis on 2026-09-14 so the arm has 2 mm of air instead of 1 (the yoke can twist on the guide)
arm_gap = 2;        // air between the arm's front face and the yoke plate
yoke_t  = 4;
yoke_h  = 12;       // yoke plate height
arm_t   = 4;        // 2 mm horn pocket (back) + 1 mm web + 1 mm screw-head recess (front)
arm_y   = yoke_y - arm_gap - arm_t;   // arm back face (= -13, unchanged: the servo wall is derived from it)
pin_d   = 4;
pin_len = arm_gap + yoke_t + 0.5;   // through the gap, through the 4 mm plate and 0.5 proud of its front face (tip stops 0.9 mm short of the rod bore)
slot_clear = 0.3;   // set from the coupon test (PLA, 0.2 mm layers): slot 2 was the best fit
slot_margin = 1.5;  // extra slot length past the pin's extreme positions
rail_x  = -17; rail_y = 0;      // guide rail, on the free side of the yoke
sleeve_h = 16;      // rail sleeve height (anti-tilt)

// ---------------- pot ----------------
H       = 114;      // overall shell height
wall    = 3;
roof_t  = 3;
R_base  = 44;       // outer radius at the floor   (88 mm dia)
R_rim   = 57;       // outer radius at the top     (114 mm dia)
spigot_h     = 4;
spigot_clear = 0.3; // diametral running fit spigot/socket
boss_orbit = 39; boss_d = 8; boss_h = 10; boss_pilot = 2.5; boss_a0 = 60; n_boss = 3;
screw_d = 3.4; screw_head_d = 6.5;
tube_sleeve_h = 8;  // sleeve under the roof: took the glued tube end until 2026-10-01, now holds the spigot's plug
// ---------------- stem spigot (2026-10-01): mechanical anchor for the stem tube ----------------
// The glue between the rubber tube and the PLA sleeve failed and the rod lifted the tube (Martin, bench 2026-10-01).
// Martin chose a barb. There is no room under the roof (the carriage's rod boss comes to 3.5 mm below the sleeve at
// erect), so the barb stands ABOVE the roof, inside the dish, under the moss: the tube comes down from the flower and
// is pushed over it; the barbs' shoulders face down, against the pull. Printed as an insert for shells already made:
// a plug glued into the sleeve bore (PLA on PLA holds; the pull also presses the plug's step onto the roof), the barb
// above. PETG, upright, 0.12 mm layers: the shank wall is only 0.7 mm because the M3 rod needs 3.4 inside a 4 mm bore
// tube. Inside the dish there is no room for a clip: the two barbs hold on their own.
spg_plug_d = tube_od + 0.2;              // 6.2 in the 6.3 sleeve bore: light press, plus CA
spg_plug_h = roof_t + tube_sleeve_h;     // fills the bore, top flush with the roof
spg_shank_d = 4.8; spg_crest_d = 5.3;    // 4 mm ID tube stretched 20 % on the shank, 32 % over the crests (4.5/5.0 first print: wall 0.65 broke under a drill, 2026-10-01)
spg_h = 9;                               // above the roof, 1 mm under the dish rim
spg_bore = 3.4;                          // the M3 rod slides through as printed; never drill the shank, the wall is 0.7 mm
dish_hole = tube_od + 1.6;               // 7.6: the tube over the barb is about 6.9 (was 6.3: drill older dishes to 7.5)
// ---------------- stem gland (2026-10-02): adjustable clamp for the stem tube ----------------
// Martin wants the tube lockable at any height, so the stem can be slid up or down and fixed: a compression gland,
// like a cable gland. The tube passes right through. A foot glues into the roof sleeve (its bore opened to 8 mm:
// drill older shells, new shells have gl_bore), a flange sits on the roof, and above it a split collet with a printed
// M9x1 thread. The nut's inner cone squeezes the four fingers onto the tube. Everything above the roof sits inside the
// dish under the moss (dish hole 14 for the nut); loosen with two fingers, slide the tube, tighten. PETG, upright.
gl_foot_d = 8.1; gl_foot_h = roof_t + tube_sleeve_h;   // in the 8.2 sleeve bore, CA
gl_sleeve_bore = 8.2;                    // new shells: the roof sleeve bore for the gland's foot (was tube_od + 0.3 = 6.3)
gl_flange_d = 12; gl_flange_h = 1;       // rests on the roof, inside the dish hole
gl_thread_d = 9; gl_pitch = 1; gl_collet_h = 7;        // threaded collet above the flange, 7 tall
gl_bore = tube_od + 0.3;                 // 6.3: the tube slides through when the nut is loose
gl_slots = 4; gl_slot_w = 1.0; gl_slot_h = 5.5;        // four fingers
gl_nut_d = 13; gl_nut_h = 6; gl_thread_slop = 0.3;     // knurled nut: thread for 3.5 mm, cone above it
gl_cone_d = 7.6;                         // the cone's top opening: closes the fingers by about 0.7 mm on the tube
gland_dish_hole = gl_nut_d + 1;          // 14: the dish's centre hole over a gland
stem_anchor = "gland";                   // "barb" (stem_spigot, fixed) or "gland" (adjustable): sets the dish hole and the shell's sleeve bore
dish_h = 10;        // planter dish that sits on the roof: makes the stem look planted, not a hole in a lid
dish_floor = 3;
dish_wall = 3;
// rim band (2026-10-04, Martin: "classic band", like a traditional terracotta pot): a separate ring that drops over
// the top of the pot and rests on the dish's rim bead, so it still spins to turn SCHOOLAIR.ORG to the audience.
// It carries the lettering and the NFC pocket (both move off the dish). Prints top down on the bed: no supports.
rim_style  = "band";   // "band": plain dish + rim_band ring · "flare": the earlier dish with lettering, no ring
// 2026-10-06: the first print would not go on. Its skirt hugged the pot's taper, but the pot is widest at the top,
// so the ring could pass neither down over the dish nor up over the pot. Now the bore is straight, sized to drop
// over the dish bead (Martin's choice over a rim moulded into the shell); it stands off the pot by about 3 mm at its
// bottom edge.
band_h     = 26;       // band height, about a fifth of the pot
band_wall  = 2.4;      // skirt wall at its thinnest (the bottom edge); sets how far the face stands out
band_clear = 0.5;      // radial clearance over the dish bead, so it drops on and turns by hand
band_lip   = 2;        // the inward lip on top that rests on the dish's bead

// ---------------- vase-to-dock interface ----------------
cable_w = 10; cable_h = 6;                      // notch in the shell's bottom edge and module disc: the dock's key tab fits it
// 4-pin magnetic pogo connector pair (generic 2.54 mm pitch, e.g. Amazon B0CPJJL2TC): female (flat pads) glued
// into a through-slot in the module floor, pads flush with the underside; male (spring pins) glued into the same
// slot in the dock lid, pins 1 mm proud of the socket floor. Magnets pull the halves together. Pins: 5 V, GND, servo, spare.
conn_c = [0, 28];                               // connector centre, long axis along x
conn_l = 20.44; conn_w = 4.0; conn_h = 4.0;     // body, from the seller's drawing
conn_clear = 0.15;                              // per side, glue fit

// ---------------- derived ----------------
R_in_base = R_base - wall;
function r_out(z) = R_base + (R_rim - R_base) * z / H;   // straight flare (terracotta)
spigot_r  = R_in_base - spigot_clear/2;
slot_x0   = cx - slot_margin;            // pin centre extremes are cx (theta=+-90) and cx+L1 (theta=0)
slot_x1   = cx + L1 + slot_margin;
slot_w    = pin_d + slot_clear;
plate_x0  = slot_x0 - 4;
plate_x1  = slot_x1 + 3.5;
pin_x     = cx + L1 * cos(theta);
pin_z     = cz + L1 * sin(theta);
sv_body_x0 = cx - sv_shaft_from_end;      // body spans x in [x0, x0 + sv_body_l]
sv_body_cx = sv_body_x0 + sv_body_l/2;
sv_top_y   = arm_y - horn_t - sv_gear_h;  // body's top face (y), horn sits between it and the arm
wall_y1    = sv_top_y - sv_tab_drop - sv_tab_t;   // servo wall front face: tabs rest on it, sv_tab_drop below the body top
wall_y0    = wall_y1 - wall_t;

// =====================================================================
//  PART B — mechanism module
// =====================================================================
module mech_features() {          // everything that stands on the floor: servo wall, tab bosses, gussets, rail foot
    translate([-18, wall_y0, 0]) cube([35, wall_t, cz + 9]);
    for (sx = [-1, 1]) translate([sv_body_cx + sx*sv_tab_pitch/2, 0, 0]) {
        translate([0, wall_y0 - 3, cz]) rotate([-90,0,0]) cylinder(d = 8, h = wall_t + 3);
        hull() {                                                                    // wedge under the boss so it prints without support
            translate([-4, wall_y0 - 0.01, cz - 7]) cube([8, 0.01, 11]);
            translate([-4, wall_y0 - 3, cz - 4]) cube([8, 0.01, 8]);
        }
    }
    for (gx = [-18, 14]) translate([gx, 0, 0]) rotate([90,0,90]) linear_extrude(3) polygon([[wall_y0, 0], [wall_y0 - 5, 0], [wall_y0, 60]]);
    // rail foot: as tall as the yoke sleeve allows (sleeve bottom is at z 6 when fully wilted), tied to the fin
    translate([rail_x, rail_y, 0]) cylinder(d = rail_d + 7, h = 4);
    translate([rail_x - 15, -5, 0]) cube([15, 10, 4]);
    // rail fin: supports the rail's top so it cannot rock in its foot. A vertical fin outboard of the rail,
    // a web tying it to the servo wall, and a collar the rail passes through above the yoke's highest position.
    translate([rail_x - 15, -5, 0]) cube([8, 10, 110]);                                           // fin, 8 x 10, x -32..-24, full height
    translate([rail_x - 15, wall_y1 - 0.01, 0]) cube([8, -wall_y1 - 5 + 0.01, 100]);               // web to the servo wall, full height: T-section buttress
    translate([rail_x - 15, -5, 104]) cube([15, 10, 6]);                                           // head block fin -> collar
    translate([rail_x, rail_y, 104]) cylinder(d = 13, h = 6);                                      // collar, z 104-110, 1 mm under the roof
}
module mech_cuts() {              // servo body cut-out, tab screw holes with M2 nut pockets, rail foot hole
    translate([sv_body_x0 - sv_clear, wall_y0 - 4, cz - sv_body_w/2 - sv_clear]) cube([sv_body_l + 2*sv_clear, wall_t + 5, sv_body_w + 2*sv_clear]);
    // tab screw slots: M2 x 10 from the back (head on the boss), nut on the front of the servo tab. The holes sit only
    // 1.9 mm from the body, so they open into the body cutout as slots instead of leaving a 0.4 mm ligament.
    for (sx = [-1, 1]) translate([sv_body_cx + sx*sv_tab_pitch/2, wall_y0 - 4, cz]) {
        rotate([-90,0,0]) cylinder(d = 2.3, h = wall_t + 5, $fn = 24);
        translate([sx < 0 ? 0 : -2.5, 0, -1.15]) cube([2.5, wall_t + 5, 2.3]);
    }
    translate([rail_x, rail_y, -2]) cylinder(d = rail_d, h = 10, $fn = 32);                        // foot hole: 1 mm floor left in the plate, 6 mm deep socket
    translate([rail_x, rail_y, 103]) cylinder(d = rail_d + 0.3, h = 8, $fn = 32);                  // collar bore, sliding fit
}
// Rev E module (separate vase floor with spigot, screw holes and connector slot). Superseded by dock_lid() in Rev F; kept for the record.
module mech_module() {
    difference() {
        union() {
            translate([0,0,-3]) cylinder(r = R_base, h = 3);
            difference() { cylinder(r = spigot_r, h = spigot_h); translate([0,0,-1]) cylinder(r = spigot_r - 3, h = spigot_h + 2); }
            mech_features();
        }
        mech_cuts();
        for (i = [0:n_boss-1]) rotate([0,0,boss_a0 + i*360/n_boss]) translate([boss_orbit, 0, 0]) {
            translate([0,0,-4]) cylinder(d = screw_d, h = 12, $fn = 32);
            translate([0,0,-3.01]) cylinder(d1 = screw_head_d, d2 = screw_d, h = 1.6, $fn = 32);
        }
        translate([-cable_w/2, 30, -0.01]) cube([cable_w, 20, cable_h + 1]);
        translate([-cable_w/2, R_base - 6, -3.1]) cube([cable_w, 10, 3.2]);
        translate([conn_c[0], conn_c[1], -4]) conn_slot(8);
    }
}

// =====================================================================
//  PART A — vase shell
// =====================================================================
module shell() {
    difference() {
        union() {
            rotate_extrude($fn = 180)
                polygon([[R_base, 0], [R_rim, H], [0, H], [0, H - roof_t],
                         [r_out(H - roof_t) - wall, H - roof_t], [R_in_base, 0]]);
            // tube sleeve under the roof
            translate([0,0,H - roof_t - tube_sleeve_h]) cylinder(d = tube_od + 6, h = tube_sleeve_h + 0.01);
        }
        // throat: tube passes through the roof
        translate([0,0,H - roof_t - tube_sleeve_h - 1]) cylinder(d = stem_anchor == "gland" ? gl_sleeve_bore : tube_od + 0.3, h = roof_t + tube_sleeve_h + 2, $fn = 48);   // sleeve bore: the gland's foot, or the barb's plug
        // bayonet: three L-slots in the base wall, hidden inside the dock lid's ring
        for (a = lug_angles) rotate([0, 0, a - BAY_TWIST]) {
            translate([R_base - wall - 1, -(LUG_W + SLOT_CLEAR)/2, -1]) cube([wall + 2, LUG_W + SLOT_CLEAR, lug_z0 + LUG_H + SLOT_CLEAR/2 + 1]);   // vertical entry
            rotate_extrude(angle = BAY_TWIST + (LUG_W + SLOT_CLEAR)/R_base * 57.3, $fn = 180)                                                  // horizontal run
                translate([R_base - wall - 1, lug_z0 - SLOT_CLEAR/2]) square([wall + 2, LUG_H + SLOT_CLEAR]);
            // 45-degree lead-in where the entry meets the run, so the lug flows round the corner
            translate([R_base - wall - 1, (LUG_W + SLOT_CLEAR)/2 - 0.01, lug_z0 - SLOT_CLEAR/2 + 0.01]) rotate([0, 90, 0]) linear_extrude(wall + 2)
                polygon([[0, 0], [0, 2.5], [2.5, 0]]);   // ramps the lug's lower corner up onto the run
        }
    }
}

// =====================================================================
//  planter dish — sits on the shell's flat roof, continues the flare, rounded bead on the rim.
//  Prints open side up, no supports. Fill with moss or gravel around the stem.
// =====================================================================
module dish() {
    r0 = r_out(H);                      // matches the shell's rim exactly
    r1 = r_out(H + dish_h);
    ri0 = r_out(H + dish_floor) - dish_wall;
    ri1 = r1 - dish_wall;
    difference() {
        union() {
            rotate_extrude($fn = 180)
                polygon([[0, 0], [r0, 0], [r1, dish_h], [ri1, dish_h], [ri0, dish_floor], [0, dish_floor]]);
            rotate_extrude($fn = 180) translate([r1 - dish_wall/2, dish_h]) circle(d = dish_wall, $fn = 32);
            if (rim_style == "flare") dish_lettering();
        }
        translate([0, 0, -1]) cylinder(d = stem_anchor == "gland" ? gland_dish_hole : dish_hole, h = dish_floor + 2, $fn = 48);   // tube over the barb, or the gland's nut
        // NFC tag pocket on the inside of the front wall, behind the lettering: a shallow rectangular recess
        // following the wall's curve (cut as a short arc), open towards the dish interior
        if (rim_style == "flare") rotate([0, 0, 90 - (nfc_tag_l / r_out(H + dish_h/2) * 57.3) / 2]) rotate_extrude(angle = nfc_tag_l / r_out(H + dish_h/2) * 57.3, $fn = 180)
            translate([r_out(H + dish_h/2) - dish_wall - 0.01, dish_h/2 - nfc_tag_h/2]) square([nfc_depth + 0.01, nfc_tag_h]);
    }
}
// "SCHOOLAIR.ORG" raised 0.5 mm on the dish's outer wall, centred on the front, letters following the curve
dish_text = "SCHOOLAIR.ORG"; dish_text_h = 4.2; dish_text_raise = 0.5; dish_text_pitch = 4.9;   // pitch in degrees per character
// small contactless mark (four arcs and a dot) to the left of the lettering, same height and relief as the letters
module nfc_mark_small() {
    intersection() {
        union() { for (r = [1.3, 2.2, 3.1, 4.0]) difference() { circle(r = r, $fn = 48); circle(r = r - 0.6, $fn = 48); } }
        polygon([[0, 0], [7, -5], [7, 5]]);
    }
    translate([-0.9, 0]) circle(d = 1.1, $fn = 16);
}
module stem_spigot() {
    difference() {
        union() {
            cylinder(d = spg_plug_d, h = spg_plug_h, $fn = 48);                                   // plug in the sleeve bore
            translate([0, 0, spg_plug_h - 0.01]) {
                cylinder(d = spg_shank_d, h = spg_h - 1, $fn = 48);
                translate([0, 0, spg_h - 1]) cylinder(d1 = spg_shank_d, d2 = spg_shank_d - 0.8, h = 1, $fn = 48);   // entry chamfer at the tip
                for (z = [1.5, 5.0]) translate([0, 0, z]) cylinder(d1 = spg_crest_d, d2 = spg_shank_d, h = 1.8, $fn = 48);   // barbs: flat shoulder down, ramp up
            }
        }
        translate([0, 0, -1]) cylinder(d = spg_bore, h = spg_plug_h + spg_h + 2, $fn = 32);
    }
}
module stem_gland() {          // the insert: foot in the roof, flange on it, split threaded collet above
    difference() {
        union() {
            cylinder(d = gl_foot_d, h = gl_foot_h, $fn = 64);
            translate([0, 0, gl_foot_h]) cylinder(d = gl_flange_d, h = gl_flange_h, $fn = 64);
            translate([0, 0, gl_foot_h + gl_flange_h]) threaded_rod(d = gl_thread_d, l = gl_collet_h, pitch = gl_pitch, anchor = BOTTOM, $fn = 48);
            translate([0, 0, gl_foot_h + gl_flange_h + gl_collet_h - 1.5])                     // the fingers' tips: a cone the nut rides on
                cylinder(d1 = gl_thread_d, d2 = gl_thread_d - 1.6, h = 1.5, $fn = 48);
        }
        translate([0, 0, -1]) cylinder(d = gl_bore, h = gl_foot_h + gl_flange_h + gl_collet_h + 2, $fn = 48);
        for (a = [0 : 360 / gl_slots : 359]) rotate([0, 0, a])                                   // slots make the collet fingers
            translate([-gl_slot_w / 2, 0, gl_foot_h + gl_flange_h + gl_collet_h - gl_slot_h]) cube([gl_slot_w, gl_thread_d, gl_slot_h + 1]);
    }
}
module stem_gland_nut() {      // knurled nut: internal thread, then a cone that squeezes the fingers
    top = gl_foot_h + gl_flange_h + gl_collet_h;
    difference() {
        cylinder(d = gl_nut_d, h = gl_nut_h, $fn = 12);                                          // 12 flats: grips with two fingers
        translate([0, 0, -1]) threaded_rod(d = gl_thread_d, l = 3.5 + 1, pitch = gl_pitch, internal = true, $slop = gl_thread_slop, anchor = BOTTOM, $fn = 48);
        translate([0, 0, 3.5 - 0.01]) cylinder(d1 = gl_thread_d + 0.6, d2 = gl_cone_d, h = gl_nut_h - 3.5 + 0.02, $fn = 48);   // the cone
        translate([0, 0, -1]) cylinder(d = gl_cone_d, h = gl_nut_h + 2, $fn = 48);                // the tube always passes
    }
}
module dish_lettering() {
    n = len(dish_text);
    for (i = [0:n-1]) rotate([0, 0, 90 + (i - (n-1)/2) * dish_text_pitch])
        translate([r_out(H + dish_h/2) - 1.0, 0, dish_h/2 - 0.8]) rotate([90, 0, 90])
            linear_extrude(dish_text_raise + 1.0) text(dish_text[i], size = dish_text_h, halign = "center", valign = "center", font = "Helvetica:style=Bold");
    rotate([0, 0, 90 - (n + 2.6) / 2 * dish_text_pitch])
        translate([r_out(H + dish_h/2) - 1.0, 0, dish_h/2 - 0.8]) rotate([90, 0, 90])
            linear_extrude(dish_text_raise + 1.0) nfc_mark_small();
}

// rim band, in the shell frame. Its lip sits on the dish's bead (top at H + dish_h + dish_wall/2); the skirt hangs
// down around the pot. Letters raised on the face at mid-band, NFC pocket behind them inside.
band_zl = H + dish_h + dish_wall/2;            // underside of the lip = top of the dish's bead
band_zt = band_zl + band_lip;                  // top of the band
band_z0 = band_zt - band_h;                    // bottom edge of the skirt
band_zm = (band_z0 + band_zt) / 2;
band_text_h = 6;                               // letter height on the band (4.2 on the old dish)
band_ri = r_out(H + dish_h) + band_clear;      // straight bore: clears the widest thing it passes, the dish bead (58.6)
band_proud = band_ri + band_wall - r_out(band_z0);   // face offset from the pot surface (5.5; was 4 with the hugging skirt)
function band_r(z) = r_out(z) + band_proud;   // the band's face still follows the pot's taper
module rim_band() {
    ri_lip = r_out(H + dish_h) - dish_wall + band_clear;     // lip reaches in over the dish wall, clear of the moss
    difference() {
        union() {
            rotate_extrude($fn = 240) offset(r = 1.2) offset(delta = -1.2)          // skirt, edges rounded
                polygon([[band_ri, band_z0], [band_r(band_z0), band_z0], [band_r(band_zt), band_zt], [band_ri, band_zt]]);
            rotate_extrude($fn = 240) translate([ri_lip, band_zl]) offset(r = 0.6) offset(delta = -0.6)   // lip (rounded on its own: 2 mm is too thin for the skirt's 1.2)
                square([band_ri + 2 - ri_lip, band_lip]);
            rotate_extrude($fn = 240) polygon([[band_ri, band_z0 + 1.2], [band_ri + 2, band_z0 + 1.2],
                         [band_ri + 2, band_zl], [band_ri, band_zl]]);   // squares the inner corners the rounding took off
            band_lettering();
        }
        // NFC tag pocket on the inside, behind the lettering
        rotate([0, 0, 90 - (nfc_tag_l / band_ri * 57.3) / 2]) rotate_extrude(angle = nfc_tag_l / band_ri * 57.3, $fn = 240)
            translate([band_ri - 0.01, band_zm - nfc_tag_h/2]) square([nfc_depth + 0.01, nfc_tag_h]);
    }
}
module band_lettering() {
    n = len(dish_text);
    pitch = band_text_h * 0.82 / band_r(band_zm) * 57.3;     // degrees per character, scaled from the dish's spacing
    for (i = [0:n-1]) rotate([0, 0, 90 + (i - (n-1)/2) * pitch])
        translate([band_r(band_zm) - 1.0, 0, band_zm]) rotate([90, 0, 90])
            linear_extrude(dish_text_raise + 1.0) text(dish_text[i], size = band_text_h, halign = "center", valign = "center", font = "Helvetica:style=Bold");
    rotate([0, 0, 90 - (n + 2.6) / 2 * pitch])
        translate([band_r(band_zm) - 1.0, 0, band_zm]) rotate([90, 0, 90])
            linear_extrude(dish_text_raise + 1.0) scale(band_text_h / dish_text_h) nfc_mark_small();
}

// =====================================================================
//  crank arm (modelled at the crank centre, theta = 0, in the shared frame)
// =====================================================================
module arm() {
    difference() {
        union() {
            // arm body: hull of hub and tip, extruded in y
            translate([0, arm_y + arm_t, 0]) rotate([90,0,0]) linear_extrude(arm_t)
                hull() { circle(d = 14); translate([L1, 0]) circle(d = 9); }
            // drive pin with a chamfered tip
            translate([L1, arm_y + arm_t - 0.01, 0]) rotate([-90,0,0]) {
                cylinder(d = pin_d, h = pin_len - 0.5, $fn = 48);
                translate([0,0,pin_len - 0.51]) cylinder(d1 = pin_d, d2 = pin_d - 1, h = 0.5, $fn = 48);
            }
        }
        // horn pocket on the back face
        translate([0, arm_y - 0.01, 0]) rotate([-90,0,0]) linear_extrude(horn_t + 0.01)
            hull() { circle(d = horn_hub_d); translate([horn_len - horn_tip_d/2, 0]) circle(d = horn_tip_d); }
        // horn screw through the arm, with a head recess on the front
        translate([0, arm_y - 1, 0]) rotate([-90,0,0]) cylinder(d = 2.1, h = arm_t + 2, $fn = 24);          // M2 x 8 pan head
        translate([0, arm_y + arm_t - 1.0, 0]) rotate([-90,0,0]) cylinder(d = 4.5, h = 2, $fn = 32);
    }
}

// arm that mounts straight on the spline, no horn. Its back face rests on the gear housing top, so it is
// horn_t thicker than arm(); the front face and the pin are in the same place, so lid and yoke are unchanged.
module arm_direct() {
    y0 = arm_y - horn_t;               // back face on the gear housing
    t  = arm_t + horn_t;               // 6 mm
    difference() {
        union() {
            translate([0, y0 + t, 0]) rotate([90,0,0]) linear_extrude(t)
                hull() { circle(d = 14); translate([L1, 0]) circle(d = 9); }
            translate([L1, y0 + t - 0.01, 0]) rotate([-90,0,0]) {
                cylinder(d = pin_d, h = pin_len - 0.5, $fn = 48);
                translate([0,0,pin_len - 0.51]) cylinder(d1 = pin_d, d2 = pin_d - 1, h = 0.5, $fn = 48);
            }
        }
        // spline bore from the back, 0.3 deeper than the spline so the face seats on the housing
        translate([0, y0 - 0.01, 0]) rotate([-90,0,0]) cylinder(d = spline_d + spline_fit, h = spline_h + 0.3, $fn = 48);
        // M2 screw through, head recess on the front
        translate([0, y0 - 1, 0]) rotate([-90,0,0]) cylinder(d = 2.1, h = t + 2, $fn = 24);
        translate([0, y0 + t - 1.0, 0]) rotate([-90,0,0]) cylinder(d = 4.5, h = 2, $fn = 32);
    }
}
// bore test for the spline: five holes, spline_d - 0.3 .. + 0.1 in 0.1 steps, notches count up from the smallest
module spline_coupon() {
    fits = [-0.3, -0.2, -0.1, 0.0, 0.1];
    difference() {
        cube([12 * len(fits) + 2, 14, spline_h + 1]);
        for (i = [0:len(fits)-1]) {
            translate([2 + 12*i + 5, 5, -1]) cylinder(d = spline_d + fits[i], h = spline_h + 3, $fn = 48);
            for (n = [0:i]) translate([2 + 12*i + 1.5 + n*2.2, 12, -1]) cylinder(d = 1.2, h = spline_h + 3, $fn = 12);
        }
    }
}

// =====================================================================
//  yoke (modelled with the slot centre at z = 0)
// =====================================================================
// bore for a hole that prints lying on its side (axis along y in the print): a teardrop with its point
// upward (+y here) so the top of the hole is two 45-degree walls instead of a sagging bridge.
module bore_teardrop(d, h) {
    hull() {
        cylinder(d = d, h = h, $fn = 48);
        translate([0, d/2 * 1.4142, 0]) cylinder(d = 0.01, h = h, $fn = 4);
    }
}
module yoke() {
    difference() {
        union() {
            translate([plate_x0, yoke_y, -yoke_h/2]) cube([plate_x1 - plate_x0, yoke_t, yoke_h]);   // slotted plate
            // drive-rod boss on the pot axis, joined to the plate's front face
            translate([0, 0, -yoke_h/2]) cylinder(d = 8, h = yoke_h);
            translate([-4, yoke_y + yoke_t - 0.01, -yoke_h/2]) cube([8, -(yoke_y + yoke_t) + 0.01, yoke_h]);
            // rail sleeve + web from the plate's left end
            translate([rail_x, rail_y, -sleeve_h/2]) cylinder(d = rail_d + 5, h = sleeve_h);
            translate([rail_x, yoke_y, -yoke_h/2]) cube([plate_x0 - rail_x + 2, -yoke_y, yoke_h]);
        }
        // the slot
        translate([0, yoke_y - 1, 0]) rotate([-90,0,0]) linear_extrude(yoke_t + 2)
            hull() { translate([slot_x0, 0]) circle(d = slot_w, $fn = 48); translate([slot_x1, 0]) circle(d = slot_w, $fn = 48); }
        // rod bore (press fit; ream to 3.0 if tight) + set screw from the front
        translate([0, 0, -yoke_h/2 - 1]) bore_teardrop(rod_d + rod_bore_clear, yoke_h + 2);
        rotate([-90,0,0]) cylinder(d = 2.5, h = 6, $fn = 24);
        // rail bore (sliding)
        translate([rail_x, rail_y, -sleeve_h/2 - 1]) bore_teardrop(rail_d + rail_bore_clear, sleeve_h + 2);
    }
}

// =====================================================================
//  slot test coupon: five slots at 0.2 .. 0.6 mm clearance + a stub of the real pin
// =====================================================================
module coupon() {
    clears = [0.2, 0.3, 0.4, 0.5, 0.6];
    difference() {
        cube([14 * len(clears) + 4, 22, yoke_t]);
        for (i = [0:len(clears)-1]) {
            w = pin_d + clears[i];
            translate([4 + 14*i + 5, 5, -1]) hull() {
                cylinder(d = w, h = yoke_t + 2, $fn = 48);
                translate([0, 12, 0]) cylinder(d = w, h = yoke_t + 2, $fn = 48);
            }
            // label: count the notches on the edge (1 notch = 0.2, 2 = 0.3, ...)
            for (n = [0:i]) translate([4 + 14*i + 1.5 + n*2.2, 20, -1]) cylinder(d = 1.4, h = yoke_t + 2, $fn = 12);
        }
    }
    // test pin on a handle
    translate([0, 28, 0]) {
        cube([20, 8, 3]);
        translate([16, 4, 3 - 0.01]) {
            cylinder(d = pin_d, h = pin_len - 0.5, $fn = 48);
            translate([0,0,pin_len - 0.51]) cylinder(d1 = pin_d, d2 = pin_d - 1, h = 0.5, $fn = 48);
        }
    }
}

// =====================================================================
//  DOCK — all the electronics live here; the vase carries only the mechanism.
//  Rounded-rectangle box with a flat back (keyholes for two wall screws) and a
//  vase socket ring on its lid. The sensor stands in a sealed pocket at the front
//  with its openings against the front wall, behind one grille.
//  Frame: same as the vase (vase axis = origin). Dock base sits from z = -3 - D_H
//  (floor) to z = -3 (lid top) in the assembly; modelled here with its floor at z = 0.
// =====================================================================
D_W = 120; D_L = 120; D_CY = 0; D_R = 24;      // Rev F: rounded square, vase centred, rim 3 mm inside the edge all round
D_H = 32; D_FLOOR = 3; D_LID = 3; D_WALL = 3;   // lid top at D_H; walls stand D_RIM proud of it
D_RIM = 1.5; LEDGE_W = 2;                       // lid sits on a ledge, clicks under four barbs
D_BACK = D_CY - D_L/2;                          // -60
D_FRONT = D_CY + D_L/2;                         // 60
KEY_X = 30; KEY_Z0 = 10; KEY_TRAVEL = 12; KEY_HEAD = 9; KEY_SLOT = 4.5;
// feet: four stick-on rubber feet (3 mm) after printing; printed feet would lift the floor off the bed
// bayonet ring on the lid: shell drops in, turns BAY_TWIST degrees, lugs lock in the shell's L-slots
RING_ID = 89.4; RING_T = 3; RING_H = 8;         // shell base is 88: 0.7 mm per side (88.3 and 89.0 were tight in print)
LUG_W = 3.2; LUG_R = 1.2; LUG_H = 1.6; lug_z0 = 5; lug_angles = [90, 210, 330]; BAY_TWIST = 30;   // smaller lugs after the first fit test
SLOT_CLEAR = 0.8;                               // slot width and run height over the lug size
// SEN6x envelope (openings face = sen_l x sen_w), standing on edge along the right wall, openings towards +x
sen_l = 55.2; sen_d = 21.3; sen_w = 25.6;
gasket_t = 3; foam_t = 2; sen_z0 = D_FLOOR + 2;             // foam 4 -> 2 on 2026-09-16: pocket 2 mm shallower
sen_face_x = D_W/2 - D_WALL - gasket_t;        // 54
pocket_l = sen_l + 6; pocket_x0 = sen_face_x - sen_d - foam_t - 3;   // pocket's inner wall starts here
grille_slots = 7; grille_pitch = 4; grille_h = 2.5; grille_len = 56;
pi_c = [pocket_x0 - 1 - 15, 1.5];               // Pi Zero centre: board along y against the sensor pocket wall (2026-09-16),
                                                // 40-pin header on the pocket side, ports toward the middle, USB ports at the back end
usb_plug = 28;                                  // room kept in front of the Pi's ports for two overmoulded micro-USB plugs
adp_c  = [34, 44];                              // Adafruit SEN6x STEMMA QT power adapter (25 x 16): front-right, between the Pi and the
                                                // sensor pocket, where Martin put it on the bench (2026-10-04). Its spare QT port is left
                                                // free for pupils' add-ons (a pressure sensor, a small I2C display). No MultiPort in the flower.
adp_l = 25; adp_w = 16; adp_holes = [20, 12.6];   // board (x, y) and hole spacing (Martin, measured 2026-10-04)
// status LED (2026-10-04, Martin): a 5 mm through-hole smart LED (WS2812D-F5 / PL9823, any colour from one data pin, so it
// can show connection status too), in a holder on the floor behind the middle slot of the front grille, so it glows
// through the grille. Replaces the floor underglow slot. A plain 5 mm LED fits the same holder.
led_x = 1;                                      // holder centre (x): clear of the driver's front-right post
led_z = sen_z0 + sen_w/2;                       // LED axis at the middle grille slot: 17.8
led_len = 8.6;                                  // flange to dome tip of a 5 mm LED
pm_c   = [42, 44];                              // (power module pad removed 2026-09-29: capacitor at the driver, adapter straight to the Pi)
drv_c  = [-22.5, 40];                           // ULN2003 driver board centre: front-left, in front of the motor body. 4 mm further
                                                // left on 2026-10-04: the old pad reached 2 mm into the Pi
drv_l = 34; drv_w = 32; drv_holes = [29, 26];   // board (x, y), hole spacing and M3 holes (Martin confirmed); motor socket on the right edge
drv_fix = 0.5;                                  // front-right driver post (next to the LED holder) 0.5 mm further toward the front
                                                // grille: on the 2026-10-04 print the board's hole sat that far off it (Martin, 2026-10-05)
// standoffs with pilot holes, so every board screws straight in (no drilling): M2.5 self-tappers in 2.2, M3 in 2.5
so_h = 4; so_d = 6;
usb_in = [-53, -39];                            // USB cable entry: right wall, back corner (y range), z from D_FLOOR + 3
wall_holes = [[-44, -30], [-44, 20]];           // keyholes through the floor for the no-vase wall mount: round hole here, slot toward the left wall
groove_y = 10;                                  // notch in the top of the pocket's inner wall for the sensor lead: 1/3 from the front end
pocket_top = D_H - D_LID - 0.3;                 // pocket walls reach 0.3 under the lid (2026-10-02; were 2 mm short, at ledge height,
                                                // so the pocket was open along its top: the Pi's warm air could reach the sensor)
cell_x = -45; cell_y0 = -19; cell_y1 = 46; cell_d = 18.6;  // 18650 along the left wall
// status screen in the front wall: Waveshare 1.47" Touch LCD, 172 x 320, JD9853 + AXS5106L, SPI + I2C, 13-pin cable at one end.
// Board 22.05 x 41.69, display 17.63 x 32.83 (Waveshare wiki). Mounted landscape, cable end on the right. Thickness to measure.
scr_board_l = 42.0; scr_board_h = 22.3; scr_board_t = 4.5;   // width (x), height (z) with clearance; thickness incl. touch glass, FPCs and connector
scr_act_l = 32.83; scr_act_h = 17.63; scr_off_z = 0;        // active area, and its vertical offset from the board's centre (measure)
scr_z0 = 4;                                             // board rests on this ledge above the floor
front = "plain";                                        // "plain": nothing on the front · "screen": window + holder for the 1.47" LCD · "qr": sticker recess (not used: no phones in schools)
qr_size = 31; qr_depth = 0.6; qr_z = 16.75;             // sticker recess in the front wall: 31 x 31, 0.6 deep; sticker 30.5 mm
// NFC: a small rectangular tag (NTAG213, about 20 x 8 mm) sits in a pocket on the inside of the dish wall, directly
// behind the raised SCHOOLAIR.ORG lettering. The lettering is the tap target; no icon anywhere.
nfc_tag_l = 21; nfc_tag_h = 8.5; nfc_depth = 0.8;
lead_hole = [22, -33];                          // servo lead passes down through the lid here (clear of the wall, its gusset and the ring)
keyed_corner = [-1, -1];                        // back-left corner of the lid is clipped so it fits one way

// ---------------- Rev H drive (2026-09-20): 28BYJ-48 stepper under the lid, Tr8x8 lead screw, nut carriage between two posts ----------------
// The servo crank / Scotch yoke of Rev D-G is replaced by a lead screw standing on the motor shaft. The motor hangs
// under the lid inside the dock (only its shaft comes up), a printed coupler joins it to a 100 mm Tr8x8 screw, and a
// carriage with a printed trapezoidal nut rides the screw. A tab each side of the carriage runs in a groove on one of
// two posts, which stops it turning with the screw. The carriage carries the drive rod on the pot axis. Homing: drive
// down until the rod boss bottoms on the stop post and the motor skips, then count steps.
mt_c        = [rail_x, rail_y];   // motor shaft axis = screw axis (where the guide rail was)
mt_body_d   = 28;  mt_body_h = 19;                 // 28BYJ-48 body, hangs below the lid
mt_offset   = 8;   mt_off_dir = [0, 1];            // body centre is 8 mm from the shaft, toward the front here
mt_boss_d   = 9.0; mt_boss_h = 1.5;                // ring around the shaft, sits in the lid hole
mt_shaft_d  = 5.0; mt_shaft_flat = 3.0; mt_shaft_l = 10;   // D-shaft with two flats, 10 above the motor face
mt_shaft_fit = 0.15;                                       // coupler pocket = shaft + this; 0.1 needed a very hard press (2026-09-24),
                                                           // 0.3 was very loose on the 2026-10-06 reprint (Martin); 0.15 fitted perfectly (Martin, 2026-10-07)
mt_ear_pitch = 35; mt_ear_hole = 2.7;              // two ears on a line perpendicular to the offset: M3 self-taps into the lid (was 3.4 + nuts)
mt_ear_dir  = [1, 0];
scr_d = 8;  scr_pitch = 2; scr_starts = 4;         // Tr8x8: lead 8 mm/rev, 10 turns for the 80 mm stroke
scr_len = 100;                                     // stock length, no cutting
scr_fit = 0.2;                                     // pocket clearance in the coupler
nut_slop = 0.25;                                   // BOSL2 $slop for the printed nut; 0.2 was a touch tight on the Tr8x8 (2026-09-24)
cpl_d = 14; cpl_z0 = 0.5; cpl_shaft_depth = 7; cpl_div = 1.5; cpl_scr_depth = 8;   // coupler: shaft pocket from below, screw pocket from above
cpl_h = cpl_shaft_depth + cpl_div + cpl_scr_depth; // 16.5
car_sleeve_od = 12.5; car_nut_h = 10;              // carriage nut sleeve (0.75 mm clear of the fin face)
car_boss_d = 10; car_boss_drop = 12;               // rod boss on the pot axis hangs 12 below the sleeve so it clears the roof sleeve at the top
car_bar_w = 8;
stop_od = 9; stop_h = 32.5;                        // home stop post under the rod boss. 7.5 until 2026-09-28: raised 25 mm so the
                                                   // mechanical bottom IS the wilted position (bench: wilted 25, horizontal 30, erect 62.5 above the old stop)
stop_old_h = 7.5;                                  // the ring on lids printed before 2026-09-28; stop_spacer() brings those up to stop_h
car_z0 = stop_h + car_boss_drop;                   // sleeve bottom when homed: 44.5 (19.5 on lids with the old 7.5 ring)
car_travel = 47.5;                                 // wilted -> erect on the bench: 25 -> 72.5 above the old stop (62.5 -> 70 on 09-29; 73 grated, 72.5 on 10-01, Martin).
                                                   // Physical top measured 75 above the old stop (2026-09-29), so 50 above this one;
                                                   // software top = erect = 72.5 (47.5 here): the sleeve top is 1 mm under the roof sleeve in this model, and
                                                   // 73 grated on the bench, so the model and the unit agree. Nothing above this.
// guides (2026-09-24): two grooved posts either side of the screw, copied from the June 2026 lead-screw actuator
// (posts 20 x 5, groove 7 wide x 2.5 deep, tabs with 0.3 mm clearance). The single fin + 3 mm tongue of the first
// Rev H print bound in its groove.
post_w = 20; post_t = 5; post_h = 108;             // post section (x by y) and height
post_x0 = mt_c[0] - post_w/2;                      // posts centred on the screw in x
post_gap = 24;                                     // inner faces at y = +-12: clear of the coupler (r 7) and the carriage bar
post_gus_out = 8; post_gus_in = 3.5; post_gus_h = 14;   // base gussets (outer, inner depth; height); groove starts above them
groove_w = 7; groove_depth = 2.5; groove_clear = 0.3;
tab_w = groove_w - 2*groove_clear;                 // carriage tabs, 6.4 wide, reach 0.3 short of the groove bottom

// limit switches (2026-10-04, proposal): two SS-5 size lever micro-switches (DICGU, Martin's measurements: case
// 20 x 9.7 x 6.5, holes 2.0 (5/64") on 9.2 mm centres, centred along the length, 2 mm from the pin edge; hinge lever,
// free end 7.5 past the case, tip 5 above the case at rest, clicks at about 2, then touches the case) on a bracket screwed to the back ends of the guide posts.
// A flat flag screwed under the carriage reaches back between the two switches: its lower edge clicks the bottom
// switch just before the rod boss lands on the stop post, its upper edge clicks the top switch at erect. (A fin
// printed on the carriage would overhang 11 mm in the carriage's upside-down print; the flag prints flat instead.) Wired to the
// NC contacts, so a broken wire reads the same as "end reached" and the motor stops.
ls_l = 20; ls_h = 9.7; ls_t = 6.5;                   // switch body: length (along y), height (z), thickness (x)
ls_pitch = 9.2; ls_hole_d = 2.0; ls_hole_e = 2;    // mounting holes: centre to centre, diameter, centre to the pin edge
ls_op = 1.2;                                       // lever height above the case where it clicks, at the flag's contact point (8 from the
                                                   // case centre toward the free end): tip 2 mm scaled by the lever ratio. About 1 mm of
                                                   // overtravel follows before the lever meets the case. The slots take +-2 mm
ls_early = 0.5;                                    // bottom switch clicks this far above home, so the stop post lands inside the overtravel
ls_slot = 4;                                       // vertical slot length in the bracket bars: the trigger point is set by hand on the bench
ls_y = 8;                                          // switch centres at y = -8 (bottom) and +8 (top), free end of each lever toward the middle:
                                                   // the flag (y = 0) meets the lever 2 mm inside the case end, where it moves most
br_t = 4;                                          // bracket thickness (x); it sits against the post ends at x = post_x0
br_x0 = post_x0 - br_t;                            // back face of the bracket: the switches sit behind it
fin_t = 4; fin_h = 4;                              // flag arm width (y) and thickness (z)
fin_top = 0;                                       // flag's upper edge = the carriage's underside (carriage frame)
fin_x1 = br_x0 - ls_t - 0.5;                       // arm reaches 0.5 past the switch's back face, so it always meets the lever
flag_screw_y = 9.5;                                // M2 x 8 up into the solid part of each carriage tab
ls_bot_edge = car_z0 + fin_top - fin_h + ls_early - ls_op;       // bottom switch lever edge (lid-top frame): 39.8
ls_top_edge = car_z0 + car_travel + fin_top + ls_op;             // top switch lever edge: 93.2
br_screw_z = [45, 77];                             // M2 x 12 self-tappers into the post ends (pilot 1.6 = 1/16" drill on old lids)
ls_wire_c = [-30, -19.5];                          // 5 mm hole through the lid for the switch leads, straight under the wire channel
// wire channel (2026-10-04): a U channel down the outer face of the -y rail, open toward the posts. The top switch's
// leads cross the top of the bracket, press into it and run down to the lid hole; the bottom switch's leads drop
// straight into the same hole. Holds four Dupont leads two by two. Under the lid: two stick-on cable clips to the Pi.
ch_w = 3; ch_d = 3; ch_wall = 1; ch_lip = 0.6;     // channel width (y), depth (x), outer wall, snap lip

module dock_outline() { translate([0, D_CY]) offset(r = D_R) offset(delta = -D_R) square([D_W, D_L], center = true); }

module dock_base() {
    hw = D_H + D_RIM;
    difference() {
        union() {
            linear_extrude(hw) dock_outline();
            for (sx = [-1, 1]) translate([sx*KEY_X - 9, D_BACK, 0]) cube([18, 8, D_H - D_LID]);   // keyhole plates
        }
        // interior below the ledge, then the lid opening above it (chamfered ledge, 2 mm wide)
        translate([0, 0, D_FLOOR]) linear_extrude(D_H - D_LID - LEDGE_W - 1 - D_FLOOR + 0.01) offset(delta = -D_WALL) dock_outline();
        hull() {                                                                  // ledge underside at 56 degrees, 2 mm in over 3 mm up
            translate([0, 0, D_H - D_LID - LEDGE_W - 1]) linear_extrude(0.01) offset(delta = -D_WALL) dock_outline();
            translate([0, 0, D_H - D_LID]) linear_extrude(0.01) offset(delta = -D_WALL - LEDGE_W) dock_outline();
        }
        translate([0, 0, D_H - D_LID - 0.01]) linear_extrude(hw) offset(delta = -D_WALL) dock_outline();
        // keyholes
        // keyholes: round entry for the screw head, then a rounded-top slot; the head pocket behind the slot is a
        // stadium too, so every ceiling is an arch rather than a flat cantilever
        for (sx = [-1, 1]) translate([sx*KEY_X, D_BACK - 1, 0]) rotate([-90,0,0]) {
            translate([0, -KEY_Z0, 0]) cylinder(d = KEY_HEAD, h = 7, $fn = 24);
            hull() { translate([0, -KEY_Z0, 0]) cylinder(d = KEY_SLOT, h = 7, $fn = 16); translate([0, -(KEY_Z0 + KEY_TRAVEL), 0]) cylinder(d = KEY_SLOT, h = 7, $fn = 16); }
            hull() { translate([0, -KEY_Z0, 6]) cylinder(d = KEY_HEAD, h = 4, $fn = 24); translate([0, -(KEY_Z0 + KEY_TRAVEL), 6]) cylinder(d = KEY_HEAD, h = 4, $fn = 24); }
        }
        // grilles: right wall (sensor), front and left walls (Pi bay ventilation)
        for (k = [0:grille_slots-1]) {
            zk = sen_z0 + sen_w/2 + (k - (grille_slots-1)/2)*grille_pitch - grille_h/2;
            translate([D_W/2 - D_WALL - 1, -grille_len/2, zk]) cube([D_WALL + 2, grille_len, grille_h]);
            translate([-D_W/2 - 1, -grille_len/2, zk]) cube([D_WALL + 2, grille_len, grille_h]);
            if (front == "plain") translate([-grille_len/2, D_FRONT - D_WALL - 1, zk]) cube([grille_len, D_WALL + 2, grille_h]);   // front grille when nothing else is there
        }
        // front: QR sticker recess, or screen window (active area + 0.5 mm, outer edge bevelled) with the ledge relieved above it
        if (front == "qr") translate([-qr_size/2, D_FRONT - qr_depth, qr_z - qr_size/2]) cube([qr_size, qr_depth + 1, qr_size]);
        if (front == "screen") translate([-scr_act_l/2 - 0.5, D_FRONT - D_WALL - 1, scr_z0 + scr_board_h/2 + scr_off_z - scr_act_h/2 - 0.5]) cube([scr_act_l + 1, D_WALL + 2, scr_act_h + 1]);
        if (front == "screen") hull() {
            translate([-scr_act_l/2 - 0.5, D_FRONT - 1, scr_z0 + scr_board_h/2 + scr_off_z - scr_act_h/2 - 0.5]) cube([scr_act_l + 1, 1.01, scr_act_h + 1]);
            translate([-scr_act_l/2 - 1.5, D_FRONT + 0.01, scr_z0 + scr_board_h/2 + scr_off_z - scr_act_h/2 - 1.5]) cube([scr_act_l + 3, 1, scr_act_h + 3]);
        }
        if (front == "screen") translate([-scr_board_l/2 - 4, D_FRONT - D_WALL - LEDGE_W - 1, D_H - D_LID - LEDGE_W - 0.01]) cube([scr_board_l + 8, LEDGE_W + 1, LEDGE_W + 1]);
        // USB jack hole (back wall, left of the keyholes), cable notch (back-bottom edge), pry notch for the lid (back wall top)
        translate([-54, D_BACK - 1, D_FLOOR + 3]) cube([14, 10, 8]);                      // back-wall cable hole, 14 x 8 like the right-wall one (2026-09-16)
        translate([-46, D_BACK - 1, -1]) cube([12, 8, D_FLOOR + 3]);
        translate([-8, D_BACK - 1, D_H - D_LID + 0.5]) cube([16, D_WALL + 2, 10]);
        // USB cable entry: right wall, back corner, sized for a USB-A plug (2026-09-16)
        translate([D_W/2 - D_WALL - 1, usb_in[0], D_FLOOR + 3]) cube([D_WALL + 2, usb_in[1] - usb_in[0], 8]);
        // wall-mount keyholes through the floor (no-vase lid, grille facing down, left side up): head hole, slot 8 mm toward the left wall
        for (h = wall_holes) translate([h[0], h[1], -1]) {
            cylinder(d = 9, h = D_FLOOR + 2, $fn = 24);
            hull() { cylinder(d = 4.5, h = D_FLOOR + 2, $fn = 16); translate([-8, 0, 0]) cylinder(d = 4.5, h = D_FLOOR + 2, $fn = 16); }
        }
        // (LED underglow slot in the floor removed 2026-10-04: the LED now sits behind the front grille, see led_holder)
    }
    // four barbs at the tops of the left and right walls: the lid clicks under them
    for (sx = [-1, 1]) for (sy = [-25, 25]) translate([sx*(D_W/2 - D_WALL), sy - 4, 0]) mirror([sx < 0 ? 0 : 1, 0, 0])
        rotate([90, 0, 0]) translate([0, 0, -8]) linear_extrude(8) polygon([[0, D_H - 0.2], [0.6, D_H + 0.4], [0.6, D_H + 0.9], [0, D_H + D_RIM]]);   // 45-degree underside
    // keyed corner block: the lid's clipped corner fits here and nowhere else
    translate([keyed_corner[0]*(D_W/2 - D_WALL), keyed_corner[1]*(D_L/2 - D_WALL), D_FLOOR - 0.01])
        linear_extrude(D_H + D_RIM - D_FLOOR + 0.01) polygon([[0, 0], [-keyed_corner[0]*12, 0], [0, -keyed_corner[1]*12]]);   // full-height post: no cantilever
    // sensor pocket along the right wall: inner wall + two end walls (the dock wall is the fourth side)
    difference() {
        translate([pocket_x0, -pocket_l/2, D_FLOOR - 0.01]) cube([3, pocket_l, pocket_top - D_FLOOR + 0.01]);
        translate([pocket_x0 - 1, groove_y - 3, pocket_top - 4]) cube([5, 6, 10]);   // lead notch, 6 wide 4 deep: just the cable, foam around it
    }
    for (sy = [-1, 1]) translate([pocket_x0, sy*pocket_l/2 - 1.5, D_FLOOR - 0.01]) cube([D_W/2 - D_WALL - pocket_x0 + 0.01, 3, pocket_top - D_FLOOR + 0.01]);
    // Pi Zero standoffs (58 x 23), then the hub and driver posts
    for (sx = [-11.5, 11.5]) for (sy = [-29, 29]) translate([pi_c[0] + sx, pi_c[1] + sy, D_FLOOR - 0.01]) standoff(3.01, 2.2);   // M2.5 x 6
    // battery cradles removed 2026-09-28: no cell, mains only with a read-only card and a 1000 uF capacitor on the 5 V rail
    // screen holder: board slides down behind the front wall between two guides, resting on a ledge
    // left guide full height; right guide low, so the 13-pin cable at the board's right end is free
    if (front == "screen") for (sx = [-1, 1]) translate([sx*(scr_board_l/2 + 0.3) - (sx < 0 ? 2.5 : 0), D_FRONT - D_WALL - scr_board_t - 2.5, D_FLOOR - 0.01])
        difference() { cube([2.5, scr_board_t + 2.5, sx < 0 ? scr_z0 + scr_board_h - 2 : scr_z0 + 6]); translate([sx < 0 ? 2 : -1, 2.5, scr_z0]) cube([1.5, scr_board_t + 1, 40]); }
    if (front == "screen") translate([-scr_board_l/2 - 2.8, D_FRONT - D_WALL - scr_board_t - 2.5, D_FLOOR - 0.01]) cube([scr_board_l + 5.6, scr_board_t + 2.5, scr_z0 - D_FLOOR + 0.01]);
    for (sx = [-1, 1], sy = [-1, 1]) {
        translate([adp_c[0] + sx*adp_holes[0]/2, adp_c[1] + sy*adp_holes[1]/2, D_FLOOR - 0.01]) standoff(so_h, 2.2);   // SEN6x adapter, M2.5 x 6
        translate([drv_c[0] + sx*drv_holes[0]/2, drv_c[1] + sy*drv_holes[1]/2 + (sx > 0 && sy > 0 ? drv_fix : 0), D_FLOOR - 0.01]) standoff(so_h, 2.5);   // driver, M3 x 6
    }
    led_holder();
}

// LED holder: a block on the floor with a 5.2 mm bore pointing at the front grille. The LED goes in from the back until
// its flange stops on the back face; the dome then just reaches the inside of the grille. Leads come out the back.
module led_holder() {
    y1 = D_FRONT - D_WALL - 1;                      // front face of the block, 1 mm short of the wall (the dome fills it)
    y0 = D_FRONT - D_WALL - led_len - 0.2;          // back face, where the flange stops
    difference() {
        translate([led_x - 4.5, y0, D_FLOOR - 0.01]) cube([9, y1 - y0, led_z + 4 - D_FLOOR]);
        translate([led_x, y0 - 1, led_z]) rotate([-90, 0, 0]) cylinder(d = 5.2, h = y1 - y0 + 2, $fn = 32);
        translate([led_x - 2.2, y0 - 1, led_z + 1.4]) cube([4.4, y1 - y0 + 2, 10]);   // 4.4 slot on top, narrower than the LED: it snaps in from above
    }
}
module standoff(h, pilot) {     // round post with a pilot hole, flared foot so it does not snap off
    difference() {
        union() { cylinder(d = so_d - (h < so_h ? 1 : 0), h = h, $fn = 32); cylinder(d1 = so_d + 3, d2 = so_d, h = 1.5, $fn = 32); }
        translate([0, 0, -2]) cylinder(d = pilot, h = h + 3, $fn = 20);   // runs 2 mm into the 3 mm floor: 6 mm screws bite fully
    }
}
// ---- Rev H mechanism on the lid (z = 0 at the lid top) ----
module screw_features() {
    // two guide posts, grooves on their inner faces, open at the top so the carriage slides in
    for (sy = [-1, 1]) difference() {
        union() {
            translate([post_x0, sy > 0 ? post_gap/2 : -post_gap/2 - post_t, 0]) cube([post_w, post_t, post_h]);
            // base gussets (2026-10-05: a post snapped at the lid on the first print of this lid). Outer face: 8 out,
            // 14 up (was 5 x 5). Inner face: 3.5 out (1.5 clear of the coupler, r 7), 14 up; they fill the groove
            // there, which the carriage tabs never reach (flag bottom 40 above the lid when homed).
            // each gusset: [face y, depth, direction away from the post]
            for (g = [[sy*(post_gap/2 + post_t), post_gus_out, sy], [sy*post_gap/2, post_gus_in, -sy]]) hull() {
                translate([post_x0, g[2] > 0 ? g[0] : g[0] - g[1], 0]) cube([post_w, g[1], 0.01]);   // foot on the lid
                translate([post_x0, g[2] > 0 ? g[0] - 0.01 : g[0], 0]) cube([post_w, 0.01, post_gus_h]);   // top edge on the face
            }
        }
        translate([mt_c[0] - groove_w/2, sy > 0 ? post_gap/2 - 1 : -post_gap/2 - groove_depth, post_gus_h]) cube([groove_w, groove_depth + 1, post_h]);
    }
    // home stop post under the rod boss (the rod passes through), flared at the base
    difference() {
        union() { cylinder(d = stop_od, h = stop_h); cylinder(d1 = stop_od + 6, d2 = stop_od, h = 3); }
        translate([0, 0, -1]) cylinder(d = rod_d + 2, h = stop_h + 2);
    }
}
// pilot holes in the post ends for the limit-switch bracket; 6 deep so they stop short of the grooves
module ls_post_pilots() {
    for (sy = [-1, 1], z = br_screw_z) translate([post_x0 - 1, sy*(post_gap/2 + post_t/2), z]) rotate([0, 90, 0]) cylinder(d = 1.6, h = 7, $fn = 16);
}
module screw_cuts() {
    ls_post_pilots();
    translate([ls_wire_c[0], ls_wire_c[1], -D_LID - 1]) cylinder(d = 5, h = D_LID + 2, $fn = 24);              // limit-switch leads into the dock
    translate([mt_c[0], mt_c[1], -D_LID - 1]) cylinder(d = mt_boss_d + 0.8, h = D_LID + 2, $fn = 48);          // motor boss and shaft through the lid
    for (se = [-1, 1]) translate([mt_c[0] + se*mt_ear_dir[0]*mt_ear_pitch/2 + mt_offset*mt_off_dir[0], mt_c[1] + se*mt_ear_dir[1]*mt_ear_pitch/2 + mt_offset*mt_off_dir[1], -D_LID - 1])
        cylinder(d = mt_ear_hole, h = D_LID + 2, $fn = 24);                                                        // M3 through the lid into the ears
}
// carriage: printed Tr8x8 nut in a sleeve on the screw, bar to the rod boss on the pot axis, tongue into the fin groove.
// Modelled at its homed position (sleeve bottom at z = 0); the rod boss hangs below.
module carriage() {
    difference() {
        union() {
            translate([mt_c[0], mt_c[1], 0]) cylinder(d = car_sleeve_od, h = car_nut_h, $fn = 64);
            translate([mt_c[0], -car_bar_w/2, 0]) cube([-mt_c[0], car_bar_w, car_nut_h]);
            translate([0, 0, -car_boss_drop]) cylinder(d = car_boss_d, h = car_boss_drop + car_nut_h, $fn = 48);
            // tabs both sides, from the sleeve out to 0.3 mm short of the groove bottoms
            for (sy = [-1, 1]) translate([mt_c[0] - tab_w/2, sy > 0 ? 0 : -(post_gap/2 + groove_depth - groove_clear), 0])
                cube([tab_w, post_gap/2 + groove_depth - groove_clear, car_nut_h]);
        }
        // the printed nut
        translate([mt_c[0], mt_c[1], -0.5]) trapezoidal_threaded_rod(d = scr_d, l = car_nut_h + 1, pitch = scr_pitch, starts = scr_starts, thread_angle = 30, internal = true, $slop = nut_slop, anchor = BOTTOM, $fn = 48);
        // rod bore and its set screw (M3 self-tapping from the front)
        translate([0, 0, -car_boss_drop - 1]) cylinder(d = rod_d + rod_bore_clear, h = car_boss_drop + car_nut_h + 2, $fn = 32);
        translate([0, 0, -car_boss_drop/2]) rotate([-90, 0, 0]) cylinder(d = 2.3, h = car_boss_d, $fn = 24);   // M3 self-taps (2.3 pilot, firmer than 2.5)
        // limit-switch flag: two M2 pilots up into the tab bottoms (1/16" drill on carriages printed before 2026-10-04)
        for (sy = [-1, 1]) translate([mt_c[0], sy*flag_screw_y, -1]) cylinder(d = 1.6, h = 9, $fn = 16);
    }
}
// D-shaft pocket opening on the bed side (z = 0 up to the pocket depth), the shaft plus `fit` across the round and the flats
module shaft_pocket(fit) {
    translate([0, 0, -1]) intersection() {
        cylinder(d = mt_shaft_d + fit, h = cpl_shaft_depth + 1, $fn = 48);
        translate([-(mt_shaft_flat + fit)/2, -mt_shaft_d, 0]) cube([mt_shaft_flat + fit, 2*mt_shaft_d, cpl_shaft_depth + 1]);
    }
}
// fit coupon for the coupler (2026-10-06): three shaft pockets, printed the coupler's way up, so the motor shaft
// can be tried in each. The number engraved on top is the fit in hundredths of a mm (10 = shaft + 0.10).
coupon_fits = [0.10, 0.15, 0.20];
module shaft_coupon() {
    pitch = 11;
    difference() {
        translate([-pitch*1.5, -6, 0]) cube([pitch*3, 12, cpl_shaft_depth + 2]);
        for (i = [0:2]) translate([(i - 1)*pitch, 0, 0]) {
            shaft_pocket(coupon_fits[i]);
            translate([0, 0, cpl_shaft_depth + 1.6]) linear_extrude(1) text(str(round(coupon_fits[i]*100)), size = 4.5, halign = "center", valign = "center", font = "Helvetica:style=Bold");
        }
    }
}
// coupler: 28BYJ-48 D-shaft pocket from below, screw pocket from above, two M3 grub screws on the screw
module coupler() {
    difference() {
        cylinder(d = cpl_d, h = cpl_h, $fn = 64);
        shaft_pocket(mt_shaft_fit);
        translate([0, 0, cpl_h - cpl_scr_depth]) cylinder(d = scr_d + scr_fit, h = cpl_scr_depth + 1, $fn = 48);
        for (a = [0, 90]) rotate([0, 0, a]) translate([0, 0, cpl_h - cpl_scr_depth/2]) rotate([0, 90, 0]) cylinder(d = 2.5, h = cpl_d, $fn = 20);
    }
}
// limit-switch flag, in the carriage frame (sleeve bottom at z = 0): a plate under the two tabs (screwed up into
// them, the lead screw passes through its middle) and an arm back between the posts to the switches.
module ls_flag() {
    difference() {
        union() {
            translate([mt_c[0] - tab_w/2, -(post_gap/2 - 0.5), -fin_h]) cube([tab_w, post_gap - 1, fin_h]);
            translate([fin_x1, -fin_t/2, -fin_h]) cube([mt_c[0] - fin_x1, fin_t, fin_h]);
        }
        translate([mt_c[0], mt_c[1], -fin_h - 1]) cylinder(d = scr_d + 2, h = fin_h + 2, $fn = 48);       // lead screw clearance
        for (sy = [-1, 1]) translate([mt_c[0], sy*flag_screw_y, -fin_h - 1]) cylinder(d = 2.3, h = fin_h + 2, $fn = 20);
    }
}
// the bracket: two rails against the post ends, a bar at the bottom and one at the top, each carrying a switch on
// its back face. Vertical slots let the switch slide +-2 mm to set the trigger point. Prints flat (back face down).
module ls_bracket() {
    bars = [[ls_bot_edge - ls_h - 3, ls_bot_edge - 1],              // bottom bar z range: under the bottom switch body
            [ls_top_edge + 1, ls_top_edge + ls_h + 3]];             // top bar
    difference() {
        union() {
            for (sy = [-1, 1]) translate([br_x0, sy > 0 ? post_gap/2 : -post_gap/2 - post_t, bars[0][0]]) cube([br_t, post_t, bars[1][1] - bars[0][0]]);
            for (b = bars) translate([br_x0, -post_gap/2 - post_t, b[0]]) cube([br_t, post_gap + 2*post_t, b[1] - b[0]]);
            // wire channel on the outer face of the -y rail: floor at the back, outer wall, a chamfered lip at the open edge
            translate([br_x0, -post_gap/2 - post_t - ch_w - ch_wall, bars[0][0]]) {
                cube([1, ch_w + ch_wall, bars[1][1] - bars[0][0]]);                                   // floor (back face side)
                cube([br_t, ch_wall, bars[1][1] - bars[0][0]]);                                       // outer wall
                translate([br_t, ch_wall, 0]) rotate([0, -90, 0]) linear_extrude(1.2)                  // lip, 45-degree back so it prints
                    polygon([[0, 0], [bars[1][1] - bars[0][0], 0], [bars[1][1] - bars[0][0], ch_lip], [0, ch_lip]]);
            }
        }
        for (sy = [-1, 1], z = br_screw_z) translate([br_x0 - 1, sy*(post_gap/2 + post_t/2), z]) rotate([0, 90, 0]) cylinder(d = 2.3, h = br_t + 2, $fn = 20);
        // switch slots: bottom switch holes 2 mm above its pin edge (pins down), top switch 2 mm below (pins up)
        // M2 x 12 from behind through the switch; the nut sits in a slotted pocket in the bar's front face (flush, because
        // the outer holes are in front of the post ends). Holes 2.4 wide: the spacing measured 9.2 once and 9.5 once.
        for (s = [[-ls_y, ls_bot_edge - ls_h + ls_hole_e], [ls_y, ls_top_edge + ls_h - ls_hole_e]], dy = [-1, 1])
            translate([0, s[0] + dy*ls_pitch/2, s[1]]) {
                translate([br_x0 - 1, 0, 0]) rotate([0, 90, 0]) hull() for (dz = [-1, 1]) translate([dz*ls_slot/2, 0, 0]) cylinder(d = 2.4, h = br_t + 2, $fn = 20);
                translate([post_x0 - 1.8, -2.1, -ls_slot/2 - 2.35]) cube([2, 4.2, ls_slot + 4.7]);          // M2 nut (4.0 flats, 1.6 thick)
            }
    }
}
module ls_switch_mock(up = true) {   // lever edge at z = 0; up = lever on top (bottom switch)
    color("#222") translate([-ls_t, -ls_l/2, up ? -ls_h : 0]) cube([ls_t, ls_l, ls_h]);
    color("Silver") translate([-ls_t/2 - 2, 0, 0]) mirror([0, 0, up ? 0 : 1])
        translate([0, -ls_l/2 + 2, 0]) rotate([5, 0, 0]) cube([4, ls_l + 4, 0.4]);   // hinge lever, drawn roughly: hinge at -y, long end toward the flag
    color("Gold") for (py = [-7, 0, 7]) translate([-ls_t/2 - 0.3, py, up ? -ls_h - 3.5 : ls_h]) cube([0.6, 3, 3.5]);   // 3.5 mm solder tabs (with holes)
}
module ls_parts() {                  // lid-top frame
    color("#5a5754") ls_bracket();
    translate([br_x0, -ls_y, ls_bot_edge]) ls_switch_mock(true);
    translate([br_x0, ls_y, ls_top_edge]) mirror([0, 1, 0]) ls_switch_mock(false);
}
// spacer for lids printed with the old 7.5 mm stop ring: slips over the ring (glue it) and raises the stop to stop_h
module stop_spacer() {
    difference() {
        cylinder(d = stop_od + 4, h = stop_h, $fn = 48);
        translate([0, 0, -1]) cylinder(d = stop_od + 0.4, h = stop_old_h + 1.2, $fn = 48);     // pocket over the old ring
        translate([0, 0, -1]) cylinder(d = rod_d + 2, h = stop_h + 2, $fn = 32);                // rod passes through
    }
}
module stepper_mock() {      // in the lid frame: face against the lid underside
    bc = [mt_c[0] + mt_offset*mt_off_dir[0], mt_c[1] + mt_offset*mt_off_dir[1]];
    color("#c0c4c8") {
        translate([bc[0], bc[1], -D_LID - mt_body_h]) cylinder(d = mt_body_d, h = mt_body_h, $fn = 64);
        for (se = [-1, 1]) translate([bc[0] + se*mt_ear_dir[0]*mt_ear_pitch/2, bc[1] + se*mt_ear_dir[1]*mt_ear_pitch/2, -D_LID - 1]) difference() { cylinder(d = 7, h = 1, $fn = 24); translate([0,0,-1]) cylinder(d = 4.2, h = 3, $fn = 20); }
        translate([bc[0] - 3.5, bc[1] - 3.5, -D_LID - 1]) cube([abs(mt_ear_pitch/2 - 3.5)*0 + 7, 7, 1]);
        translate([mt_c[0], mt_c[1], -D_LID - 0.01]) cylinder(d = mt_boss_d, h = D_LID + mt_boss_h, $fn = 48);
        translate([mt_c[0], mt_c[1], -D_LID]) intersection() { cylinder(d = mt_shaft_d, h = D_LID + mt_shaft_l, $fn = 32); translate([-mt_shaft_flat/2, -5, 0]) cube([mt_shaft_flat, 10, D_LID + mt_shaft_l]); }
    }
    color("#3b6ea5") translate([bc[0] - 7.5, bc[1] - mt_body_d/2 - 2, -D_LID - mt_body_h + 4]) cube([15, 3, 8]);   // lead exit, back side
}

// flat lid for the box model (sensor, Pi and LED, no flower): the same plate the vase lid uses, nothing on it.
// Clicks into the same base; the keyed corner orients it. Wall-mount with the two floor keyholes.
module dock_lid_flat() {
    translate([0, 0, D_H - D_LID]) linear_extrude(D_LID) difference() {
        offset(delta = -D_WALL - 0.2) dock_outline();
        translate([keyed_corner[0]*(D_W/2 - D_WALL), keyed_corner[1]*(D_L/2 - D_WALL)]) polygon([[0, 0], [-keyed_corner[0]*12.4, 0], [0, -keyed_corner[1]*12.4]]);
    }
}
module dock_lid() {
    difference() {
        union() {
            // plate, sits on the ledge; keyed corner clipped
            translate([0, 0, D_H - D_LID]) linear_extrude(D_LID) difference() {
                offset(delta = -D_WALL - 0.2) dock_outline();
                translate([keyed_corner[0]*(D_W/2 - D_WALL), keyed_corner[1]*(D_L/2 - D_WALL)]) polygon([[0, 0], [-keyed_corner[0]*12.4, 0], [0, -keyed_corner[1]*12.4]]);
            }
            // bayonet ring with three lugs (chamfered underneath so they print)
            translate([0, 0, D_H - 0.01]) difference() { cylinder(d = RING_ID + 2*RING_T, h = RING_H); translate([0,0,-1]) cylinder(d = RING_ID, h = RING_H + 2); }
            for (a = lug_angles) rotate([0, 0, a]) translate([RING_ID/2 - LUG_R, -LUG_W/2, D_H + lug_z0])
                hull() {
                    translate([LUG_R + 0.8, 0, -(LUG_R + 0.8)]) cube([0.6, LUG_W, LUG_H + LUG_R + 0.8]);          // rooted in the ring wall, 45-degree underside
                    translate([0, 0.8, 0]) cube([LUG_R + 0.8, LUG_W - 0.8, LUG_H]);                                  // tip, leading edge chamfered
                    translate([0.6, 0, 0]) cube([LUG_R + 0.2, LUG_W, LUG_H]);
                }
            // the mechanism, standing on the lid (Rev H: fin with groove, home stop)
            translate([0, 0, D_H]) screw_features();
        }
        translate([0, 0, D_H]) screw_cuts();
    }
}

// blank panel standing in for the screen until it arrives: same footprint as the module, slides into the
// same holder, with a raised face that fills the window flush with the outside and a hole for a 5 mm LED.
led_d = 5.2;
module screen_blank() {
    difference() {
        union() {
            cube([scr_board_l - 0.6, scr_board_t - 1, scr_board_h - 0.6]);                                         // body, the "board"
            translate([(scr_board_l - 0.6)/2 - scr_act_l/2, scr_board_t - 1 - 0.01, (scr_board_h - 0.6)/2 + scr_off_z - scr_act_h/2])
                cube([scr_act_l, D_WALL + 0.01, scr_act_h]);                                                           // face, fills the window
        }
        translate([(scr_board_l - 0.6)/2, -1, (scr_board_h - 0.6)/2 + scr_off_z]) rotate([-90,0,0]) cylinder(d = led_d, h = 20, $fn = 32);   // LED hole
        translate([(scr_board_l - 0.6)/2 - 6, -1, (scr_board_h - 0.6)/2 + scr_off_z - 6]) cube([12, scr_board_t - 1 - 1, 12]);   // pocket behind for the LED legs
    }
}

// mock-ups of the dock electronics for the preview
module dock_electronics(motor = true) {          // motor = false: the exploded view draws the stepper lifted out on its own
    color("#2f8f4e") translate([pi_c[0] - 15, pi_c[1] - 32.5, D_FLOOR + 3]) cube([30, 65, 1.6]);
    color("#222")    translate([pi_c[0] + 11, pi_c[1] - 25.5, D_FLOOR + 4.6]) cube([5, 51, 8.5]);                      // 40-pin header, pocket side
    color("#444")    for (py = [-28, -18]) translate([pi_c[0] - 15 - usb_plug, pi_c[1] + py, D_FLOOR + 3]) cube([usb_plug, 8, 6]);   // two micro-USB plugs
    color("#eee")    translate([pi_c[0] - 15 - 0.5, pi_c[1] + 16, D_FLOOR + 4.6]) cube([7.5, 11, 3.5]);                 // mini HDMI, on the board
    color("#2a6b3e") translate([drv_c[0] - drv_l/2, drv_c[1] - drv_w/2, D_FLOOR + so_h]) cube([drv_l, drv_w, 1.6]);     // ULN2003 driver board
    color("#8a5a2b") translate([drv_c[0] - 8, drv_c[1] - 8, D_FLOOR + so_h + 1.6]) cube([10, 16, 8]);                     // its chip and parts
    color("#eee")    translate([drv_c[0] + drv_l/2 - 7, drv_c[1] - 6, D_FLOOR + so_h + 1.6]) cube([6, 12, 9]);             // motor socket, right edge
    if (motor) translate([0, 0, D_H]) stepper_mock();
    color("#1c2a4a") translate([adp_c[0] - adp_l/2, adp_c[1] - adp_w/2, D_FLOOR + so_h]) cube([adp_l, adp_w, 1.6]);   // SEN6x STEMMA QT adapter
    color("#eee")    translate([adp_c[0] - 6, adp_c[1] - 2, D_FLOOR + so_h + 1.6]) cube([12, 6, 5]);
    color("#e33", 0.9) translate([led_x, D_FRONT - D_WALL - led_len, led_z]) rotate([-90, 0, 0]) cylinder(d = 5, h = led_len, $fn = 24);   // 5 mm LED
    if (front == "screen") {
        color("#151515") translate([-scr_board_l/2, D_FRONT - D_WALL - scr_board_t, scr_z0]) cube([scr_board_l, scr_board_t, scr_board_h]);                       // screen board
        color("#2a6fd6") translate([-scr_act_l/2, D_FRONT - D_WALL - 0.2, scr_z0 + scr_board_h/2 + scr_off_z - scr_act_h/2]) cube([scr_act_l, 0.3, scr_act_h]);   // active area
    }
    color("#d97a2b") translate([sen_face_x - sen_d, -sen_l/2, sen_z0]) cube([sen_d, sen_l, sen_w]);                // SEN6x, openings to +x
    color("#666")    difference() { translate([sen_face_x, -sen_l/2 - 1, D_FLOOR + 1]) cube([gasket_t, sen_l + 2, sen_w + 2]);
                       translate([sen_face_x - 1, -sen_l/2 + 2, sen_z0 + 1.5]) cube([6, 10, 20.5]);
                       translate([sen_face_x - 1, -sen_l/2 + 32, sen_z0 + 2]) cube([6, 21, 21]); }
    color("#999")    translate([sen_face_x - sen_d - foam_t, -20, D_FLOOR + 4]) cube([foam_t, 40, sen_w - 4]);
}
module conn_slot(h) { hull() for (sx = [-1, 1]) translate([sx*(conn_l - conn_w)/2, 0, 0]) cylinder(d = conn_w + 2*conn_clear, h = h, $fn = 24); }
module conn_body() { hull() for (sx = [-1, 1]) translate([sx*(conn_l - conn_w)/2, 0, 0]) cylinder(d = conn_w, h = conn_h, $fn = 24); }
module contact_screws() { color("#111") translate([conn_c[0], conn_c[1], -3]) conn_body(); }   // female half in the module floor (mock)

// =====================================================================
//  non-printed parts (for the preview only)
// =====================================================================
module servo_mock() {
    color("SteelBlue", 0.8) {
        translate([sv_body_x0, sv_top_y - sv_body_h, cz - sv_body_w/2]) cube([sv_body_l, sv_body_h, sv_body_w]);
        translate([sv_body_cx - (sv_tab_pitch + 4.5)/2, wall_y1, cz - 1.5]) cube([sv_tab_pitch + 4.5, sv_tab_t, 3]);
        translate([cx, sv_top_y, cz]) rotate([-90,0,0]) cylinder(d = 6, h = sv_gear_h + 0.5);
    }
}
module rods_and_tube(z_rod0) {      // z_rod0: bottom of the drive rod (flush with the carriage boss)
    color("Silver") {
        translate([0, 0, z_rod0]) cylinder(d = rod_d, h = rod_len, $fn = 24);
        translate([mt_c[0], mt_c[1], cpl_z0 + cpl_h - cpl_scr_depth]) cylinder(d = scr_d, h = scr_len, $fn = 32);   // T8x8 screw (drawn plain)
    }
    color("YellowGreen", 0.9) translate([0, 0, H - roof_t - tube_sleeve_h]) 
        difference() { cylinder(d = tube_od, h = tube_len + tube_sleeve_h + roof_t, $fn = 32); translate([0,0,-1]) cylinder(d = 3.2, h = 400, $fn = 32); }
}

module assembly(show_shell = true, cut = false, show_ls = true) {
    z_c = car_z0 + car_travel * pos;
    difference() {
        union() {
            color("#4a4744") translate([0, 0, -D_H]) dock_base();
            color("#5a5754") translate([0, 0, -D_H]) dock_lid();
            translate([0, 0, -D_H]) dock_electronics();
            color("SeaGreen")  translate([0, 0, z_c]) carriage();
            if (show_ls) { color("Tomato") translate([0, 0, z_c]) ls_flag(); ls_parts(); }
            color("DarkOrange") translate([mt_c[0], mt_c[1], cpl_z0]) coupler();
            stepper_mock();
            rods_and_tube(z_c - car_boss_drop);
            if (show_shell) color("Wheat", cut ? 0.5 : 0.35) shell();
            if (show_shell) color("#5a4636", cut ? 0.6 : 0.5) translate([0, 0, H]) dish();
            if (show_shell && rim_style == "band") color("#c8794a", cut ? 0.6 : 0.5) rim_band();
        }
        if (cut) translate([-200, 1, -60]) cube([400, 400, 500]);   // remove everything in front of the rod axis
    }
}

// =====================================================================
//  output selector — each printed part is emitted in its print orientation
// =====================================================================
if (part == "shell")    translate([0, 0, H]) rotate([180, 0, 0]) shell();        // inverted: roof on the bed
if (part == "module")   translate([0, 0, 3]) mech_module();                        // legacy Rev E floor, floor on the bed
if (part == "arm")      translate([0, 0, -arm_y]) rotate([90, 0, 0]) arm();       // horn pocket on the bed, pin up
if (part == "arm_direct") translate([0, 0, -(arm_y - horn_t)]) rotate([90, 0, 0]) arm_direct();   // spline bore on the bed, pin up
if (part == "spline_coupon") spline_coupon();
if (part == "yoke")     translate([0, 0, -yoke_y]) rotate([90, 0, 0]) yoke();     // plate on the bed, slot walls vertical
if (part == "dish")     dish();                                                   // open side up
if (part == "rim_band") translate([0, 0, band_zt]) rotate([180, 0, 0]) rim_band();                    // top down on the bed, skirt up (rotated, not mirrored: the letters must read)
if (part == "dock_base") dock_base();                                              // floor on the bed; rubber feet stuck on afterwards
if (part == "dock_lid")  translate([0, 0, -(D_H - D_LID)]) dock_lid();              // flat, mechanism and ring up
if (part == "dock_lid_flat") translate([0, 0, -(D_H - D_LID)]) dock_lid_flat();   // box model: plain plate
if (part == "dock_floor") { color("#4a4744") dock_base(); dock_electronics(); }
if (part == "bayonet") { color("#5a5754") dock_lid(); color("Wheat", 0.6) translate([0,0,D_H]) shell(); }
if (part == "coupon")   coupon();
if (part == "carriage") translate([0, 0, car_nut_h]) rotate([180, 0, 0]) carriage();   // Rev H: sleeve top on the bed, rod boss up, thread vertical
if (part == "coupler")  coupler();
if (part == "shaft_coupon") shaft_coupon();                                            // fit test for the coupler pocket, bed side = pocket side                                                    // Rev H: pockets vertical
if (part == "stop_spacer") stop_spacer();
if (part == "ls_bracket") translate([0, 0, -br_x0]) rotate([0, -90, 0]) ls_bracket();     // limit switches: back face on the bed
if (part == "ls_flag") translate([0, 0, fin_h]) ls_flag();                                                         // flat
if (part == "ls_view") { color("#5a5754") translate([0,0,-D_H]) dock_lid(); for (p = [0, 1]) translate([0,0,car_z0 + car_travel*p]) { color("SeaGreen", 0.6) carriage(); color("Tomato") ls_flag(); } ls_parts(); }                                              // bench fix for lids with the 7.5 mm ring
if (part == "stem_spigot") stem_spigot();                                              // upright, bore vertical, PETG (fixed barb)
if (part == "stem_gland") stem_gland();                                                // upright, PETG (adjustable clamp, the insert)
if (part == "stem_gland_nut") stem_gland_nut();                                        // upright, PETG (its nut)
if (part == "screen_blank") rotate([90, 0, 0]) translate([0, -(scr_board_t - 1 + D_WALL), 0]) screen_blank();   // face down on the bed
if (part == "assembly") assembly();
if (part == "cutaway")  assembly(true, true);
if (part == "noshell")  assembly(false);
