diff --git a/.junie/plans/nasa-star-map.md b/.junie/plans/nasa-star-map.md index 24f5203..5bafde4 100644 --- a/.junie/plans/nasa-star-map.md +++ b/.junie/plans/nasa-star-map.md @@ -311,3 +311,4 @@ Real photography where it exists, and a surface reasoned from measurements where - 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. +- Floor the star's halo against the framed radius, so a star sized against its innermost orbit still reads at the distance that frames its outermost one. diff --git a/README.md b/README.md index ef1666d..3bfab13 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ instead of having to be inferred from a shape in space. The camera frames that g 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. +The star at the centre is sized against the system's *innermost* orbit, so it can never swallow +its closest planet, while the camera is placed to frame the *outermost* ring — and in the solar +system those differ by a factor of a hundred. At the distance that fits Pluto in view, a disc +that stays clear of Mercury is about a pixel across, and no radius satisfies both. So the disc +stays honest to the orbits and the star's halo carries its visibility, floored against the framed +radius: light is not a surface, and a glow reaching past the innermost orbit says the star is +bright rather than that it is large. + **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 own measurements where it does not. See "On surfaces that were never photographed" below. diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 3e2d868..667b9ba 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -21,6 +21,7 @@ import { InfoPanelComponent } from './info-panel.component'; /** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */ const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']); +/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */ const GLOW_SCALE = 2.6; /** @@ -201,7 +202,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { } const atmosphereColor = atmosphereColorFor(viewModel.id); if (atmosphereColor !== undefined) { - this.glow = createGlowSprite(atmosphereColor, 1, GLOW_SCALE); + this.glow = createGlowSprite(atmosphereColor, GLOW_SCALE); this.scene.add(this.glow); } } 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 a5b352e..d057aa9 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -19,7 +19,7 @@ import { CameraRigController } from './camera-rig-controller'; import { DeepSkyRenderer } from './deep-sky-renderer'; import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane'; import { MilkyWayRenderer } from './milky-way-renderer'; -import { starMarkerRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; +import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; import { HudReadout, StarmapHudComponent } from './starmap-hud.component'; import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; @@ -27,7 +27,8 @@ import { SystemOrbitsRenderer } from './system-orbits-renderer'; /** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */ const SOL_STAR_ID = 0; -const SUN_GLOW_SCALE = 3.2; +/** Stars drawn from a colour rather than a photograph get a more restrained halo. */ +const DIM_STAR_GLOW_SCALE = 0.6; /** Stars closer than this to the camera get a name label (always includes the selection). */ const LABEL_MAX_DISTANCE_PC = 20; @@ -698,6 +699,14 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity); this.systemGroup.add(this.systemRenderer.object); + // 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. Computed before the star, because how far away the star will be + // seen from is what decides how big its halo has to be to stay visible. + const viewport = { fovDegrees: camera.fov, aspect: camera.aspect }; + const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport); + const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport); + // Sized against this system's innermost orbit, so the star never swallows its own planets. const starRadiusAu = starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu); this.starMarkerGeometry?.dispose(); @@ -710,10 +719,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, starRadiusAu, SUN_GLOW_SCALE); + this.starGlow = createGlowSprite(0xfff2c0, starGlowExtentAu(starRadiusAu, frameRadiusAu)); } else { starMarkerMaterial.color.copy(starColor); - this.starGlow = createGlowSprite(starColor, starRadiusAu, SUN_GLOW_SCALE * 0.6); + this.starGlow = createGlowSprite(starColor, starGlowExtentAu(starRadiusAu, frameRadiusAu, DIM_STAR_GLOW_SCALE)); } this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial); this.systemGroup.add(this.starMarker, this.starGlow); @@ -733,10 +742,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }); - // 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 fcae3fb..e7e9d21 100644 --- a/src/app/features/galaxy-system/system-framing.spec.ts +++ b/src/app/features/galaxy-system/system-framing.spec.ts @@ -5,6 +5,7 @@ import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/co import { bodyMarkerRadiusAu, DEFAULT_STAR_MARKER_RADIUS_AU, + starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, @@ -112,6 +113,75 @@ describe('systemFramingDistanceAu', () => { }); }); +describe('starGlowExtentAu', () => { + /** Apparent size on screen, as a fraction of the frame's half-height. */ + function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number { + const rings = systemGridRingsAu(outermostAu); + const distance = systemFramingDistanceAu(rings[rings.length - 1]); + const frame = systemFrameRadiusAu(distance); + // The sprite's extent is its full width, so half of it is what reaches out from the star. + return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frame, glowScale) / 2 / frame; + } + + it('scales with the star for a compact system, where the star is already big enough', () => { + // A tight frame relative to the star, so the star's own multiple is what decides. + const marker = 0.02; + const tightFrame = 0.5; + expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9); + expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9); + }); + + it('floors against the frame once the star would otherwise vanish into it', () => { + // A star sized against a close-in orbit, framed from far enough out to hold a wide system: + // the multiple of the star is nothing, so the frame decides instead. + const tinyStar = 0.001; + const wideFrame = 56; + expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100); + }); + + it('keeps the Sun visible at the distance that frames the solar system', () => { + // The case that prompted this: the solar system spans a factor of a hundred from Mercury to + // Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view. + expect(apparentFraction(0.387, 39.288)).toBeGreaterThan(0.015); + }); + + it('holds the floor across every system scale the datasets contain', () => { + // A compact system's star is genuinely large relative to its own system and keeps the bigger + // halo; the floor is not there to equalise them, only to stop the wide ones disappearing. + for (const [innermost, outermost] of [ + [0.387, 39.288], + [0.035, 0.204], + [0.01154, 0.06189], + [1.2, 12.4] + ]) { + expect(apparentFraction(innermost, outermost)).toBeGreaterThan(0.015); + } + }); + + it('does not blot out the system it sits in', () => { + for (const [innermost, outermost] of [ + [0.387, 39.288], + [0.035, 0.204], + [0.01154, 0.06189] + ]) { + expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2); + } + }); + + it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => { + // Above the floor the multiplier applies... + expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1)); + // ...and at the floor it cannot dim a star into invisibility. + expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1)); + }); + + it('falls back to the star alone when there is no frame to measure against', () => { + for (const frame of [0, -1, Number.NaN]) { + expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9); + } + }); +}); + 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 } { diff --git a/src/app/features/galaxy-system/system-framing.ts b/src/app/features/galaxy-system/system-framing.ts index b0aaa24..361a50b 100644 --- a/src/app/features/galaxy-system/system-framing.ts +++ b/src/app/features/galaxy-system/system-framing.ts @@ -25,7 +25,25 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2; * 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; +const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45; + +/** + * Halo extent as a multiple of the star's own radius, and the floor on that extent as a + * fraction of the framed radius. + * + * The floor is what keeps a star visible. A system's star is sized against its *innermost* + * orbit — it must never swallow its closest planet — while the camera is placed to frame the + * *outermost* ring, and those differ by a factor of a hundred in the solar system. At the + * distance that fits Pluto in view, a disc that stays clear of Mercury is about one pixel + * across; there is no radius that satisfies both, because the information genuinely does not + * fit on one screen at that zoom. + * + * The halo resolves it, because light is not a surface: a glow that reaches past the innermost + * orbit does not claim the star is that large, it claims the star is bright. So the disc stays + * honest to the orbits and the halo is floored against the frame. + */ +const STAR_GLOW_TO_MARKER = 3.2; +const MIN_STAR_GLOW_TO_FRAME = 0.035; /** * Clear space left around the framed radius, as a fraction of it. The camera backs off this @@ -125,6 +143,20 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport return distanceAu * tightHalfExtent(viewport); } +/** + * Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius. + * + * Normally a multiple of the star's own radius, so a compact system keeps the corona it has. + * Floored against the framed radius, so a star framed from far enough out to hold its whole + * system still reads as a bright point rather than disappearing into it. `glowScale` lets a + * caller dim the halo for stars drawn without a real photograph. + */ +export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number { + const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale; + const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0; + return Math.max(fromStar, fromFrame); +} + /** * Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin * around it. diff --git a/src/app/shared/rendering/skybox.ts b/src/app/shared/rendering/skybox.ts index 6600279..2f2990d 100644 --- a/src/app/shared/rendering/skybox.ts +++ b/src/app/shared/rendering/skybox.ts @@ -88,12 +88,16 @@ export function createGlowTexture(color: THREE.ColorRepresentation, profile: Glo /** * Builds a soft additive-blended glow halo (used for planetary atmospheres and the Sun's - * corona) sized relative to the given object radius. Cheap billboard-sprite approximation - * rather than a view-angle-correct Fresnel shader, chosen to stay within built-in material - * types the WebGPU backend renders natively (see plan risk on TSL/shader maturity). Falls back - * to a flat-colored (gradient-less) sprite if canvas rendering is unavailable. + * corona), `extent` across in world units. Cheap billboard-sprite approximation rather than a + * view-angle-correct Fresnel shader, chosen to stay within built-in material types the WebGPU + * backend renders natively (see plan risk on TSL/shader maturity). Falls back to a + * flat-colored (gradient-less) sprite if canvas rendering is unavailable. + * + * Takes the finished extent rather than a radius and a multiplier: how big a star's halo should + * be is not a fixed multiple of the star, it depends on how the system is framed, and that + * decision belongs with the framing (see `starGlowExtentAu`). */ -export function createGlowSprite(color: THREE.ColorRepresentation, radius: number, scale: number): THREE.Sprite { +export function createGlowSprite(color: THREE.ColorRepresentation, extent: number): THREE.Sprite { const material = new THREE.SpriteMaterial({ map: createGlowTexture(color), color: color, @@ -102,6 +106,6 @@ export function createGlowSprite(color: THREE.ColorRepresentation, radius: numbe blending: THREE.AdditiveBlending }); const sprite = new THREE.Sprite(material); - sprite.scale.setScalar(radius * scale); + sprite.scale.setScalar(extent); return sprite; }