/** * 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; vesselMeshes: Map; orbitLines: Map; controller: CameraController; raf: number; } const ORBIT_OPACITY: Record = { 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(null); const refsRef = useRef(null); // Latest-snapshot / ut / followId refs so the camera controller // always sees the current values without needing to be recreated. const stateRef = useRef({ snapshot, ut, followId, onSelect }); stateRef.current = { snapshot, ut, followId, onSelect }; // One-time scene setup useEffect(() => { const mount = mountRef.current; if (!mount) return; const refs = createScene(mount, 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
; } // ─── 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); } }