Merge the solar-system branch, so the star catalogue lands on the sky it now shares

Both branches changed the system view's star, the body card's provenance line, the exoplanet
fetch and the ETL's validators. Resolved by keeping both sides:

- The system view's star is the catalogue's (its own radius and temperature, a limb-darkened
  surface in its colour) and turns like a planet when it is the Sun (the solar branch's IAU pole
  and 25.38-day turn), keyed on SUN_STAR_ID, since the catalogue branch dropped the scene's own
  SOL_STAR_ID. Framing takes the outermost thing drawn (an eccentric orbit's aphelion, from the
  solar branch) and the star's radius for a giant (from the catalogue). The solar branch's comment
  about a halo is dropped: there has been none since #33.
- The card's no-temperature sentence is the catalogue's (the host's luminosity or the orbit's size,
  not "not in the catalogue", which holds for 27 planets) and ends with the solar branch's reason
  why no image is used (a point of light for the 101 imaged planets, none for the rest).
- fetchExoplanets reads the composite table and the distance errors (catalogue) and the imaged
  list (solar); build.ts runs both branches' validators.

The data were regenerated by the full ETL on the merged code, from cache (nothing refetched):
stars.bin, stars-meta.bin, stars-index.json and deepsky.json come out byte for byte the catalogue
branch's, bodies.json the solar branch's, and exoplanets.json the catalogue branch's but for the
imaged flag on 101 planets, WASP-108 b not among them. Unit suite 977 passed, the two branches'
870 and 837 over their shared 730, so no test was lost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 22:07:34 +02:00
co-authored by Claude Opus 5.5
67 changed files with 6791 additions and 593 deletions
@@ -24,7 +24,9 @@ import { spectralClassification } from '../../shared/astro/spectral';
import { blackbodyColor } from '../../shared/astro/stellar';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService, SceneCamera } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
import { BodyRecord, RotationalElements } from '../../shared/models/body.model';
import { SUN_ROTATIONAL_ELEMENTS } from '../../shared/astro/rotational-elements';
import { bodyOrientation } from '../../shared/rendering/body-orientation';
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { applyMilkyWaySkybox } from '../../shared/rendering/skybox';
@@ -34,7 +36,7 @@ import {
SUN_TEXTURE_PATH,
} from '../../shared/rendering/texture-catalog';
import { isDesignation } from '../../shared/models/star-catalog';
import { StarRecord } from '../../shared/models/star.model';
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
import { Bookmark } from '../../shared/state/bookmarks.store';
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
import { TimeStore } from '../../shared/state/time.store';
@@ -319,8 +321,13 @@ const SYSTEM_FAR_AU = 20000;
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;
@@ -588,6 +595,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private starMarker?: THREE.Mesh;
/** The system's star's radius and temperature, worked out once on entering it. */
private currentStarSurface?: StarSurface;
/** How the star marker is turned: the Sun's IAU elements for the Sun, nothing for any other star. */
private starRotation?: RotationalElements;
constructor(
private readonly engine: EngineService,
@@ -864,6 +873,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.systemGroup.visible) {
this.systemRenderer?.update(this.time.julianDate());
if (this.starMarker && this.starRotation) {
bodyOrientation(this.starRotation, this.time.julianDate(), this.starMarker.quaternion);
}
this.keepMarkersLegible(camera);
}
this.updateSelectionMark(camera);
@@ -893,8 +905,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
const world = new THREE.Vector3();
const drawnRadiusAu = new Map<string, number>();
const radiusOf = (marker: THREE.Object3D): number | undefined =>
// A body's marker is a unit sphere scaled to its radius, kept in `userData.radiusAu`; the
// star's is built at its own.
const sphereRadius = (marker: THREE.Object3D): number | undefined =>
((marker as THREE.Mesh).geometry as THREE.SphereGeometry | undefined)?.parameters?.radius;
const radiusOf = (marker: THREE.Object3D): number | undefined =>
(marker.userData['radiusAu'] as number | undefined) ?? sphereRadius(marker);
const floorFor = (marker: THREE.Object3D): number => {
marker.getWorldPosition(world);
return (
@@ -923,7 +939,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
: 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);
marker.scale.setScalar(drawn / sphereRadius(marker)!);
}
}
@@ -1796,7 +1812,14 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
},
...starReadouts(star, this.currentStarSurface),
]);
this.hudNote.set(this.time.atNow() ? 'Orbits propagated from published elements to the current date.' : 'Orbits propagated from published elements to the date on the clock.');
// Where the orbits come from, and for the Sun how far from the present they hold: each
// body's card names its own source, and for a moon or an SBDB dwarf planet how far it strays from
// Horizons over the span it was checked.
const source = this.bodies.some((body) => body.systemStarId === star.id) ? 'JPL mean elements, the planets’ fit for 3000 BC to AD 3000 and the moons’ checked from 1950 to 2100, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, checked over the same span,' : 'published elements';
// Named to the minute, in UTC like the date field: a jump to 18:00 on a given day is a
// question about that hour, and the note is where the answer says which sky it is.
const drawnFor = `${this.time.date().toISOString().slice(0, 16).replace('T', ' ')} UTC`;
this.hudNote.set(`Orbits propagated from ${source} to ${this.time.atNow() ? 'now, ' : ''}${drawnFor}.`);
this.hudRange.set(
formatAu(
this.engine.visibleHalfHeight(
@@ -2247,16 +2270,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const starRadiusAu = this.currentStarSurface.radiusSolar === null ? UNMEASURED_STAR_RADIUS_AU : this.currentStarSurface.radiusSolar * SUN_RADIUS_AU;
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
// the wider of the two — or against the star, for a giant wider than both; and against the
// camera this scene actually has, so the margin holds whatever the window shape.
// Framed against the outermost thing drawn — the grid's outer ring, or an eccentric orbit's
// aphelion where it runs past it — or against the star, for a giant wider than both; and
// against the camera this scene actually has, so the margin holds whatever the window shape.
// Framed against the perspective camera whichever is active: the framing distance is what
// the orthographic frustum is then sized from, so both projections show the same extent.
const framingCamera = this.engine.getPerspectiveCamera();
const canvas = this.canvasRef().nativeElement;
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect, shorterSidePx: Math.min(canvas.clientWidth, canvas.clientHeight) };
const framingDistance = systemFramingDistanceAu(
this.systemRenderer.gridOuterRadiusAu,
this.systemRenderer.outermostRadiusAu,
viewport,
starRadiusAu,
);
@@ -2272,6 +2295,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// 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, this.starMarkerMaterial);
// Its pole 115 degrees from the one the IAU gives, and still, until it was turned like a planet.
// The map's longitudes are Solar System Scope's, not Carrington's, so only the pole and the
// 25.38-day turn are the Sun's own.
this.starRotation = star.id === SUN_STAR_ID ? SUN_ROTATIONAL_ELEMENTS : undefined;
this.systemGroup.add(this.starMarker);
this.galaxyGroup.visible = false;
@@ -2332,7 +2359,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,