Scale

A true-scale solar system is mostly empty black. The Sun is ~109 Earth radii across, Earth’s orbit is ~23,000 Earth radii out, and at any zoom that frames the orbits the planets are sub-pixel. So every view cheats scale — it inflates bodies and compresses distances until the scene reads as a solar system instead of a starfield.

The scale axis (src/solar-system/state/scale.js, #91) turns that cheat into a knob. It is the second of the five animatable axes (see storytelling.md): a tweenable, subscribable state surface that interpolates the scene continuously between the artistic default and true astronomical proportions. “Go to true scale” is a voyage tween, not a rebuild.

This document is the developer/agent guide to that module — the mental model, the API, and the scale-axis discipline every scene/camera consumer must follow (#183). It is also the canonical record of which compressions each view bakes in and why (the role the older doc played, still linked from architecture.md and physics.md).

Scope: the scale axis is a solar-system view feature. The galaxy and universe views compress scale too (see the per-view table, below) but bake it statically into their inlined tables — they have no scale axis. Everything from The state API on is solar-system-only.


1. The mental model: two rulers

Imagine two independent rulers measuring the same scene in world units:

The two rulers differ by a factor of ~1565 — that is STRICT_FACTOR ≈ 1/1565, the ratio of km-per-unit on the size ruler to km-per-unit on the distance ruler. If you measured bodies and orbits on the same ruler, planets would be sub-pixel against their orbits. Exaggeration is the gap between the two rulers, and the scale axis lets you dial it.

The four sub-axes

Axis Domain 0 means 1 means 2 means
size [0, 1] compressed display radii (PLANETS table) true relative radii (Earth-anchored)
distance [0, 1] compressed orbit distances true orbit distances
exaggeration [0, 2] strict scale (size ruler = distance ruler; bodies sub-pixel) default ≈1500× radius inflation cinematic 2× over default
lunarOrbit [0, 2] compressed moon orbits (default look) true proportional moon orbits pushed beyond true

Defaults: { size: 0, distance: 0, exaggeration: 1, lunarOrbit: 0 } — i.e. the pleasing artistic look the scene has always shown.

Key decoupling: lunarOrbit is its own axis, not driven by distance. distance drives planet orbits + asteroid populations; lunarOrbit drives moon orbits. A moon orbits at multiple × parentEffectiveRadius, so it scales in lock-step with its parent and never collapses inside it.

No clamping, anywhere. Voyage scripts may pass values outside the documented domains; behavior is linear/log extrapolation by the active branch. Don’t add clamps.

How exaggeration maps to a multiplier

exaggerationToFactor(slider) converts the exaggeration sub-axis into the multiplier applied to a body’s radius:

exaggerationToFactor(slider) =
  slider <= 1 ? Math.pow(STRICT_FACTOR, 1 - slider)   // log-uniform STRICT_FACTOR→1
              : slider;                                // linear 1→2 (and beyond)

At exaggeration = 0 the factor is STRICT_FACTOR (size ruler collapses onto the distance ruler — strict scale, bodies sub-pixel, markers take over). At exaggeration = 1 the factor is 1 (the default inflation). The log-uniform ramp gives perceptually even steps across the ~1565× range.


2. Effective values: the only thing consumers read

A consumer must never read a body’s static radius / distance. It reads the effective value — the live result of folding the four sub-axes (global + any per-body override) into the static endpoints. There are two layers of accessors:

View-level (src/views/solar-system.js) — fold size/distance/exaggeration into a view PLANETS record:

effectiveRadius(p)    // lerp(p.radius, p.radiusTrue, size) × exaggerationToFactor(exaggeration)
effectiveDistance(p)  // lerp(p.distance, p.distanceTrue, distance)
earthEffectiveRadius() // effectiveRadius(<Earth record>) — sampled by city vcams (#173)

Each view computes the true endpoints once at module init: p.radiusTrue = radiusKm × SIZE_UNITS_PER_KM and p.distanceTrue = orbitKm × DISTANCE_UNITS_PER_KM.

Module-level (state/scale.js) — for moons and for raw axis reads:

getEffectiveScale(id) → { size, distance, exaggeration, lunarOrbit }
                        // per-body override falling back to global, per sub-axis
effectiveMoonRadius(md)                       // size + exaggeration, keyed by `${parentId}:${moonId}`
effectiveMoonDistance(md, parentEffectiveRadius) // multiple driven by lunarOrbit, on the parent-radius ruler

getEffectiveScale(id) is the primitive: id is a lowercased body name ('earth', 'mars', 'sun') or a moon key '<parentId>:<moonId>'. Override resolution is per sub-axis — an override that sets only size still inherits the global distance/exaggeration/lunarOrbit.


3. The state API

Imported from the barrel src/solar-system/index.js (not ./state/scale.js directly). All setters follow the cross-axis conventions: return Promise<reachedValues>, tween when given { duration }, and a same-target setter cancels-and-replaces the in-flight tween silently.

import {
  scaleState,        // frozen read-only mirror: { global, overrides }
  setScale,          // global axis setter
  setBodyScale,      // per-body override setter (or clear)
  setArtisticScale,  // single-knob artistic→real, tweenable
  scaleArtisticToReal, // pure: s → { size, distance, exaggeration, lunarOrbit }
  onScaleChange,     // subscribe; returns unsubscribe
} from '../index.js';
Call Effect
setScale({ size, distance, exaggeration, lunarOrbit }, opts) Tween any subset of the global axes. Omitted axes are untouched.
setBodyScale(id, values, opts) Per-body override. values = null clears the override (kills its tweens, drops the entry).
setArtisticScale(s, opts) Drives all four sub-axes along the curated artistic→real line; tweens like any axis.
scaleArtisticToReal(s) Pure helper: { size: s, distance: s, exaggeration: 1−s, lunarOrbit: s }. The single coherence-preserving line — verified no orbit crossings, no planet larger than its orbit across s ∈ [0,1].
onScaleChange(cb) cb({ global, overrides, source }), source ∈ {'instant','tween','user','panel'}. Returns the unsubscribe fn.

opts: { duration = 0, curve = 'easeInOutCubic', source }. duration: 0 is instant (source defaults to 'instant'); duration > 0 tweens (source defaults to 'tween').

Also exported from the module (used by the view’s animate loop and tests):

Driving it from a voyage

setScale / setBodyScale are allowlisted phases in the voyage grammar (src/voyages/schema.js); the runner forces a trailing { duration } and dispatches { values, body? }. Scripted voyages call the helpers directly, e.g. await setArtisticScale(1, { duration: 3000 }) in scripted/showcase.js. The UI sliders are just thin callers: the dev panel’s four-slider scale-block and the élève scale-simple-block (single artistic→real knob) both call setScale on input and re-sync from onScaleChange.


4. Scale-axis discipline (#183)

Every geometric value the scene consumes — a mesh scale, a vcam offset, a layer’s geometry, a particle position — must read the live effective value at consumption time. Never capture p.radius / p.distance / earthRadius / mesh.geometry.parameters.radius into a closure and reuse it as a constant. That capture is the bug class behind #173 and #175.

The camera/scene always reads the scale axis; it must never write it (no setBodyScale from a vcam onActivate hook). Exaggeration stays an explicit user/voyage choice.

Three sub-patterns cover every case:

(1) Read live at consumption time. Per-frame components take a number-or-getter so they re-read each frame:

(2) Debounced brain.reactivate(activeId) on onScaleChange. A body that reads scale live still needs to re-snap to its canonical pose at the new scale (e.g. “orbit at 8× the radius” means a different world position once the radius changes). Reactivating every frame of a slider drag would thrash the blend, so vcams/destinations.js debounces (~150 ms) and reactivates once scale activity settles:

function onScaleChanged() {
  clearTimeout(_scaleRebuildTimer);
  _scaleRebuildTimer = setTimeout(() => {
    const activeId = brain.getActiveId();
    if (isRadiusFramedDest(activeId)) brain.reactivate(activeId, performance.now());
  }, 150);
}
onScaleChange(onScaleChanged);

A mode toggle that swaps a baked offset config (not a continuous slider) rebuilds the affected bodies before reactivating instead.

(3) Unit geometry + per-frame mesh.scale. For a layer whose vertex data depends on the scaled radius but that isn’t parented to the scaled body group, build the geometry at unit radius once and scale it each frame from the live effective radius. The tide water layer is the canonical example (scene/earth.js: built at TIDE_LAYER_RADIUS ≈ 1.001, then mesh.scale.setScalar(getEffectiveEarthRadius()) per frame, carrying its child force-arrows with it).

The camera near plane is the same kind of quantity

A fixed near plane clips a body as it shrinks toward sub-pixel. So the near is not hardcoded — the brain derives it adaptively each frame from the active vcam’s nearReference (falling back to camera→target distance):

near = min(baseNear, max(NEAR_MIN, refDist * NEAR_FRACTION))  // NEAR_FRACTION = 0.02

Because object-framing vcams’ camera→target distance is itself scale-derived (the offset uses getScale), the near tracks scale for free. Surface-anchored city/satellite cams supply an explicit nearReference (distance to the marker). Never reintroduce a fixed per-vcam near (the old 0.0001 is gone).


5. Calibration constants

These tie the abstract sub-axes to concrete world units. Keep them in sync — the size ruler and distance ruler are independently calibrated.

Constant Where Value Meaning
EARTH_DISTANCE_UNITS state/scale.js 15 Earth’s orbit in world units; mirrors AU_SS_BREAKPOINTS[2][1].
STRICT_FACTOR state/scale.js ≈ 1/1565 size-ruler ÷ distance-ruler km-per-unit; the strict-scale multiplier.
KM_PER_UNIT_AT_TRUE_SCALE state/scale.js ≈ 9.97e6 world unit → km at distance = 1 (readouts only).
SIZE_UNITS_PER_KM solar-system.js 1 / EARTH_RADIUS_KM size ruler; Earth’s true radius = 1.0 unit.
DISTANCE_UNITS_PER_KM solar-system.js 15 / KM_PER_AU distance ruler; 1 AU = 15 units.
AU_SS_BREAKPOINTS solar-system.js table piecewise AU→units for the compressed orbits + asteroid clouds.

6. Per-view compression {#per-view-compression}

The scale axis only interpolates from the compressed defaults to true scale. Those compressed defaults live in each view’s own inlined PLANETS table — no view imports src/data/planets.js, by design (see CLAUDE.md Design rules). Here is which view cheats what, and why:

View Table Compression baked in
src/views/solar-system.js PLANETS Display radii inflated ~1500× vs orbits (the exaggeration = 1 default); orbits compressed via AU_SS_BREAKPOINTS with outer planets widened beyond linear (#59, Mars/Jupiter aphelion clearance). The scale axis lerps these toward radiusTrue / distanceTrue.
src/views/galaxy.js PLANETS French-named, visually-scaled inner solar system embedded in the galaxy view — decorative, not real values, and static (no scale axis here).
src/views/launches.js N/A No scale-axis compression — 1 world unit = 1 000 km (WGS-84). Satellite positions are computed by SGP4 from real TLEs; sizes and distances are not compressed. Only visual exaggerations: atmosphere glow, orbit ring widths, screen-size-constant point sprites.

The solar-system distance breakpoints (AU_SS_BREAKPOINTS, AU → world units):

0.387→8   0.723→11   1.0→15   1.524→19   5.203→32   9.537→48   19.19→62   30.07→74

These are deliberately non-linear: inner planets are spread out for legibility, outer gaps compressed, with extra clearance past Mars so orbits never visually cross. A new view may inline its own table with a different shape if it needs one — but document the divergence in CLAUDE.md’s Design rules table.


Quick reference

// Read (consumers): always live, never captured
getEffectiveScale('mars')                 // { size, distance, exaggeration, lunarOrbit }
effectiveRadius(p) / effectiveDistance(p)  // view-level, folds the axes
effectiveMoonRadius(md) / effectiveMoonDistance(md, parentEffR)

// Write (UI / voyages only): tween, return Promise
await setScale({ distance: 1 }, { duration: 2000 });
await setArtisticScale(1, { duration: 3000, curve: 'easeInOutCubic' });
await setBodyScale('mars', { exaggeration: 2 }, { duration: 1000 });
setBodyScale('mars', null);                // clear override

// React
const off = onScaleChange(({ global, source }) => { /* re-sync UI / debounce reframe */ });

// Per frame (animate loop only)
tick(performance.now());

Don’ts: don’t capture a radius/distance into a closure; don’t hardcode a near plane; don’t write the scale axis from a camera hook; don’t clamp axis values.

launches.html — satellite orbits

1 world unit = 1 000 km. At this scale:

Satellite positions are computed by SGP4 from real TLEs — sizes and distances are not compressed. The Earth sphere, atmosphere glow, and orbit ring widths are the only visual exaggerations; point markers are screen-size-constant sprites. Goal: the altitude hierarchy is visible — LEO satellites hug the Earth, GPS sits far out, GEO is a thin ring near the edge of view.

← Astrarium