Derive the mechanism instead of animating it
I asked Claude to build a continuously variable transmission as a single HTML file: the real push-belt kind, photoreal, interactive, and small enough to sit in a 400 by 400 box on a page. It came back working. The render was not the interesting part. The interesting part was a decision made in the first ten minutes that turned "does this look right" into a question with a numerical answer.
The part of a CVT that changes the ratio has no gears in it at all. It has two pulleys, and each pulley is a pair of cones facing each other with a steel belt wedged into the V between them. The cones are called sheaves. Squeeze a pair together and the belt is forced out to a larger radius. Pull them apart and it sinks inward. Two pairs of sheaves, one belt, and every ratio in between.
The shortcut that would have looked fine
The obvious way to animate that is to keyframe it. Take a slider from 0 to 1. At 0 the drive pulley is open this far and the belt rides at that radius, at 1 the reverse, interpolate everything in between.
It would have looked fine. It would also have been wrong in the worst available way, which is wrong without a symptom. Keyframed, the belt changes length as the ratio sweeps, because nothing holds it fixed. The sheave gap and the belt radius drift out of agreement, because nothing ties them together. Parts intersect at ratios you never happened to screenshot. The render still reads as a transmission. It just is not one, and nothing in the picture tells you which.
Three relations
A real variator has one input, and everything else follows from it. Writing down what follows from what is most of the work.
Each sheave face sits at 11 degrees to the radial plane, so the axial gap between a facing pair, at radius r, is:
gap(r) = D + 2 * r * tan(11 degrees)D is the separation of the two cones. A belt element is a wedge cut to the same 11 degrees, so it settles wherever the gap matches its own width W. Both grow at the same rate with radius, which is why the belt bears along its whole flank instead of pinching on a line. Rearranged, the radius the belt sits at stops being a free parameter:
r = (W - D) / (2 * tan(11 degrees))One axial degree of freedom per pulley. Move the sheave and the radius is decided for you.
The belt cannot stretch, so the two radii are not independent either. Its length is the two wrap arcs plus the two straight runs between them:
function beltLength(r1, r2) {
const d = Math.abs(r1 - r2)
const a = Math.asin(d / CENTRE)
const big = Math.max(r1, r2)
const small = Math.min(r1, r2)
return big * (Math.PI + 2 * a) + small * (Math.PI - 2 * a)
+ 2 * Math.sqrt(CENTRE * CENTRE - d * d)
}That rises monotonically with r1 at a fixed ratio, so a bisection finds the pair of radii that hits a target ratio while holding the length fixed. This is the relation that makes the driven pulley open by exactly as much as the drive pulley closes. Not approximately, and not because someone tuned two curves until they looked synchronised.
The third relation is the easy one. The belt runs at a single linear speed, so the output follows from the two radii. Hold the input at a constant rpm and the output rpm is not animated, it is read off.
What derivation buys
Once the mechanism is derived, correctness stops being a matter of opinion. Every clearance in the assembly is a formula over the same ratio parameter, so the model can report them, and a test can read them.
Sweeping the full range and printing the clearances looks like this:
ratio 2.333 1.003 0.430
drive radius 30.0 mm 51.6 mm 70.0 mm
driven radius 70.0 mm 51.7 mm 30.1 mm
input 7.64 rpm 7.64 rpm 7.64 rpm
output 3.27 rpm 7.62 rpm 17.77 rpm
belt length 634.5 mm 634.5 mm 634.5 mm
min clearance 1.30 mm 1.40 mm 1.35 mmThe belt length column is the one I would have got wrong by hand. It stays constant to the limits of double-precision arithmetic across the sweep, which is the solve working rather than a coincidence. The input column staying flat while the output column moves is the entire point of the device, stated as data.
There is a free check hiding in the middle column too. At exactly 1 to 1 the belt plane should sit dead centre, because the two pulleys are mirror images at that ratio. It lands at 0.02 mm with zero misalignment. Nobody wrote that number down anywhere. It falls out, and if the axial layout algebra were wrong it would not.
The bug that only exists at full stroke
The first actuator design put the hydraulic cylinder on the shaft and let the movable sheave slide toward it. Every clearance I checked was comfortable.
I had checked the wrong end of the travel.
The sheave sweeps 15.5 mm, and it sweeps toward the cylinder. At the far end of that stroke the sheave body and the cylinder wall want the same space. Worse, there is no way to park the piston that fixes it: the piston has to sit further back than the sheave's own rear face ever reaches, and by then the cylinder mouth has been driven through the sheave. The arrangement has no solution, not a bad parameter.
The fix is what production units actually do. Put the cylinder on the movable sheave and fix an annular piston plate to the shaft inside it. Now the cylinder and the sheave are rigidly joined, so they cannot collide by construction, and the only remaining requirement is that the plate stays inside the bore. That is one inequality instead of an impossible pair.
The clearance report is what surfaced it, as a negative number, long before anything looked wrong in a render. The general version of the lesson is small and I keep forgetting it: when a moving part sweeps toward a fixed one, check the end of travel that closes the gap, not the one that opens it.
The belt looks wrong and is right
A push belt is a chain of loose steel blocks held on a path by two packs of thin steel rings. The blocks carry the load in compression, which is the part that surprises people: the belt pushes rather than pulls.
Each block has a relief angle below its rocking edge, and that angle is not styling. On a wrap of radius R, adjacent blocks only have so much angular room, so the block has to be thinner than that room everywhere down its height. Work it through and the relief has to be at least the element pitch divided by the smallest running radius. At 2 mm pitch and a 30 mm minimum radius, that is 3.8 degrees.
The consequence looks like a defect. On the straight runs there is no wrap at all, so the relief has nothing to close it and opens a visible wedge gap between every pair of blocks. The belt reads as a comb. My first instinct was to close it up, which would have meant removing the relief, which would have meant the blocks driving into each other at the small radius. The gap is about 1 mm against a 1.85 mm block, and it is not a styling choice I get to make. This is the kind of detail where the correct model and the expected picture disagree, and the model is right.
Measuring what the GPU cannot tell you
The last part worth copying is how any of this got checked. The build ran in headless Chromium, which on a server has no GPU and falls back to software rasterisation. It reported 1.2 frames per second for a scene that is trivial on real hardware. That number is worth nothing.
So measure what does not depend on the GPU under test. The per-frame CPU cost of rebuilding the belt, 317 instance matrices and about 4000 vertices, came out at 0.145 ms, which is under one percent of a 60 fps budget. Draw calls, triangle count and texture count came straight from the renderer. Those travel; the frame rate on a software rasteriser does not.
The other trick I will reuse: the page pulls Three.js from a CDN, and the browser in that container would not trust the proxy certificate. Rather than turning off certificate checking, the test intercepts that one request and serves a local copy whose hash matches the live file. It exercises the real cross-origin module path, and because the catch-all route aborts everything else, it also proves the page makes no other external request.
What I keep
None of this is about transmissions. The same shape shows up whenever a model has parts that have to agree: a layout engine, a scheduler, a pricing table, anything with an invariant nobody is enforcing.
If you can write down the relation that makes a design correct, write it down and let the code derive the rest. Then correctness is a number you can print, and the bugs you cannot see are the ones that show up first.