Planetary gearbox
A reduction stage with its face off: a sun driving planets inside a held ring, with real meshing teeth. The reduction is read off the tooth counts rather than typed in.
Open in workbenchview
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/planetary-gearbox.jsonNotes
- The teeth are geometry. Every member's phase comes from the mesh relation in transmission-geometry, so the planets counter-rotate at −sun/planet and the ring provably does not move at all with the sun — which is what makes the carrier the output and the reduction exactly 1 + ring/sun.
- Tooth counts you ask for are not always the counts you get: the ring is sun + 2 × planet, and (sun + ring) has to divide by the planet count or the planets cannot all mesh at once. The planet count is raised until it does, and the readout shows what was built.
- Geometry only — no torque, no backlash, no friction, no efficiency. It will happily turn a ratio that would strip itself.
Usage
import { PlanetaryGearbox } from "@/components/ui/planetary-gearbox"
<PlanetaryGearbox sunTeeth={18} planetTeeth={12} planets={3} />
// Controlled, or a shaft you can wind.
<PlanetaryGearbox angle={140} />
<PlanetaryGearbox interactive onAngleChange={setAngle} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| view | "plan" | "front" | "profile" | "iso" | "front" | Where the camera stands. One gearbox, four projections: straight down, straight on, side elevation, or three-quarter from above. |
| angle | number | — | Controlled input shaft angle in degrees. Omit it and the shaft runs behavior. |
| behavior | "run" | "jog" | "static" | "run" | Turn continuously, or index in half-turn steps with a dwell between. |
| speed | number | 0.3 | Input turns per second running, or half-turn steps per second jogging. |
| animate | boolean | true | Off parks the machine at phase and stops rendering. A reduced-motion preference does the same. |
| paused | boolean | false | Freeze where it stands. |
| phase | number | 0 | Seconds of offset, so a row of machines breaks step. |
| interactive | boolean | false | Drag round the centre to wind the input shaft, or arrow-key it 10° at a time. |
| onAngleChange | (angle: number) => void | — | Input angle, wrapped to 0–360, whenever a person moves it. |
| sunTeeth | number | 16 | Teeth on the sun, rounded and clamped to 8–40. |
| planetTeeth | number | 12 | Teeth on each planet. Raised to the nearest count that assembles, and capped so neighbours cannot overlap. |
| planets | number | 3 | Equally spaced planets, rounded and clamped to 3–5. |
| showHousing | boolean | true | Draw the case and its bolt circle around the train. |
| showRatio | boolean | true | Print the tooth counts and the reduction under the drawing. |
| label | string | — | Caption below the ratio readout. |
| variant | "solid" | "outline" | "blueprint" | "wire" | "solid" | How the machine is painted. Geometry never changes between variants. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | number | "md" | Rendered width in pixels, or a step on the scale. |
| color | string | var(--robot-shell) | Body panels — the colour the machine reads as. |
| accent | string | var(--robot-accent) | Status colour: tip light, live tool, readouts. |
| metal | string | var(--robot-metal) | Bare machined parts: collars, bolts, tool bodies. |
| dark | string | var(--robot-dark) | Cast joints, base, shadow side. |
| palette | Partial<RobotPalette> | — | Override any subset of roles at once, including glow and grid. |
Source
src/components/ui/planetary-gearbox.tsx
"use client"
/**
* planetary-gearbox — a reduction stage with its face off.
*
* The teeth are geometry, not decoration: the sun drives the planets and the
* planets drive nothing, because the ring is held — which is what makes the
* carrier the output and gives the reduction `1 + ring/sun`. Every phase in
* the drawing comes out of `transmission.ts`, so the sun cannot turn without
* the planets turning the right way at the right rate, and the ring provably
* does not move at all.
*/
import * as React from "react"
import { arrowStep, useRobotDrag, useRobotScalar } from "@/hooks/use-robot-motion"
import type { Vec2 } from "@/lib/robocn/kinematics"
import {
aboutPoint,
capsulePath,
extrudedPath,
px,
resolveRobotPalette,
resolveRobotSize,
robotCamera,
robotSurface,
roundedFootprint,
type RobotPaletteProps,
type RobotSize,
type RobotVariant,
type RobotView,
} from "@/lib/robocn/style"
import { gearPath, planetaryPose, planetaryTrain } from "@/lib/robocn/transmission"
import { cn } from "@/lib/utils"
const VIEW = 180
/** The output axis, in view units. Drags are measured from here. */
const CENTRE = { x: 90, y: 84 }
/** Pitch radius of the ring, which sets the module every other gear uses. */
const RING_PITCH = 58
const RIM = 68
const CASE = 76
/** Degrees of input shaft per second while slewing to a new position. */
const SLEW_RATE = 280
/** A press that sweeps less than this is a click, not a wind. */
const CLICK_SLOP = 3
/** Drawn face on, which is the camera it defaults to. */
const NATIVE_VIEW: RobotView = "front"
/** Depths the face never had to give, toward the reader. */
const CASE_BACK = -18
const OUTPUT_OUT = -38
const INPUT_OUT = 28
const fits: Record<RobotView, number> = { plan: 0.88, front: 1, profile: 0.88, iso: 0.84 }
const viewNames: Record<RobotView, string> = {
plan: "plan view",
front: "front elevation",
profile: "side elevation",
iso: "isometric view",
}
export type GearboxBehavior = "run" | "jog" | "static"
export interface PlanetaryGearboxProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
/** Controlled input shaft angle in degrees. Omit to run `behavior`. */
angle?: number
/** What the input shaft does when `angle` is not supplied. */
behavior?: GearboxBehavior
/** Where the camera stands. One gearbox, four projections. */
view?: RobotView
/** Input turns per second running, or half-turn steps per second jogging. */
speed?: number
animate?: boolean
paused?: boolean
phase?: number
/** Drag round the centre to wind the input, or arrow-key it. */
interactive?: boolean
onAngleChange?: (angle: number) => void
/** Teeth on the sun, clamped to 8–40. */
sunTeeth?: number
/** Teeth on each planet. Raised to the nearest count that assembles. */
planetTeeth?: number
/** Planets, clamped to 3–5. */
planets?: number
showHousing?: boolean
showRatio?: boolean
label?: string
size?: RobotSize | number
variant?: RobotVariant
}
function PlanetaryGearbox({
angle,
behavior = "run",
view = NATIVE_VIEW,
speed = 0.3,
animate = true,
paused = false,
phase = 0,
interactive = false,
onAngleChange,
sunTeeth = 16,
planetTeeth = 12,
planets = 3,
showHousing = true,
showRatio = true,
label,
size = "md",
variant = "solid",
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
role,
tabIndex,
onKeyDown,
onBlur,
...props
}: PlanetaryGearboxProps) {
const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
const width = resolveRobotSize(size)
const train = planetaryTrain(sunTeeth, planetTeeth, planets)
const controlled = angle !== undefined
const svgRef = React.useRef<SVGSVGElement>(null)
const [held, setHeld] = React.useState<number | null>(null)
const hold = controlled ? (Number.isFinite(angle) ? angle : 0) : held
const goal = React.useCallback((clock: number) => gearboxGoal(behavior, clock), [behavior])
const motion = useRobotScalar(goal, {
// Running has to keep up with its own goal; jogging gets the slew rate,
// which is what leaves a dwell between steps.
rate: behavior === "run" ? Math.max(SLEW_RATE, Math.abs(speed) * 720) : SLEW_RATE,
hold,
speed,
animate: animate && !controlled && behavior !== "static",
paused,
phase,
})
const input = Number.isFinite(motion.value) ? motion.value : 0
const pose = planetaryPose(train, input)
const apply = React.useCallback(
(next: number) => {
setHeld(next)
onAngleChange?.(wrap360(next))
},
[onAngleChange, setHeld],
)
// Winding: the pointer's swept angle is added to where the shaft was.
const press = React.useRef<{ from: number; at: number; moved: boolean } | null>(null)
const dragging = useRobotDrag(svgRef, {
enabled: interactive,
onDrag: React.useCallback(
(unit: Vec2) => {
const dx = unit.x * VIEW - CENTRE.x
const dy = unit.y * VIEW - CENTRE.y
if (Math.hypot(dx, dy) < 6) return
const pointer = (Math.atan2(dx, -dy) * 180) / Math.PI
if (!press.current) {
press.current = { from: motion.value, at: pointer, moved: false }
return
}
const swept = wrapSigned(pointer - press.current.at)
if (Math.abs(swept) > CLICK_SLOP) press.current.moved = true
if (press.current.moved) apply(press.current.from + swept)
},
[apply, motion.value],
),
onDragEnd: React.useCallback(() => {
press.current = null
}, []),
})
const shell = robotSurface("shell", variant, palette)
const machined = robotSurface("metal", variant, palette)
const cast = robotSurface("dark", variant, palette)
// One module for the whole train, so a pitch radius is just its tooth count.
const pitchModule = RING_PITCH / train.ring
const sunRadius = pitchModule * train.sun
const planetRadius = pitchModule * train.planet
const carrierRadius = sunRadius + planetRadius
const tooth = { addendum: pitchModule * 0.9, dedendum: pitchModule * 1.1 }
const readout = px(wrap360(input))
const output = px(wrap360(pose.carrier))
const ratio = px(train.ratio)
// The drawing is the gearbox's own face, so it goes through `wall` where it
// already stands and comes out untouched straight on. The case barrel and
// the two shafts are the depth the face could never show.
const camera = robotCamera(view)
const offAxis = view !== NATIVE_VIEW
const fit = fits[view] ?? 1
const face = aboutPoint(camera.wall(), CENTRE.x, CENTRE.y)
const Frame = (face ? "g" : React.Fragment) as React.FC<{
transform?: string
children?: React.ReactNode
}>
const frame = face ? { transform: face } : {}
/** A point on the face, `depth` units toward the reader. */
const at = (dx: number, dy: number, depth = 0) => camera.project(-dx, -dy, -depth)
/** A cylinder on the machine's axis, between two depths. */
const cyl = (radius: number, from: number, to: number) =>
capsulePath(at(0, 0, from), at(0, 0, to), radius)
/** A block through the machine: a footprint on the floor of the drawing. */
const slab = (dx: number, halfWidth: number, halfDepth: number, top: number, bottom: number) =>
extrudedPath(
roundedFootprint(halfWidth, halfDepth, 2, 4).map((p) => ({ x: p.x - dx, y: p.y })),
camera,
-top,
-bottom,
)
return (
<svg
ref={svgRef}
role={role ?? (interactive ? "slider" : "img")}
aria-label={`Planetary gearbox, ${train.planets} planets, ${ratio} to 1 reduction, input ${readout} degrees, ${viewNames[view] ?? viewNames.front}`}
aria-valuemin={interactive ? 0 : undefined}
aria-valuemax={interactive ? 360 : undefined}
aria-valuenow={interactive ? readout : undefined}
aria-valuetext={interactive ? `input ${readout} degrees, output ${output} degrees` : undefined}
tabIndex={tabIndex ?? (interactive ? 0 : undefined)}
onKeyDown={(event) => {
onKeyDown?.(event)
if (!interactive || event.defaultPrevented) return
const delta = arrowStep(event.key, event.shiftKey ? 30 : 10, 90)
if (delta !== 0) apply(motion.value + delta)
else if (event.key === "Home") apply(0)
else if (event.key === "Escape") setHeld(null)
else return
event.preventDefault()
}}
onBlur={(event) => {
onBlur?.(event)
if (!dragging) setHeld(null)
}}
viewBox="0 0 180 180"
width={width}
height={width}
className={cn(
"max-w-full select-none",
interactive &&
"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]",
dragging && "cursor-grabbing",
className,
)}
style={{ color: palette.foreground, ...style }}
data-view={view}
{...props}
>
<g transform={`translate(90 84) ${fit === 1 ? "" : `scale(${fit})`}`.trimEnd()}>
{offAxis && (
<g data-solids>
<path d={cyl(11, OUTPUT_OUT, CASE_BACK)} {...machined} />
{showHousing && (
<>
<path d={cyl(CASE, CASE_BACK, 0)} {...cast} />
<path d={slab(0, CASE + 6, 9, CASE - 4, CASE + 4)} {...cast} />
</>
)}
<path d={cyl(RIM, CASE_BACK + 3, 0)} {...machined} />
</g>
)}
<Frame {...frame}>
<g data-gearbox>
{showHousing && (
<g data-housing>
<circle r={CASE} {...cast} />
{[0, 60, 120, 180, 240, 300].map((degrees) => (
<g key={degrees} transform={`rotate(${degrees})`}>
<circle cx={CASE - 5} r={3.6} {...machined} />
<circle cx={CASE - 5} r={1.6} fill={palette.dark} />
</g>
))}
</g>
)}
<g data-ring transform={`rotate(${px(pose.ring)})`}>
<path
d={gearPath(train.ring, RING_PITCH, { ...tooth, rim: RIM })}
fillRule="evenodd"
{...machined}
/>
</g>
<g data-carrier transform={`rotate(${px(pose.carrier)})`}>
{Array.from({ length: train.planets }, (_, i) => {
const bearing = (i * 360) / train.planets
const arm = {
x: Math.cos((bearing * Math.PI) / 180) * carrierRadius,
y: Math.sin((bearing * Math.PI) / 180) * carrierRadius,
}
return <path key={i} d={capsulePath({ x: 0, y: 0 }, arm, 7)} {...cast} />
})}
<circle r={sunRadius * 0.55 + 7} {...cast} />
<path
d={`M ${px(carrierRadius - 5)} 0 h 10`}
stroke={palette.accent}
strokeWidth={2}
fill="none"
/>
</g>
{pose.planets.map((planet, i) => {
const a = (planet.bearing * Math.PI) / 180
return (
<g
key={i}
data-planet={i}
transform={`translate(${px(Math.cos(a) * carrierRadius)} ${px(Math.sin(a) * carrierRadius)})`}
>
<g transform={`rotate(${px(planet.angle)})`}>
<path d={gearPath(train.planet, planetRadius, tooth)} {...shell} />
<path
d={`M 0 0 L ${px(planetRadius - pitchModule)} 0`}
stroke={palette.dark}
strokeWidth={1}
/>
</g>
<circle r={Math.max(2.5, planetRadius * 0.26)} {...machined} />
</g>
)
})}
<g data-sun transform={`rotate(${px(input)})`}>
<path d={gearPath(train.sun, sunRadius, tooth)} {...machined} />
<circle r={sunRadius * 0.5} {...cast} />
<path
d={`M ${px(sunRadius * 0.5 - 1)} -2 h 3 v 4 h -3 Z`}
fill={palette.accent}
/>
</g>
{variant === "blueprint" && (
<g fill="none" stroke={palette.grid} strokeWidth={0.5} strokeDasharray="2 3" opacity={0.7}>
<circle r={px(sunRadius)} />
<circle r={px(carrierRadius)} />
<circle r={RING_PITCH} />
</g>
)}
</g>
</Frame>
{offAxis && (
<g data-shaft>
<path d={cyl(10, 0, INPUT_OUT)} {...machined} />
<path d={cyl(4, INPUT_OUT - 2, INPUT_OUT + 8)} {...cast} />
</g>
)}
</g>
<g fontFamily="ui-monospace, monospace" textAnchor="middle" fill={palette.foreground}>
{showRatio && (
<text x={90} y={169} fontSize={5}>
{`${train.sun}:${train.planet}:${train.ring} / ${ratio}:1`}
</text>
)}
{label && (
<text x={90} y={177} fontSize={4.5}>
{label}
</text>
)}
</g>
</svg>
)
}
/**
* Where the input shaft is aiming at `clock`. `jog` is a deliberate staircase:
* the slew rate is what draws the move between steps, the way a stepper index
* actually gets there.
*/
export function gearboxGoal(behavior: GearboxBehavior, clock: number) {
if (behavior === "static" || !Number.isFinite(clock)) return 0
if (behavior === "jog") return Math.floor(clock) * 180
return clock * 360
}
/** Degrees folded into 0..360. Non-finite input parks at zero. */
const wrap360 = (value: number) => (Number.isFinite(value) ? ((value % 360) + 360) % 360 : 0)
/** The same bearing as the shortest signed turn, -180..180. */
const wrapSigned = (value: number) => {
const wrapped = wrap360(value)
return wrapped > 180 ? wrapped - 360 : wrapped
}
export { PlanetaryGearbox }