07cc5321d1
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.
296 lines
9.6 KiB
TypeScript
296 lines
9.6 KiB
TypeScript
/**
|
|
* Scene — the 3D Three.js rendering of the universe.
|
|
*
|
|
* - 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';
|
|
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;
|
|
followId: string | null;
|
|
showPlanetOrbits: boolean;
|
|
showMoonOrbits: boolean;
|
|
showVesselOrbits: boolean;
|
|
onSelect: (id: string | null) => void;
|
|
}
|
|
|
|
interface SceneRefs {
|
|
scene: THREE.Scene;
|
|
camera: THREE.PerspectiveCamera;
|
|
renderer: THREE.WebGLRenderer;
|
|
bodyMeshes: Map<string, THREE.Mesh>;
|
|
vesselMeshes: Map<string, THREE.Mesh>;
|
|
orbitLines: Map<string, THREE.Line>;
|
|
controller: CameraController;
|
|
raf: number;
|
|
}
|
|
|
|
const ORBIT_OPACITY: Record<string, number> = {
|
|
planet: 0.5,
|
|
moon: 0.4,
|
|
vessel: 0.6,
|
|
};
|
|
|
|
export function Scene(props: SceneProps) {
|
|
const { snapshot, ut, followId, showPlanetOrbits, showMoonOrbits, showVesselOrbits, onSelect } =
|
|
props;
|
|
const mountRef = useRef<HTMLDivElement>(null);
|
|
const refsRef = useRef<SceneRefs | null>(null);
|
|
// Latest-snapshot / ut / followId refs so the camera controller
|
|
// always sees the current values without needing to be recreated.
|
|
const stateRef = useRef({ snapshot, ut, followId, onSelect });
|
|
stateRef.current = { snapshot, ut, followId, onSelect };
|
|
|
|
// One-time scene setup
|
|
useEffect(() => {
|
|
const mount = mountRef.current;
|
|
if (!mount) return;
|
|
const refs = createScene(mount, stateRef);
|
|
refsRef.current = refs;
|
|
|
|
const onResize = () => {
|
|
const r = refsRef.current;
|
|
if (!r) return;
|
|
const w = mount.clientWidth;
|
|
const h = mount.clientHeight;
|
|
r.camera.aspect = w / h;
|
|
r.camera.updateProjectionMatrix();
|
|
r.renderer.setSize(w, h);
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
|
|
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);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 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 visibility
|
|
useEffect(() => {
|
|
const refs = refsRef.current;
|
|
if (!refs) return;
|
|
for (const [id, line] of refs.orbitLines.entries()) {
|
|
const isPlanet = snapshot.bodies.find((b) => b.id === id && b.kind === 'planet');
|
|
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;
|
|
}
|
|
}, [showPlanetOrbits, showMoonOrbits, showVesselOrbits, snapshot.bodies]);
|
|
|
|
// Per-frame
|
|
useEffect(() => {
|
|
const refs = refsRef.current;
|
|
if (!refs) return;
|
|
|
|
let lastUt = Number.NEGATIVE_INFINITY;
|
|
const render = () => {
|
|
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);
|
|
}, []);
|
|
|
|
return <div ref={mountRef} style={{ width: '100%', height: '100%', cursor: 'grab' }} />;
|
|
}
|
|
|
|
// ─── Three.js setup helpers ────────────────────────────────────────────────
|
|
|
|
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;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x000005);
|
|
|
|
const camera = new THREE.PerspectiveCamera(60, width / height, 1e6, 1e12);
|
|
camera.position.set(0, 5e9, 1.5e10);
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(width, height);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
mount.appendChild(renderer.domElement);
|
|
|
|
scene.add(new THREE.AmbientLight(0x404040, 0.4));
|
|
scene.add(new THREE.PointLight(0xffffff, 2, 0, 0));
|
|
|
|
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,
|
|
camera,
|
|
renderer,
|
|
bodyMeshes: new Map(),
|
|
vesselMeshes: new Map(),
|
|
orbitLines: new Map(),
|
|
controller,
|
|
raf: 0,
|
|
};
|
|
}
|
|
|
|
function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
|
|
for (const mesh of refs.bodyMeshes.values()) {
|
|
refs.scene.remove(mesh);
|
|
mesh.geometry.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);
|
|
mesh.geometry.dispose();
|
|
(mesh.material as THREE.Material).dispose();
|
|
}
|
|
for (const line of refs.orbitLines.values()) {
|
|
refs.scene.remove(line);
|
|
line.geometry.dispose();
|
|
(line.material as THREE.Material).dispose();
|
|
}
|
|
refs.bodyMeshes.clear();
|
|
refs.vesselMeshes.clear();
|
|
refs.orbitLines.clear();
|
|
|
|
for (const body of snap.bodies) {
|
|
if (body.kind === 'star') {
|
|
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;
|
|
|
|
const displayRadius = Math.max(body.radius, 1e6);
|
|
const geo = new THREE.SphereGeometry(displayRadius, 32, 16);
|
|
const mat = new THREE.MeshPhongMaterial({
|
|
color: bodyColor(body.id),
|
|
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);
|
|
points.forEach((p, i) => {
|
|
positions[i * 3] = p.x;
|
|
positions[i * 3 + 1] = p.y;
|
|
positions[i * 3 + 2] = p.z;
|
|
});
|
|
const lineGeo = new THREE.BufferGeometry();
|
|
lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
const lineMat = new THREE.LineBasicMaterial({
|
|
color: bodyColor(body.id),
|
|
opacity: ORBIT_OPACITY[body.kind] ?? 0.5,
|
|
transparent: true,
|
|
});
|
|
const line = new THREE.LineLoop(lineGeo, lineMat);
|
|
refs.scene.add(line);
|
|
refs.orbitLines.set(body.id, line);
|
|
}
|
|
|
|
for (const vessel of snap.vessels) {
|
|
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);
|
|
|
|
if (vessel.referenceBodyId) {
|
|
const ref = snap.bodies.find((b) => b.id === vessel.referenceBodyId);
|
|
if (ref) {
|
|
const points = sampleOrbit(vessel.orbit, ref.gravitationalParameter, 128);
|
|
const positions = new Float32Array(points.length * 3);
|
|
points.forEach((p, i) => {
|
|
positions[i * 3] = p.x;
|
|
positions[i * 3 + 1] = p.y;
|
|
positions[i * 3 + 2] = p.z;
|
|
});
|
|
const lineGeo = new THREE.BufferGeometry();
|
|
lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
const lineMat = new THREE.LineBasicMaterial({
|
|
color: vesselColor(vessel.owner),
|
|
opacity: 0.5,
|
|
transparent: true,
|
|
});
|
|
const line = new THREE.LineLoop(lineGeo, lineMat);
|
|
refs.scene.add(line);
|
|
refs.orbitLines.set(vessel.id, line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function positionMeshes(refs: SceneRefs, snap: UniverseSnapshot, ut: number): void {
|
|
for (const body of snap.bodies) {
|
|
if (body.kind === 'star' || body.parentId === null) continue;
|
|
const pos = bodyPositionAt(snap.bodies, body.id, ut);
|
|
const mesh = refs.bodyMeshes.get(body.id);
|
|
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
|
|
}
|
|
for (const vessel of snap.vessels) {
|
|
const pos = vesselPositionAt(snap.bodies, vessel, ut);
|
|
const mesh = refs.vesselMeshes.get(vessel.id);
|
|
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
|
|
}
|
|
}
|