Donut World — Figure-Eight Orbit Viewer #
Interactive 3D visualization of the exact figure-eight solar orbit
derived in orbit/torus/ — a uniform solid torus (major radius R=1,
minor radius r=0.3) with a sun on a closed, periodic orbit that threads
the hole and loops around each side, exactly as described in the
project's CLAUDE.md.
Live at shibbi.me/donut, rebuilt automatically
on every push to main (see .tangled/workflows/deploy.yml).
Quick start #
npm install
npm run dev # dev server with HMR
npm run build # type-checks, then builds dist/
npm run preview # serves the production build locally
npm run typecheck # tsc --noEmit only
Architecture #
The physics is not simulated in the browser. It's precomputed once by
the verified Python model (orbit/torus/family_shape_sweep.py +
export_family_grid.py, which mapped the whole family of closed,
hole-threading figure-eight orbits across a grid of torus aspect ratios —
see orbit/torus/README.md sections 4-5 for why this is a family at all,
not a single isolated orbit) into one JSON grid:
public/data/orbit_family.json. Re-run those scripts and copy the output
here any time the upstream physics changes.
Two sliders sit on top of that grid: torus radius (r/R) and
orbit size (which member of the family) are the two axes the grid
actually interpolates over — moving either does a bilinear lookup into
already-solved orbits, no live ODE-solving.
There's no "world size" or "gravity" slider, and deliberately so: one
full figure-eight is defined as one day (see the DAY constant in
liveOrbit.ts), and the torus's major radius is fixed at the reference
value R=1. With both an absolute length and an absolute time already
pinned down by convention rather than measurement, there's no remaining
degree of freedom for a size or mass slider to change — see
orbit/torus/README.md's slider-design section for the full argument.
src/
physics/
types.ts - shared types for the family grid dataset
orbitFamily.ts - loads the grid, bilinear interpolation
liveOrbit.ts - ties the grid + the two slider values to the one current orbit
orbitClock.ts - the single playback clock; everything else reacts to it
render3d/
Scene3D.ts - three.js: torus mesh, cutaway, orbit outline, sun + lighting;
rebuilds live when the physics sliders change
ui/
Controls.ts - play/pause, scrubber (ticked at 24 evenly-spaced hour marks), speed
PhysicsPanel.ts - the two physics sliders
theme.ts - shared color/font tokens
main.ts - wires it all together
Sync model: OrbitClock is the only thing that owns "what time is
it." LiveOrbit is the only thing that owns "what orbit is this." Both
are plain subscribable stores; the view and the controls react to
whichever one changed rather than tracking state independently. See
orbitClock.ts for why play()/pause() notify subscribers immediately
rather than waiting for the next animation frame (an earlier version had
the play/pause icon read stale state at construction time; fixed by
making play-state changes always notify synchronously), and
liveOrbit.ts for why changing a slider recomputes the whole orbit in
one place and lets everything downstream (Scene3D, Controls'
scrubber ticks) just subscribe.
Coordinate mapping: the physics model's axis of revolution (its
z-axis) maps to three.js's Y (world up), so the torus sits like a ring on
a table and the sun's orbital plane (physics y=0, always) becomes the
vertical plane threading its hole: physics (x, y=0, z) → three.js
(x, z, 0). See the comment at the top of Scene3D.ts.
Cutaway view: the "Cutaway" button clips the torus mesh along the
physics x-z plane (three.js's Z=0 plane) and drops the near half, so you
can see straight into the hole from any camera angle instead of the near
tube wall blocking the view. This works out cleanly because the sun's
entire orbit — the path, the hour markers, the sun itself — lives
exactly at physics y=0, i.e. exactly in the cutting plane, so
nothing but the torus itself ever needs to be clipped. A plane through a
torus's axis meets its volume in exactly two circles of radius r
(centered on the core circle at physics (±R, 0, 0)); those become the
two flat cap disks you see when cutaway is on. See the comment above
setCutawayEnabled in Scene3D.ts.
Gotcha worth knowing if you touch this: the cap disks render with
depthTest: false and a high renderOrder, deliberately. Without that,
they're invisible — confirmed empirically (temporarily hiding the torus
mesh made the caps, and anything else placed near them, render exactly
where expected; re-showing the torus hid them again, from every camera
angle including straight down the cut's own normal). The torus's own
clipped-away fragments still appear to occlude things behind them via
the depth buffer even though clippingPlanes correctly hides their
color — so a normal depth test loses to material that's supposedly not
there. The sun's actual orbit doesn't pass close enough to the exact cut
cross-sections to visibly hit this, so it didn't need the same treatment,
but keep it in mind if a future addition puts geometry near (±R, 0, 0)
while cutaway is active.
Resilience: the app needs WebGL, which isn't always available
(disabled GPU, some sandboxed environments). main.ts isolates
Scene3D's construction in its own try/catch, so a WebGL failure
degrades to a message in the panel — with the cutaway button hidden,
since there's nothing for it to act on — rather than the page going
fully blank.
Design #
Color and type choices are documented inline in src/ui/theme.ts. Short
version: colors are assigned by what they represent (torus = glazed
terracotta, sun = gold, orbit trace = teal, cutaway cap = a more muted
"interior" tone) rather than picked as an arbitrary brand palette.
The scrubber's tick marks and the 3D view's small dots along the orbit
path used to be the eight physically-derived critical points from
CLAUDE.md's orbit table (A–H) — useful for defining the closure
problem, not for anything a viewer of the finished orbit would care
about. They're now 24 points evenly spaced in time (hour 0–23 in
LiveOrbit.hourMarkers()), a plain "where's the sun right now" position
reference now that a full orbit is one day.
Where this is headed #
There's exactly one torus and one sun in this setting — no other bodies are coming. The interesting open questions are about how these two interact, seen from the surface of the torus: tides, day/night and "seasonal" insolation as the sun's distance and angle to a given point on the torus change through the figure-eight, and so on. A few things worth knowing if you're building that on top of this:
- Widening the grid: the family grid currently spans the
r/Rvalues solved byorbit/torus/family_shape_sweep.py's grid and each shape's own solvedx_Arange. Adding morer/Rsamples, or pushing the family's extent further out, means re-running that sweep (andexport_family_grid.py) and dropping the neworbit_family.jsonin -- everything downstream picks it up automatically, same as before. - Live simulation instead of playback: right now
LiveOrbitserves precomputed, interpolated samples. A sim that needs the sun's instantaneous state for a physical calculation (distance and bearing from a given point on the torus surface, say, for a tidal or insolation model) can already get exact position fromLiveOrbit.positionAt(phase); velocity would need a derivative (finite difference against the samples, or exposing it from the Python export alongside position). If a future sim needs to integrate trajectories in the browser (e.g. showing what happens when you perturb the orbit — relevant, since every orbit in this family is linearly unstable, perorbit/torus/README.md), the natural seam is the samepositionAt()-shaped interface, backed by a live integrator instead of a lookup;OrbitClockand the UI don't care where positions come from. - A point on the torus surface: nothing here currently models a
location on the torus (as opposed to the torus as a whole) — that's
the missing piece for tides/climate questions. It'd need its own
parametrization (major angle around the ring, angle around the tube)
and a way to compute its instantaneous relationship to the sun's
position from
LiveOrbit.