January 2, 2026 · internals · 9 min read

Inside the geometry engine: how Molenspin places gear teeth

When I first wrote the SVG exporter in 0.1.0, I traced tooth profiles by hand — a literal list of points I'd computed in a spreadsheet for one specific module and pressure angle. It looked fine for the demo, but the first time someone tried a module-1.5 gear with a 25° pressure angle it produced something that looked like a cartoon monster's mouth. This post is about how I replaced that with a proper involute geometry engine.

What an involute profile actually is

An involute curve is the path traced by the end of a taut string unwinding from a circle (the base circle). The key property that makes it useful for gears is that two involute profiles in contact always share a common tangent line — the line of action — regardless of the exact center distance between the gears. This makes the transmission ratio insensitive to small mounting errors, which is why involute gears took over from cycloidal gears in the 18th century.

Parametrically, a point on an involute at angle t from the base circle is:

x(t) = r_b * (cos(t) + t * sin(t))
y(t) = r_b * (sin(t) - t * cos(t))

where r_b = r_pitch * cos(pressure_angle) is the base circle radius.

The Rust implementation

The geometry module lives in src/geometry.rs. For each gear, it computes a tooth outline as a sequence of 2D points:

// Simplified sketch of tooth_outline() in geometry.rs
pub fn tooth_outline(teeth: u32, module: f64, pressure_angle_deg: f64) -> Vec<[f64; 2]> {
    let pa = pressure_angle_deg.to_radians();
    let r_pitch = module * teeth as f64 / 2.0;
    let r_base  = r_pitch * pa.cos();
    let r_tip   = r_pitch + module;          // addendum = 1 module
    let r_root  = r_pitch - 1.25 * module;   // dedendum = 1.25 module

    // sample involute from base circle to tip circle
    let t_tip = ((r_tip / r_base).powi(2) - 1.0).sqrt();
    let pts: Vec<_> = (0..=24)
        .map(|i| i as f64 / 24.0 * t_tip)
        .map(|t| {
            let x = r_base * (t.cos() + t * t.sin());
            let y = r_base * (t.sin() - t * t.cos());
            [x, y]
        })
        .collect();
    // mirror, add fillet, rotate for each tooth...
    pts
}

The fillet at the root is a circular arc that blends the involute flank into the root circle. Getting this right took three iterations — the first version left a sharp corner that caused the constraint solver to detect spurious collision events during contact.

Contact detection

Contact between two gears is detected by checking whether the tip circle of gear A intersects the path of action for gear B. The path of action is a straight line through the pitch point at the pressure angle. If the tip circle crosses this line within the base circle of B, the contact is valid; if not, there's interference (undercutting).

Molenspin raises a GeometryWarning rather than an error on interference, because marginal interference is common in fine-pitch gears and many users are intentionally modeling worn or non-standard assemblies.

Fixed-frame constraints and the windmill pivot

One thing that surprised me about the windmill model was the fixed-frame constraint for the intermediate shaft bearings. In a real windmill, the vertical shaft (the one driven by the sails) rotates around a fixed vertical axis. That's easy. But the brake wheel — the large horizontal gear on the vertical shaft — meshes with the wallower, a small gear on a horizontal shaft. The wallower shaft has to be anchored at a specific position relative to the frame.

In Molenspin, you express this as a PivotConstraint:

asm.add_constraint(ms.PivotConstraint(
    shaft=wallower_shaft,
    position=(0.0, -650.0),   # mm from frame origin
    fixed_angle=True,          # prevents the shaft from orbiting the pivot
))

The constraint solver treats this as two scalar equations (x-position and y-position), which reduces the system's degrees of freedom by two. Without it, the wallower would float freely and the assembly would be underdetermined.

SVG path generation

The final step is converting the point lists into SVG path elements. Each gear tooth is a single <path> with cubic Bézier segments fitted to the involute samples. I use the standard chord-length parameterization for the Bézier fit — it's not the most accurate method but it's fast enough and the visual error at the default 24-point sample rate is under 0.01 mm at module 2.

The animation is handled by SVG animateTransform elements on each gear group — no JavaScript required. This keeps the exported files self-contained and means they display correctly in any SVG viewer, not just browsers.