Spaces:
Configuration error
Configuration error
| 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; | |
| } | |