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 51c8a40..c0238eb 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -16,7 +16,7 @@ import { EngineService, SceneCamera } from '../../core/engine/engine.service'; import { BodyRecord } from '../../shared/models/body.model'; import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; -import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; +import { applyMilkyWaySkybox } from '../../shared/rendering/skybox'; import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { isDesignation } from '../../shared/models/star-catalog'; import { StarRecord } from '../../shared/models/star.model'; @@ -26,7 +26,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 { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; +import { starMarkerRadiusAu, SUN_RADIUS_AU, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity'; import { distanceRings, formatRoundLength, scaleBar, type LengthUnit, type ScaleBar } from '../../shared/format/scale-bar'; import { BodyDetailViewModel } from '../body-detail/body-detail.model'; @@ -50,8 +50,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; -/** Stars drawn from a colour rather than a photograph get a more restrained halo. */ -const DIM_STAR_GLOW_SCALE = 0.6; +/** Radius, in CSS pixels, below which a body in the system view is scaled up to be seen at all. */ +const MIN_MARKER_PIXELS = 3; /** * How far from what the camera is looking at a star can be and still be named, as a fraction of @@ -465,7 +465,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private currentStarId: number | null = null; private systemRenderer?: SystemOrbitsRenderer; private starMarker?: THREE.Mesh; - private starGlow?: THREE.Sprite; constructor( private readonly engine: EngineService, @@ -513,7 +512,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.labelOverlay?.dispose(); this.systemRenderer?.dispose(); (this.starMarker?.material as THREE.Material | undefined)?.dispose(); - (this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose(); this.starMarkerGeometry?.dispose(); this.starMarkerMaterial.dispose(); this.engine.dispose(); @@ -704,12 +702,58 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { if (this.systemGroup.visible) { this.systemRenderer?.update(dateToJulianDate()); + this.keepMarkersLegible(camera); } this.updateSelectionMark(camera); this.updateNeighbourRing(camera); this.labelOverlay?.render(camera); } + /** + * Holds every body in the system view to a minimum size on screen, by scaling the markers that + * would otherwise be smaller than {@link MIN_MARKER_PIXELS}. + * + * A system is framed to hold its outermost orbit, and at that distance the bodies on the inner + * ones are sub-pixel: at the solar system's arrival distance Jupiter projects to about a pixel + * and Earth to less, so the labels and the selection arcs point at nothing. The halo used to + * cover the star's half of this — a light that reached past the innermost orbit, claiming + * brightness rather than size — but it covered the star only, and at a fixed extent that filled + * the screen once the camera closed in. + * + * Sizing in pixels instead keeps the exaggeration where it is needed and takes it away where it + * is not: a body whose true radius already spans more than the floor is drawn at that radius, so + * zooming in walks back to the real proportions rather than away from them. + */ + private keepMarkersLegible(camera: SceneCamera): void { + const heightPx = this.canvasRef().nativeElement.clientHeight; + if (!this.systemRenderer || heightPx === 0) { + return; + } + const world = new THREE.Vector3(); + const drawnRadiusAu = new Map(); + const radiusOf = (marker: THREE.Object3D): number | undefined => ((marker as THREE.Mesh).geometry as THREE.SphereGeometry | undefined)?.parameters?.radius; + const floorFor = (marker: THREE.Object3D): number => { + marker.getWorldPosition(world); + return MIN_MARKER_PIXELS * ((2 * this.engine.visibleHalfHeight(camera.position.distanceTo(world))) / heightPx); + }; + + // Parents first: a moon's ceiling is its planet's drawn radius, which has to be known by then. + const members = [...this.systemRenderer.members].sort((a, b) => Number(a.kind === 'moon') - Number(b.kind === 'moon')); + for (const { id, marker, parentId } of this.starMarker ? [...members, { id: 'star', marker: this.starMarker, parentId: undefined }] : members) { + const radiusAu = radiusOf(marker); + if (!radiusAu) { + continue; + } + // Lifted to the floor, but never past half of what it orbits: at the arrival framing every + // body is sub-pixel, and floored on its own a moon comes out the size of its planet and + // sitting on top of it — which is the thing true scale was adopted to stop. + const ceiling = parentId !== undefined ? (drawnRadiusAu.get(parentId) ?? Number.POSITIVE_INFINITY) / 2 : Number.POSITIVE_INFINITY; + const drawn = Math.min(Math.max(radiusAu, floorFor(marker)), Math.max(radiusAu, ceiling)); + drawnRadiusAu.set(id, drawn); + marker.scale.setScalar(drawn / radiusAu); + } + } + /** * Blends between the two things that share parsec space: the catalogued star field with its * local grid, and the Milky Way model with its galactic one. Driven by how far the camera @@ -1811,11 +1855,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemGroup.remove(this.starMarker); (this.starMarker.material as THREE.Material).dispose(); } - if (this.starGlow) { - this.systemGroup.remove(this.starGlow); - (this.starGlow.material as THREE.SpriteMaterial).dispose(); - this.starGlow = undefined; - } const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id); const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id); @@ -1838,10 +1877,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const framingCamera = this.engine.getPerspectiveCamera(); const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.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); + // The Sun at its own radius; every other star sized against its innermost orbit, which is all + // the catalogue supports, and which at least never lets it swallow its own planets. + const starRadiusAu = star.id === SOL_STAR_ID ? SUN_RADIUS_AU : starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu); this.starMarkerGeometry?.dispose(); this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 24, 16); @@ -1852,13 +1891,14 @@ 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, starGlowExtentAu(starRadiusAu, frameRadiusAu)); } else { starMarkerMaterial.color.copy(starColor); - this.starGlow = createGlowSprite(starColor, starGlowExtentAu(starRadiusAu, frameRadiusAu, DIM_STAR_GLOW_SCALE)); } + // No halo. It was a sprite sized against the arrival frame — 1.12 AU for the Sun — so it + // stayed put as the camera closed in and ended up filling the screen with the flat gradient + // that was meant to dress the star, over the photograph underneath it. this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial); - this.systemGroup.add(this.starMarker, this.starGlow); + this.systemGroup.add(this.starMarker); this.galaxyGroup.visible = false; this.systemGroup.visible = true; diff --git a/src/app/features/galaxy-system/star-field-renderer.spec.ts b/src/app/features/galaxy-system/star-field-renderer.spec.ts index 53b9ca2..7e4d90e 100644 --- a/src/app/features/galaxy-system/star-field-renderer.spec.ts +++ b/src/app/features/galaxy-system/star-field-renderer.spec.ts @@ -172,6 +172,23 @@ describe('StarFieldRenderer', () => { renderer.dispose(); }); + it('ignores a star just outside the frame, however close the pointer gets to the edge', () => { + // Its hit area is the drawn size plus a slop, so near an edge that area reaches past the + // frame — and a system nobody can see is not one a click should fly into. + const offScreen = [star({ id: 9, x: 0, y: 0, z: -10, magnitude: -2 })]; + const renderer = new StarFieldRenderer(offScreen, packPositions(offScreen)); + const centre = new THREE.Vector3(0, 0, -10).project(camera); + expect(renderer.pickAt(new THREE.Vector2(centre.x, centre.y), camera, camera.aspect)).toBe(9); + + // The same star, now a hair outside the top of the frame. + const above = [star({ id: 9, x: 0, y: 10 * Math.tan((camera.fov * Math.PI) / 360) * 1.02, z: -10, magnitude: -2 })]; + const outside = new StarFieldRenderer(above, packPositions(above)); + + expect(outside.pickAt(new THREE.Vector2(0, 0.99), camera, camera.aspect)).toBeUndefined(); + renderer.dispose(); + outside.dispose(); + }); + it('picks the star nearest the pointer when several are in view', () => { const spread = [ star({ id: 1, x: 0, y: 0, z: -10 }), diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index 4014bf6..8aeb494 100644 --- a/src/app/features/galaxy-system/star-field-renderer.ts +++ b/src/app/features/galaxy-system/star-field-renderer.ts @@ -406,6 +406,11 @@ export class StarFieldRenderer { * needed: each star is tested against the size it is actually drawn at, so the hit area matches * what the user sees at every zoom level instead of being over-permissive up close and * sub-pixel at the far end of the camera's range. + * + * Only stars on screen can be picked. The hit area is the drawn size plus a slop of + * {@link PICK_NDC_SLOP}, and near an edge that slop reaches past the frame: a click in the + * last few pixels of the view used to be able to fly into a system whose star was outside it, + * with nothing on screen to explain where it had gone. */ pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined { // What a unit of angular size is worth on screen. Under perspective the field of view sets diff --git a/src/app/features/galaxy-system/system-framing.spec.ts b/src/app/features/galaxy-system/system-framing.spec.ts index 03e7e35..4bd2c68 100644 --- a/src/app/features/galaxy-system/system-framing.spec.ts +++ b/src/app/features/galaxy-system/system-framing.spec.ts @@ -5,7 +5,6 @@ import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/co import { bodyMarkerRadiusAu, DEFAULT_STAR_MARKER_RADIUS_AU, - starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, @@ -113,105 +112,6 @@ describe('systemFramingDistanceAu', () => { }); }); -describe('starGlowExtentAu', () => { - /** A typical viewport, so a screen-space claim can be made in pixels rather than in ratios. */ - const REFERENCE_VIEWPORT_HALF_HEIGHT_PX = 450; - - /** The halo's visual radius, in AU, at the distance this system is framed from. */ - function haloRadiusAu(innermostAu: number, outermostAu: number, glowScale = 1): number { - // The sprite's extent is its full width, so half of it is what reaches out from the star. - return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frameRadiusFor(outermostAu), glowScale) / 2; - } - - function frameRadiusFor(outermostAu: number): number { - const rings = systemGridRingsAu(outermostAu); - return systemFrameRadiusAu(systemFramingDistanceAu(rings[rings.length - 1])); - } - - /** Apparent size on screen, as a fraction of the frame's half-height. */ - function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number { - return haloRadiusAu(innermostAu, outermostAu, glowScale) / frameRadiusFor(outermostAu); - } - - function apparentPixels(innermostAu: number, outermostAu: number): number { - return apparentFraction(innermostAu, outermostAu) * REFERENCE_VIEWPORT_HALF_HEIGHT_PX; - } - - 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(apparentPixels(0.387, 39.288)).toBeGreaterThan(4); - }); - - it('leaves the inner orbits clear of the halo', () => { - // The other half of the same trade. Venus and Earth have to stay legible as rings around the - // star, which bounds the halo from above just as visibility bounds it from below. - const halo = haloRadiusAu(0.387, 39.288); - const VENUS_AU = 0.723; - const EARTH_AU = 1; - expect(halo).toBeLessThan(VENUS_AU); - expect(halo).toBeLessThan(EARTH_AU); - }); - - it('cannot clear Mercury as well, and does not pretend to', () => { - // Mercury's orbit is 0.7% of the framed radius — about three pixels — so it is inside any - // halo big enough to see. Pinned so the trade is a decision rather than an oversight. - expect(haloRadiusAu(0.387, 39.288)).toBeGreaterThan(0.387); - }); - - 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(apparentPixels(innermost, outermost)).toBeGreaterThan(4); - } - }); - - 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 } { @@ -278,54 +178,41 @@ describe('star and framing together', () => { describe('bodyMarkerRadiusAu', () => { const EARTH_RADIUS_KM = 6371; - const SOLAR_SPAN_AU = 30.07; + const KM_PER_AU = 149597870.7; - it('scales in proportion to the system span', () => { - const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU); - const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100); - - expect(compact / wide).toBeCloseTo(0.01, 6); + it('draws a body at its true size', () => { + expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12); + expect(bodyMarkerRadiusAu(696340)).toBeCloseTo(0.00465, 5); // the Sun }); - it('keeps a marker far smaller than the orbits it sits on, at any scale', () => { - // A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one - // planet swallowed the whole view. - for (const span of [0.06, 0.204, 1, 30.07, 800]) { - expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5); - } + it('keeps a moon smaller than its planet and outside it, which the exaggeration did not', () => { + // Jupiter and Ganymede both ran past the old 0.09 AU ceiling and came out one size, so + // Ganymede orbited inside Jupiter; Phobos and Triton sat entirely within Mars and Neptune. + const jupiter = bodyMarkerRadiusAu(69911); + const ganymede = bodyMarkerRadiusAu(2634); + const callisto = bodyMarkerRadiusAu(2410); + const GANYMEDE_SEMI_MAJOR_AXIS_AU = 0.007155; + + expect(ganymede).toBeLessThan(jupiter); + expect(callisto).toBeLessThan(ganymede); + expect(jupiter + ganymede).toBeLessThan(GANYMEDE_SEMI_MAJOR_AXIS_AU); }); - it('gives compact and wide systems the same apparent marker size', () => { - const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span); + it('keeps Phobos outside Mars, where a marker scaled to the system buried it', () => { + const PHOBOS_SEMI_MAJOR_AXIS_AU = 0.00006268; - expect(apparent(0.204)).toBeCloseTo(apparent(10), 6); + expect(bodyMarkerRadiusAu(3390) + bodyMarkerRadiusAu(11.27)).toBeLessThan(PHOBOS_SEMI_MAJOR_AXIS_AU); }); it('still renders a bigger body as a bigger marker', () => { - const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU); - const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU); - - expect(jupiter).toBeGreaterThan(pluto); + expect(bodyMarkerRadiusAu(69911)).toBeGreaterThan(bodyMarkerRadiusAu(1188)); }); - it('falls back to the smallest marker for a body with no known radius', () => { - const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU); - const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU); - - expect(unknown).toBeGreaterThan(0); - expect(unknown).toBeLessThanOrEqual(pluto); - }); - - it('treats a missing span as the reference scale rather than collapsing to zero', () => { - for (const span of [0, -5, Number.NaN]) { - expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0); + it('falls back to an Earth for a body with no published radius', () => { + for (const nothing of [undefined, 0, -1]) { + expect(bodyMarkerRadiusAu(nothing as number | undefined)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12); } }); - - it('leaves the solar system essentially as it was before scaling', () => { - // The constants were tuned at this span, so the scale factor here is ~1. - expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2); - }); }); describe('systemGridRingsAu', () => { diff --git a/src/app/features/galaxy-system/system-framing.ts b/src/app/features/galaxy-system/system-framing.ts index ba85391..01da365 100644 --- a/src/app/features/galaxy-system/system-framing.ts +++ b/src/app/features/galaxy-system/system-framing.ts @@ -27,31 +27,6 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2; */ 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. - * - * The floor is set by what it must not cover. Its visual radius is half the extent, so a floor - * of `f` puts the halo's edge at `f / 2` of the frame radius — and the orbits it has to leave - * legible sit at their own fraction of that same radius. In the solar system, framed to hold - * Pluto, Venus's orbit is at 1.3% of the frame radius and Earth's at 1.8%, so a floor of 2% - * leaves both of them outside the halo. Mercury's, at 0.7%, is inside it — and would be at any - * halo large enough to see, since the orbit itself is only a few pixels wide there. - */ -const STAR_GLOW_TO_MARKER = 3.2; -const MIN_STAR_GLOW_TO_FRAME = 0.02; - /** * 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 @@ -150,20 +125,6 @@ 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. @@ -223,32 +184,35 @@ export function systemGridRingsAu(outermostOrbitAu: number): number[] { } /** - * Span of the solar system, in AU, used as the reference every other system's marker sizes are - * scaled against. The marker constants below were tuned by eye at this scale. + * A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with no + * published radius is drawn as — Earth, which is the middle of the range for the exoplanets that + * reach here without one. */ -const REFERENCE_SYSTEM_SPAN_AU = 30; - -/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */ -const MIN_MARKER_RADIUS_AU = 0.012; -const MAX_MARKER_RADIUS_AU = 0.09; -/** Physical radius (km) that maps to one AU of marker radius before clamping. */ -const MARKER_RADIUS_KM_PER_AU = 18000; +const KM_PER_AU = 149597870.7; +const DEFAULT_BODY_RADIUS_KM = 6371; /** - * Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in. + * The Sun's own radius, in AU — the one star whose size this map knows. * - * Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its - * own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU - * sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth - * that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so - * a single planet swallows the entire view. - * - * Scaling by the span keeps every system looking like the solar system does: orbits legible, - * planets as small dots on them. + * Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost + * orbit rather than measured, because no stellar radius reaches the app: the catalogue carries + * positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn + * here, and until the ETL fetches it, a system's star is the one body in the view that is not + * to scale. */ -export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number { - const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU; - const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU; +export const SUN_RADIUS_AU = 696340 / KM_PER_AU; - return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU); +/** + * Radius (AU) to draw a planet, moon or exoplanet marker at: its own, unexaggerated. + * + * Sizes used to be exaggerated and scaled to the system span, which is what made a moon the size + * of its planet — Jupiter and Ganymede both ran past the ceiling and were drawn at one radius, so + * every moon orbited inside its parent. True scale needs no rule to prevent that: physics already + * puts a moon outside the planet it orbits, and the Sun at a hundredth of Mercury’s orbit. + * + * What true scale costs is visibility at the framing that holds a whole system, where every body + * is sub-pixel. That is paid for on screen instead, in pixels, by the scene's `keepMarkersLegible`. + */ +export function bodyMarkerRadiusAu(radiusKm: number | undefined): number { + return (radiusKm && radiusKm > 0 ? radiusKm : DEFAULT_BODY_RADIUS_KM) / KM_PER_AU; } diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 4f1cea7..d003445 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -18,6 +18,8 @@ export interface SystemMember { id: string; kind: SystemMemberKind; marker: THREE.Object3D; + /** For a moon, the id of the body it orbits: what its drawn size is held against. */ + parentId?: string; } const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0); @@ -126,8 +128,8 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame * The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what * survives is essentially its average colour, and generating it costs well under a millisecond. */ -function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh { - const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12); +function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh { + const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), 16, 12); const material = appearance ? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) }) : new THREE.MeshBasicMaterial({ color: colorForKind(kind) }); @@ -241,7 +243,7 @@ export class SystemOrbitsRenderer { continue; // orphaned moon reference; skip rather than crash. } const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); - members.push({ id: body.id, kind: 'moon', marker: moon.marker }); + members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id }); } // Every exoplanet in a system shares the same line of sight, so the frame is built once. @@ -374,7 +376,7 @@ export class SystemOrbitsRenderer { appearance?: PlanetAppearance ): TrackedTopLevelBody { const orbitLine = buildOrbitLine(elements, kind, frame); - const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); + const marker = buildMarker(kind, radiusKm, appearance); this.object.add(orbitLine, marker); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); @@ -395,7 +397,9 @@ export class SystemOrbitsRenderer { ): TrackedMoon { const pivot = new THREE.Group(); const orbitLine = buildOrbitLine(elements, 'moon', frame); - const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); + // A moon's own orbit is the thing it must not swallow: drawn at the system's exaggeration it + // is the same size as its planet, and every moon here orbits inside one. + const marker = buildMarker('moon', radiusKm, appearance); pivot.add(orbitLine, marker); this.object.add(pivot); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);