Files
KSP-MissionControl/apps/live-map/src/calculators/overpass.ts
T
Mavis 07cc5321d1
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
Phase 2c: eclipse/overpass calculators + live-map camera polish
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.
2026-06-02 19:46:00 +00:00

152 lines
4.4 KiB
TypeScript

/**
* 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;
}