Files
star-map/src/app/features/galaxy-system/system-framing.ts
T
Claude 6019987fc4 Frame the system view from the camera it actually has
The grid overflowed the frame in 368 of the 371 systems the datasets
contain — median fill 1.11, and the outermost ring cut off by the viewport
edge in almost every one.

Two compounding causes. The framing distance was a fixed multiple of the
outermost orbit, tuned by eye against a 55-degree field of view; the
engine's camera is 50. And it framed the outermost *orbit*, while the
widest thing actually drawn is the grid's outer ring, which by
construction always sits beyond it.

Neither is fixable by adjusting the multiple, because a multiple is the
wrong shape of answer: what has to fit is a radius on screen, and how much
radius a given distance buys depends entirely on the lens. So the distance
now comes from the camera's own vertical field of view and aspect —
picking whichever screen axis is the tighter one, so a portrait window
backs off further rather than clipping — applied to the grid's outer ring
with an explicit margin around it.

The ceiling goes up with it. Eighty AU could not frame the solar system
out to Pluto once the real field of view was accounted for; that needs 120
on a landscape display and 140 on a portrait one. Only companions hundreds
of AU out reach the new ceiling, and those still arrive framed on their
inner region.

Measured across every system in the data, at three window shapes: the
overflow count drops from 368 to 2, the fill settles at exactly 0.89 —
the margin, uniformly — and the outer ring still encloses the outermost
orbit everywhere, so neither invariant was traded for the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-05 07:30:02 +00:00

216 lines
10 KiB
TypeScript

import * as THREE from 'three/webgpu';
import { CartesianCoordinates } from '../../shared/astro/coordinates';
/**
* How the system view sizes itself to whatever system it is showing.
*
* Real planetary systems span four orders of magnitude: TRAPPIST-1's outermost planet orbits
* closer than Mercury by a factor of six, while some directly-imaged companions sit hundreds of
* AU out. A single fixed star size and camera distance cannot serve both, and the fixed pair
* that used to be hard-coded served only the wide end — 29% of systems had *every* orbit inside
* the star marker, so they rendered as a lone sphere with nothing around it, and 52% were
* framed from a distance floor far larger than the system itself.
*
* Both quantities are therefore derived from the system's own scale. Because the star and the
* camera scale together, a compact system ends up looking like a wide one: same apparent star,
* same apparent spread of orbits.
*/
/** Star size when there are no orbits to scale against, and the ceiling everywhere else. */
export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
/**
* Star radius as a fraction of the innermost orbit. Comfortably below 1 so there is visible
* space between the star's limb and the closest orbit, rather than the orbit grazing or
* disappearing inside it.
*/
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.35;
/**
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
* with room around it rather than grazing the edge.
*/
const FRAME_MARGIN = 0.12;
/**
* The camera the system view is framed for. The vertical field of view is what
* `EngineService` creates its camera with; the aspect decides which screen axis is the tighter
* one, since a perspective camera's `fov` is vertical and the horizontal extent scales with the
* aspect. Anything landscape is bound by the vertical, anything portrait by the horizontal.
*/
export interface SystemViewport {
fovDegrees: number;
aspect: number;
}
export const DEFAULT_SYSTEM_VIEWPORT: SystemViewport = { fovDegrees: 50, aspect: 1 };
/** Half-angle tangent along whichever screen axis is the tighter of the two. */
function tightHalfExtent(viewport: SystemViewport): number {
return Math.tan((viewport.fovDegrees * Math.PI) / 360) * Math.min(1, viewport.aspect);
}
/**
* Floor on the framing distance. Only guards the degenerate case — it sits just above the
* orbit controls' own minimum distance, so for any real system the fit above decides.
*/
const MIN_FRAMING_DISTANCE_AU = 0.06;
/**
* Ceiling on the framing distance, so a distant companion does not push the star to a dot.
*
* Generous enough to frame the solar system out to Pluto in any window shape, which needs 120 AU
* on a landscape display and 140 on a portrait one once the camera's real field of view is
* accounted for. Only genuinely pathological systems reach it now — the handful with
* directly-imaged companions hundreds of AU out — and those still arrive framed on their inner
* region, with the orbit controls reaching far enough to pull back to the rest.
*/
const MAX_FRAMING_DISTANCE_AU = 200;
/** Framing for a star with no known planets, where there is nothing to fit. */
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/**
* Where the camera settles when arriving at a system, as a unit direction from the star —
* expressed in the system's *own* reference plane, before that plane is rotated into the scene.
*
* A three-quarter view, about 37 degrees off the plane's normal, so a system reads as a disc
* rather than as a line.
*/
export const SYSTEM_VIEW_DIRECTION_IN_PLANE: CartesianCoordinates = { x: 0, y: 0.6, z: 0.8 };
/**
* That direction carried into the scene's equatorial frame by the system's own reference frame.
*
* The scene is equatorial so that orbits and stars share one frame, but no system's orbits lie
* in the equatorial plane: the solar system's are measured against the ecliptic, 23.4 degrees
* out of it, and an exoplanet system's against the plane of the sky, which depends on where its
* host star happens to be. Left to the equatorial axes — or to any single fixed direction — some
* systems come out edge-on.
*
* Rather than rotate the world into a comfortable pose, which would put the orbits back at odds
* with the sky, the camera is placed relative to whichever plane the system was measured in. So
* every system reads as a disc while staying exactly where it truly sits.
*/
export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vector3 {
const { x, y, z } = SYSTEM_VIEW_DIRECTION_IN_PLANE;
return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame);
}
/**
* Radius (AU) to draw the system's star at, given its innermost orbit.
*
* Never larger than {@link DEFAULT_STAR_MARKER_RADIUS_AU}, and never large enough to reach the
* closest orbit. Falls back to that default when the system has no planets, since there is
* then nothing for the star to crowd.
*/
export function starMarkerRadiusAu(innermostOrbitAu: number): number {
if (!Number.isFinite(innermostOrbitAu) || innermostOrbitAu <= 0) {
return DEFAULT_STAR_MARKER_RADIUS_AU;
}
return Math.min(DEFAULT_STAR_MARKER_RADIUS_AU, innermostOrbitAu * STAR_RADIUS_TO_INNERMOST_ORBIT);
}
/**
* Radius, in AU, that the camera can see at the star's own distance — the half-height of the
* view frustum where the system sits, along whichever screen axis is tighter.
*/
export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
return distanceAu * tightHalfExtent(viewport);
}
/**
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
* around it.
*
* Derived from the camera's actual field of view rather than from a multiple of the outermost
* orbit. A plain multiple cannot be right: what has to fit is a *radius* on screen, and how much
* radius a given distance buys depends entirely on the lens. The multiple that used to be here
* was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the
* grid overflowing the frame in 368 of the 371 systems the datasets contain.
*
* Callers pass the outermost thing actually drawn, which is the reference grid's outer ring
* rather than the outermost orbit — the ring is always the wider of the two, by construction.
*/
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
return EMPTY_SYSTEM_FRAMING_DISTANCE_AU;
}
const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
}
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
const TARGET_GRID_RING_COUNT = 8;
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
/** Ring spacings are always one of these times a power of ten, so the numbers stay readable. */
const RING_STEP_MANTISSAS = [1, 2, 5, 10];
/**
* Ring radii (AU) for the system view's reference grid, given the system's outermost orbit.
*
* Snapped to a 1-2-5 ladder rather than evenly dividing the system, because the point of the
* grid is to put a number on a distance: rings at 5, 10, 15 AU can be read off at a glance, and
* rings at 4.34, 8.68, 13.02 AU cannot. That holds across the four orders of magnitude real
* systems span — the solar system gets 5 AU rings, TRAPPIST-1 gets 0.01 AU ones.
*
* Empty for a system with no orbits to scale against; there is no distance to mark out.
*/
export function systemGridRingsAu(outermostOrbitAu: number): number[] {
if (!Number.isFinite(outermostOrbitAu) || outermostOrbitAu <= 0) {
return [];
}
const extent = outermostOrbitAu * GRID_EXTENT_TO_OUTERMOST_ORBIT;
const target = extent / TARGET_GRID_RING_COUNT;
const magnitude = Math.pow(10, Math.floor(Math.log10(target)));
const step = magnitude * (RING_STEP_MANTISSAS.find((mantissa) => magnitude * mantissa >= target) ?? 10);
// Rounded up, not truncated: the last ring has to enclose the outermost orbit rather than fall
// just inside it, or the outermost planet spends its year outside the grid meant to measure it.
const count = Math.ceil(extent / step);
const rings: number[] = [];
// Multiplied rather than accumulated, so a step of 0.01 does not drift into 0.060000000000000005.
for (let index = 1; index <= count; index++) {
rings.push(index * step);
}
return rings;
}
/**
* Span of the solar system, in AU, used as the reference every other system's marker sizes are
* scaled against. The marker constants below were tuned by eye at this scale.
*/
const REFERENCE_SYSTEM_SPAN_AU = 30;
/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */
const MIN_MARKER_RADIUS_AU = 0.012;
const MAX_MARKER_RADIUS_AU = 0.09;
/** Physical radius (km) that maps to one AU of marker radius before clamping. */
const MARKER_RADIUS_KM_PER_AU = 18000;
/**
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in.
*
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so
* a single planet swallows the entire view.
*
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
* planets as small dots on them.
*/
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number {
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU);
}