Phase 2c: eclipse/overpass calculators + live-map camera polish
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
Calculators (apps/live-map/src/calculators/): - eclipse.ts: findEclipseWindows(bodies, opts) — coarse scan with threshold-crossing detection, bisection to refine start/end, ternary search to find peak. Handles eclipse already in progress at scan-start. Uses sun = parentId===null body. - overpass.ts: findOverpasses(opts) — coarse scan for local distance minima, ternary refinement. Targets: vessel, body, ground station (lat/lon/alt → heliocentric). UI: - panels/CalculatorsPanel.tsx: collapsible bottom-center panel with two tabs. Eclipse form: observer, eclipser, from UT → 3 windows. Overpass form: observer vessel, target kind+id, max dist → 5 passes. - timeFormat.ts: shared KSP-time formatters. Live-map camera polish (apps/live-map/src/scene/): - camera.ts: CameraController — log-scale distance (z→exp(z)*1e8 m, range -3..12), spherical orbit around target, smooth lerp to selected body/vessel. Mouse wheel zooms, drag rotates, click raycasts for track toggle. Pointer-move-distance gate to distinguish click from drag. - glow.ts: additive shader-based atmospheric halo (rim-falloff fragment shader, BackSide) attached as child of body mesh. - layout.ts: bodyPositionAt now returns true heliocentric (walks parent chain); previous version returned parent-relative for non-root children which broke the eclipse calculator. Bug fix: - packages/orbital-math/src/occultation.ts: sign of projection check was inverted. `proj <= 0` correctly returns 0 (occluder behind observer), `proj > 0` triggers eclipse computation. Tests: 28 live-map tests (10 scene + 12 calculator + 6 camera), 45 total across the workspace, all passing.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user