Transmission geometry
Where the flexible thing goes, and where the teeth are: gear outlines and mesh phase, assembly-valid planetary trains, taut belt paths, and an energy chain over its bend.
view
variant
drive
housing
sun16T
planet12T
planets3
Drag round the centre to wind the input shaft. The ring never moves.
Theming
Set a role and the same CSS goes in your own app — every robot under it follows.
Install
bunx --bun shadcn@latest add https://robocn.dev/r/transmission-geometry.jsonNotes
- Invariants the tests hold it to: a planetary train only comes back if it assembles and its reduction is exactly 1 + ring/sun; meshing gears counter-rotate at −N₁/N₂ and present a tooth to a space at the line of centres; a belt round two equal pulleys is 2 × centres + 2πr and the segments always add to the total; and a chain keeps its link count and pitch at every travel while its bend moves at exactly half the carriage.
- No dynamics anywhere: no torque, no belt tension, no backlash, no friction, no efficiency.
- Pure functions over plain objects. No React, no dependencies beyond the vector helpers.
Usage
import { planetaryTrain, planetaryPose, beltLayout } from "@/lib/robocn/transmission"
const train = planetaryTrain(18, 12, 3) // { sun: 18, planet: 12, ring: 42, ratio: 3.33 }
const pose = planetaryPose(train, 140) // carrier, planet bearings and spins, held ring
const belt = beltLayout([{ x: 0, y: 0, radius: 20 }, { x: 70, y: 0, radius: 8 }])
belt.length // spans plus wrapsAPI
| Prop | Type | Default | Description |
|---|---|---|---|
| gearPath | (teeth, pitchRadius, options?) => string | — | One gear's outline, tooth zero centred on the +x axis so a group rotate() is the gear's own angle. With a rim, the teeth point inward and the path is an annulus to be filled even-odd: a ring gear. |
| meshAngle | (driverTeeth, driverAngle, drivenTeeth, bearing, internal?) => number | — | The angle that puts the driven gear's teeth in the driver's spaces, given where it sits. Differentiating it gives the ratio: −N₁/N₂ externally, +N₁/N₂ for a pinion inside a ring. |
| planetaryTrain | (sunTeeth, planetTeeth, planets) => PlanetaryTrain | — | Tooth counts that actually assemble: ring = sun + 2 × planet, (sun + ring) divisible by the planet count, and planets capped so neighbours clear. Returns the reduction 1 + ring/sun. |
| planetaryPose | (train, sunAngle) => PlanetaryPose | — | Where every member of a fixed-ring train stands: the carrier at the reduction, each planet's bearing and spin, and the ring — which comes out constant, as a held ring must. |
| beltLayout | (pulleys) => BeltLayout | — | The taut path round a loop of pulleys taken in order: external tangents, a wrap arc on each, the total length and one path string. Two equal pulleys give 2 × centres + 2πr. |
| beltSample | (layout, distance) => { x, y, heading } | — | A point on the belt and the direction it is travelling, at an arc length. Wraps whole laps, which is how belt teeth march. |
| carrierLinks | (travel, geometry) => CarrierPose | — | An energy chain folded over itself: link placements at constant pitch along a fixed-length path, plus where the fold sits — at half the carriage, because the length cannot change. |
Source
src/lib/robocn/transmission.ts
/**
* robocn — transmission geometry.
*
* Where the flexible thing goes, and where the teeth are. Four mechanisms that
* every machine shop drawing has and that nothing else in the set solves: a
* gear's outline, the phase that makes two gears mesh, the taut path of a belt
* around a set of pulleys, and an energy chain folded over its own bend.
*
* Pure functions over plain objects. No React, no dependencies, no dynamics —
* there is no torque, tension, backlash or friction here, only geometry.
*/
import { clamp, toRadians, type Vec2 } from "@/lib/robocn/kinematics"
/* -------------------------------------------------------------------------- */
/* gears */
/* -------------------------------------------------------------------------- */
/**
* Angles are SVG degrees: clockwise from the +x axis, so a value handed to
* `rotate()` and a value handed to these functions mean the same thing.
*/
const point = (cx: number, cy: number, radius: number, degrees: number): Vec2 => {
const a = toRadians(degrees)
return { x: cx + Math.cos(a) * radius, y: cy + Math.sin(a) * radius }
}
const round = (value: number) => Number(value.toFixed(3))
export interface GearPathOptions {
/** How far the tooth tip stands above the pitch circle. Default `radius/12`. */
addendum?: number
/** How far the root falls below it. Default `addendum * 1.2`. */
dedendum?: number
/** Teeth pointing inward from a rim of this radius: a ring gear. */
rim?: number
/** Tooth width at the tip, as a fraction of the circular pitch. */
width?: number
}
/** Teeth are clamped to a range that can actually be drawn and meshed. */
export const gearTeeth = (teeth: number) =>
Number.isFinite(teeth) ? clamp(Math.round(teeth), 6, 160) : 12
/**
* One gear's outline: `teeth` trapezoidal teeth on a pitch circle, tooth zero
* centred on the +x axis so a group `rotate()` is the gear's own angle.
*
* With `rim`, the teeth point inward and the path is an annulus — the rim
* circle plus the toothed bore, wound so `fill-rule="evenodd"` cuts the bore
* out. That is the ring gear of a planetary set.
*/
export function gearPath(teeth: number, pitchRadius: number, options: GearPathOptions = {}) {
const count = gearTeeth(teeth)
const radius = Number.isFinite(pitchRadius) ? Math.max(1, pitchRadius) : 10
const addendum = Math.max(0.2, options.addendum ?? radius / 12)
const dedendum = Math.max(0.2, options.dedendum ?? addendum * 1.2)
const width = clamp(options.width ?? 0.42, 0.15, 0.48)
const inward = options.rim !== undefined
// A ring gear's teeth grow inward from the pitch circle, so the tooth sits
// at the smaller radius and the root at the larger one.
const tip = radius + addendum
const root = radius - dedendum
const outer = inward ? root : tip
const inner = inward ? tip : root
const pitch = 360 / count
const tipHalf = pitch * width
const rootHalf = pitch * 0.5
const corners: Vec2[] = []
for (let i = 0; i < count; i += 1) {
const centre = i * pitch
// Root, flank up, across the tip, flank down, root again.
corners.push(point(0, 0, inner, centre - rootHalf + pitch * 0.06))
corners.push(point(0, 0, outer, centre - tipHalf))
corners.push(point(0, 0, outer, centre + tipHalf))
corners.push(point(0, 0, inner, centre + rootHalf - pitch * 0.06))
}
const bore = `${corners
.map((p, i) => `${i ? "L" : "M"} ${round(p.x)} ${round(p.y)}`)
.join(" ")} Z`
if (!inward) return bore
const r = round(Math.max(radius + addendum + 1, options.rim ?? radius * 1.18))
return `M ${r} 0 A ${r} ${r} 0 1 0 ${-r} 0 A ${r} ${r} 0 1 0 ${r} 0 Z ${bore}`
}
/**
* The angle the driven gear has to stand at for its teeth to sit in the
* driver's spaces, given where it is: `bearing` is the direction from the
* driver's centre to the driven gear's, in SVG degrees.
*
* Differentiating it gives the ratio for free — `-N₁/N₂` for an external pair,
* `+N₁/N₂` for a pinion running inside a ring.
*/
export function meshAngle(
driverTeeth: number,
driverAngle: number,
drivenTeeth: number,
bearing: number,
internal = false,
): number {
const n1 = gearTeeth(driverTeeth)
const n2 = gearTeeth(drivenTeeth)
const angle = Number.isFinite(driverAngle) ? driverAngle : 0
const to = Number.isFinite(bearing) ? bearing : 0
const half = 180 / n2
if (internal) {
// `bearing` runs from the ring's centre out to the pinion.
return to + half + (angle - to) * (n1 / n2)
}
return to + 180 + half + (to - angle) * (n1 / n2)
}
/* -------------------------------------------------------------------------- */
/* planetary trains */
/* -------------------------------------------------------------------------- */
export interface PlanetaryTrain {
sun: number
planet: number
ring: number
/** Equally spaced planets. */
planets: number
/** Reduction from the sun to the carrier, with the ring held: `1 + ring/sun`. */
ratio: number
}
/**
* Tooth counts that actually assemble. Two constraints do the work, and both
* are the reason a real gearbox cannot take any numbers you like:
*
* - the ring is `sun + 2 × planet`, so the planets reach across the annulus;
* - `(sun + ring)` must divide by the planet count, or equally spaced planets
* cannot all present a tooth to the ring's spaces at once.
*
* `planet` is raised to the nearest count that satisfies the second, and
* capped so neighbouring planets do not overlap.
*/
export function planetaryTrain(
sunTeeth: number,
planetTeeth: number,
planets: number,
): PlanetaryTrain {
const count = Number.isFinite(planets) ? clamp(Math.round(planets), 3, 5) : 3
const sun = Number.isFinite(sunTeeth) ? clamp(Math.round(sunTeeth), 8, 40) : 16
const asked = Number.isFinite(planetTeeth) ? clamp(Math.round(planetTeeth), 6, 40) : 12
// Adjacent planets sit on a carrier of radius ∝ (sun + planet) and are
// themselves ∝ planet across; this is the largest that still clears.
const gap = Math.sin(Math.PI / count)
const cap = clamp(Math.floor((sun * gap) / Math.max(0.05, 1 - gap)) - 1, 6, 40)
const start = clamp(asked, 6, cap)
const assembles = (teeth: number) => (2 * (sun + teeth)) % count === 0
let planet = start
if (!assembles(planet)) {
// Up to the cap first, then back down — never past the clearance limit.
for (let p = start + 1; p <= cap; p += 1) if (assembles(p)) { planet = p; break }
for (let p = start - 1; p >= 6 && !assembles(planet); p -= 1) if (assembles(p)) planet = p
}
const ring = sun + 2 * planet
return { sun, planet, ring, planets: count, ratio: 1 + ring / sun }
}
export interface PlanetaryPose {
/** Input shaft angle, in degrees. */
sun: number
/** Output: the carrier, turning `ratio` times slower and the same way. */
carrier: number
/** Held still — and the maths says so: this does not move with the sun. */
ring: number
planets: { bearing: number; angle: number }[]
}
/** Where every member of a fixed-ring train stands when the sun is at `sunAngle`. */
export function planetaryPose(train: PlanetaryTrain, sunAngle: number): PlanetaryPose {
const sun = Number.isFinite(sunAngle) ? sunAngle : 0
const carrier = sun / train.ratio
const planets = Array.from({ length: train.planets }, (_, i) => {
const bearing = carrier + (i * 360) / train.planets
return { bearing, angle: meshAngle(train.sun, sun, train.planet, bearing) }
})
const first = planets[0]
return {
sun,
carrier,
ring: meshAngle(train.planet, first.angle, train.ring, first.bearing, true),
planets,
}
}
/* -------------------------------------------------------------------------- */
/* belts */
/* -------------------------------------------------------------------------- */
export interface Pulley {
x: number
y: number
radius: number
}
export type BeltSegment =
| { kind: "span"; from: Vec2; to: Vec2; length: number; heading: number }
| {
kind: "wrap"
centre: Vec2
radius: number
/** Normal angles, in SVG degrees; the belt runs from `from` to `to` clockwise. */
from: number
to: number
length: number
}
export interface BeltLayout {
segments: BeltSegment[]
/** Wrap angle at each pulley, in degrees, in the order they were given. */
wraps: number[]
/** Total belt length: the spans plus the wraps. */
length: number
d: string
}
const wrapPositive = (degrees: number) => ((degrees % 360) + 360) % 360
/**
* The taut path of a belt round a loop of pulleys, taken in the order given
* and wrapping every one the same way — the outline of the set, which is what
* a belt with nothing pushing it out of line does.
*
* Two pulleys of radius `r` at centres `L` apart give `2L + 2πr`, which is the
* closed form the tests hold this to.
*/
export function beltLayout(pulleys: readonly Pulley[]): BeltLayout {
const wheels = pulleys
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.radius > 0)
.map((p) => ({ x: p.x, y: p.y, radius: p.radius }))
if (wheels.length < 2) {
return { segments: [], wraps: wheels.map(() => 0), length: 0, d: "" }
}
// The outward normal of the tangent leaving `i` for the next wheel. Both
// touch points share it, which is what makes the tangent external.
const normals = wheels.map((a, i) => {
const b = wheels[(i + 1) % wheels.length]
const span = Math.hypot(b.x - a.x, b.y - a.y)
const bearing = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI
const cosine = span > 0 ? clamp((a.radius - b.radius) / span, -1, 1) : 0
return bearing - (Math.acos(cosine) * 180) / Math.PI
})
const segments: BeltSegment[] = []
const wraps: number[] = []
for (let i = 0; i < wheels.length; i += 1) {
const wheel = wheels[i]
const incoming = normals[(i - 1 + wheels.length) % wheels.length]
const outgoing = normals[i]
const sweep = wrapPositive(outgoing - incoming)
wraps[i] = sweep
segments.push({
kind: "wrap",
centre: { x: wheel.x, y: wheel.y },
radius: wheel.radius,
from: incoming,
to: incoming + sweep,
length: toRadians(sweep) * wheel.radius,
})
const next = wheels[(i + 1) % wheels.length]
const from = point(wheel.x, wheel.y, wheel.radius, outgoing)
const to = point(next.x, next.y, next.radius, outgoing)
segments.push({
kind: "span",
from,
to,
length: Math.hypot(to.x - from.x, to.y - from.y),
heading: (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI,
})
}
const length = segments.reduce((total, segment) => total + segment.length, 0)
const d = segments
.map((segment, i) => {
if (segment.kind === "span") return `L ${round(segment.to.x)} ${round(segment.to.y)}`
const start = point(segment.centre.x, segment.centre.y, segment.radius, segment.from)
const end = point(segment.centre.x, segment.centre.y, segment.radius, segment.to)
const large = segment.to - segment.from > 180 ? 1 : 0
const move = i === 0 ? `M ${round(start.x)} ${round(start.y)} ` : ""
return `${move}A ${round(segment.radius)} ${round(segment.radius)} 0 ${large} 1 ${round(end.x)} ${round(end.y)}`
})
.join(" ")
return { segments, wraps, length, d: d ? `${d} Z` : "" }
}
/** A point on the belt and the direction it is travelling, at an arc length. */
export function beltSample(layout: BeltLayout, distance: number) {
if (!layout.length) return { x: 0, y: 0, heading: 0 }
const along = Number.isFinite(distance)
? ((distance % layout.length) + layout.length) % layout.length
: 0
let walked = 0
for (const segment of layout.segments) {
if (walked + segment.length >= along || segment === layout.segments.at(-1)) {
const t = segment.length > 0 ? (along - walked) / segment.length : 0
if (segment.kind === "span") {
return {
x: segment.from.x + (segment.to.x - segment.from.x) * t,
y: segment.from.y + (segment.to.y - segment.from.y) * t,
heading: segment.heading,
}
}
const angle = segment.from + (segment.to - segment.from) * t
const at = point(segment.centre.x, segment.centre.y, segment.radius, angle)
return { x: at.x, y: at.y, heading: angle + 90 }
}
walked += segment.length
}
return { x: 0, y: 0, heading: 0 }
}
/* -------------------------------------------------------------------------- */
/* energy chain */
/* -------------------------------------------------------------------------- */
export interface CarrierGeometry {
/** Where the fixed end is anchored, in drawing units along the run. */
anchor: number
/** How far the carriage can travel from the anchor. */
span: number
/** Bend radius. The two runs sit `2 × radius` apart. */
radius: number
/** Link pitch along the chain. */
pitch: number
}
export interface CarrierLink {
index: number
x: number
y: number
/** Direction of travel along the chain, in SVG degrees. */
angle: number
}
export interface CarrierPose {
links: CarrierLink[]
/** Chain length: constant, which is the whole mechanism. */
length: number
/** Where the fold sits. It moves at half the carriage's rate. */
bend: number
carriage: number
}
/**
* An energy chain folded over itself: a fixed run out from the anchor, a 180°
* bend, and a moving run back to the carriage on the upper level.
*
* The chain cannot change length, so the bend has to take up half of whatever
* the carriage does — that single fact is the mechanism, and it falls straight
* out of solving `(bend − anchor) + πr + (bend − carriage) = length`.
*/
export function carrierLinks(travel: number, geometry: CarrierGeometry): CarrierPose {
const radius = Number.isFinite(geometry.radius) ? Math.max(1, geometry.radius) : 8
const span = Number.isFinite(geometry.span) ? Math.max(0, geometry.span) : 0
const anchor = Number.isFinite(geometry.anchor) ? geometry.anchor : 0
const pitch = Number.isFinite(geometry.pitch) ? Math.max(1, geometry.pitch) : 6
const at = Number.isFinite(travel) ? clamp(travel, 0, 1) : 0
const arc = Math.PI * radius
// Enough slack that the fold never runs into the carriage at full travel.
const length = arc + span + radius * 2
const carriage = anchor + at * span
const bend = (length - arc + anchor + carriage) / 2
const lower = bend - anchor
const count = Math.max(1, Math.floor(length / pitch))
const links = Array.from({ length: count }, (_, index) => {
const s = (index + 0.5) * pitch
if (s <= lower) return { index, x: anchor + s, y: 0, angle: 0 }
if (s <= lower + arc) {
// Round the fold: from the lower run, over the top, onto the upper one.
const degrees = 90 - ((s - lower) / radius) * (180 / Math.PI)
const on = point(bend, -radius, radius, degrees)
return { index, x: on.x, y: on.y, angle: degrees - 90 }
}
return { index, x: bend - (s - lower - arc), y: -2 * radius, angle: 180 }
})
return { links, length, bend, carriage }
}