File size: 6,420 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
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;
}