From 936d1c01f8055aecd8e5db75d60644e24d69d377 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Tue, 29 Sep 2026 23:17:00 +0200 Subject: [PATCH] Leave a system outwards from wherever the camera stands, and test that the scene frames the furthest it draws Leaving a system flew the camera to a fixed 400 AU. Since c45d916 frames the Sun's system on Eris's 97.7 AU aphelion, a phone held upright arrives 508 AU out, so leaving drew the system 21 per cent nearer during the 0.9 s exit (review, 390x844: 507.9 -> 400.0 AU). The exit now flies to 400 AU or half as far again as the camera already stands, whichever is further. Measured live on :4301, the distance from the Sun through the exit, first and last frame: 390x844 507.9 -> 761.8 AU (was 507.9 -> 400.0) 360x780 508.5 -> 762.7 768x1024 312.9 -> 469.3 (was 312.9 -> 400) 1600x1000 234.7 -> 400.0 (unchanged) and never nearer in between. The swap puts the camera at GALAXY_APPROACH_DISTANCE_PC whatever the exit distance, so only the animation changes. No test checked that the scene hands outermostRadiusAu to the framing: framing on the 67.9 AU semi-major axis instead, which on a square window puts Eris off screen on arrival, passed all 841 tests. The scene spec's fixture held Earth alone. A describe now adds Eris (a = 67.934, e = 0.4382): - "frames the furthest the system draws" requires the settled camera to stand at systemFramingDistanceAu(outermostRadiusAu) from its target, 234.7 AU at aspect 1. - "leaves the system outwards even from a phone's framing" enters at aspect 390/844, then samples the camera each frame of the exit until the swap: never nearer, and further at the end. Controls, each run on the full suite: - outermostRadiusAu -> maxTopLevelSemiMajorAxisAu in the scene fails both (2 failed, 842 passed); the second fails too because the arrival is then under 500 AU. - Math.max -> Math.min in the exit (back to 400 AU) fails the exit test only (1 failed, 843 passed). The framing spec's fit() said it was "what the scene actually composes"; it frames the ring only, so its comment now says it is the grid's half and where the aphelion is tested. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../galaxy-system-scene.component.spec.ts | 54 +++++++++++++++++++ .../galaxy-system-scene.component.ts | 9 +++- .../galaxy-system/system-framing.spec.ts | 6 ++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index ce5033b..766c8b5 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -19,6 +19,8 @@ import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { galacticNormal } from './grid-plane'; import { JumpLinkRenderer } from './jump-link-renderer'; import { StarFieldRenderer } from './star-field-renderer'; +import { systemFramingDistanceAu } from './system-framing'; +import { SystemOrbitsRenderer } from './system-orbits-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; // jsdom does not implement ResizeObserver; the component only uses it to react to real @@ -877,6 +879,58 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(note()).toMatch(/ to 2020-12-21 18:00 UTC\.$/); }); + describe('with Eris, whose aphelion runs past the grid', () => { + type FramedScene = { bodies: BodyRecord[]; controls: { target: THREE.Vector3 }; systemRenderer: SystemOrbitsRenderer; systemGroup: THREE.Group }; + // Its 67.9 AU axis gives the grid an 80 AU outer ring; at aphelion it is 97.7 AU out. + const ERIS: BodyRecord = { + ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, + orbit: { ...EARTH.orbit, semiMajorAxisAu: 67.934, eccentricity: 0.4382 }, rates: keplerRates(67.934, GM_SUN_AU3_PER_DAY2) + }; + + async function enterTheSun(aspect: number): Promise { + const component = fixture.componentInstance as unknown as FramedScene; + component.bodies = [EARTH, ERIS]; + engine.getPerspectiveCamera().aspect = aspect; + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + return component; + } + + it('frames the furthest the system draws, Eris’s aphelion, not the ring inside it nor its semi-major axis', async () => { + const component = await enterTheSun(1); + const camera = engine.getPerspectiveCamera(); + expect(component.systemRenderer.outermostRadiusAu).toBeCloseTo(67.934 * 1.4382, 9); + // 234.7 AU; framed on the 67.9 AU axis the camera would stand at 163 AU and Eris arrive off screen. + expect(camera.position.distanceTo(component.controls.target)).toBeCloseTo( + systemFramingDistanceAu(component.systemRenderer.outermostRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect }), + 6 + ); + }); + + it('leaves the system outwards even from a phone’s framing, which stands past the 400 AU it used to fly to', async () => { + const component = await enterTheSun(390 / 844); + const camera = engine.getCamera(); + const arrival = camera.position.length(); + expect(arrival).toBeGreaterThan(500); + + navigationStore.selectStar(null); + await flushAsync(1); + // Until the swap: it flew 508 AU in to 400, and the system grew on screen while the reader left it. + let previous = arrival; + for (let frame = 0; frame < 100 && component.systemGroup.visible; frame++) { + engine.tick(0.05); + await flushAsync(1); + if (component.systemGroup.visible) { + expect(camera.position.length()).toBeGreaterThanOrEqual(previous - 1e-9); + previous = camera.position.length(); + } + } + expect(component.systemGroup.visible).toBe(false); + expect(previous).toBeGreaterThan(arrival); + }); + }); + it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => { navigationStore.selectStar(ALPHA_CENTAURI.id); await flushAsync(); 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 f472c75..2b08ca8 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -289,8 +289,13 @@ const SYSTEM_MIN_DISTANCE_AU = 0.05; const SYSTEM_MAX_DISTANCE_AU = 5000; /** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */ const SYSTEM_ENTRY_DISTANCE_AU = 200; -/** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */ +/** + * How far out (AU) the camera flies, at least, before swapping back to galaxy/parsec space. A camera + * already beyond it flies half as far again: a phone held upright frames the Sun's system from 508 AU, + * and flying to 400 drew the system 21 per cent nearer while the reader was leaving it. + */ const SYSTEM_EXIT_DISTANCE_AU = 400; +const SYSTEM_EXIT_PULL_BACK = 1.5; const APPROACH_DURATION_SECONDS = 1.0; const SETTLE_DURATION_SECONDS = 0.9; @@ -2321,7 +2326,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.rig.flyTo( { - position: direction.clone().multiplyScalar(SYSTEM_EXIT_DISTANCE_AU), + position: direction.clone().multiplyScalar(Math.max(SYSTEM_EXIT_DISTANCE_AU, SYSTEM_EXIT_PULL_BACK * camera.position.length())), target: new THREE.Vector3(0, 0, 0), }, EXIT_DURATION_SECONDS, diff --git a/src/app/features/galaxy-system/system-framing.spec.ts b/src/app/features/galaxy-system/system-framing.spec.ts index b920144..e86e951 100644 --- a/src/app/features/galaxy-system/system-framing.spec.ts +++ b/src/app/features/galaxy-system/system-framing.spec.ts @@ -117,7 +117,11 @@ describe('systemFramingDistanceAu', () => { }); describe('the grid and the framing together', () => { - /** What the scene actually composes: rings from the orbits, then a distance from the rings. */ + /** + * The grid's half of what the scene composes: rings from the orbits, then a distance from the outer + * ring. The scene frames the larger of that ring and the furthest aphelion (`outermostRadiusAu`), + * which the Eris test below frames where it runs past the ring, and the scene's own spec checks. + */ function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } { const rings = systemGridRingsAu(outermostOrbitAu); const ring = rings[rings.length - 1];