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) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 23:17:00 +02:00
co-authored by Claude Opus 5.5
parent f6bb2b9584
commit 936d1c01f8
3 changed files with 66 additions and 3 deletions
@@ -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<FramedScene> {
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();
@@ -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,
@@ -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];