Tilt worm drive, real tooth geometry
Real involute worm-drive geometry replacing the readable placeholders
(gear_disc / worm in src/build.py). Generated by
tools/gears/gen_worm_drive.py, verified by tools/gears/verify_mesh.py.
Files (both watertight, mesh.is_volume == True):
stl/neck/worm_wheel_real.stl: 12T helical involute wheel, axis X, centered at originstl/neck/tilt_worm_real.stl: single-start trapezoidal-thread worm, axis Y, centered at origin
The numbers
| Quantity | Value | Why |
|---|---|---|
| Module | 1.25 | PARAMS["worm_module"] |
| Wheel teeth | 12 | PARAMS["worm_wheel_teeth"] (ratio 12:1) |
| Pressure angle | 25° (not 20°) | 12T undercuts at 20° (limit 17.1T); at 25° the limit is 11.2T, so zero profile shift and full-strength roots. Both members are printed as a matched pair, so the nonstandard PA costs nothing. |
| Wheel pitch r | 7.500 | m·z/2 |
| Wheel tip r | 8.250 | stub addendum 0.6m = 0.75 (see core constraint below) |
| Wheel root r | 6.475 | worm addendum 0.875 + 0.15 clearance below pitch |
| Wheel face width | 7.0 | PARAMS["worm_wheel_w"] |
| Wheel bore | Ø5.2 through | axle Ø5 + clearance (blank only: no hub/tubes/grub, build.py adds those) |
| Wheel helix angle | 8.085° right-hand | must equal the worm lead angle (crossed 90° axes); a spur wheel would clash ~0.5 mm at the face edges |
| Worm starts | 1 | self-locking |
| Worm pitch r | 4.400 | derived, see below |
| Worm tip r / OD | 5.275 / 10.55 | worm addendum 0.7m = 0.875 |
| Worm root r / core | 3.500 / Ø7.00 | wall for the double-D bore build.py cuts |
| Worm thread length | 14.0 | PARAMS["worm_len"], exactly ±7 about origin |
| Axial pitch = lead | 3.92699 | π·m, fixed by the wheel |
| Lead angle λ | 8.085° | atan(lead / (2π·4.4)) |
| Center distance | 11.900 | 7.5 + 4.4 (vs worm_cd() = 11.5 → +0.40 mm, see below) |
| Backlash | 0.25 circular | all taken on the wheel tooth (thinned at the pitch line) |
| Worm crest width | 1.147 mm | printable (> 0.6) |
| Wheel tip tooth width | ~0.97 mm | printable |
Why CD is 11.9, not worm_cd()'s 11.5
Three constraints can't coexist: module 1.25 (fixes lead at 3.927), the Ø7
solid worm core (root r ≥ 3.5), and CD 11.5 (worm pitch r 4.0). With pitch r
4.0 the whole thread depth budget below the pitch line is 0.5 mm, leaving the
wheel ~0.35 mm of usable addendum, no real tooth engagement. Resolving in
favor of meshing physics: worm pitch r = root 3.5 + wheel addendum 0.75 +
clearance 0.15 = 4.4, so CD = 11.9. The worm axis drops 0.4 mm relative
to the axle; worm_cd() is the single source of truth for the bracket AND the
assembly, so it propagates as a one-line change.
Why the lead angle is 8.1°, not < 4–5°
At module 1.25 single-start, λ = atan(3.927 / (π·d_pitch)). Hitting λ ≤ 4.5° needs worm pitch Ø ≥ 16 → CD ≥ 15.5, blowing both the OD and the bracket. The < 4–5° rule is for steel-on-steel (μ ≈ 0.07–0.10). Self-locking requires μ > tan λ · cos α = 0.129; printed PLA-on-PLA runs μ ≈ 0.3–0.4 dry, so the drive self-locks with 2–3× margin. Caveat: heavy grease (μ ≈ 0.1) makes it marginal, if lubricating, use a light dry film or rely on the balanced head (near-zero back-drive torque by design; CLAUDE.md: "pre-balance the head so the worm barely works").
Verification results (2026-07-07)
- Both STLs:
is_watertight = True,is_volume = True. Wheel bounds ±(3.5, 8.25, 8.25); worm bounds ±(5.275, 7.0, 5.275). Pitch radii check out (tip r = pitch r + addendum on both members). - Static mesh check (wheel at origin axis X, worm axis Y at z = −11.9, wheel phase scanned over one tooth pitch): minimum boolean intersection 0.0000 mm³ (placeholder was < 30 mm³); worst deliberately-misaligned phase 16.1 mm³ (tooth-on-crest, as expected).
- Engagement proof (zero overlap ≠ floating apart): shrinking CD by 0.10 mm produces first flank contact (0.003 mm³), 0.30 mm gives 0.41 mm³, flanks sit ~0.1 mm apart at nominal CD, i.e. exactly the designed backlash.
- Hand check: opposite-hand wheel helix can't find a zero-interference phase (min 0.75 mm³); the exported right-hand pair can.
- Visual:
.claude/renders/worm_mesh_section.png/worm_mesh_zoom.png(x=0 section, pitch circles tangent, crests centered in gaps, no crossings).
How to regenerate
cd tools/gears
uv venv .venv --python 3.10 # any >= 3.10; system python3 is 3.9
uv pip install --python .venv/bin/python -r requirements.txt
cd ../.. # run from the repo root (output paths)
tools/gears/.venv/bin/python tools/gears/gen_worm_drive.py
PYTHONPATH=tools/gears tools/gears/.venv/bin/python tools/gears/verify_mesh.py
Route note: bd_warehouse (the "preferred" build123d route) only generates spur
gears, no helical wheel, no worm, so the pair is generated from first
principles: numpy involute outline → manifold3d twisted extrude
(Manifold.extrude(..., twist_degrees=7.596)) for the wheel; a trapezoidal
rib swept along the helix + core cylinder, manifold-booleaned and trimmed, for
the worm. OpenSCAD/BOSL2 is not installed on this machine.
Integration notes for src/build.py (not applied, do this in a follow-up)
worm_cd()(line ~420): returnP["worm_module"] * P["worm_wheel_teeth"] / 2 + 4.4(= 11.9). Cleaner: addPARAMS["worm_pitch_r"] = 4.4and use it. Everything keyed offworm_cd()(bracket, cradle, motor pose,wz) moves 0.4 mm down automatically. UpdatePARAMS["worm_od"]10.0 → 10.55 for honesty (it only feeds the placeholder radius today). The cradle's Ø11.8 thread-relief bore still clears: new tip r 5.275 < the 5.34 it was sized for.- Wheel (line ~1703): replace
wheel = gear_disc(wheel_r, ..., axis="x")withwheel = trimesh.load(stlp("worm_wheel_real.stl"), force="mesh"). Keep everything after it unchanged: the hub/spacer-tube union, the Ø5.2 bore re-cut (harmless, the blank already has it), and the grub pilot all still apply. The blank is centered at origin, axis X, exactly likegear_discoutput. - Worm (line ~1720): replace
wm = worm(P["worm_od"] / 2, P["worm_len"], axis="y")withwm = trimesh.load(stlp("tilt_worm_real.stl"), force="mesh"). Keep the tail-stub union and thedbore_negsubtraction (flats ±X) unchanged. Core is Ø7.0, so the Ø~5.2 D-bore leaves a 0.9 mm wall, thin but adequate; the D-flats carry the torque. The thread span is exactly 14 mm (the placeholder's ribs overhung 1 mm per end; envelope shrinks slightly). - Render phase: tooth/thread clocking now matters visually. The wheel's
meshing phase depends on the worm's y-offset (the wheel plane crosses the
worm at worm-local y ≈ +6 in the assembly). To render without fake
interference, scan the wheel rotation about X over one tooth pitch (30°)
for minimum boolean intersection and bake that phase in, see
mesh_check()intools/gears/gen_worm_drive.pyfor the exact snippet.EXPORT=1should export these real parts under the existingworm_wheel/tilt_wormnames (they're print/buy parts now, not placeholders). - Print notes: wheel prints on its face (teeth are near-vertical walls, 7.6° twist is fine); worm prints vertical (axis Z on the bed): the 25° flank angle is self-supporting. Grease the mesh lightly; design clearance doubles as the reservoir.
3-start regeneration record (2026-07-13)
PARAMS worm_starts = 3 (ratio 12:1 -> 4:1, 22.5 deg/s tilt; tradeoff notes in
params.py / CLAUDE.md). The committed *_real.stl pair IS the 3-start generation:
same m 1.25 / pitch r 4.4 / CD 11.9 / 12T wheel / PA 25 deg; lead 11.781, lead angle
23.081 deg (NOT self-locking), wheel helix matched (22.79 deg twist over the 7 mm
face), backlash 0.25 circular, crest width 1.147 (printable). OD 10.55 / core Ø7
unchanged so cradle/carrier interfaces carry over. Verification: static min
intersection 0.000 mm3 at CD 11.9; coupled full-worm-rev sweep (wheel follows at 3
teeth/rev) max 0.000 mm3; contact onset between CD-0.05 and CD-0.10; wrong-hand wheel
7.41 mm3 (direction confirmed). Assembly clocking constant in build.py: 24.5 -> 17.75.
gen_worm_drive.py reads STARTS from PARAMS and writes stl/neck/worm_real_meta.json;
worm_real_ok() (src/gears.py) compares meta to PARAMS and falls back to placeholders
on ANY mismatch -- the honesty gate is the sidecar now, not worm_starts != 1.
FDM caveat: flank gap measures ~0.05-0.10 radial (tighter than the pan spurs); confirm
the first printed pair doesn't bind at +0.1 oversize before greasing.
worm_len 13 + end-chamfer regeneration (2026-07-13 evening, Pi 5 cooler pass)
PARAMS worm_len = 13.0 (was 14.0) and the generator grew a 0.6 mm 45 deg crest
END CHAMFER on both thread ends (END_CH in gen_worm_drive.py, implemented as a
per-ring radius clamp in the helix sweep, recorded in the meta as end_chamfer).
Why: at the -33.8 nose-down tilt stall the Pi 5 active-cooler keep-out's swung rear
face reaches y = -20.0 + 0.669(z - 131.5); the old front thread end (y -17, crest
bottom z 135.8) penetrated ~0.1 and the Ø5 tail stub past it +2.7. New span: world
y -30.5..-17.5 (full crest to -18.1), stall margins +0.81 full-crest end / +0.64
chamfer edge (tools/probe_cooler.py reports tilt_worm -0.61). The wheel contact
plane (y -18) keeps 0.5 mm of full flank past it; the rear trim (-31 -> -30.5) is
dead thread (wheel-envelope overlap ends ~-22.9). WORM_LEN now reads from PARAMS
(single source of truth). Assembly knock-on: worm center stays y -24 (build.py's
plate-face offset 9.5 -> 10.0 and thread-start gap 3.5 -> 4.0, so plate/motor/
carrier/can-pocket all HOLD at face_y -34.5); the TAIL STUB union in build.py is
GONE -- the tail is now carried by a crest-riding r5.5 half-groove in the neck
(build_neck_clevis), riding the thread crest envelope at 0.225 radial clearance.
Verification (same tooling): both meshes watertight; static min intersection 0.000
mm3 at CD 11.9 (worst deliberate misphase 16.5); hand check wrong-hand 7.40 mm3;
contact onset between CD-0.05 and CD-0.10 (unchanged); coupled full-worm-rev sweep
0.000 mm3; assembly-pose zero window center 17.88 deg (build.py clocking
constant, was 17.75). Wheel blank unchanged (byte-identical regeneration).
Wallcheck: tilt_worm.stl p1 0.25-0.27 = thread RUN-OUT FEATHERS where the helical
rib meets the flat trim faces (machined worms carry the same edge unless a run-out
relief is cut) + crest grazing rays; unloaded dead-thread ends, prints vertical
(bottom wedge on the bed, top wedge tip-rounded by the slicer) -- dispositioned as
a documented whitelist floor of 0.2 in src/wallcheck.py, not a generator defect.
Fallback single-start pair (self-locking)
The 3-start pair remains the default because it delivers about 22.5 deg/s, but its tilt torque margin is only about 1.3x and its 23 deg lead back-drives. The assumed residual imbalance is about 25 mNm, while the physical head could exceed 100 mNm. The committed 1-start pair restores mechanical self-locking and is a drop-in for the same cartridge, center distance and axle. Physics decides between them on the bench.
Regenerate and verify the fallback from the repository root with:
STARTS=1 OUT_SUFFIX=_1s tools/gears/.venv/bin/python tools/gears/gen_worm_drive.py
STARTS=1 OUT_SUFFIX=_1s PYTHONPATH=tools/gears tools/gears/.venv/bin/python tools/gears/verify_mesh.py
STARTS=1 OUT_SUFFIX=_1s PYTHONPATH=tools/gears tools/gears/.venv/bin/python tools/gears/probe_worm_sweep.py
The sidecar records starts 1, module 1.25, 12 wheel teeth, worm pitch radius 4.4, face width 7.0 and worm length 13.0. The 2026-07-16 verification reported both meshes watertight and volumetric, correct-hand minimum 0.0000 mm3 at 10.5 deg, wrong-hand minimum 0.7457 mm3, and contact onset at CD-0.10 with 0.0026 mm3. The assembly-pose probe found a 9/121-step zero window centered at 25.50 deg. The correct coupled direction, sign -1, stayed at 0.0000 mm3 penetration over 49 steps and one full worm revolution. Sign +1 deliberately fails because it is the wrong kinematic direction.
To swap, set only PARAMS["worm_starts"] = 1, rebuild, and print only
tilt_worm.stl and worm_wheel.stl. The cartridge, center distance and axle stay
unchanged. Tilt speed drops from 22.5 to about 7.5 deg/s and the ratio changes from
4:1 to 12:1, restoring self-locking. The starts-keyed filenames and matching meta
honesty gate prevent stale geometry from loading. With a stalled or dead motor,
drive the head fully up before pulling the cartridge, the old extraction rule for
the self-locking pair applies again.
Pan spur pair (2026-07-13)
tools/gears/gen_pan_spurs.py: m 0.8, PA 20 deg, 32T (width 5.0, D-bore cut in
build.py on the motor shaft flats) + 16T (width 5.5, fused into the pan_platform
shank), CD 19.2, backlash 0.20 circular split 0.10/member, full-depth teeth (tips
13.6 / 7.2, roots 11.8 / 5.4). 16T undercut is a non-issue against a 32T mate (pair
limit ~14.2T). Verification: static 0.000 mm3, zero window centered 5.625 deg (half a
32T pitch, stored in the meta as gear32_mesh_deg), coupled sweep over one pinion
pitch 0.000 mm3. Cluster reach 32.8 <= 33.0 budget (race ID 34); deck gear pocket runs
0.9 clear. Meta sidecar stl/neck/pan_gears_real_meta.json, gated by pan_real_ok().
NOTE: the 32T clocking assumes pan_shaft_azim = 180 -- re-derive if the azimuth moves.