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 distance ruler places orbits. Calibrated so 1 AU = 15 world units (Earth’s orbit sits at 15). At true scale 1 world unit ≈ 9.97 million km.
- The size ruler places body radii. Calibrated so Earth’s true radius equals its compressed display radius (1.0 world unit). At true scale 1 world unit ≈ 6371 km (one Earth radius).
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):
tick(now)— per-frame ticker; the animate loop passes the rAF timestamp so tweens advance and fire'tween'. Withouttick, tweens never progress.isTweening()— true while any scale tween is in flight.STRICT_FACTOR,KM_PER_UNIT_AT_TRUE_SCALE— calibration constants (the latter used byreadouts.jsfor light-time/Voyager readouts, only meaningful atdistance = 1).
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.radiusinto 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:
- Planets/moons/sun: the animate loop scales each mesh from its effective
radius every frame —
parent.scale.setScalar(effectiveRadius(p) / p.radius),mesh.scale … × (effectiveMoonRadius(data) / data.radius), orbit rings by the effective-distance ratio. - Camera bodies:
localFrameRide’sgetScaleis a getter, e.g.() => Math.max(effectiveRadius(p) * 8, MIN_ORBIT_OFFSET); city vcams sampleearthEffectiveRadiuslive (#173); the satellite chase converts km→world through a livekmToWorld().
(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:
- Earth radius ≈ 6.38 world units (WGS-84: 6 378.137 km)
- ISS orbit radius ≈ 6.79 world units (altitude ~410 km)
- GPS orbit radius ≈ 26.6 world units (altitude ~20 200 km)
- GEO orbit radius ≈ 42.2 world units (altitude 35 786 km)
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