# Architecture

A map of how the pieces fit together — read this before making a non-trivial change. Each module's own doc comments explain *why* it works the way it does; this doc is about how a click becomes a rendered, labeled, selectable body, and which file owns which step.

## The pipeline, in order

A body's journey from data to pixel goes through roughly this sequence every frame:

1. **Position** — `kepler.ts` (`positionAtTime`/`orbitPath`) turns `OrbitalElements` into a real km position via standard Keplerian propagation, in the plane given by `orientation.ts`: the one place a pole direction (a body's spin axis, `CelestialBody.pole`, or the plane an orbit's elements are quoted against, `OrbitalElements.referencePlanePole`) becomes axes, so a planet's rings and its moons' orbits share one definition of its equator (`poleFromEquatorialJ2000` converts the IAU's published RA/Dec poles). `resolve.ts`/`render.ts` (`resolveWorldPosition`/`resolveAllWorldPositions`) turn that into an absolute position, composing each parent→child step independently rather than summing real offsets first; both walk the parent chain through `hierarchy.ts`, one shared, iterative walk over a cached id index (`lookupBody`) that stops at a cycle or unknown parent instead of overflowing the stack. `constants.ts` (`AU_KM`, `MS_PER_DAY`) and `vec3.ts` (tuple vector helpers for the camera math) are shared plumbing.
2. **Scale** — `scale.ts` (`compressDistance`/`trueRadius`) maps real km into this engine's compressed render-space units — logarithmic for distance (so a whole solar system and a body's own true radius can share one coordinate space), linear for radius (so nothing catastrophically distorts shape).
3. **Visibility floor** — `pixelFloor.ts` (`renderRadius`/`minVisibleWorldRadius`) floors a body's rendered radius so a true-scale-tiny body (the ISS is a fraction of a pixel at Earth-orbit scale) still reads as a visible dot, without lying about its actual position.
4. **Level of detail** — `levelOfDetail.ts` (`apparentSize` + the `*DetailFor` functions) picks polygon/segment counts from how much of the view a body actually fills right now, not a fixed per-type constant.
5. **Which bodies render at all** — `visibility.ts` (`computeVisibleBodyIds`) ranks every secondary body (moons, stations, asteroids, ...) by prominence and caps the total, so a sparse system shows everything while a crowded one thins down to something readable. `isAlwaysVisible` is the shared "never hide this" rule (primaries, the current selection, its children) — used both here and by the screen-space pass below, from one definition.
6. **Screen-space thinning** — `labelDeclutter.ts` (`thinByScreenProximity`, and `computeVisibleLabels` as its label-specific wrapper) is the shared greedy algorithm for "too many things landed too close together on screen this frame" — applied both to whether a body's TEXT LABEL draws and, separately, to whether its own DOT renders at all, on top of whatever step 5 already decided.
7. **Selection** — `proximitySelection.ts` (`findNearestCandidate`) is bubble-cursor hit-testing: snap to the nearest visible body within a screen-space radius, since raycasting fails once a body's on-screen size drops under a pixel.
8. **Camera framing** — `camera.ts` computes where a fly-to should end up (`computeFocusForBody`/`computeFocusForSystem`/`computeFocusForBelt`, `distanceToFit`/`distanceToFillView`) — a body in its `'body'` framing (the body itself at `BODY_VIEW_FILL` of the view, the default arrival) or its `'system'` framing (the body plus its local moon system; `framingForBodySelection` toggles between them when the selected body is picked again) and the per-selection min-distance floor (`minCameraDistanceForRadius`, bottoming out at `ABSOLUTE_MIN_CAMERA_DISTANCE`) that lets the camera get arbitrarily close to something true-scale-tiny without a one-size-fits-all floor holding it at arm's length.
9. **Rings and belts** — `rings.ts` (`ringWorldRadius`/`ringPlaneQuaternion`/`ringOuterRadiusKm`) sizes a body's own ring system relative to the body as rendered and orients it in the body's equatorial plane, from its `pole` (or `axialTiltDeg` as a fallback; `ringTiltRadians` is deprecated); `belts.ts` (`beltShape`/`sampleBeltParticles`/`beltGlowInnerRatio`) resolves a belt's shape and produces its seeded, gap-aware scatter — uniform by area for rings and clusters, by volume and solid angle for shells. Both also carry the validation checks `validateSystemData` runs (`findInvalidRings`/`findInvalidBelts`).
10. **The zoom slider's mapping** — `zoomSlider.ts` (`sliderPositionToDistance`/`distanceToSliderPosition`) is a small, isolated log-scale conversion, kept separate because getting the direction/curve wrong here is exactly the kind of thing worth unit-testing in isolation from the rest of the camera rig.

Steps 1–10 are all pure functions in `src/*.ts` — no React, no three.js scene graph, and fully line-covered by the unit suite (branch coverage is high but not total; `npm run test:coverage` enforces the floor). Nothing here needs a real renderer to verify.

## The rendering layer (`src/react/`)

`OrbitalSystemScene` (in `OrbitalSystemScene.tsx`) is the public component — it wires a handful of hooks (state) to the UI components and the `<Canvas>`, and composes everything else:

| File | Owns |
| --- | --- |
| `OrbitalSystemScene.tsx` | Composition: the hooks below, camera focus, the overlay UI and `<Canvas>` setup |
| `useSimulationClock.ts`, `useSelectionNavigation.ts`, `useActionLog.ts` | Top-level state: simulated time/playback (an authoritative ref advanced every frame, plus a ~10Hz React `simDate` for UI text only), the selection and its up/down hierarchy navigation, the debounced `onAction` log |
| `useSceneInteraction.ts`, `sceneInteractionStore.ts` | Per-frame hover/label/dot-visibility state in a tiny external store, so a hover change re-renders only the bodies involved rather than the whole scene |
| `useStableCallback.ts` | `useStableCallback`/`useLatestRef` — stable callback identities so memoized children don't re-render every tick |
| `Toolbar.tsx`, `TimeBar.tsx`, `InfoPanel.tsx`, `DevPerfPanel.tsx` | The overlay UI: object dropdown/navigation/zoom slider, playback controls, the selection's info panel, the `devMode` overlay (`PerfStats.tsx` samples renderer stats inside the Canvas) |
| `TimeDriver.tsx`, `framePositions.ts`, `useFollowPosition.ts` | Advances the simulated clock every frame from inside the Canvas's render loop and re-resolves every body's position for it into live arrays updated in place (`framePositions.ts`); scene objects copy those onto their object3Ds each frame (`useFollowPosition`), so motion is per-frame smooth while React only re-renders when what's shown changes |
| `SceneContent.tsx` | Composition: decides what's eligible via `visibility.ts`, renders bodies/orbits/belts/route preview, each handed its live position arrays; splits bodies into the instanced small-body path and the full `BodyMarker` path (`instanceShapeFor`) |
| `InstancedBodies.tsx`, `smallBodyInstancing.ts` | Small bodies drawn as one `InstancedMesh` per placeholder shape — see "Large systems" below |
| `CameraRig.tsx`, `cameraFlightLifecycle.ts` | Owns drei's `CameraControls` (wrapping the `camera-controls` library), the fly-to, invisible collider proxies so the camera can't clip through a body (manual drag included), and tracking a moving selected body; the flight/tracking/collider state machine itself is pure, in `cameraFlightLifecycle.ts` — see "Camera" below |
| `ProximitySelector.tsx` | The R3F-side wiring for `proximitySelection.ts`/`labelDeclutter.ts` — one per-frame screen-space projection pass driving hover, label visibility, and body-dot visibility together |
| `BodyMarker.tsx` | One body's rendered group — shape + label + the grow/shrink fade animation when a body crosses in/out of the visible set. The label is a plain DOM element it creates and positions itself (not a drei `<Html>`), so it exists in the same commit that selects its body |
| `BodyShapes.tsx`, `placeholderShapes.ts` | The actual geometry per body type (`PlaceholderBodyShape`, from `placeholderShapes.ts`'s parts, shared with the instanced path), the real-model/real-texture loading paths and their fallbacks, `AtmosphereRim` — built at unit radius and scaled to the rendered radius by a wrapping group, so zooming doesn't rebuild geometry |
| `OrbitLines.tsx`, `orbitBatch.ts` | `OrbitLineBatch` (every Kepler orbit and every `fixedPosition` body's flat reference circle, on the GPU — see "Large systems" below), `RoutePreviewLine` |
| `BeltRendering.tsx`, `PointCloud.tsx` | Belt glow disc (ring-shaped belts only), particle scatter, and in-belt site markers; `PointCloud` is a plain `THREE.Points` that only re-uploads its buffer when handed a new one |
| `BodyRings.tsx` | A body's own ring system, drawn inside the body's group at unit radius in the plane of the body's spin pole (`ringPlaneQuaternion`) |
| `StarLight.tsx` | The opt-in `lightFromStar` point light at the star's position (for glTF models and the non-sphere placeholders), and the per-frame update of the star-lighting store |
| `starLighting.ts`, `starLitMaterials.ts`, `StarLitShapes.tsx`, `sceneLighting.ts` | Star lighting — see "Star lighting" below |
| `CometTails.tsx`, `tailRibbon.ts` | Ion and dust tails for comets orbiting the star (physics in `comets.ts`), drawn as camera-facing ribbons |
| `RenderPass.tsx`, `floatingOrigin.ts` | Draws each frame camera-relative and distance-normalized — see "Floating origin" below |
| `screenProjection.ts`, `proximityFrame.ts`, `labelLayout.ts`, `selectionNavigation.ts`, `simulationTime.ts`, `beltGeometry.ts`, `lineGeometry.ts`, `infoPanelText.ts`, `dataWarningText.ts`, `animation.ts`, `keyedDebouncer.ts`, `resourceCache.ts`, `memoEquality.ts`, `setUtils.ts` | Pure, unit-tested helpers behind the components above — projection and depth culling, the per-frame proximity pass, label offsets, selection parsing/navigation, simulated-time maths, parent-relative belt/line buffers, panel and warning text, debounce timers, texture LRU/ref-counting |
| `theme.ts`, `bodyTypeStyles.ts`, `textures.ts` | Small, dependency-free constants and canvas-texture helpers shared across the above |

### Camera

`TimeDriver` runs first each frame (priority -3), advancing the simulated time and every body's position; `CameraRig`'s `useFrame` runs at -2, resolving the tracked body's (smoothed) position at that time, before drei's `CameraControls` calls `controls.update()` at -1, so every write reaches the screen the same frame; objects follow their bodies at -0.5, before labels and the proximity pass project them at 0. Then `RenderPass` computes the frame's render transform at 0.5, the batched orbit lines and instanced bodies write their camera-relative buffers from it at 0.75, and `RenderPass` draws at 1. A fly-to is interpolated by the engine itself (`sampleFlightPath`, `camera.ts`) along van Wijk & Nuij's zoom-and-pan path (`zoomPanPath`), eased in and out (`smoothstep`), towards a focus computed for where the body will be when the flight ends. Its duration is proportional to that path's length (`flightDurationMs`, tuned by the `flightPacing` prop); `useSelectionNavigation` plans it from the live camera pose before predicting the arrival (`planFocusFlight`), and the flight reuses that same duration. The settled view looks back toward the focused body's parent (`computeEndDirection`'s `lookBias`), turned out by `lookBiasClearanceDeg` when the close-up body would otherwise eclipse it. With `lightFromStar`, it's then turned onto the body's day side, the star 30–50° off the view (`FocusTarget.starDirection`, `DAY_SIDE_PHASE_RANGE_DEG`), and the clearance applied again. Once settled, steady tracking anchors the target on the tracked body's live position every frame via `moveTo` — absolute, not delta-based, so no gap can accumulate — leaving the user's orbit/dolly offset (and its damping) alone; a manual pan is therefore overridden while the body moves, and works while paused. Moving the zoom slider mid-flight ends the flight and re-anchors on its destination (the live tracked body, or the flight's end target for a belt/system focus) rather than the mid-flight look-at point. Both the outgoing and incoming body are excluded as colliders during a flight, and the outgoing one is released once the camera is clear of it (deselecting included).

**A load-bearing detail**: no line or point buffer bakes a large absolute position into its float32 vertices. Route lines, belts and point clouds keep their vertex data *parent-relative* (small numbers) and apply the parent's own (potentially large) world offset via a wrapping `<group position={...}>`; the batched orbit lines have no per-orbit group, so they write each orbit's point nearest the camera camera-relative in float64 on the CPU every frame instead, and build the line out from it (see "Large systems"). Three.js composes `Object3D` transforms in double precision (regular JS numbers) all the way through matrix multiplication, converting to float32 only at the final GPU upload — baking a large absolute offset directly into a `Float32Array`-backed geometry instead wastes most of float32's ~7 significant digits on the shared offset, which showed up as visible jittering for a tight orbit (the ISS) around a body far from the origin (Earth's own compressed distance from the star is ~31 world units). If you add a new line/point-cloud component, follow the same pattern.

### Floating origin

Everything in the scene — positions, the camera, camera-controls, flights, tracking, colliders, labels, hover — works in plain world coordinates (JS doubles). Only the draw call is rebased: `RenderPass` (a positive-priority `useFrame`, so it runs last and does the rendering) scales and offsets the one group holding every rendered world object so each sits at `(world − camera) / viewingDistance`, puts the camera at the origin with its clip planes divided by the same distance, renders, and puts both back. three composes that in float64 before anything reaches the GPU, so the GPU always works around a unit distance from the camera whatever the zoom. That's what lifted the old 1×10⁻⁶ zoom floor: three's logarithmic depth buffer is log2(1 + w) of the *absolute* view distance, near-linear (a ~1×10⁻⁶ step) below one world unit, so a true-scale ISS used to render as overlapping garbage once framed close. Because camera-controls never sees rebased coordinates, nothing about its damping, a flight in progress or tracking's absolute anchor changes. The world camera's near plane follows the viewing distance every frame (`clipPlanesFor`); with a logarithmic depth buffer the near plane only clips, so it costs nothing to keep it small. If you add an object that's drawn in world space, put it under that root group (`SceneContent` and `StarLight` are); something camera-controls raycasts (`CameraRig`'s collider proxies) stays outside it. The one exception is batched geometry (`OrbitLineBatch`, `InstancedBodies`): a single draw call holds thousands of objects, so there's no per-object float64 transform for three to compose. Those meshes are added to the scene directly, outside the root, and write every object's position already in render space — `(world − camera) × scale`, subtracted in float64 (`writeInstanceMatrix`, `writeOrbitFrame`) from the shared `RenderFrame` that `RenderPass` fills at priority 0.5 and draws with at 1.

### Large systems

A game-data system has thousands of stations, asteroids and points of interest; draw calls and per-body React work, not position maths, were what scaled badly (one mesh, one `<Line>` and two per-frame callbacks per body). Two batched paths keep both a small constant:

- **Orbit lines** (`OrbitLineBatch`, `orbitBatch.ts`): one instanced draw call per orbit LOD tier (`ORBIT_SEGMENT_TIERS`). Each orbit is an instance carrying its ellipse, colour and width; the vertex shader evaluates both ends of each segment at an eccentric anomaly looked up in a shared table of even-arc-length anomalies per eccentricity row (`arcLengthEccentricAnomalies`, the same parameterisation as `orbitPath`), projects them and widens the quad in screen space like drei's `<Line>`. Reference rings are `e = 0` instances. Every frame, in float64, `writeOrbitFrame` anchors each orbit at its point nearest the camera, written camera-relative, and when the camera is close enough for even chords to sag visibly, bunches the segments round that point (`|t|ᵖ` spacing); the shader builds the line out from the anchor, so near the camera it's small numbers, and a true-scale orbit framed from beside its satellite stays on the satellite. Tiers are picked from each orbit's own distance to the camera (`orbitSegmentsFor`), and repack only when one crosses a tier or the selection changes. The selected body's orbit draws brighter and 1.5px wide.
- **Small bodies** (`InstancedBodies`, `smallBodyInstancing.ts`): every body whose placeholder would be at its lowest LOD (no model, texture, rings, atmosphere or glow sprite) is an instance of one `InstancedMesh` per shape, its matrix written each frame from its live position, pixel-floored radius and visibility fade. The shapes' geometry and materials come from `placeholderShapes.ts`, the same definition `BodyShapes` draws the full path from, so the two can't drift apart; under `lightFromStar` the natural shapes take an instanced star-lit material with a per-instance direction to the star (Lambert and the night-side floor, no eclipses). The selected and hovered body, and any body large enough for a higher LOD tier, are promoted to their own `BodyMarker`; a labelled body gets a label-only `BodyMarker` (`drawShape={false}`) while its shape stays instanced. Hit-testing is unaffected — `ProximitySelector` projects the live positions, not meshes.

`npm run bench:scene` measures the per-frame CPU path on 2,000- and 10,000-body synthetic systems (`src/fixtures/syntheticSystem.ts`; the basic example loads one with `?system=synthetic-2000`).

### Star lighting

With `lightFromStar`, natural bodies (planets, moons and asteroids, on the full path and instanced), ring discs and atmosphere rims are drawn with `starLitMaterials.ts` ShaderMaterials (`lights: false`), not three's lights. A scene light sits in compressed world space (`scale.ts`), where distances aren't real, so its geometry can't produce a true-size shadow. Instead, `starLighting.ts` (one store per system, updated by `StarLight` each frame the simulated time changes) resolves every body's **true** km position (`truePositions.ts`). For each body with a mounted lit material, it computes the star's direction and angular size and picks the four casters nearest to shadowing it (`shadows.ts`'s `rankShadowCasters`), all in that body's frame: centred on it, in units of its radius. Each mesh's `onBeforeRender` rotates those into its own object space through the rotation part of its `matrixWorld`. The shaders work entirely in object space (unit-sphere body, rings in body radii), so RenderPass's rebase, a uniform scale plus a translation, never reaches them. The GLSL eclipse (circle–circle overlap of star and occluder discs) and ring-plane functions are ports of `shadows.ts`, which is where the maths is unit-tested. Settings (`starLighting` prop) live on the store and are read at draw time, so toggling them rewrites uniforms without a remount. Comet tails (`CometTails.tsx`) take their shape from `comets.ts`: the ion tail along the solar wind's apparent direction, and dust syndynes integrated under GM(1 − β). They are mounted from `SceneContent` and fade out when the camera is inside them.

## Two-tier testing, deliberately

`src/*.ts` (steps 1–10 above) is unit-tested to full line coverage and a CI-enforced coverage threshold (see `coverageThreshold` in `jest.config.ts`; `src/fixtures/` is test data and excluded) — plain functions, no rendering, fast and precise. `src/react/*` cannot be meaningfully unit-tested the same way: Jest's jsdom environment has no real WebGL context, so nothing about actual rendering, camera behavior, or visual output can be verified there — only its pure helpers (`src/react/__tests__/`) are. Its rendering correctness is instead covered by the Playwright e2e suite (`e2e/*.spec.ts`), which drives both example apps in a real browser — see `CONTRIBUTING.md`'s "End-to-end tests" section before adding a feature that touches rendering behavior.

## Reference data (`solarSystemData.ts`)

The one data file of meaningful size (~1,000 lines) — deliberately not code-golfed shorter, since every body's entry is meant to be independently checkable against its cited real source (NASA JPL, IAU, USGS). Size here reflects the size of the real solar system, not a code-organization problem.

## Build output

`tsup` builds both entry points (`orbital-engine` → `src/index.ts`, `orbital-engine/react` → `src/react/index.ts`) as ESM and CJS with code splitting, so the core lives in one shared `dist/chunk-*` file both entries import — an app using both ships the core once. The react entry keeps `"use client"` as its first directive in both formats (`tsup.config.ts` re-hoists it in the CJS output). `package.json` `exports` give each condition its own declarations (`.d.ts` for `import`, `.d.cts` for `require`); CI checks the packed tarball with `publint` and `@arethetypeswrong/cli`.
