Frame the system view from the camera it actually has

The grid overflowed the frame in 368 of the 371 systems the datasets
contain — median fill 1.11, and the outermost ring cut off by the viewport
edge in almost every one.

Two compounding causes. The framing distance was a fixed multiple of the
outermost orbit, tuned by eye against a 55-degree field of view; the
engine's camera is 50. And it framed the outermost *orbit*, while the
widest thing actually drawn is the grid's outer ring, which by
construction always sits beyond it.

Neither is fixable by adjusting the multiple, because a multiple is the
wrong shape of answer: what has to fit is a radius on screen, and how much
radius a given distance buys depends entirely on the lens. So the distance
now comes from the camera's own vertical field of view and aspect —
picking whichever screen axis is the tighter one, so a portrait window
backs off further rather than clipping — applied to the grid's outer ring
with an explicit margin around it.

The ceiling goes up with it. Eighty AU could not frame the solar system
out to Pluto once the real field of view was accounted for; that needs 120
on a landscape display and 140 on a portrait one. Only companions hundreds
of AU out reach the new ceiling, and those still arrive framed on their
inner region.

Measured across every system in the data, at three window shapes: the
overflow count drops from 368 to 2, the fill settles at exactly 0.89 —
the margin, uniformly — and the outer ring still encloses the outermost
orbit everywhere, so neither invariant was traded for the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-05 07:30:02 +00:00
parent ac296f5133
commit 6019987fc4
6 changed files with 152 additions and 18 deletions
+5
View File
@@ -306,3 +306,8 @@ Real photography where it exists, and a surface reasoned from measurements where
- Derive equilibrium temperature and bulk density from it, and classify each world by size, temperature and density; validate against the solar system's own bodies. - Derive equilibrium temperature and bulk density from it, and classify each world by size, temperature and density; validate against the solar system's own bodies.
- Paint the surface procedurally from that class, seeded per body so it is stable between visits, and apply it in both the body-detail view and the system-view markers. - Paint the surface procedurally from that class, seeded per body so it is stable between visits, and apply it in both the body-detail view and the system-view markers.
- State the derivation and its limits on screen, next to the measurements it rests on. - State the derivation and its limits on screen, next to the measurements it rests on.
### ✓ Step 10: Frame the system view from the camera it actually has
- Replace the fixed distance-to-outermost-orbit multiple with a distance derived from the camera's vertical field of view and aspect, so what fits is a radius on screen rather than a guess.
- Frame against the reference grid's outer ring, which is always wider than the outermost orbit, and leave an explicit margin around it.
- Raise the framing ceiling far enough to hold the solar system out to Pluto in a portrait window; only companions hundreds of AU out reach it now.
+3 -1
View File
@@ -46,7 +46,9 @@ stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies ar
along them by a Kepler solver against the current epoch. Under them, a dashed grid marks out along them by a Kepler solver against the current epoch. Under them, a dashed grid marks out
round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a
drop line from each body, so eccentricity and inclination read against a circular reference drop line from each body, so eccentricity and inclination read against a circular reference
instead of having to be inferred from a shape in space. instead of having to be inferred from a shape in space. The camera frames that grid rather than
the orbits, from the field of view it actually has, so the outermost ring sits inside the frame
with room around it at any system scale and any window shape.
**Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet, **Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet,
with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's
@@ -733,7 +733,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }); this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
const framingDistance = systemFramingDistanceAu(this.systemRenderer.maxTopLevelSemiMajorAxisAu); // Framed against the grid's outer ring rather than the outermost orbit — the ring is always
// the wider of the two — and against the camera this scene actually has, so the margin holds
// whatever the window shape.
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect });
// Arrives along whichever direction the approach came from, then swings round to look down // Arrives along whichever direction the approach came from, then swings round to look down
// on this system's own orbital plane as it settles — so the swap stays continuous but the // on this system's own orbital plane as it settles — so the swap stays continuous but the
// system is not presented edge-on. See `systemViewDirection`. // system is not presented edge-on. See `systemViewDirection`.
@@ -6,8 +6,10 @@ import {
bodyMarkerRadiusAu, bodyMarkerRadiusAu,
DEFAULT_STAR_MARKER_RADIUS_AU, DEFAULT_STAR_MARKER_RADIUS_AU,
starMarkerRadiusAu, starMarkerRadiusAu,
systemFrameRadiusAu,
systemFramingDistanceAu, systemFramingDistanceAu,
systemGridRingsAu, systemGridRingsAu,
SystemViewport,
SYSTEM_VIEW_DIRECTION_IN_PLANE, SYSTEM_VIEW_DIRECTION_IN_PLANE,
systemViewDirection systemViewDirection
} from './system-framing'; } from './system-framing';
@@ -51,9 +53,9 @@ describe('starMarkerRadiusAu', () => {
}); });
describe('systemFramingDistanceAu', () => { describe('systemFramingDistanceAu', () => {
it('fits the whole system in view', () => { it('fits the radius it is given in view, with room around it', () => {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) { for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) {
expect(systemFramingDistanceAu(outermost)).toBeGreaterThan(outermost); expect(systemFrameRadiusAu(systemFramingDistanceAu(radius))).toBeGreaterThan(radius);
} }
}); });
@@ -63,13 +65,39 @@ describe('systemFramingDistanceAu', () => {
expect(systemFramingDistanceAu(GL_357.outermost)).toBeLessThan(1); expect(systemFramingDistanceAu(GL_357.outermost)).toBeLessThan(1);
}); });
it('scales in proportion to the outermost orbit', () => { it('scales in proportion to the radius it has to frame', () => {
expect(systemFramingDistanceAu(0.2) / systemFramingDistanceAu(0.1)).toBeCloseTo(2, 9); expect(systemFramingDistanceAu(0.2) / systemFramingDistanceAu(0.1)).toBeCloseTo(2, 9);
}); });
it('backs off further for a narrower field of view, which a fixed multiple could not', () => {
// The bug this replaced: the multiple was tuned by eye against a 55-degree field and the
// engine's camera is 50, so everything sat that much too close.
const wide = systemFramingDistanceAu(1, { fovDegrees: 70, aspect: 1.78 });
const narrow = systemFramingDistanceAu(1, { fovDegrees: 30, aspect: 1.78 });
expect(narrow).toBeGreaterThan(wide);
});
it('backs off further for a portrait window, where the horizontal axis is the tighter one', () => {
const landscape = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1.78 });
const portrait = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 0.6 });
expect(portrait).toBeCloseTo(landscape / 0.6, 6);
});
it('ignores aspect once the window is landscape, since the vertical binds there', () => {
const square = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1 });
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
});
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => { it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000)); expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
expect(systemFramingDistanceAu(SOLAR.outermost)).toBeLessThanOrEqual(80); });
it('reaches far enough to frame the solar system out to Pluto', () => {
// The old 80 AU ceiling could not: at the camera's real field of view this needs 120.
const rings = systemGridRingsAu(39.288);
const distance = systemFramingDistanceAu(rings[rings.length - 1]);
expect(distance).toBeLessThan(200);
expect(systemFrameRadiusAu(distance)).toBeGreaterThan(39.288);
}); });
it('stays outside the orbit controls minimum distance', () => { it('stays outside the orbit controls minimum distance', () => {
@@ -78,8 +106,50 @@ describe('systemFramingDistanceAu', () => {
}); });
it('uses a sensible default for a star with no known planets', () => { it('uses a sensible default for a star with no known planets', () => {
for (const outermost of [0, -1, Number.NaN]) { for (const radius of [0, -1, Number.NaN]) {
expect(systemFramingDistanceAu(outermost)).toBe(3); expect(systemFramingDistanceAu(radius)).toBe(3);
}
});
});
describe('the grid and the framing together', () => {
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
const rings = systemGridRingsAu(outermostOrbitAu);
const ring = rings[rings.length - 1];
return { ring, frame: systemFrameRadiusAu(systemFramingDistanceAu(ring, viewport), viewport) };
}
const VIEWPORTS: SystemViewport[] = [
{ fovDegrees: 50, aspect: 1.78 },
{ fovDegrees: 50, aspect: 1 },
{ fovDegrees: 50, aspect: 0.6 }
];
it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => {
// The whole point of framing against the grid rather than the orbits: before this, 368 of
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
for (const viewport of VIEWPORTS) {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
const { ring, frame } = fit(outermost, viewport);
expect(ring).toBeLessThan(frame);
expect(ring / frame).toBeLessThan(0.93);
}
}
});
it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
expect(fit(outermost).ring).toBeGreaterThan(outermost);
}
});
it('does not overshoot either: the grid still fills most of the frame', () => {
// A margin is not the same as framing a system from orbit. Half the frame empty would be as
// wrong as none of it.
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) {
const { ring, frame } = fit(outermost);
expect(ring / frame).toBeGreaterThan(0.6);
} }
}); });
}); });
@@ -27,8 +27,30 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
*/ */
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.35; 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; * 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 * Floor on the framing distance. Only guards the degenerate case — it sits just above the
@@ -36,8 +58,16 @@ const FRAMING_TO_OUTERMOST_ORBIT = 2.4;
*/ */
const MIN_FRAMING_DISTANCE_AU = 0.06; 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; * 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;
/** Framing for a star with no known planets, where there is nothing to fit. */ /** Framing for a star with no known planets, where there is nothing to fit. */
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3; const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
@@ -88,14 +118,32 @@ export function starMarkerRadiusAu(innermostOrbitAu: number): number {
} }
/** /**
* Distance (AU) to settle the camera at, given the system's outermost orbit — far enough that * Radius, in AU, that the camera can see at the star's own distance — the half-height of the
* every orbit fits in frame, close enough that a compact system is not a cluster of specks. * view frustum where the system sits, along whichever screen axis is tighter.
*/ */
export function systemFramingDistanceAu(outermostOrbitAu: number): number { export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
if (!Number.isFinite(outermostOrbitAu) || outermostOrbitAu <= 0) { 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): number {
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
return EMPTY_SYSTEM_FRAMING_DISTANCE_AU; return EMPTY_SYSTEM_FRAMING_DISTANCE_AU;
} }
return clamp(outermostOrbitAu * FRAMING_TO_OUTERMOST_ORBIT, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU); const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
} }
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */ /** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
@@ -169,6 +169,11 @@ export class SystemOrbitsRenderer {
* frame: the ecliptic for the solar system, the plane of the sky for everything else. * frame: the ecliptic for the solar system, the plane of the sky for everything else.
*/ */
readonly referenceFrame: THREE.Quaternion; readonly referenceFrame: THREE.Quaternion;
/**
* Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost
* orbit — is the widest thing the system draws, so it is what the camera has to frame.
*/
readonly gridOuterRadiusAu: number;
private readonly topLevelBodies: TrackedTopLevelBody[] = []; private readonly topLevelBodies: TrackedTopLevelBody[] = [];
private readonly moons: TrackedMoon[] = []; private readonly moons: TrackedMoon[] = [];
@@ -266,6 +271,7 @@ export class SystemOrbitsRenderer {
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame; this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu); const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0;
if (rings.length > 0) { if (rings.length > 0) {
this.grid = new PolarGridPlane({ this.grid = new PolarGridPlane({
ringRadii: rings, ringRadii: rings,