Files
star-map/src/app/features/galaxy-system/system-framing.ts
T
SenrokaiandClaude Opus 5.5 d1aa22ed61 Frame a giant so its disc sits inside the ring its neighbours are named on
The system view names a star's neighbours on a ring at 0.74 of the view's tighter half-extent,
whatever the star. A giant was framed to fill the frame, 2.4 radii out, which the three-radius
closest approach then overrode, so it settled at 3 radii with its disc projecting to 0.758 of the
half-extent: in the app, Betelgeuse's disc was 379 px on a 1 000 px view against the 370 px ring,
and HD 39374 and HD 38118 were printed on it at about 1.3:1 contrast.

The star's term in the framing distance now places its silhouette, asin(R / d), at half the
tighter half-extent, which puts the camera 4.4 radii out on a 50° view. Measured on :4302 at
1600 by 1000: Betelgeuse settles at 13.9 AU (closest approach 9.5) with a 250 px disc and Antares at
14.1 AU; the nearest corner of any neighbour's name is 292 px from the centre, so none is on
either disc (it was two and one of them before). Closing in to the closest approach can still bring
the disc over the names; that is the viewer's choice, not the arrival's.

This also gives the star's term a job: at 2.4 radii it never exceeded the closest approach, so
dropping it changed nothing (the previous commit's one surviving control). Controls, each failing
its named test: the star left out of the framing (1 of 788 failed), the star framed to fill the
frame (2 of 788), and the disc taken as flat (1 of 788).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 20:23:43 +02:00

218 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
*
* The camera's distance is therefore derived from the system's own scale. The star is not: it is
* drawn at its own radius, like every body here, and the framing only makes room for it when the
* star is a giant wider than its system.
*/
/**
* 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;
/**
* How much of the view's tighter half-extent a giant's disc may take on arrival: inside the ring
* the system view names the star's neighbours on, at 0.74 of it, and clear of the names hung
* inward from it, whose nearest corners come within 292 px of the centre on a 1 000 px view.
* Framed to fill the frame instead, Betelgeuse settled at the three-radius closest approach with
* a disc of 379 px, past the 370 px ring, and its neighbours' names on it.
*/
const STAR_FRAME_FRACTION = 0.5;
/** 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, 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, starRadiusAu = 0): number {
// A giant drawn at its own radius can be wider than the system around it — Betelgeuse's 584
// solar radii are 2.7 AU — or than the empty framing. Its disc is a sphere's, whose silhouette
// from d subtends asin(R / d): the distance that makes it the fraction above of the view.
const star = starRadiusAu * Math.sqrt(1 + 1 / (STAR_FRAME_FRACTION * tightHalfExtent(viewport)) ** 2);
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
return Math.max(EMPTY_SYSTEM_FRAMING_DISTANCE_AU, star);
}
const required = Math.max((framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport), star);
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
}
/** How close the camera may come to the star's centre, whatever the star: ten solar radii. */
const MIN_APPROACH_AU = 0.05;
/** From three radii out a star spans 39 degrees, most of the view's 50, and the camera stays out of it. */
const STAR_CLEARANCE_RADII = 3;
/**
* The orbit controls' minimum distance in a system. The fixed 0.05 AU it used to be leaves the
* Sun 11 degrees across; but 23 211 stars on the map are drawn wider than 3.6 solar radii, which
* puts 0.05 AU inside three of their radii, and a giant's surface further out still — a zoom
* would have carried the camera through it.
*/
export function closestApproachAu(starRadiusAu: number): number {
return Math.max(MIN_APPROACH_AU, STAR_CLEARANCE_RADII * starRadiusAu);
}
/** 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;
}
/**
* A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with neither
* a radius nor a mass to estimate one from is drawn as: an Earth, for want of anything better —
* exoplanets with a mass and no radius get an estimate from their mass before they reach here.
*/
const KM_PER_AU = 149597870.7;
const DEFAULT_BODY_RADIUS_KM = 6371;
/**
* The Sun's own radius, in AU: the unit every star's radius is drawn in, the archive's or the one
* `starSurfaceOf` derives from its colour and brightness.
*/
export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
/**
* Radius (AU) to draw a planet, moon or exoplanet marker at: its own, unexaggerated.
*
* Sizes used to be exaggerated and scaled to the system span, which is what made a moon the size
* of its planet — Jupiter and Ganymede both ran past the ceiling and were drawn at one radius, so
* every moon orbited inside its parent. True scale needs no rule to prevent that: physics already
* puts a moon outside the planet it orbits, and the Sun at a hundredth of Mercury’s orbit.
*
* What true scale costs is visibility at the framing that holds a whole system, where every body
* is sub-pixel. That is paid for on screen instead, in pixels, by the scene's `keepMarkersLegible`.
*/
export function bodyMarkerRadiusAu(radiusKm: number | undefined): number {
return (radiusKm && radiusKm > 0 ? radiusKm : DEFAULT_BODY_RADIUS_KM) / KM_PER_AU;
}