diff --git a/.junie/plans/nasa-star-map.md b/.junie/plans/nasa-star-map.md index bf28aba..24f5203 100644 --- a/.junie/plans/nasa-star-map.md +++ b/.junie/plans/nasa-star-map.md @@ -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. - 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. + +### ✓ 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. diff --git a/README.md b/README.md index 73191d1..ef1666d 100644 --- a/README.md +++ b/README.md @@ -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 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 -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, with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's 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 2db32ee..a5b352e 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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) }); - 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 // 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`. diff --git a/src/app/features/galaxy-system/system-framing.spec.ts b/src/app/features/galaxy-system/system-framing.spec.ts index 00ee68b..fcae3fb 100644 --- a/src/app/features/galaxy-system/system-framing.spec.ts +++ b/src/app/features/galaxy-system/system-framing.spec.ts @@ -6,8 +6,10 @@ import { bodyMarkerRadiusAu, DEFAULT_STAR_MARKER_RADIUS_AU, starMarkerRadiusAu, + systemFrameRadiusAu, systemFramingDistanceAu, systemGridRingsAu, + SystemViewport, SYSTEM_VIEW_DIRECTION_IN_PLANE, systemViewDirection } from './system-framing'; @@ -51,9 +53,9 @@ describe('starMarkerRadiusAu', () => { }); describe('systemFramingDistanceAu', () => { - it('fits the whole system in view', () => { - for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) { - expect(systemFramingDistanceAu(outermost)).toBeGreaterThan(outermost); + it('fits the radius it is given in view, with room around it', () => { + for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) { + expect(systemFrameRadiusAu(systemFramingDistanceAu(radius))).toBeGreaterThan(radius); } }); @@ -63,13 +65,39 @@ describe('systemFramingDistanceAu', () => { 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); }); + 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', () => { 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', () => { @@ -78,8 +106,50 @@ describe('systemFramingDistanceAu', () => { }); 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); + for (const radius of [0, -1, Number.NaN]) { + 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); } }); }); diff --git a/src/app/features/galaxy-system/system-framing.ts b/src/app/features/galaxy-system/system-framing.ts index 73ccfa1..b0aaa24 100644 --- a/src/app/features/galaxy-system/system-framing.ts +++ b/src/app/features/galaxy-system/system-framing.ts @@ -27,8 +27,30 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2; */ 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 @@ -36,8 +58,16 @@ const FRAMING_TO_OUTERMOST_ORBIT = 2.4; */ 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. */ 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 - * every orbit fits in frame, close enough that a compact system is not a cluster of specks. + * 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 systemFramingDistanceAu(outermostOrbitAu: number): number { - if (!Number.isFinite(outermostOrbitAu) || outermostOrbitAu <= 0) { +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): number { + if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) { 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. */ diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 6a98e4c..4209822 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -169,6 +169,11 @@ export class SystemOrbitsRenderer { * frame: the ecliptic for the solar system, the plane of the sky for everything else. */ 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 moons: TrackedMoon[] = []; @@ -266,6 +271,7 @@ export class SystemOrbitsRenderer { this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame; const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu); + this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0; if (rings.length > 0) { this.grid = new PolarGridPlane({ ringRadii: rings,