import { ArcType, Cartesian3, ClockRange, Color, ColorGeometryInstanceAttribute, CustomDataSource, GeometryInstance, JulianDate, Matrix3, PolylineColorAppearance, PolylineGeometry, SampledPositionProperty, Transforms, } from "@cesium/engine"; import type { Viewer } from "@cesium/widgets"; import { PolylineBatch } from "../util/PolylineBatch"; import { EARTH, initialState, type PropagationRequest, type PropagationSample, propagate } from "./propagator"; export interface SimulationSceneRequest extends PropagationRequest { magneticField: boolean; } /** * Bridges the custom numerical propagator into Satvis's Cesium scene and clock. * Satvis tracks use Cesium entities too, so custom scenarios behave like native * visual objects: they orbit, scrub, play, and can be inspected in the same view. */ export class SimulationManager { readonly #source = new CustomDataSource("orbital-simulation"); readonly #orbits: PolylineBatch; #orbitGeometry: GeometryInstance | undefined; #run = 0; constructor(private readonly viewer: Viewer) { void viewer.dataSources.add(this.#source); this.#orbits = new PolylineBatch(viewer, "inertial"); } clear(): void { this.#source.entities.removeAll(); if (this.#orbitGeometry) { this.#orbits.remove(this.#orbitGeometry); this.#orbitGeometry = undefined; } } run(request: SimulationSceneRequest): PropagationSample[] { const samples = propagate(request); this.clear(); const runId = ++this.#run; const start = JulianDate.now(); const positions = samples.map((sample) => inertialToFixed(sample.state, JulianDate.addSeconds(start, sample.elapsedSeconds, new JulianDate()))); const sampledPosition = new SampledPositionProperty(); samples.forEach((sample, index) => { const position = positions[index]; if (position) sampledPosition.addSample(JulianDate.addSeconds(start, sample.elapsedSeconds, new JulianDate()), position); }); this.#source.entities.add({ id: `simulation-spacecraft-${runId}`, name: "Custom propagated spacecraft", position: sampledPosition, point: { pixelSize: 10, color: Color.CYAN, outlineColor: Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY }, label: { text: "SIMULATION", font: "12px sans-serif", fillColor: Color.CYAN, outlineColor: Color.BLACK, outlineWidth: 2, pixelOffset: new Cartesian3(10, -12, 0), disableDepthTestDistance: Number.POSITIVE_INFINITY }, }); this.addClosedInertialOrbit(request, runId); if (request.magneticField) this.addMagneticField(runId); this.viewer.clock.startTime = JulianDate.clone(start); this.viewer.clock.stopTime = JulianDate.addSeconds(start, request.durationSeconds, new JulianDate()); this.viewer.clock.currentTime = JulianDate.clone(start); this.viewer.clock.clockRange = ClockRange.LOOP_STOP; this.viewer.clock.multiplier = Math.max(1, request.durationSeconds / 90); this.viewer.clock.shouldAnimate = true; this.viewer.scene.requestRender(); return samples; } /** * The propagated samples above are transformed into Earth-fixed coordinates so * the spacecraft moves correctly above the rotating globe. They must not be * used to draw the orbit itself: that would be a finite ground-track segment, * which is why the old cyan line looked open. The guide below is one osculating * revolution in the inertial frame, rendered by Satvis's inertial batch. */ private addClosedInertialOrbit(request: SimulationSceneRequest, runId: number): void { const period = osculatingPeriod(request); const guideSamples = propagate({ ...request, durationSeconds: period, sampleCount: Math.max(360, Math.ceil(period / 20) + 1), }); const positions = guideSamples.map((sample) => new Cartesian3(sample.state[0], sample.state[1], sample.state[2])); const firstPosition = positions[0]; if (!firstPosition) return; // Numerical perturbations mean the final integration point can differ by a // few metres. This is a closed one-revolution guide, so join it exactly at // its epoch rather than drawing an artificial open seam. positions[positions.length - 1] = Cartesian3.clone(firstPosition); this.#orbitGeometry = new GeometryInstance({ geometry: new PolylineGeometry({ positions, width: 2.5, arcType: ArcType.NONE, vertexFormat: PolylineColorAppearance.VERTEX_FORMAT, }), attributes: { color: ColorGeometryInstanceAttribute.fromColor(Color.CYAN.withAlpha(0.92)) }, id: `simulation-orbit-${runId}`, }); this.#orbits.add(this.#orbitGeometry); } /** A dipole field-line visualization, deliberately separate from force integration. */ private addMagneticField(runId: number): void { const fieldColor = Color.fromCssColorString("#6ce8ff").withAlpha(0.34); for (let shell = 1.25; shell <= 3.5; shell += 0.38) { const limit = Math.acos(Math.sqrt(1 / shell)); const positions: Cartesian3[] = []; for (let latitude = -limit; latitude <= limit; latitude += 0.025) { const radius = shell * EARTH.radius * Math.cos(latitude) ** 2; positions.push(new Cartesian3(radius * Math.cos(latitude), 0, radius * Math.sin(latitude))); } this.#source.entities.add({ id: `simulation-magnetic-${runId}-${shell.toFixed(2)}`, name: "Earth magnetic field (visual guide)", polyline: { positions, width: 1, material: fieldColor, clampToGround: false } }); } } } /** The Keplerian period of the initial osculating state, in seconds. */ function osculatingPeriod(request: PropagationRequest): number { const state = initialState(request); const radius = Math.hypot(state[0], state[1], state[2]); const speedSquared = state[3] ** 2 + state[4] ** 2 + state[5] ** 2; const semimajorAxis = 1 / (2 / radius - speedSquared / EARTH.mu); return 2 * Math.PI * Math.sqrt(semimajorAxis ** 3 / EARTH.mu); } /** Keep the moving sample in the exact frame used by the inertial orbit primitive. */ function inertialToFixed(state: PropagationSample["state"], time: JulianDate): Cartesian3 { const inertial = new Cartesian3(state[0], state[1], state[2]); const icrfToFixed = Transforms.computeIcrfToFixedMatrix(time); return icrfToFixed ? Matrix3.multiplyByVector(icrfToFixed, inertial, new Cartesian3()) : inertial; }