A world that is a torus, with a figure-eight sun
README.md

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/R values solved by orbit/torus/family_shape_sweep.py's grid and each shape's own solved x_A range. Adding more r/R samples, or pushing the family's extent further out, means re-running that sweep (and export_family_grid.py) and dropping the new orbit_family.json in -- everything downstream picks it up automatically, same as before.
  • Live simulation instead of playback: right now LiveOrbit serves 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 from LiveOrbit.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, per orbit/torus/README.md), the natural seam is the same positionAt()-shaped interface, backed by a live integrator instead of a lookup; OrbitClock and 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.