Phase 2c: eclipse/overpass calculators + live-map camera polish
CI / Lint, typecheck, test, build (pull_request) Failing after 9s

Calculators (apps/live-map/src/calculators/):
- eclipse.ts: findEclipseWindows(bodies, opts) — coarse scan with
  threshold-crossing detection, bisection to refine start/end,
  ternary search to find peak. Handles eclipse already in progress
  at scan-start. Uses sun = parentId===null body.
- overpass.ts: findOverpasses(opts) — coarse scan for local distance
  minima, ternary refinement. Targets: vessel, body, ground station
  (lat/lon/alt → heliocentric).

UI:
- panels/CalculatorsPanel.tsx: collapsible bottom-center panel with
  two tabs. Eclipse form: observer, eclipser, from UT → 3 windows.
  Overpass form: observer vessel, target kind+id, max dist → 5 passes.
- timeFormat.ts: shared KSP-time formatters.

Live-map camera polish (apps/live-map/src/scene/):
- camera.ts: CameraController — log-scale distance (z→exp(z)*1e8 m,
  range -3..12), spherical orbit around target, smooth lerp to
  selected body/vessel. Mouse wheel zooms, drag rotates, click
  raycasts for track toggle. Pointer-move-distance gate to
  distinguish click from drag.
- glow.ts: additive shader-based atmospheric halo (rim-falloff
  fragment shader, BackSide) attached as child of body mesh.
- layout.ts: bodyPositionAt now returns true heliocentric (walks
  parent chain); previous version returned parent-relative for
  non-root children which broke the eclipse calculator.

Bug fix:
- packages/orbital-math/src/occultation.ts: sign of projection check
  was inverted. `proj <= 0` correctly returns 0 (occluder behind
  observer), `proj > 0` triggers eclipse computation.

Tests: 28 live-map tests (10 scene + 12 calculator + 6 camera),
45 total across the workspace, all passing.
This commit is contained in:
Mavis
2026-06-02 19:46:00 +00:00
parent 9e76ec9328
commit 07cc5321d1
13 changed files with 1533 additions and 86 deletions
+60 -60
View File
@@ -1,9 +1,10 @@
/**
* Scene — the 3D Three.js rendering of the universe.
*
* Re-renders when the bodies, vessels, or focus settings change.
* The animation loop runs the orbit propagation and positions the
* meshes for the current `ut`.
* - Builds body meshes, orbit lines, vessel markers
* - Adds atmospheric glow on planets
* - Uses CameraController for log-scale distance, mouse zoom,
* drag-to-rotate, click-to-track via raycasting
*/
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
@@ -11,16 +12,17 @@ import { sampleOrbit } from '@kerbal-rt/orbital-math';
import type { UniverseSnapshot } from '@kerbal-rt/shared-types';
import { bodyColor, vesselColor } from './color.js';
import { bodyPositionAt, vesselPositionAt } from './layout.js';
import { CameraController } from './camera.js';
import { createGlow } from './glow.js';
export interface SceneProps {
snapshot: UniverseSnapshot;
ut: number;
/** Which body or vessel the camera should follow. null = free. */
followId: string | null;
/** Toggle visibility of orbit lines by category. */
showPlanetOrbits: boolean;
showMoonOrbits: boolean;
showVesselOrbits: boolean;
onSelect: (id: string | null) => void;
}
interface SceneRefs {
@@ -30,7 +32,7 @@ interface SceneRefs {
bodyMeshes: Map<string, THREE.Mesh>;
vesselMeshes: Map<string, THREE.Mesh>;
orbitLines: Map<string, THREE.Line>;
mount: HTMLDivElement;
controller: CameraController;
raf: number;
}
@@ -41,15 +43,20 @@ const ORBIT_OPACITY: Record<string, number> = {
};
export function Scene(props: SceneProps) {
const { snapshot, ut, followId, showPlanetOrbits, showMoonOrbits, showVesselOrbits } = props;
const { snapshot, ut, followId, showPlanetOrbits, showMoonOrbits, showVesselOrbits, onSelect } =
props;
const mountRef = useRef<HTMLDivElement>(null);
const refsRef = useRef<SceneRefs | null>(null);
// Latest-snapshot / ut / followId refs so the camera controller
// always sees the current values without needing to be recreated.
const stateRef = useRef({ snapshot, ut, followId, onSelect });
stateRef.current = { snapshot, ut, followId, onSelect };
// One-time scene setup
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
const refs = createScene(mount);
const refs = createScene(mount, stateRef);
refsRef.current = refs;
const onResize = () => {
@@ -66,6 +73,7 @@ export function Scene(props: SceneProps) {
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(refs.raf);
refs.controller.dispose();
refs.renderer.dispose();
if (mount.contains(refs.renderer.domElement)) {
mount.removeChild(refs.renderer.domElement);
@@ -73,15 +81,14 @@ export function Scene(props: SceneProps) {
};
}, []);
// Rebuild the body / vessel meshes whenever the snapshot's set of
// bodies or vessels changes (not on every snapshot — they're stable).
// Rebuild meshes when bodies/vessels set changes
useEffect(() => {
const refs = refsRef.current;
if (!refs) return;
rebuildMeshes(refs, snapshot);
}, [snapshot.bodies, snapshot.vessels, snapshot]);
// Toggle orbit line visibility
// Toggle orbit visibility
useEffect(() => {
const refs = refsRef.current;
if (!refs) return;
@@ -90,48 +97,44 @@ export function Scene(props: SceneProps) {
const isMoon = snapshot.bodies.find((b) => b.id === id && b.kind === 'moon');
if (isPlanet) line.visible = showPlanetOrbits;
else if (isMoon) line.visible = showMoonOrbits;
else line.visible = showVesselOrbits; // vessel
else line.visible = showVesselOrbits;
}
}, [showPlanetOrbits, showMoonOrbits, showVesselOrbits, snapshot.bodies]);
// Per-frame: propagate, position meshes, follow camera
// Per-frame
useEffect(() => {
const refs = refsRef.current;
if (!refs) return;
let lastUt = Number.NEGATIVE_INFINITY;
const render = () => {
// Re-propagate positions whenever ut changes
if (ut !== lastUt) {
lastUt = ut;
positionMeshes(refs, snapshot, ut);
}
// Camera follow
if (followId) {
const followPos = getFollowPosition(snapshot, followId, ut);
if (followPos) {
refs.camera.position.lerp(
new THREE.Vector3(followPos.x, followPos.y, followPos.z).multiplyScalar(1.05),
0.05,
);
// Also add a small offset for context
const target = new THREE.Vector3(followPos.x, followPos.y, followPos.z);
refs.camera.lookAt(target);
}
const cur = stateRef.current;
if (cur.ut !== lastUt) {
lastUt = cur.ut;
positionMeshes(refs, cur.snapshot, cur.ut);
}
refs.controller.update();
refs.renderer.render(refs.scene, refs.camera);
refs.raf = requestAnimationFrame(render);
};
refs.raf = requestAnimationFrame(render);
return () => cancelAnimationFrame(refs.raf);
}, [snapshot, ut, followId]);
}, []);
return <div ref={mountRef} style={{ width: '100%', height: '100%' }} />;
return <div ref={mountRef} style={{ width: '100%', height: '100%', cursor: 'grab' }} />;
}
// ─── Three.js setup helpers ────────────────────────────────────────────────
function createScene(mount: HTMLDivElement): SceneRefs {
function createScene(
mount: HTMLDivElement,
stateRef: React.MutableRefObject<{
snapshot: UniverseSnapshot;
ut: number;
followId: string | null;
onSelect: (id: string | null) => void;
}>,
): SceneRefs {
const width = mount.clientWidth;
const height = mount.clientHeight;
@@ -148,17 +151,16 @@ function createScene(mount: HTMLDivElement): SceneRefs {
mount.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0x404040, 0.4));
const sunLight = new THREE.PointLight(0xffffff, 2, 0, 0);
scene.add(sunLight);
scene.add(new THREE.PointLight(0xffffff, 2, 0, 0));
const onResize = () => {
const w = mount.clientWidth;
const h = mount.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', onResize);
const controller = new CameraController({
camera,
domElement: mount,
getSnapshot: () => stateRef.current.snapshot,
getUt: () => stateRef.current.ut,
getFollowId: () => stateRef.current.followId,
onSelect: (id) => stateRef.current.onSelect(id),
});
return {
scene,
@@ -167,17 +169,20 @@ function createScene(mount: HTMLDivElement): SceneRefs {
bodyMeshes: new Map(),
vesselMeshes: new Map(),
orbitLines: new Map(),
mount,
controller,
raf: 0,
};
}
function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
// Remove existing meshes / lines
for (const mesh of refs.bodyMeshes.values()) {
refs.scene.remove(mesh);
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
if (Array.isArray(mesh.material)) {
mesh.material.forEach((m) => m.dispose());
} else {
(mesh.material as THREE.Material).dispose();
}
}
for (const mesh of refs.vesselMeshes.values()) {
refs.scene.remove(mesh);
@@ -198,13 +203,13 @@ function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
const geo = new THREE.SphereGeometry(Math.max(body.radius, 1e8), 32, 16);
const mat = new THREE.MeshBasicMaterial({ color: bodyColor(body.id) });
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: body.id };
refs.scene.add(mesh);
refs.bodyMeshes.set(body.id, mesh);
continue;
}
if (body.parentId === null) continue;
// Body sphere
const displayRadius = Math.max(body.radius, 1e6);
const geo = new THREE.SphereGeometry(displayRadius, 32, 16);
const mat = new THREE.MeshPhongMaterial({
@@ -212,9 +217,16 @@ function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
emissive: 0x111111,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: body.id };
refs.scene.add(mesh);
refs.bodyMeshes.set(body.id, mesh);
// Atmospheric glow for planets/moons (skip very small bodies)
if (body.kind === 'planet' || (body.kind === 'moon' && body.radius > 100_000)) {
const glow = createGlow(body.radius, bodyColor(body.id), 0.35, 1.35);
mesh.add(glow); // attach as child so it follows position
}
// Orbit line
const points = sampleOrbit(body.orbit, body.gravitationalParameter, 256);
const positions = new Float32Array(points.length * 3);
@@ -239,10 +251,10 @@ function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
const geo = new THREE.SphereGeometry(2e5, 12, 8);
const mat = new THREE.MeshBasicMaterial({ color: vesselColor(vessel.owner) });
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: vessel.id };
refs.scene.add(mesh);
refs.vesselMeshes.set(vessel.id, mesh);
// Vessel orbit (relative to its reference body)
if (vessel.referenceBodyId) {
const ref = snap.bodies.find((b) => b.id === vessel.referenceBodyId);
if (ref) {
@@ -281,15 +293,3 @@ function positionMeshes(refs: SceneRefs, snap: UniverseSnapshot, ut: number): vo
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
}
}
function getFollowPosition(
snap: UniverseSnapshot,
id: string,
ut: number,
): { x: number; y: number; z: number } | null {
const vessel = snap.vessels.find((v) => v.id === id);
if (vessel) return vesselPositionAt(snap.bodies, vessel, ut);
const body = snap.bodies.find((b) => b.id === id);
if (body) return bodyPositionAt(snap.bodies, id, ut);
return null;
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Camera controller — log-scale distance, mouse wheel zoom,
* drag-to-rotate, click-to-track via raycasting.
*
* Three modes:
* - 'free' : user-controlled, no target
* - 'follow' : tracks a vessel/body (smooth lerp to position)
*
* Log-scale: the user operates in "zoom levels" z ∈ [-3, 12]. We
* map z → camera distance via d = exp(z) * 1e8 m. This gives a smooth
* range from ~50 Mm to ~1 Tm, covering Kerbin (13.6 Gm) to Jool (68.8 Gm)
* with reasonable framing.
*/
import * as THREE from 'three';
import type { UniverseSnapshot, CelestialBody, Vessel } from '@kerbal-rt/shared-types';
import { bodyPositionAt, vesselPositionAt } from './layout.js';
import { inverseLogScale } from './layout.js';
export interface CameraControllerOptions {
camera: THREE.PerspectiveCamera;
domElement: HTMLElement;
getSnapshot: () => UniverseSnapshot;
getUt: () => number;
getFollowId: () => string | null;
/** Notify host when a vessel/body is clicked in the scene. */
onSelect: (id: string | null) => void;
}
export class CameraController {
private opts: CameraControllerOptions;
/** Camera "zoom level" — log-scale distance from origin/target. */
private zoomLevel = inverseLogScale(1.5e10);
/** Spherical coords around the current target. */
private azimuth = 0;
private elevation = 0.3;
private target = new THREE.Vector3(0, 0, 0);
/** "free" or "follow" */
private follow: boolean;
/** Last computed distance (for follow lerp). */
private desiredDistance = 1.5e10;
// Mouse state
private dragging = false;
private lastX = 0;
private lastY = 0;
private pointerDownPos: { x: number; y: number } | null = null;
constructor(opts: CameraControllerOptions) {
this.opts = opts;
this.follow = opts.getFollowId() !== null;
const dom = opts.domElement;
dom.style.touchAction = 'none';
dom.addEventListener('mousedown', this.onMouseDown);
dom.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
dom.addEventListener('wheel', this.onWheel, { passive: false });
dom.addEventListener('click', this.onClick);
}
dispose(): void {
const dom = this.opts.domElement;
dom.removeEventListener('mousedown', this.onMouseDown);
dom.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
dom.removeEventListener('wheel', this.onWheel);
dom.removeEventListener('click', this.onClick);
}
/** Call once per frame to keep the camera in sync. */
update(): void {
const id = this.opts.getFollowId();
const wantFollow = id !== null;
if (wantFollow !== this.follow) {
this.follow = wantFollow;
}
if (this.follow && id) {
const pos = this.resolveTargetPosition(id);
if (pos) {
this.target.lerp(new THREE.Vector3(pos.x, pos.y, pos.z), 0.1);
// Zoom level is preserved (user zoom still works)
this.desiredDistance = this.distanceForZoom();
}
} else {
// Free mode: keep current target, just orbit around it
this.desiredDistance = this.distanceForZoom();
}
// Position the camera at (target + offset) where offset is
// determined by spherical coords + distance.
const sinE = Math.sin(this.elevation);
const cosE = Math.cos(this.elevation);
const sinA = Math.sin(this.azimuth);
const cosA = Math.cos(this.azimuth);
const offset = new THREE.Vector3(
this.desiredDistance * cosE * sinA,
this.desiredDistance * sinE,
this.desiredDistance * cosE * cosA,
);
const desiredPos = this.target.clone().add(offset);
this.opts.camera.position.lerp(desiredPos, 0.1);
this.opts.camera.lookAt(this.target);
}
/** Expose current target + distance for tests / external code. */
getState(): { target: THREE.Vector3; distance: number; zoomLevel: number } {
return {
target: this.target.clone(),
distance: this.desiredDistance,
zoomLevel: this.zoomLevel,
};
}
// ── helpers ──────────────────────────────────────────────────────────
private distanceForZoom(): number {
return Math.exp(this.zoomLevel) * 1e8;
}
private resolveTargetPosition(id: string): { x: number; y: number; z: number } | null {
const snap = this.opts.getSnapshot();
const ut = this.opts.getUt();
const vessel = snap.vessels.find((v: Vessel) => v.id === id);
if (vessel) return vesselPositionAt(snap.bodies, vessel, ut);
const body = snap.bodies.find((b: CelestialBody) => b.id === id);
if (body) return bodyPositionAt(snap.bodies, id, ut);
return null;
}
// ── event handlers ──────────────────────────────────────────────────
private onMouseDown = (e: MouseEvent): void => {
if (e.button !== 0) return;
this.dragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.pointerDownPos = { x: e.clientX, y: e.clientY };
};
private onMouseMove = (e: MouseEvent): void => {
if (!this.dragging) return;
const dx = e.clientX - this.lastX;
const dy = e.clientY - this.lastY;
this.lastX = e.clientX;
this.lastY = e.clientY;
// Sensitivity scaled to viewport
const sens = 0.005;
this.azimuth -= dx * sens;
this.elevation += dy * sens;
// Clamp elevation to avoid gimbal flip
const HALF_PI = Math.PI / 2 - 0.05;
if (this.elevation > HALF_PI) this.elevation = HALF_PI;
if (this.elevation < -HALF_PI) this.elevation = -HALF_PI;
};
private onMouseUp = (): void => {
this.dragging = false;
};
private onWheel = (e: WheelEvent): void => {
e.preventDefault();
// Positive deltaY = scroll down = zoom out (increase distance)
const delta = e.deltaY * 0.001;
this.zoomLevel = Math.max(-3, Math.min(12, this.zoomLevel + delta));
};
private onClick = (e: MouseEvent): void => {
// Only treat as a click if the pointer barely moved (not a drag)
if (!this.pointerDownPos) return;
const dx = e.clientX - this.pointerDownPos.x;
const dy = e.clientY - this.pointerDownPos.y;
if (Math.hypot(dx, dy) > 5) {
this.pointerDownPos = null;
return;
}
this.pointerDownPos = null;
// Raycast against body and vessel meshes
const dom = this.opts.domElement;
const rect = dom.getBoundingClientRect();
const ndc = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1,
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(ndc, this.opts.camera);
// Build a list of {mesh, id} from the scene
const hits: { id: string; dist: number }[] = [];
const snap = this.opts.getSnapshot();
raycaster.intersectObjects(this.opts.camera.parent?.children ?? [], true).forEach((hit) => {
const id = (hit.object.userData as { id?: string }).id;
if (id) hits.push({ id, dist: hit.distance });
});
if (hits.length === 0) {
this.opts.onSelect(null);
return;
}
hits.sort((a, b) => a.dist - b.dist);
// Toggle: if clicking the same selected object, deselect
const current = this.opts.getFollowId();
if (current && hits[0] && hits[0].id === current) {
this.opts.onSelect(null);
} else {
this.opts.onSelect(hits[0]?.id ?? null);
}
// Snap zoom to a reasonable level for the new target
if (hits[0]) {
const pos = this.resolveTargetPosition(hits[0].id);
if (pos) {
this.target.set(pos.x, pos.y, pos.z);
// Set zoom based on body size (bodies need more zoom out)
const body = snap.bodies.find((b: CelestialBody) => b.id === hits[0]?.id);
if (body) {
this.zoomLevel = inverseLogScale(body.radius * 10);
} else {
this.zoomLevel = inverseLogScale(5e6);
}
}
}
};
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Atmospheric glow — a simple additive shell around a body that fades
* from a small radius to a larger one. Looks like a soft halo when
* viewed from any angle.
*
* Implementation: a slightly larger sphere with a custom shader that
* fades from opaque at the inner edge to transparent at the outer edge.
* The fade uses view-direction dot product so we always see the rim.
*/
import * as THREE from 'three';
const VERTEX = /* glsl */ `
varying vec3 vNormal;
varying vec3 vViewDir;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
vNormal = normalize(normalMatrix * normal);
vViewDir = normalize(-mvPosition.xyz);
}
`;
const FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uIntensity;
varying vec3 vNormal;
varying vec3 vViewDir;
void main() {
// Strongest at the rim (where the surface is parallel to view)
float rim = 1.0 - max(0.0, dot(vNormal, vViewDir));
rim = pow(rim, 2.5); // sharpen the falloff
gl_FragColor = vec4(uColor * rim * uIntensity, rim);
}
`;
/**
* Create a glow shell mesh for a body. Add it to the scene as a child
* of the body so it follows the body's transform.
*
* @param bodyRadius the actual radius of the body
* @param color the glow color
* @param intensity brightness multiplier (0..1, default 0.4)
* @param scaleFactor how much bigger than the body to make the shell
*/
export function createGlow(
bodyRadius: number,
color: number,
intensity = 0.4,
scaleFactor = 1.4,
): THREE.Mesh {
const inner = Math.max(bodyRadius, 1e6);
const outer = inner * scaleFactor;
const geo = new THREE.SphereGeometry(outer, 32, 16);
const mat = new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color(color) },
uIntensity: { value: intensity },
},
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
transparent: true,
blending: THREE.AdditiveBlending,
side: THREE.BackSide, // render the back side, so the rim shows on the outside
depthWrite: false,
});
return new THREE.Mesh(geo, mat);
}
+10 -3
View File
@@ -20,7 +20,8 @@ export function findBodyMu(bodies: CelestialBody[], id: string | null): number {
/**
* Position of a body in the heliocentric inertial frame, propagated
* to the given UT by walking up the parent chain.
* to the given UT. Walks up the parent chain so the result is the
* true absolute position, not the parent-relative position.
*/
export function bodyPositionAt(
bodies: CelestialBody[],
@@ -29,10 +30,16 @@ export function bodyPositionAt(
): { x: number; y: number; z: number } {
const body = bodies.find((b) => b.id === bodyId);
if (!body) return { x: 0, y: 0, z: 0 };
if (!body.parentId) return { x: 0, y: 0, z: 0 }; // root (the star)
if (!body.parentId) return { x: 0, y: 0, z: 0 }; // system root
const parent = bodies.find((b) => b.id === body.parentId);
if (!parent) return { x: 0, y: 0, z: 0 };
return positionAt(body.orbit, parent.gravitationalParameter, ut);
const parentPos = bodyPositionAt(bodies, parent.id, ut);
const local = positionAt(body.orbit, parent.gravitationalParameter, ut);
return {
x: parentPos.x + local.x,
y: parentPos.y + local.y,
z: parentPos.z + local.z,
};
}
/** Position of a vessel, propagated to UT, in the heliocentric frame. */