orbit-studio / src /modules /simulation /SimulationManager.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
6.42 kB
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;
}