Merge pull request 'Phase 2c: eclipse/overpass calculators + live-map camera polish' (#3) from phase-2c into main
CI / Lint, typecheck, test, build (push) Failing after 9s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-06-02 20:47:42 +00:00
13 changed files with 1533 additions and 86 deletions
+4
View File
@@ -18,6 +18,7 @@ import { TimeControls } from './panels/TimeControls.js';
import { VesselList } from './panels/VesselList.js'; import { VesselList } from './panels/VesselList.js';
import { FocusPanel } from './panels/FocusPanel.js'; import { FocusPanel } from './panels/FocusPanel.js';
import { StatusPill } from './panels/StatusPill.js'; import { StatusPill } from './panels/StatusPill.js';
import { CalculatorsPanel } from './panels/CalculatorsPanel.js';
import { useLiveState } from './hooks/useLiveState.js'; import { useLiveState } from './hooks/useLiveState.js';
import type { UniverseSnapshot } from '@kerbal-rt/shared-types'; import type { UniverseSnapshot } from '@kerbal-rt/shared-types';
@@ -144,6 +145,7 @@ export function App() {
showPlanetOrbits={showPlanetOrbits} showPlanetOrbits={showPlanetOrbits}
showMoonOrbits={showMoonOrbits} showMoonOrbits={showMoonOrbits}
showVesselOrbits={showVesselOrbits} showVesselOrbits={showVesselOrbits}
onSelect={setSelectedId}
/> />
<StatusPill <StatusPill
@@ -192,6 +194,8 @@ export function App() {
onToggleVessel={() => setShowVesselOrbits((v) => !v)} onToggleVessel={() => setShowVesselOrbits((v) => !v)}
/> />
<CalculatorsPanel snapshot={displaySnapshot} scanFromUt={displayUt} />
{error && ( {error && (
<div <div
style={{ style={{
+243
View File
@@ -0,0 +1,243 @@
/**
* Eclipse calculator — finds the next N times when the sun (Kerbol)
* is occluded from an observer's point of view by another body.
*
* Algorithm: scan forward in UT at fixed steps, refining around
* the transition points with bisection. Treats the sun as a point
* source (parallel rays) — accurate for KSP scale.
*/
import type { CelestialBody } from '@kerbal-rt/shared-types';
import { bodyPositionAt } from '../scene/layout.js';
import { shadowFraction } from '@kerbal-rt/orbital-math';
export interface EclipseWindow {
/** UT at which the eclipse begins (sun starts being occluded). */
utStart: number;
/** UT at which the eclipse ends. */
utEnd: number;
/** Maximum shadow fraction during the window (0..1). */
maxFraction: number;
/** UT at which the max occurs. */
utPeak: number;
}
export interface EclipseOptions {
observerId: string;
eclipserId: string;
/** Scan starts at this UT. */
startUt: number;
/** How many windows to find. */
count?: number;
/** Max time to scan forward (default ~1 KSP year). */
maxSearchTime?: number;
/** Coarse scan step (default 600 = 10 KSP minutes). */
stepSec?: number;
/** Threshold for "eclipse started". */
threshold?: number;
}
/**
* Find the next `count` eclipse windows where `eclipser` occludes
* the sun as seen from `observer`.
*
* Returns an empty array if observer and eclipser are the same body
* or if either is Kerbol (the sun can't eclipse itself, and
* eclipsers behind the sun don't make sense).
*/
export function findEclipseWindows(
bodies: CelestialBody[],
options: EclipseOptions,
): EclipseWindow[] {
const {
observerId,
eclipserId,
startUt,
count = 3,
maxSearchTime = 426 * 6 * 3600, // 1 KSP year
stepSec = 600,
threshold = 0.05,
} = options;
if (observerId === eclipserId) return [];
const observer = bodies.find((b) => 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<EclipseWindow> = {};
// 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;
}
+151
View File
@@ -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;
}
@@ -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<Tab>('eclipse');
return (
<div style={PANEL_STYLE}>
<div style={HEADER_STYLE} onClick={() => setOpen((o) => !o)}>
<span
style={{
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: 10,
}}
>
Calculators
</span>
<div style={{ flex: 1 }} />
<span style={{ opacity: 0.6, fontSize: 10 }}>{open ? '▼' : '▲'}</span>
</div>
{open && (
<>
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<div style={TAB_STYLE(tab === 'eclipse')} onClick={() => setTab('eclipse')}>
Eclipse
</div>
<div style={TAB_STYLE(tab === 'overpass')} onClick={() => setTab('overpass')}>
Overpass
</div>
</div>
<div style={BODY_STYLE}>
{tab === 'eclipse' ? (
<EclipseTab snapshot={snapshot} scanFromUt={scanFromUt} />
) : (
<OverpassTab snapshot={snapshot} scanFromUt={scanFromUt} />
)}
</div>
</>
)}
</div>
);
}
// ─── 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<string>(observers[0]?.id ?? '');
const [eclipserId, setEclipserId] = useState<string>(eclipsers[1]?.id ?? eclipsers[0]?.id ?? '');
const [result, setResult] = useState<EclipseWindow[] | null>(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 (
<div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 70 }}>Observer:</span>
<select
style={INPUT_STYLE}
value={observerId}
onChange={(e) => setObserverId(e.target.value)}
>
{observers.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 70 }}>Eclipser:</span>
<select
style={INPUT_STYLE}
value={eclipserId}
onChange={(e) => setEclipserId(e.target.value)}
>
{eclipsers.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 70 }}>From UT:</span>
<span style={{ opacity: 0.8 }}>{formatKspTime(scanFromUt)}</span>
</div>
<div style={ROW_STYLE}>
<button style={BUTTON_STYLE} onClick={calculate} disabled={!canRun}>
Calculate
</button>
</div>
{result !== null && (
<div>
{result.length === 0 ? (
<div style={{ ...RESULT_STYLE, opacity: 0.6 }}>
No eclipse windows found within 1 KSP year.
</div>
) : (
result.map((w, i) => (
<div key={i} style={RESULT_STYLE}>
<div>
#{i + 1} {formatKspTime(w.utStart)} {formatKspTime(w.utEnd)}
</div>
<div style={{ opacity: 0.7, fontSize: 10 }}>
peak at {formatKspTime(w.utPeak)}, max fraction {(w.maxFraction * 100).toFixed(0)}
%
</div>
</div>
))
)}
</div>
)}
</div>
);
}
// ─── Overpass tab ─────────────────────────────────────────────────────────
function OverpassTab({ snapshot, scanFromUt }: { snapshot: UniverseSnapshot; scanFromUt: number }) {
const vessels = snapshot.vessels;
const allBodies = snapshot.bodies;
const [observerId, setObserverId] = useState<string>(vessels[0]?.id ?? '');
const [targetKind, setTargetKind] = useState<'vessel' | 'body' | 'station'>('body');
const [targetId, setTargetId] = useState<string>(
allBodies.find((b) => b.kind === 'planet')?.id ?? '',
);
const [distanceKm, setDistanceKm] = useState(1000);
const [result, setResult] = useState<OverpassWindow[] | null>(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 (
<div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Observer:</span>
<select
style={INPUT_STYLE}
value={observerId}
onChange={(e) => setObserverId(e.target.value)}
>
{vessels.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Target kind:</span>
<select
style={INPUT_STYLE}
value={targetKind}
onChange={(e) => {
const k = e.target.value as 'vessel' | 'body' | 'station';
setTargetKind(k);
setTargetId(targetOptions[0]?.id ?? '');
}}
>
<option value="vessel">Vessel</option>
<option value="body">Body</option>
<option value="station">Ground station</option>
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Target:</span>
<select style={INPUT_STYLE} value={targetId} onChange={(e) => setTargetId(e.target.value)}>
{targetOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Max dist:</span>
<input
type="number"
min={1}
step={100}
value={distanceKm}
onChange={(e) => setDistanceKm(Number(e.target.value))}
style={{ ...INPUT_STYLE, width: 90 }}
/>
<span style={{ opacity: 0.6, fontSize: 10 }}>km</span>
</div>
<div style={ROW_STYLE}>
<button style={BUTTON_STYLE} onClick={calculate} disabled={!canRun}>
Calculate
</button>
</div>
{result !== null && (
<div>
{result.length === 0 ? (
<div style={{ ...RESULT_STYLE, opacity: 0.6 }}>
No passes within {distanceKm} km in the next KSP year.
</div>
) : (
result.map((w, i) => (
<div key={i} style={RESULT_STYLE}>
<div>
#{i + 1} {formatKspTime(w.utPeak)}
</div>
<div style={{ opacity: 0.7, fontSize: 10 }}>
{target?.name}: min distance {(w.minDistance / 1000).toFixed(1)} km
</div>
</div>
))
)}
</div>
)}
</div>
);
}
+1 -20
View File
@@ -8,6 +8,7 @@
* playback. Higher speeds fast-forward. * playback. Higher speeds fast-forward.
*/ */
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import { formatKspTime, formatKspDuration } from '../timeFormat.js';
export interface TimeControlsProps { export interface TimeControlsProps {
ut: number; ut: number;
@@ -122,23 +123,3 @@ export function TimeControls(props: TimeControlsProps) {
</div> </div>
); );
} }
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`;
}
+60 -60
View File
@@ -1,9 +1,10 @@
/** /**
* Scene the 3D Three.js rendering of the universe. * Scene the 3D Three.js rendering of the universe.
* *
* Re-renders when the bodies, vessels, or focus settings change. * - Builds body meshes, orbit lines, vessel markers
* The animation loop runs the orbit propagation and positions the * - Adds atmospheric glow on planets
* meshes for the current `ut`. * - Uses CameraController for log-scale distance, mouse zoom,
* drag-to-rotate, click-to-track via raycasting
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import * as THREE from 'three'; 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 type { UniverseSnapshot } from '@kerbal-rt/shared-types';
import { bodyColor, vesselColor } from './color.js'; import { bodyColor, vesselColor } from './color.js';
import { bodyPositionAt, vesselPositionAt } from './layout.js'; import { bodyPositionAt, vesselPositionAt } from './layout.js';
import { CameraController } from './camera.js';
import { createGlow } from './glow.js';
export interface SceneProps { export interface SceneProps {
snapshot: UniverseSnapshot; snapshot: UniverseSnapshot;
ut: number; ut: number;
/** Which body or vessel the camera should follow. null = free. */
followId: string | null; followId: string | null;
/** Toggle visibility of orbit lines by category. */
showPlanetOrbits: boolean; showPlanetOrbits: boolean;
showMoonOrbits: boolean; showMoonOrbits: boolean;
showVesselOrbits: boolean; showVesselOrbits: boolean;
onSelect: (id: string | null) => void;
} }
interface SceneRefs { interface SceneRefs {
@@ -30,7 +32,7 @@ interface SceneRefs {
bodyMeshes: Map<string, THREE.Mesh>; bodyMeshes: Map<string, THREE.Mesh>;
vesselMeshes: Map<string, THREE.Mesh>; vesselMeshes: Map<string, THREE.Mesh>;
orbitLines: Map<string, THREE.Line>; orbitLines: Map<string, THREE.Line>;
mount: HTMLDivElement; controller: CameraController;
raf: number; raf: number;
} }
@@ -41,15 +43,20 @@ const ORBIT_OPACITY: Record<string, number> = {
}; };
export function Scene(props: SceneProps) { 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 mountRef = useRef<HTMLDivElement>(null);
const refsRef = useRef<SceneRefs | null>(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 // One-time scene setup
useEffect(() => { useEffect(() => {
const mount = mountRef.current; const mount = mountRef.current;
if (!mount) return; if (!mount) return;
const refs = createScene(mount); const refs = createScene(mount, stateRef);
refsRef.current = refs; refsRef.current = refs;
const onResize = () => { const onResize = () => {
@@ -66,6 +73,7 @@ export function Scene(props: SceneProps) {
return () => { return () => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
cancelAnimationFrame(refs.raf); cancelAnimationFrame(refs.raf);
refs.controller.dispose();
refs.renderer.dispose(); refs.renderer.dispose();
if (mount.contains(refs.renderer.domElement)) { if (mount.contains(refs.renderer.domElement)) {
mount.removeChild(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 // Rebuild meshes when bodies/vessels set changes
// bodies or vessels changes (not on every snapshot — they're stable).
useEffect(() => { useEffect(() => {
const refs = refsRef.current; const refs = refsRef.current;
if (!refs) return; if (!refs) return;
rebuildMeshes(refs, snapshot); rebuildMeshes(refs, snapshot);
}, [snapshot.bodies, snapshot.vessels, snapshot]); }, [snapshot.bodies, snapshot.vessels, snapshot]);
// Toggle orbit line visibility // Toggle orbit visibility
useEffect(() => { useEffect(() => {
const refs = refsRef.current; const refs = refsRef.current;
if (!refs) return; if (!refs) return;
@@ -90,48 +97,44 @@ export function Scene(props: SceneProps) {
const isMoon = snapshot.bodies.find((b) => b.id === id && b.kind === 'moon'); const isMoon = snapshot.bodies.find((b) => b.id === id && b.kind === 'moon');
if (isPlanet) line.visible = showPlanetOrbits; if (isPlanet) line.visible = showPlanetOrbits;
else if (isMoon) line.visible = showMoonOrbits; else if (isMoon) line.visible = showMoonOrbits;
else line.visible = showVesselOrbits; // vessel else line.visible = showVesselOrbits;
} }
}, [showPlanetOrbits, showMoonOrbits, showVesselOrbits, snapshot.bodies]); }, [showPlanetOrbits, showMoonOrbits, showVesselOrbits, snapshot.bodies]);
// Per-frame: propagate, position meshes, follow camera // Per-frame
useEffect(() => { useEffect(() => {
const refs = refsRef.current; const refs = refsRef.current;
if (!refs) return; if (!refs) return;
let lastUt = Number.NEGATIVE_INFINITY; let lastUt = Number.NEGATIVE_INFINITY;
const render = () => { const render = () => {
// Re-propagate positions whenever ut changes const cur = stateRef.current;
if (ut !== lastUt) { if (cur.ut !== lastUt) {
lastUt = ut; lastUt = cur.ut;
positionMeshes(refs, snapshot, ut); positionMeshes(refs, cur.snapshot, cur.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);
}
} }
refs.controller.update();
refs.renderer.render(refs.scene, refs.camera); refs.renderer.render(refs.scene, refs.camera);
refs.raf = requestAnimationFrame(render); refs.raf = requestAnimationFrame(render);
}; };
refs.raf = requestAnimationFrame(render); refs.raf = requestAnimationFrame(render);
return () => cancelAnimationFrame(refs.raf); 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 ──────────────────────────────────────────────── // ─── 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 width = mount.clientWidth;
const height = mount.clientHeight; const height = mount.clientHeight;
@@ -148,17 +151,16 @@ function createScene(mount: HTMLDivElement): SceneRefs {
mount.appendChild(renderer.domElement); mount.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0x404040, 0.4)); scene.add(new THREE.AmbientLight(0x404040, 0.4));
const sunLight = new THREE.PointLight(0xffffff, 2, 0, 0); scene.add(new THREE.PointLight(0xffffff, 2, 0, 0));
scene.add(sunLight);
const onResize = () => { const controller = new CameraController({
const w = mount.clientWidth; camera,
const h = mount.clientHeight; domElement: mount,
camera.aspect = w / h; getSnapshot: () => stateRef.current.snapshot,
camera.updateProjectionMatrix(); getUt: () => stateRef.current.ut,
renderer.setSize(w, h); getFollowId: () => stateRef.current.followId,
}; onSelect: (id) => stateRef.current.onSelect(id),
window.addEventListener('resize', onResize); });
return { return {
scene, scene,
@@ -167,17 +169,20 @@ function createScene(mount: HTMLDivElement): SceneRefs {
bodyMeshes: new Map(), bodyMeshes: new Map(),
vesselMeshes: new Map(), vesselMeshes: new Map(),
orbitLines: new Map(), orbitLines: new Map(),
mount, controller,
raf: 0, raf: 0,
}; };
} }
function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void { function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
// Remove existing meshes / lines
for (const mesh of refs.bodyMeshes.values()) { for (const mesh of refs.bodyMeshes.values()) {
refs.scene.remove(mesh); refs.scene.remove(mesh);
mesh.geometry.dispose(); 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()) { for (const mesh of refs.vesselMeshes.values()) {
refs.scene.remove(mesh); 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 geo = new THREE.SphereGeometry(Math.max(body.radius, 1e8), 32, 16);
const mat = new THREE.MeshBasicMaterial({ color: bodyColor(body.id) }); const mat = new THREE.MeshBasicMaterial({ color: bodyColor(body.id) });
const mesh = new THREE.Mesh(geo, mat); const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: body.id };
refs.scene.add(mesh); refs.scene.add(mesh);
refs.bodyMeshes.set(body.id, mesh); refs.bodyMeshes.set(body.id, mesh);
continue; continue;
} }
if (body.parentId === null) continue; if (body.parentId === null) continue;
// Body sphere
const displayRadius = Math.max(body.radius, 1e6); const displayRadius = Math.max(body.radius, 1e6);
const geo = new THREE.SphereGeometry(displayRadius, 32, 16); const geo = new THREE.SphereGeometry(displayRadius, 32, 16);
const mat = new THREE.MeshPhongMaterial({ const mat = new THREE.MeshPhongMaterial({
@@ -212,9 +217,16 @@ function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
emissive: 0x111111, emissive: 0x111111,
}); });
const mesh = new THREE.Mesh(geo, mat); const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: body.id };
refs.scene.add(mesh); refs.scene.add(mesh);
refs.bodyMeshes.set(body.id, 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 // Orbit line
const points = sampleOrbit(body.orbit, body.gravitationalParameter, 256); const points = sampleOrbit(body.orbit, body.gravitationalParameter, 256);
const positions = new Float32Array(points.length * 3); 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 geo = new THREE.SphereGeometry(2e5, 12, 8);
const mat = new THREE.MeshBasicMaterial({ color: vesselColor(vessel.owner) }); const mat = new THREE.MeshBasicMaterial({ color: vesselColor(vessel.owner) });
const mesh = new THREE.Mesh(geo, mat); const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: vessel.id };
refs.scene.add(mesh); refs.scene.add(mesh);
refs.vesselMeshes.set(vessel.id, mesh); refs.vesselMeshes.set(vessel.id, mesh);
// Vessel orbit (relative to its reference body)
if (vessel.referenceBodyId) { if (vessel.referenceBodyId) {
const ref = snap.bodies.find((b) => b.id === vessel.referenceBodyId); const ref = snap.bodies.find((b) => b.id === vessel.referenceBodyId);
if (ref) { 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); 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 * 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( export function bodyPositionAt(
bodies: CelestialBody[], bodies: CelestialBody[],
@@ -29,10 +30,16 @@ export function bodyPositionAt(
): { x: number; y: number; z: number } { ): { x: number; y: number; z: number } {
const body = bodies.find((b) => b.id === bodyId); const body = bodies.find((b) => b.id === bodyId);
if (!body) return { x: 0, y: 0, z: 0 }; 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); const parent = bodies.find((b) => b.id === body.parentId);
if (!parent) return { x: 0, y: 0, z: 0 }; 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. */ /** Position of a vessel, propagated to UT, in the heliocentric frame. */
+28
View File
@@ -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`;
}
+305
View File
@@ -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([]);
});
});
+68
View File
@@ -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);
});
});
+7 -3
View File
@@ -28,10 +28,14 @@ export function shadowFraction(
const sy = observerToSun.y / sunDist; const sy = observerToSun.y / sunDist;
const sz = observerToSun.z / 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; const proj = occluderToObserver.x * sx + occluderToObserver.y * sy + occluderToObserver.z * sz;
if (proj >= 0) { if (proj <= 0) {
// Occluder is behind the observer relative to the sun → no eclipse // Occluder is behind the observer (opposite direction from sun) → no eclipse
return 0; return 0;
} }
// Perpendicular distance from occluder center to sun ray // Perpendicular distance from occluder center to sun ray