From 07cc5321d1af05d1d47a6fa5b1ab7a700c98eb00 Mon Sep 17 00:00:00 2001 From: Mavis Date: Tue, 2 Jun 2026 19:46:00 +0000 Subject: [PATCH] Phase 2c: eclipse/overpass calculators + live-map camera polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/live-map/src/App.tsx | 4 + apps/live-map/src/calculators/eclipse.ts | 243 ++++++++++++ apps/live-map/src/calculators/overpass.ts | 151 ++++++++ apps/live-map/src/panels/CalculatorsPanel.tsx | 365 ++++++++++++++++++ apps/live-map/src/panels/TimeControls.tsx | 21 +- apps/live-map/src/scene/Scene.tsx | 120 +++--- apps/live-map/src/scene/camera.ts | 224 +++++++++++ apps/live-map/src/scene/glow.ts | 67 ++++ apps/live-map/src/scene/layout.ts | 13 +- apps/live-map/src/timeFormat.ts | 28 ++ apps/live-map/tests/calculators.test.ts | 305 +++++++++++++++ apps/live-map/tests/camera.test.ts | 68 ++++ packages/orbital-math/src/occultation.ts | 10 +- 13 files changed, 1533 insertions(+), 86 deletions(-) create mode 100644 apps/live-map/src/calculators/eclipse.ts create mode 100644 apps/live-map/src/calculators/overpass.ts create mode 100644 apps/live-map/src/panels/CalculatorsPanel.tsx create mode 100644 apps/live-map/src/scene/camera.ts create mode 100644 apps/live-map/src/scene/glow.ts create mode 100644 apps/live-map/src/timeFormat.ts create mode 100644 apps/live-map/tests/calculators.test.ts create mode 100644 apps/live-map/tests/camera.test.ts diff --git a/apps/live-map/src/App.tsx b/apps/live-map/src/App.tsx index 3d0d027..141d2ba 100644 --- a/apps/live-map/src/App.tsx +++ b/apps/live-map/src/App.tsx @@ -18,6 +18,7 @@ import { TimeControls } from './panels/TimeControls.js'; import { VesselList } from './panels/VesselList.js'; import { FocusPanel } from './panels/FocusPanel.js'; import { StatusPill } from './panels/StatusPill.js'; +import { CalculatorsPanel } from './panels/CalculatorsPanel.js'; import { useLiveState } from './hooks/useLiveState.js'; import type { UniverseSnapshot } from '@kerbal-rt/shared-types'; @@ -144,6 +145,7 @@ export function App() { showPlanetOrbits={showPlanetOrbits} showMoonOrbits={showMoonOrbits} showVesselOrbits={showVesselOrbits} + onSelect={setSelectedId} /> setShowVesselOrbits((v) => !v)} /> + + {error && (
b.id === observerId); + const eclipser = bodies.find((b) => b.id === eclipserId); + if (!observer || !eclipser) return []; + + const windows: EclipseWindow[] = []; + const end = startUt + maxSearchTime; + + // Coarse scan: find the start of an eclipse (transition from below + // threshold to above threshold). Then refine to find utStart, utPeak, + // utEnd. + let inEclipse = false; + let current: Partial = {}; + + // If we start in the middle of an eclipse, bisect backwards to find utStart. + const startF = computeShadowFraction(bodies, observerId, eclipserId, startUt); + if (startF > threshold) { + inEclipse = true; + const utStart = refine( + bodies, + observerId, + eclipserId, + Math.max(0, startUt - stepSec), + startUt, + threshold, + 'down', + ); + current = { utStart }; + } + + let t = startUt + stepSec; + while (t < end && windows.length < count) { + const f = computeShadowFraction(bodies, observerId, eclipserId, t); + if (!inEclipse && f > threshold) { + // Eclipse just started — bisect to find exact start + const utStart = refine(bodies, observerId, eclipserId, t - stepSec, t, threshold, 'up'); + inEclipse = true; + current = { utStart }; + } else if (inEclipse && f < threshold) { + // Eclipse just ended + const utEnd = refine(bodies, observerId, eclipserId, t - stepSec, t, threshold, 'down'); + const utPeak = refine( + bodies, + observerId, + eclipserId, + current.utStart!, + utEnd, + threshold, + 'max', + ); + const maxFraction = computeShadowFraction(bodies, observerId, eclipserId, utPeak); + windows.push({ + utStart: current.utStart!, + utPeak, + utEnd, + maxFraction, + }); + inEclipse = false; + current = {}; + } + t += stepSec; + } + + // If we ended while still in eclipse, close it. + if (inEclipse) { + const utEnd = refine( + bodies, + observerId, + eclipserId, + t - stepSec, + t + stepSec, + threshold, + 'down', + ); + const utPeak = refine( + bodies, + observerId, + eclipserId, + current.utStart!, + utEnd, + threshold, + 'max', + ); + const maxFraction = computeShadowFraction(bodies, observerId, eclipserId, utPeak); + windows.push({ + utStart: current.utStart!, + utPeak, + utEnd, + maxFraction, + }); + } + + return windows; +} + +/** Shadow fraction at a single instant. */ +export function computeShadowFraction( + bodies: CelestialBody[], + observerId: string, + eclipserId: string, + ut: number, +): number { + const observer = bodies.find((b) => b.id === observerId); + const eclipser = bodies.find((b) => b.id === eclipserId); + if (!observer || !eclipser) return 0; + + // Sun is Kerbol (the system root). For this to work the catalog + // must have a single body with parentId === null — the star. + const sun = bodies.find((b) => b.parentId === null); + if (!sun) return 0; + + // The reference frame for shadowFraction is: vectors from the + // observer toward the sun, and from the observer toward the + // eclipser. We compute them in heliocentric coordinates then + // shift. + const sunPos = bodyPositionAt(bodies, sun.id, ut); + const eclipserPos = bodyPositionAt(bodies, eclipserId, ut); + const observerPos = bodyPositionAt(bodies, observerId, ut); + + // For solar eclipses, "eclipser" sits between observer and sun. + // We treat the eclipser as the occluder and the sun as the light + // source. shadowFraction() expects: vector from observer to sun, + // and vector from observer to eclipser. + // Observer-to-sun = sunPos - observerPos + // Observer-to-eclipser = eclipserPos - observerPos + // BUT shadowFraction actually wants: vector from observer to sun, + // and the eclipser center RELATIVE to the observer-to-sun line. + // Re-reading the function: it computes perpDist of occluder + // center to sun-direction line, and uses the sun-direction + // projection. So the right call is: + return shadowFraction( + { x: sunPos.x - observerPos.x, y: sunPos.y - observerPos.y, z: sunPos.z - observerPos.z }, + { + x: eclipserPos.x - observerPos.x, + y: eclipserPos.y - observerPos.y, + z: eclipserPos.z - observerPos.z, + }, + eclipser.radius, + ); +} + +/** + * Bisection to find when the shadow fraction crosses the threshold. + * direction = 'up' → find t such that f(t) ≈ threshold going upward + * direction = 'down' → find t such that f(t) ≈ threshold going downward + * direction = 'max' → find t that maximizes f in the range + */ +function refine( + bodies: CelestialBody[], + observerId: string, + eclipserId: string, + tLo: number, + tHi: number, + threshold: number, + direction: 'up' | 'down' | 'max', +): number { + if (direction === 'max') { + // Golden-section or simple ternary search on a small interval. + // For 600s windows this is plenty. + let lo = tLo; + let hi = tHi; + for (let i = 0; i < 32; i++) { + const m1 = lo + (hi - lo) / 3; + const m2 = hi - (hi - lo) / 3; + const f1 = computeShadowFraction(bodies, observerId, eclipserId, m1); + const f2 = computeShadowFraction(bodies, observerId, eclipserId, m2); + if (f1 < f2) lo = m1; + else hi = m2; + } + return (lo + hi) / 2; + } + + // Bisection: 30 iterations gets us to 1-second precision on a 600s range. + let lo = tLo; + let hi = tHi; + for (let i = 0; i < 30; i++) { + const mid = (lo + hi) / 2; + const f = computeShadowFraction(bodies, observerId, eclipserId, mid); + if (direction === 'up' ? f < threshold : f > threshold) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} diff --git a/apps/live-map/src/calculators/overpass.ts b/apps/live-map/src/calculators/overpass.ts new file mode 100644 index 0000000..604c6ba --- /dev/null +++ b/apps/live-map/src/calculators/overpass.ts @@ -0,0 +1,151 @@ +/** + * Overpass calculator — finds the next N closest approaches between + * a vessel and a target (vessel, body, or ground station). + * + * Approach: sample the distance at fixed steps, then refine each + * minimum with ternary search. + */ +import type { CelestialBody, Vessel, GroundStation } from '@kerbal-rt/shared-types'; +import { bodyPositionAt, vesselPositionAt } from '../scene/layout.js'; + +export type TargetKind = 'vessel' | 'body' | 'station'; + +export interface Target { + kind: TargetKind; + id: string; + name: string; +} + +export interface OverpassWindow { + /** UT at which the closest approach occurs. */ + utPeak: number; + /** Distance at closest approach (meters). */ + minDistance: number; + /** The target. */ + target: Target; +} + +export interface OverpassOptions { + observer: Vessel; + target: Target; + bodies: CelestialBody[]; + vessels: Vessel[]; + groundStations: GroundStation[]; + startUt: number; + count?: number; + maxSearchTime?: number; + stepSec?: number; + /** Distance threshold for "interesting" passes (default 1000 km). */ + distanceThreshold?: number; +} + +/** + * Find the next `count` closest-approach events. + * + * Coarse scan: at each step, compute distance. Track the local minima + * (a point lower than both neighbors). For each local min, refine + * with ternary search to find the true peak (min distance). + * + * Returns at most `count` events with minDistance < distanceThreshold. + */ +export function findOverpasses(options: OverpassOptions): OverpassWindow[] { + const { + observer, + target, + bodies, + vessels, + groundStations, + startUt, + count = 5, + maxSearchTime = 426 * 6 * 3600, // 1 KSP year + stepSec = 120, // 2 minutes + distanceThreshold = 1_000_000, // 1000 km + } = options; + + const dist = (t: number) => distanceAt(bodies, vessels, groundStations, observer, target, t); + + const passes: OverpassWindow[] = []; + const end = startUt + maxSearchTime; + let prev = dist(startUt); + let curr = prev; + let t = startUt + stepSec; + + while (t < end && passes.length < count) { + const next = dist(t); + // Local minimum: curr < prev AND curr < next + if (curr < prev && curr < next && curr < distanceThreshold) { + // Refine + const utPeak = refineMinimum((a: number) => dist(a), t - stepSec, t + stepSec); + const minDistance = dist(utPeak); + passes.push({ utPeak, minDistance, target }); + // Skip ahead past the peak so we don't double-count + t = utPeak + stepSec * 5; + prev = dist(t); + curr = prev; + t += stepSec; + continue; + } + prev = curr; + curr = next; + t += stepSec; + } + + return passes; +} + +function distanceAt( + bodies: CelestialBody[], + vessels: Vessel[], + groundStations: GroundStation[], + observer: Vessel, + target: Target, + ut: number, +): number { + const obs = vesselPositionAt(bodies, observer, ut); + let tgt: { x: number; y: number; z: number }; + if (target.kind === 'vessel') { + const v = vessels.find((x) => x.id === target.id); + if (!v) return Number.POSITIVE_INFINITY; + tgt = vesselPositionAt(bodies, v, ut); + } else if (target.kind === 'body') { + tgt = bodyPositionAt(bodies, target.id, ut); + } else { + // station + const s = groundStations.find((x) => x.id === target.id); + if (!s) return Number.POSITIVE_INFINITY; + const body = bodies.find((b) => b.id === s.bodyId); + if (!body) return Number.POSITIVE_INFINITY; + const bodyPos = bodyPositionAt(bodies, body.id, ut); + // Ground station sits on the body surface. Convert (lat, lon, alt) + // to a heliocentric offset relative to the body's center. + // For simplicity (no rotation) we just add a small offset along + // the body's orbital direction. + const R = body.radius + s.alt; + const lat = (s.lat * Math.PI) / 180; + const lon = (s.lon * Math.PI) / 180; + tgt = { + x: bodyPos.x + R * Math.cos(lat) * Math.cos(lon), + y: bodyPos.y + R * Math.cos(lat) * Math.sin(lon), + z: bodyPos.z + R * Math.sin(lat), + }; + } + const dx = obs.x - tgt.x; + const dy = obs.y - tgt.y; + const dz = obs.z - tgt.z; + return Math.hypot(dx, dy, dz); +} + +/** Ternary search to find the minimum of dist in [tLo, tHi]. */ +function refineMinimum(dist: (t: number) => number, tLo: number, tHi: number): number { + let lo = tLo; + let hi = tHi; + for (let i = 0; i < 40; i++) { + const m1 = lo + (hi - lo) / 3; + const m2 = hi - (hi - lo) / 3; + const f1 = dist(m1); + const f2 = dist(m2); + if (f1 > f2) lo = m1; + else hi = m2; + } + return (lo + hi) / 2; +} diff --git a/apps/live-map/src/panels/CalculatorsPanel.tsx b/apps/live-map/src/panels/CalculatorsPanel.tsx new file mode 100644 index 0000000..9a2b353 --- /dev/null +++ b/apps/live-map/src/panels/CalculatorsPanel.tsx @@ -0,0 +1,365 @@ +/** + * CalculatorsPanel — collapsible panel with two calculators: + * + * - Eclipse: when does the sun get occluded for a given observer + * by a given body? + * - Overpass: when does a vessel come closest to a target + * (vessel / body / ground station)? + * + * The math lives in ./calculators/{eclipse,overpass}.ts and is + * tested independently. + */ +import { useMemo, useState } from 'react'; +import type { UniverseSnapshot, Vessel } from '@kerbal-rt/shared-types'; +import { findEclipseWindows, type EclipseWindow } from '../calculators/eclipse.js'; +import { findOverpasses, type OverpassWindow, type Target } from '../calculators/overpass.js'; +import { formatKspTime } from '../timeFormat.js'; + +export interface CalculatorsPanelProps { + snapshot: UniverseSnapshot; + /** UT to start scanning from. Usually the current scene UT. */ + scanFromUt: number; +} + +const PANEL_STYLE: React.CSSProperties = { + position: 'absolute', + bottom: 12, + left: '50%', + transform: 'translateX(-50%)', + width: 540, + maxHeight: 'calc(100vh - 200px)', + background: 'rgba(0,0,0,0.7)', + color: 'white', + borderRadius: 6, + fontFamily: 'monospace', + fontSize: 12, + backdropFilter: 'blur(4px)', + border: '1px solid rgba(255,255,255,0.1)', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}; + +const HEADER_STYLE: React.CSSProperties = { + padding: '0.4rem 0.7rem', + display: 'flex', + alignItems: 'center', + gap: 6, + borderBottom: '1px solid rgba(255,255,255,0.1)', + cursor: 'pointer', + userSelect: 'none', +}; + +const TAB_STYLE: (active: boolean) => React.CSSProperties = (active) => ({ + padding: '0.4rem 0.7rem', + cursor: 'pointer', + background: active ? 'rgba(255,255,255,0.12)' : 'transparent', + borderRight: '1px solid rgba(255,255,255,0.05)', +}); + +const BODY_STYLE: React.CSSProperties = { + padding: '0.6rem 0.7rem', + overflow: 'auto', + maxHeight: 280, +}; + +const ROW_STYLE: React.CSSProperties = { + display: 'flex', + gap: 4, + alignItems: 'center', + marginBottom: 4, + fontSize: 11, +}; + +const INPUT_STYLE: React.CSSProperties = { + background: 'rgba(0,0,0,0.4)', + color: 'white', + border: '1px solid rgba(255,255,255,0.15)', + padding: '0.15rem 0.3rem', + borderRadius: 3, + fontFamily: 'inherit', + fontSize: 11, +}; + +const RESULT_STYLE: React.CSSProperties = { + marginTop: 8, + padding: '0.3rem 0.5rem', + background: 'rgba(255,255,255,0.04)', + borderRadius: 3, + fontSize: 11, +}; + +const BUTTON_STYLE: React.CSSProperties = { + background: 'rgba(68, 170, 255, 0.2)', + border: '1px solid rgba(68, 170, 255, 0.4)', + color: 'white', + padding: '0.2rem 0.5rem', + borderRadius: 3, + cursor: 'pointer', + fontFamily: 'inherit', + fontSize: 11, +}; + +type Tab = 'eclipse' | 'overpass'; + +export function CalculatorsPanel({ snapshot, scanFromUt }: CalculatorsPanelProps) { + const [open, setOpen] = useState(false); + const [tab, setTab] = useState('eclipse'); + + return ( +
+
setOpen((o) => !o)}> + + Calculators + +
+ {open ? '▼' : '▲'} +
+ {open && ( + <> +
+
setTab('eclipse')}> + Eclipse +
+
setTab('overpass')}> + Overpass +
+
+
+ {tab === 'eclipse' ? ( + + ) : ( + + )} +
+ + )} +
+ ); +} + +// ─── Eclipse tab ────────────────────────────────────────────────────────── + +function EclipseTab({ snapshot, scanFromUt }: { snapshot: UniverseSnapshot; scanFromUt: number }) { + const planets = snapshot.bodies.filter((b) => b.kind === 'planet'); + const moons = snapshot.bodies.filter((b) => b.kind === 'moon'); + const observers = [...planets, ...moons]; + const eclipsers = snapshot.bodies.filter((b) => b.kind !== 'star'); + + const [observerId, setObserverId] = useState(observers[0]?.id ?? ''); + const [eclipserId, setEclipserId] = useState(eclipsers[1]?.id ?? eclipsers[0]?.id ?? ''); + const [result, setResult] = useState(null); + + const canRun = !!observerId && !!eclipserId && observerId !== eclipserId; + + const calculate = () => { + if (!canRun) return; + const windows = findEclipseWindows(snapshot.bodies, { + observerId, + eclipserId, + startUt: scanFromUt, + count: 3, + }); + setResult(windows); + }; + + return ( +
+
+ Observer: + +
+
+ Eclipser: + +
+
+ From UT: + {formatKspTime(scanFromUt)} +
+
+ +
+ {result !== null && ( +
+ {result.length === 0 ? ( +
+ No eclipse windows found within 1 KSP year. +
+ ) : ( + result.map((w, i) => ( +
+
+ #{i + 1} — {formatKspTime(w.utStart)} → {formatKspTime(w.utEnd)} +
+
+ peak at {formatKspTime(w.utPeak)}, max fraction {(w.maxFraction * 100).toFixed(0)} + % +
+
+ )) + )} +
+ )} +
+ ); +} + +// ─── Overpass tab ───────────────────────────────────────────────────────── + +function OverpassTab({ snapshot, scanFromUt }: { snapshot: UniverseSnapshot; scanFromUt: number }) { + const vessels = snapshot.vessels; + const allBodies = snapshot.bodies; + + const [observerId, setObserverId] = useState(vessels[0]?.id ?? ''); + const [targetKind, setTargetKind] = useState<'vessel' | 'body' | 'station'>('body'); + const [targetId, setTargetId] = useState( + allBodies.find((b) => b.kind === 'planet')?.id ?? '', + ); + const [distanceKm, setDistanceKm] = useState(1000); + const [result, setResult] = useState(null); + + const observer = vessels.find((v: Vessel) => v.id === observerId); + const targetOptions = useMemo(() => { + if (targetKind === 'vessel') return vessels.filter((v) => v.id !== observerId); + if (targetKind === 'body') return allBodies.filter((b) => b.kind !== 'star'); + return snapshot.groundStations; + }, [targetKind, observerId, vessels, allBodies, snapshot.groundStations]); + + const target: Target | null = useMemo(() => { + if (!targetId) return null; + return { + kind: targetKind, + id: targetId, + name: targetOptions.find((o) => o.id === targetId)?.name ?? targetId, + }; + }, [targetKind, targetId, targetOptions]); + + const canRun = !!observer && !!target; + + const calculate = () => { + if (!canRun || !target) return; + const passes = findOverpasses({ + observer, + target, + bodies: snapshot.bodies, + vessels: snapshot.vessels, + groundStations: snapshot.groundStations, + startUt: scanFromUt, + count: 5, + distanceThreshold: distanceKm * 1000, + }); + setResult(passes); + }; + + return ( +
+
+ Observer: + +
+
+ Target kind: + +
+
+ Target: + +
+
+ Max dist: + setDistanceKm(Number(e.target.value))} + style={{ ...INPUT_STYLE, width: 90 }} + /> + km +
+
+ +
+ {result !== null && ( +
+ {result.length === 0 ? ( +
+ No passes within {distanceKm} km in the next KSP year. +
+ ) : ( + result.map((w, i) => ( +
+
+ #{i + 1} — {formatKspTime(w.utPeak)} +
+
+ {target?.name}: min distance {(w.minDistance / 1000).toFixed(1)} km +
+
+ )) + )} +
+ )} +
+ ); +} diff --git a/apps/live-map/src/panels/TimeControls.tsx b/apps/live-map/src/panels/TimeControls.tsx index 5486e98..9d71484 100644 --- a/apps/live-map/src/panels/TimeControls.tsx +++ b/apps/live-map/src/panels/TimeControls.tsx @@ -8,6 +8,7 @@ * playback. Higher speeds fast-forward. */ import type { ChangeEvent } from 'react'; +import { formatKspTime, formatKspDuration } from '../timeFormat.js'; export interface TimeControlsProps { ut: number; @@ -122,23 +123,3 @@ export function TimeControls(props: TimeControlsProps) {
); } - -const KSP_DAY_SECONDS = 6 * 3600; -const KSP_YEAR_DAYS = 426; - -function formatKspTime(ut: number): string { - const totalDays = ut / KSP_DAY_SECONDS; - const year = Math.floor(totalDays / KSP_YEAR_DAYS) + 1; - const day = (Math.floor(totalDays) % KSP_YEAR_DAYS) + 1; - const secondsInDay = ut % KSP_DAY_SECONDS; - const h = Math.floor(secondsInDay / 3600); - const m = Math.floor((secondsInDay % 3600) / 60); - return `Y${year} D${String(day).padStart(3, '0')} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; -} - -function formatKspDuration(seconds: number): string { - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`; - if (seconds < KSP_YEAR_DAYS * KSP_DAY_SECONDS) return `${(seconds / 86400).toFixed(1)}d`; - return `${(seconds / (KSP_YEAR_DAYS * KSP_DAY_SECONDS)).toFixed(1)}y`; -} diff --git a/apps/live-map/src/scene/Scene.tsx b/apps/live-map/src/scene/Scene.tsx index 71725be..c9ea2f5 100644 --- a/apps/live-map/src/scene/Scene.tsx +++ b/apps/live-map/src/scene/Scene.tsx @@ -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; vesselMeshes: Map; orbitLines: Map; - mount: HTMLDivElement; + controller: CameraController; raf: number; } @@ -41,15 +43,20 @@ const ORBIT_OPACITY: Record = { }; 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(null); const refsRef = useRef(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
; + return
; } // ─── 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; -} diff --git a/apps/live-map/src/scene/camera.ts b/apps/live-map/src/scene/camera.ts new file mode 100644 index 0000000..0dc60cd --- /dev/null +++ b/apps/live-map/src/scene/camera.ts @@ -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); + } + } + } + }; +} diff --git a/apps/live-map/src/scene/glow.ts b/apps/live-map/src/scene/glow.ts new file mode 100644 index 0000000..4b96b4e --- /dev/null +++ b/apps/live-map/src/scene/glow.ts @@ -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); +} diff --git a/apps/live-map/src/scene/layout.ts b/apps/live-map/src/scene/layout.ts index 7e6356d..06b2681 100644 --- a/apps/live-map/src/scene/layout.ts +++ b/apps/live-map/src/scene/layout.ts @@ -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. */ diff --git a/apps/live-map/src/timeFormat.ts b/apps/live-map/src/timeFormat.ts new file mode 100644 index 0000000..9c46aea --- /dev/null +++ b/apps/live-map/src/timeFormat.ts @@ -0,0 +1,28 @@ +/** + * KSP time formatting helpers — 1 KSP day = 6 hours, 1 KSP year = 426 days. + * Source: stock KSP config. + */ + +const KSP_DAY_SECONDS = 6 * 3600; +const KSP_YEAR_DAYS = 426; + +/** Format a UT value as "Y1 D001 12:34". */ +export function formatKspTime(ut: number): string { + const totalDays = ut / KSP_DAY_SECONDS; + const year = Math.floor(totalDays / KSP_YEAR_DAYS) + 1; + const day = (Math.floor(totalDays) % KSP_YEAR_DAYS) + 1; + const secondsInDay = ut % KSP_DAY_SECONDS; + const h = Math.floor(secondsInDay / 3600); + const m = Math.floor((secondsInDay % 3600) / 60); + return `Y${year} D${String(day).padStart(3, '0')} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +/** Format a duration in seconds as a human-readable string. */ +export function formatKspDuration(seconds: number): string { + const abs = Math.abs(seconds); + if (abs < 60) return `${Math.floor(abs)}s`; + if (abs < 3600) return `${Math.floor(abs / 60)}m`; + if (abs < 86400) return `${(abs / 3600).toFixed(1)}h`; + if (abs < KSP_YEAR_DAYS * KSP_DAY_SECONDS) return `${(abs / 86400).toFixed(1)}d`; + return `${(abs / (KSP_YEAR_DAYS * KSP_DAY_SECONDS)).toFixed(1)}y`; +} diff --git a/apps/live-map/tests/calculators.test.ts b/apps/live-map/tests/calculators.test.ts new file mode 100644 index 0000000..6b7d0d0 --- /dev/null +++ b/apps/live-map/tests/calculators.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect } from 'vitest'; +import { findEclipseWindows, computeShadowFraction } from '../src/calculators/eclipse.js'; +import { findOverpasses, type Target } from '../src/calculators/overpass.js'; +import type { CelestialBody, GroundStation, Vessel } from '@kerbal-rt/shared-types'; + +const KSP_DAY = 6 * 3600; + +// ─── Minimal solar system: Kerbol + Kerbin + Mun (a simple eclipse scenario) +const kerbol: CelestialBody = { + id: 'kerbol', + name: 'Kerbol', + kind: 'star', + parentId: null, + radius: 261_600_000, + sphereOfInfluence: 1e30, + gravitationalParameter: 1.172e18, + rotationPeriod: 432_000, + axialTilt: 0, + orbit: { + semiMajorAxis: 0, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, +}; + +// Kerbin in a circular orbit at 13.6e9 m +const kerbin: CelestialBody = { + id: 'kerbin', + name: 'Kerbin', + kind: 'planet', + parentId: 'kerbol', + radius: 600_000, + sphereOfInfluence: 84_159_286, + gravitationalParameter: 3.5316e12, + rotationPeriod: 21_600, + axialTilt: 0, + orbit: { + semiMajorAxis: 13_599_840_256, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, +}; + +// Mun in a circular orbit around Kerbin at 12e6 m +const mun: CelestialBody = { + id: 'mun', + name: 'Mun', + kind: 'moon', + parentId: 'kerbin', + radius: 200_000, + sphereOfInfluence: 2_429_559, + gravitationalParameter: 6.514e10, + rotationPeriod: 138_984, + axialTilt: 0, + orbit: { + semiMajorAxis: 12_000_000, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, // start at (12e6, 0, 0) in kerbin frame + epoch: 0, + }, +}; + +const systemBodies = [kerbol, kerbin, mun]; + +describe('computeShadowFraction', () => { + it('returns 0 when eclipser is on the far side of the observer from the sun', () => { + // t=0, Mun meanAnomalyAtEpoch=0 → Mun at (+12e6, 0, 0) in kerbin frame + // → Mun world position (13.6e9 + 12e6, 0, 0) — BEHIND Kerbin from the sun. + // Sun is at (-x) from Kerbin; Mun is at (+x). No eclipse. + const munBehind: CelestialBody = { + ...mun, + orbit: { ...mun.orbit, meanAnomalyAtEpoch: 0 }, + }; + const sys = [kerbol, kerbin, munBehind]; + const f = computeShadowFraction(sys, 'kerbin', 'mun', 0); + expect(f).toBe(0); + }); + + it('returns high fraction when occluder sits between observer and sun', () => { + // Set up Mun directly between Kerbin and Kerbol (anti-aligned). + // Mun's meanAnomalyAtEpoch = π → Mun at (-12e6, 0, 0) in kerbin frame, + // i.e. world position (13.6e9 - 12e6, 0, 0). Sun at (0,0,0). + // Kerbin is at (13.6e9, 0, 0). So Mun is between them. + const munAntialigned: CelestialBody = { + ...mun, + orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI }, + }; + const sys = [kerbol, kerbin, munAntialigned]; + const f = computeShadowFraction(sys, 'kerbin', 'mun', 0); + // Should be ≥ 0.5 (Mun is ~0.6 Mm radius, observer is 12 Mm from it; + // angular size is small but the center of Mun is exactly on the + // sun-line so the umbra is total) + expect(f).toBeGreaterThan(0.5); + }); + + it('returns 0 for self-eclipse (observer == eclipser)', () => { + // findEclipseWindows early-returns on this, but computeShadowFraction + // would compute a 1.0 trivially. Either is fine; just verify the API + // returns a number. + const f = computeShadowFraction(systemBodies, 'kerbin', 'kerbin', 0); + expect(typeof f).toBe('number'); + }); + + it('returns 1 when occluder is exactly on the sun-line (centered eclipse)', () => { + // Mun anti-aligned → directly between Kerbin and Kerbol at t=0 + const munAntialigned: CelestialBody = { + ...mun, + orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI }, + }; + const sys = [kerbol, kerbin, munAntialigned]; + const f = computeShadowFraction(sys, 'kerbin', 'mun', 0); + expect(f).toBeGreaterThan(0.99); + }); + + it('returns a value in [0, 1]', () => { + const f = computeShadowFraction(systemBodies, 'kerbin', 'mun', 0); + expect(f).toBeGreaterThanOrEqual(0); + expect(f).toBeLessThanOrEqual(1); + }); +}); + +describe('findEclipseWindows', () => { + it('returns empty array for self-eclipse', () => { + const w = findEclipseWindows(systemBodies, { + observerId: 'kerbin', + eclipserId: 'kerbin', + startUt: 0, + }); + expect(w).toEqual([]); + }); + + it('returns empty array for unknown bodies', () => { + const w = findEclipseWindows(systemBodies, { + observerId: 'kerbin', + eclipserId: 'unknown', + startUt: 0, + }); + expect(w).toEqual([]); + }); + + it('finds an eclipse window when Mun passes between Kerbin and Kerbol', () => { + // Set up a system where Mun is currently in front of the sun from Kerbin's + // perspective. The Mun orbits Kerbin in ~6.8 days, so we should find + // an eclipse within a few days of t=0. + const sys = [ + kerbol, + kerbin, + { ...mun, orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI } }, // start anti-aligned + ]; + const windows = findEclipseWindows(sys, { + observerId: 'kerbin', + eclipserId: 'mun', + startUt: 0, + count: 1, + stepSec: 300, // 5 KSP minutes for the coarse scan + }); + expect(windows.length).toBeGreaterThan(0); + if (windows[0]) { + expect(windows[0].utStart).toBeGreaterThanOrEqual(0); + expect(windows[0].utEnd).toBeGreaterThan(windows[0].utStart); + expect(windows[0].utPeak).toBeGreaterThanOrEqual(windows[0].utStart); + expect(windows[0].utPeak).toBeLessThanOrEqual(windows[0].utEnd); + expect(windows[0].maxFraction).toBeGreaterThan(0); + } + }); +}); + +// ─── Overpass tests ─────────────────────────────────────────────────────── + +const vesselA: Vessel = { + id: 'v-a', + name: 'Vessel A', + type: 'Probe', + owner: 'KASA', + situation: 'ORBITING', + status: 'ACTIVE', + orbit: { + semiMajorAxis: 7_000_000, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, + referenceBodyId: 'kerbin', + createdAt: '2026-01-01T00:00:00Z', + retiredAt: null, +}; + +const vesselB: Vessel = { + id: 'v-b', + name: 'Vessel B', + type: 'Probe', + owner: 'SPES', + situation: 'ORBITING', + status: 'ACTIVE', + orbit: { + semiMajorAxis: 7_000_000, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: Math.PI, // opposite side + epoch: 0, + }, + referenceBodyId: 'kerbin', + createdAt: '2026-01-01T00:00:00Z', + retiredAt: null, +}; + +const station: GroundStation = { + id: 'montana', + name: 'Montana DSN', + bodyId: 'kerbin', + lat: 47.0, + lon: -110.0, + alt: 1200, +}; + +describe('findOverpasses', () => { + it('finds a close approach between two vessels in different orbits', () => { + // Two vessels in different circular orbits around Kerbin. They will + // occasionally align and approach each other. Use a small step to + // catch the close approach. + const vesselBDifferent: Vessel = { + ...vesselB, + orbit: { ...vesselB.orbit, semiMajorAxis: 7_500_000 }, // different SMA + }; + const passes = findOverpasses({ + observer: vesselA, + target: { kind: 'vessel', id: 'v-b', name: 'Vessel B' }, + bodies: systemBodies, + vessels: [vesselA, vesselBDifferent], + groundStations: [], + startUt: 0, + count: 1, + stepSec: 60, // 1 min coarse scan + distanceThreshold: 5_000_000, // 5000 km + maxSearchTime: 426 * 6 * 3600, // 1 KSP year + }); + expect(passes.length).toBeGreaterThan(0); + if (passes[0]) { + expect(passes[0].minDistance).toBeLessThan(5_000_000); + expect(passes[0].utPeak).toBeGreaterThan(0); + } + }); + + it('handles body target (observer passes near a body)', () => { + // Vessel A is in LKO around Kerbin, so distance to Mun varies a lot. + // We should find at least one "close" approach (within 100 Mm). + const passes = findOverpasses({ + observer: vesselA, + target: { kind: 'body', id: 'mun', name: 'Mun' }, + bodies: systemBodies, + vessels: [vesselA], + groundStations: [], + startUt: 0, + count: 1, + stepSec: 3600, + distanceThreshold: 100_000_000, // 100 Mm + }); + // Just verify the API works; we can't easily assert on the value + expect(Array.isArray(passes)).toBe(true); + }); + + it('handles ground station target', () => { + const passes = findOverpasses({ + observer: vesselA, + target: { kind: 'station', id: 'montana', name: 'Montana' }, + bodies: systemBodies, + vessels: [vesselA], + groundStations: [station], + startUt: 0, + count: 1, + stepSec: 60, + distanceThreshold: 50_000_000, // 50 Mm + }); + expect(Array.isArray(passes)).toBe(true); + }); + + it('returns empty when target is unknown', () => { + const target: Target = { kind: 'vessel', id: 'unknown', name: '?' }; + const passes = findOverpasses({ + observer: vesselA, + target, + bodies: systemBodies, + vessels: [vesselA], + groundStations: [], + startUt: 0, + }); + expect(passes).toEqual([]); + }); +}); diff --git a/apps/live-map/tests/camera.test.ts b/apps/live-map/tests/camera.test.ts new file mode 100644 index 0000000..fecdc04 --- /dev/null +++ b/apps/live-map/tests/camera.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { inverseLogScale, logScale } from '../src/scene/layout.js'; + +describe('camera log-scale', () => { + it('inverseLogScale undoes logScale', () => { + for (const t of [-3, 0, 4, 8, 12]) { + expect(inverseLogScale(logScale(t))).toBeCloseTo(t, 6); + } + }); + + it('produces distances spanning the KSP system', () => { + // t = 0: 1e8 m = 100 Mm (close zoom) + expect(logScale(0)).toBeCloseTo(1e8, -3); + // t = 4: ~5.5e9 m (Kerbin at 13.6 Gm is just outside) + expect(logScale(4)).toBeGreaterThan(1e9); + // t = 10: ~22e12 m (Eeloo at 90 Gm is well inside) + expect(logScale(10)).toBeGreaterThan(1e10); + // t = 12: ~1.6e13 m (max zoom) + expect(logScale(12)).toBeGreaterThan(1e12); + }); +}); + +describe('spherical math (used by camera)', () => { + // The camera controller uses spherical coords. Test the + // cartesian conversion (extracted for testability). + function sphericalToCartesian(distance: number, az: number, el: number) { + const sinE = Math.sin(el); + const cosE = Math.cos(el); + const sinA = Math.sin(az); + const cosA = Math.cos(az); + return { + x: distance * cosE * sinA, + y: distance * sinE, + z: distance * cosE * cosA, + }; + } + + it('produces a point on the sphere of given radius', () => { + for (const az of [0, 1, 2.5, 4.7]) { + for (const el of [-0.5, 0, 0.7]) { + const p = sphericalToCartesian(1e10, az, el); + const d = Math.hypot(p.x, p.y, p.z); + expect(d).toBeCloseTo(1e10, 4); + } + } + }); + + it('azimuth=0, elevation=0 produces +Z vector', () => { + const p = sphericalToCartesian(100, 0, 0); + expect(p.z).toBeCloseTo(100, 6); + expect(p.x).toBeCloseTo(0, 6); + expect(p.y).toBeCloseTo(0, 6); + }); + + it('azimuth=π/2, elevation=0 produces +X vector', () => { + const p = sphericalToCartesian(100, Math.PI / 2, 0); + expect(p.x).toBeCloseTo(100, 6); + expect(p.z).toBeCloseTo(0, 6); + expect(p.y).toBeCloseTo(0, 6); + }); + + it('elevation=π/2 produces +Y vector', () => { + const p = sphericalToCartesian(100, 0, Math.PI / 2); + expect(p.y).toBeCloseTo(100, 6); + expect(p.x).toBeCloseTo(0, 6); + expect(p.z).toBeCloseTo(0, 6); + }); +}); diff --git a/packages/orbital-math/src/occultation.ts b/packages/orbital-math/src/occultation.ts index 98ca9c3..ce703b1 100644 --- a/packages/orbital-math/src/occultation.ts +++ b/packages/orbital-math/src/occultation.ts @@ -28,10 +28,14 @@ export function shadowFraction( const sy = observerToSun.y / sunDist; const sz = observerToSun.z / sunDist; - // Project occluder center onto the sun-direction line + // Project occluder center onto the sun-direction line. + // Both `observerToSun` and `occluderToObserver` point AWAY from + // the observer (toward the sun / toward the occluder). When the + // occluder sits between observer and sun, both vectors point in + // roughly the same direction and `proj` is positive. const proj = occluderToObserver.x * sx + occluderToObserver.y * sy + occluderToObserver.z * sz; - if (proj >= 0) { - // Occluder is behind the observer relative to the sun → no eclipse + if (proj <= 0) { + // Occluder is behind the observer (opposite direction from sun) → no eclipse return 0; } // Perpendicular distance from occluder center to sun ray