diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 61b8bd4..53774cc 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -15,6 +15,7 @@ import { StarRecord } from '../../shared/models/star.model'; import { NavigationStore } from '../../shared/state/navigation.store'; import { CameraRigController } from './camera-rig-controller'; import { DeepSkyRenderer } from './deep-sky-renderer'; +import { starMarkerRadiusAu, systemFramingDistanceAu } from './system-framing'; import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; import { SystemOrbitsRenderer } from './system-orbits-renderer'; @@ -55,16 +56,12 @@ const SYSTEM_MAX_DISTANCE_AU = 5000; const SYSTEM_ENTRY_DISTANCE_AU = 200; /** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */ const SYSTEM_EXIT_DISTANCE_AU = 400; -const MIN_SYSTEM_FRAMING_DISTANCE_AU = 3; -const MAX_SYSTEM_FRAMING_DISTANCE_AU = 80; const APPROACH_DURATION_SECONDS = 1.0; const SETTLE_DURATION_SECONDS = 0.9; const EXIT_DURATION_SECONDS = 0.9; const RETURN_DURATION_SECONDS = 1.1; -const STAR_MARKER_RADIUS_AU = 0.2; - /** * Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select * picking, proximity-based name labels, and — once a star is selected — a camera-flight @@ -102,7 +99,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private readonly galaxyGroup = new THREE.Group(); private readonly systemGroup = new THREE.Group(); private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); - private readonly starMarkerGeometry = new THREE.SphereGeometry(STAR_MARKER_RADIUS_AU, 24, 16); + /** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */ + private starMarkerGeometry?: THREE.SphereGeometry; private controls?: OrbitControls; private rig?: CameraRigController; @@ -157,7 +155,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemRenderer?.dispose(); (this.starMarker?.material as THREE.Material | undefined)?.dispose(); (this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose(); - this.starMarkerGeometry.dispose(); + this.starMarkerGeometry?.dispose(); this.starMarkerMaterial.dispose(); this.engine.dispose(); } @@ -397,6 +395,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets); this.systemGroup.add(this.systemRenderer.object); + // Sized against this system's innermost orbit, so the star never swallows its own planets. + const starRadiusAu = starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu); + this.starMarkerGeometry?.dispose(); + this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 24, 16); + const starMarkerMaterial = this.starMarkerMaterial.clone(); const starColor = colorIndexToRgb(star.colorIndex, star.spectralType); if (star.id === SOL_STAR_ID) { @@ -404,10 +407,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // other point in the galaxy view is far too distant to be resolved as a disk. starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH); starMarkerMaterial.color.set(0xffffff); - this.starGlow = createGlowSprite(0xfff2c0, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE); + this.starGlow = createGlowSprite(0xfff2c0, starRadiusAu, SUN_GLOW_SCALE); } else { starMarkerMaterial.color.copy(starColor); - this.starGlow = createGlowSprite(starColor, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE * 0.6); + this.starGlow = createGlowSprite(starColor, starRadiusAu, SUN_GLOW_SCALE * 0.6); } this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial); this.systemGroup.add(this.starMarker, this.starGlow); @@ -427,11 +430,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }); - const framingDistance = THREE.MathUtils.clamp( - this.systemRenderer.maxTopLevelSemiMajorAxisAu * 2.4 || MIN_SYSTEM_FRAMING_DISTANCE_AU, - MIN_SYSTEM_FRAMING_DISTANCE_AU, - MAX_SYSTEM_FRAMING_DISTANCE_AU - ); + const framingDistance = systemFramingDistanceAu(this.systemRenderer.maxTopLevelSemiMajorAxisAu); this.rig!.flyTo({ position: direction.clone().multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => { this.currentStarId = star.id; diff --git a/src/app/features/galaxy-system/system-framing.spec.ts b/src/app/features/galaxy-system/system-framing.spec.ts new file mode 100644 index 0000000..90364e5 --- /dev/null +++ b/src/app/features/galaxy-system/system-framing.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { bodyMarkerRadiusAu, DEFAULT_STAR_MARKER_RADIUS_AU, starMarkerRadiusAu, systemFramingDistanceAu } from './system-framing'; + +/** Real systems spanning the range the view has to cope with. */ +const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 }; +const GL_357 = { innermost: 0.035, outermost: 0.204 }; +const SOLAR = { innermost: 0.387, outermost: 30.07 }; + +describe('starMarkerRadiusAu', () => { + it('never reaches the innermost orbit', () => { + for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) { + expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost); + } + }); + + it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => { + // Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system. + expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost); + expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost); + }); + + it('never grows beyond the default, however wide the system', () => { + expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU); + expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU); + }); + + it('scales in proportion to the innermost orbit', () => { + expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9); + }); + + it('falls back to the default when there are no planets to scale against', () => { + for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU); + } + }); + + it('stays positive for an extremely tight orbit', () => { + expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0); + }); +}); + +describe('systemFramingDistanceAu', () => { + it('fits the whole system in view', () => { + for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) { + expect(systemFramingDistanceAu(outermost)).toBeGreaterThan(outermost); + } + }); + + it('closes right in on a compact system instead of hanging back at a fixed floor', () => { + // The old floor was 3 AU — some 48x the width of the entire TRAPPIST-1 system. + expect(systemFramingDistanceAu(TRAPPIST_1.outermost)).toBeLessThan(1); + expect(systemFramingDistanceAu(GL_357.outermost)).toBeLessThan(1); + }); + + it('scales in proportion to the outermost orbit', () => { + expect(systemFramingDistanceAu(0.2) / systemFramingDistanceAu(0.1)).toBeCloseTo(2, 9); + }); + + it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => { + expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000)); + expect(systemFramingDistanceAu(SOLAR.outermost)).toBeLessThanOrEqual(80); + }); + + it('stays outside the orbit controls minimum distance', () => { + // Framing closer than the controls allow would be clamped straight back out again. + expect(systemFramingDistanceAu(0.00001)).toBeGreaterThanOrEqual(0.05); + }); + + it('uses a sensible default for a star with no known planets', () => { + for (const outermost of [0, -1, Number.NaN]) { + expect(systemFramingDistanceAu(outermost)).toBe(3); + } + }); +}); + +describe('star and framing together', () => { + it('gives compact and wide systems a comparable apparent star size', () => { + // Both scale with the system, so the star subtends a similar angle either way — the point + // of deriving them from the same measurements rather than fixing them. + const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) => + starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost); + + const compact = apparent(TRAPPIST_1); + const midRange = apparent(GL_357); + + expect(compact).toBeGreaterThan(0); + expect(compact / midRange).toBeGreaterThan(0.25); + expect(compact / midRange).toBeLessThan(4); + }); + + it('always leaves the innermost orbit outside the star, at every scale', () => { + for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) { + expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost); + } + }); +}); + +describe('bodyMarkerRadiusAu', () => { + const EARTH_RADIUS_KM = 6371; + const SOLAR_SPAN_AU = 30.07; + + it('scales in proportion to the system span', () => { + const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU); + const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100); + + expect(compact / wide).toBeCloseTo(0.01, 6); + }); + + it('keeps a marker far smaller than the orbits it sits on, at any scale', () => { + // A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one + // planet swallowed the whole view. + for (const span of [0.06, 0.204, 1, 30.07, 800]) { + expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5); + } + }); + + it('gives compact and wide systems the same apparent marker size', () => { + const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span); + + expect(apparent(0.204)).toBeCloseTo(apparent(10), 6); + }); + + it('still renders a bigger body as a bigger marker', () => { + const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU); + const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU); + + expect(jupiter).toBeGreaterThan(pluto); + }); + + it('falls back to the smallest marker for a body with no known radius', () => { + const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU); + const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU); + + expect(unknown).toBeGreaterThan(0); + expect(unknown).toBeLessThanOrEqual(pluto); + }); + + it('treats a missing span as the reference scale rather than collapsing to zero', () => { + for (const span of [0, -5, Number.NaN]) { + expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0); + } + }); + + it('leaves the solar system essentially as it was before scaling', () => { + // The constants were tuned at this span, so the scale factor here is ~1. + expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2); + }); +}); diff --git a/src/app/features/galaxy-system/system-framing.ts b/src/app/features/galaxy-system/system-framing.ts new file mode 100644 index 0000000..3307eb7 --- /dev/null +++ b/src/app/features/galaxy-system/system-framing.ts @@ -0,0 +1,99 @@ +/** + * 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; + +/** Camera distance as a multiple of the outermost orbit, so the whole system fits in view. */ +const FRAMING_TO_OUTERMOST_ORBIT = 2.4; + +/** + * 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. */ +const MAX_FRAMING_DISTANCE_AU = 80; + +/** 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)); +} + +/** + * 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); +} + +/** + * Distance (AU) to settle the camera at, given the system's outermost orbit — far enough that + * every orbit fits in frame, close enough that a compact system is not a cluster of specks. + */ +export function systemFramingDistanceAu(outermostOrbitAu: number): number { + if (!Number.isFinite(outermostOrbitAu) || outermostOrbitAu <= 0) { + return EMPTY_SYSTEM_FRAMING_DISTANCE_AU; + } + return clamp(outermostOrbitAu * FRAMING_TO_OUTERMOST_ORBIT, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU); +} + +/** + * 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); +} diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 810c1d3..6979bee 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -3,6 +3,7 @@ import * as THREE from 'three/webgpu'; import { gmForParent } from '../../shared/astro/constants'; import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; +import { bodyMarkerRadiusAu } from './system-framing'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet'; @@ -27,16 +28,6 @@ const ORBIT_LINE_OPACITY_BY_KIND: Record = { }; const EARTH_RADIUS_KM = 6371; -const MIN_MARKER_RADIUS_AU = 0.012; -const MAX_MARKER_RADIUS_AU = 0.09; - -/** Exaggerated (non-physical) marker radius so planets stay visible at AU scale. */ -function markerRadiusAu(radiusKm: number | undefined): number { - if (!radiusKm) { - return MIN_MARKER_RADIUS_AU; - } - return THREE.MathUtils.clamp(radiusKm / 18000, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU); -} function colorForKind(kind: SystemMemberKind): THREE.Color { switch (kind) { @@ -72,8 +63,8 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THRE return new THREE.Line(geometry, material); } -function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined): THREE.Mesh { - const geometry = new THREE.SphereGeometry(markerRadiusAu(radiusKm), 16, 12); +function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number): THREE.Mesh { + const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12); const material = new THREE.MeshBasicMaterial({ color: colorForKind(kind) }); return new THREE.Mesh(geometry, material); } @@ -108,6 +99,8 @@ export class SystemOrbitsRenderer { readonly members: readonly SystemMember[]; /** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */ readonly maxTopLevelSemiMajorAxisAu: number; + /** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */ + readonly minTopLevelSemiMajorAxisAu: number; private readonly topLevelBodies: TrackedTopLevelBody[] = []; private readonly moons: TrackedMoon[] = []; @@ -117,6 +110,15 @@ export class SystemOrbitsRenderer { const members: SystemMember[] = []; const topLevelBodiesById = new Map(); + // Measured before anything is built, because marker sizes are scaled against the span and + // the markers are created as the bodies are added. + const topLevelAxes = [ + ...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu), + ...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!) + ].filter((axis) => Number.isFinite(axis) && axis > 0); + this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0; + this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0; + for (const body of bodies) { if (!body.parentBodyId) { topLevelBodiesById.set(body.id, body); @@ -168,7 +170,6 @@ export class SystemOrbitsRenderer { } this.members = members; - this.maxTopLevelSemiMajorAxisAu = this.topLevelBodies.reduce((max, body) => Math.max(max, body.elements.semiMajorAxisAu), 0); } /** Recomputes every marker's position for the given Julian date. Call once per tick. */ @@ -215,7 +216,7 @@ export class SystemOrbitsRenderer { private addTopLevelBody(id: string, kind: SystemMemberKind, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined): TrackedTopLevelBody { const orbitLine = buildOrbitLine(elements, kind); - const marker = buildMarker(kind, radiusKm); + const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu); this.object.add(orbitLine, marker); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); @@ -228,7 +229,7 @@ export class SystemOrbitsRenderer { private addMoon(id: string, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody): TrackedMoon { const pivot = new THREE.Group(); const orbitLine = buildOrbitLine(elements, 'moon'); - const marker = buildMarker('moon', radiusKm); + const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu); pivot.add(orbitLine, marker); this.object.add(pivot); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);