Parviz · Design docs

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):

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)

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)

  1. worm_cd() (line ~420): return P["worm_module"] * P["worm_wheel_teeth"] / 2 + 4.4 (= 11.9). Cleaner: add PARAMS["worm_pitch_r"] = 4.4 and use it. Everything keyed off worm_cd() (bracket, cradle, motor pose, wz) moves 0.4 mm down automatically. Update PARAMS["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.
  2. Wheel (line ~1703): replace wheel = gear_disc(wheel_r, ..., axis="x") with wheel = 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 like gear_disc output.
  3. Worm (line ~1720): replace wm = worm(P["worm_od"] / 2, P["worm_len"], axis="y") with wm = trimesh.load(stlp("tilt_worm_real.stl"), force="mesh"). Keep the tail-stub union and the dbore_neg subtraction (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).
  4. 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() in tools/gears/gen_worm_drive.py for the exact snippet. EXPORT=1 should export these real parts under the existing worm_wheel / tilt_worm names (they're print/buy parts now, not placeholders).
  5. 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.