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; }