refactor(launches): Cesium rendering behind a renderer interface

This commit is contained in:
German Khokhlov committed 2026-10-23 00:32:10 +04:00
1 parent 1839d62160
commit 4baefeb8f9
31 files changed
+1202 -420

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ const RENDERED_LAYER_DIRS = [
'src/layers/localAdsb/',
'src/layers/vessels/',
'src/layers/satellites/',
'src/layers/launches/',
'src/layers/aircraft/',
'src/layers/shared/',
];
+7 -1
View File
@@ -1149,5 +1149,11 @@
"src/layers/satellites/render/navara.js",
"src/layers/satellites/render/navara.test.mjs",
"src/layers/satellites/render/cesium.test.mjs",
"src/geo/ellipsoidGeodesic.test.mjs"
"src/geo/ellipsoidGeodesic.test.mjs",
"src/bench/launchFixture.js",
"src/layers/launches/replayClock.test.mjs",
"scripts/qa-launches.mjs",
"src/geo/boundingSphere.test.mjs",
"src/layers/launches/render/cesium.js",
"src/layers/launches/launchPadFrame.test.mjs"
]
+28
View File
@@ -529,6 +529,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -695,6 +696,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -819,6 +821,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -888,6 +891,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -948,6 +952,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -994,6 +999,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1039,6 +1045,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1102,6 +1109,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1145,6 +1153,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1167,6 +1176,8 @@
"src/layers/launches/camera.js",
"src/layers/launches/controls.js",
"src/layers/launches/index.js",
"src/layers/shared/color.js",
"src/layers/launches/render/cesium.js",
"src/layers/launches/ingestion.js",
"src/layers/launches/launchPad.js",
"src/layers/launches/lifecycle.js",
@@ -1189,6 +1200,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1242,6 +1254,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1286,6 +1299,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1344,6 +1358,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1384,6 +1399,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1442,6 +1458,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1503,6 +1520,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1540,6 +1558,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1590,6 +1609,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -1624,6 +1644,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -2086,6 +2107,7 @@
"src/layers/launches/camera.js",
"src/layers/launches/controls.js",
"src/layers/launches/index.js",
"src/layers/launches/render/cesium.js",
"src/layers/launches/ingestion.js",
"src/layers/launches/launchPad.js",
"src/layers/launches/lifecycle.js",
@@ -2580,6 +2602,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -2901,6 +2924,7 @@
"src/layers/launches/camera.js",
"src/layers/launches/controls.js",
"src/layers/launches/index.js",
"src/layers/launches/render/cesium.js",
"src/layers/launches/ingestion.js",
"src/layers/launches/launchPad.js",
"src/layers/launches/lifecycle.js",
@@ -3192,6 +3216,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -3292,6 +3317,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -3776,6 +3802,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
@@ -3831,6 +3858,7 @@
"src/geo/ellipsoid.js",
"src/geo/cartographic.js",
"src/geo/ellipsoidGeodesic.js",
"src/geo/boundingSphere.js",
"src/geo/cartesian2.js",
"src/geo/cartesian3.js",
"src/geo/matrix3.js",
+17 -1
View File
@@ -8,6 +8,7 @@
// military:synthetic[:count] readsb rows, `count` aircraft (1 000); the
// first 25 are also synthetic civil flights
// vessels:fixture 12 000 AIS rows, or those within an area
// launches:fixture three LL2 missions; also supplies the satellite dependency
// satellites:fixture 839 core TLEs + 6000 dense points
// local-adsb:fixture the local receivers' answer (server format,
// /api/local-receivers/aircraft): 40 aircraft
@@ -24,6 +25,8 @@ import {
syntheticMilitary,
syntheticVessels,
} from '../../bench/w1Feeds.js';
import { launchFixture } from '../../bench/launchFixture.js';
import { createLaunchSource } from '../../layers/launches/source.js';
import { satelliteFixture } from '../../bench/satelliteFixture.js';
import { createSatelliteSource } from '../../layers/satellites/source.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
@@ -33,6 +36,7 @@ const KINDS = Object.freeze({
military: new Set(['synthetic']),
vessels: new Set(['fixture']),
satellites: new Set(['fixture']),
launches: new Set(['fixture']),
'local-adsb': new Set(['fixture']),
});
@@ -48,7 +52,7 @@ export function parseQaFeed(search = '') {
(count !== null && !(Number.isInteger(count) && count > 0))
)
throw new TypeError(
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, local-adsb:fixture)`,
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, launches:fixture, local-adsb:fixture)`,
);
return { layer, kind, count };
});
@@ -64,6 +68,18 @@ const json = (payload) =>
export function createQaSources(entries, { now = () => Date.now() } = {}) {
const sources = {};
for (const { layer, count } of entries) {
if (layer === 'launches') {
sources.launches = createLaunchSource({
fetchImpl: async (url) =>
String(url).includes('/celestrak/')
? new Response(satelliteFixture('stations'))
: json(launchFixture()),
});
sources.satellites ??= createSatelliteSource({
fetchImpl: async (url) =>
new Response(satelliteFixture(String(url).split('/').at(-1))),
});
}
if (layer === 'satellites')
sources.satellites = createSatelliteSource({
fetchImpl: async (url) =>
+2
View File
@@ -1,3 +1,4 @@
import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js';
// Which engine draws which layer: the layer renderer registry (plan, "layer
// renderer interface") and the layer availability API built on it.
//
@@ -88,6 +89,7 @@ export const layerAvailableOn = (layerId, engineKind) =>
/** Cesium renderers extracted from their layers (render/cesium.js). */
export const CESIUM_LAYER_RENDERERS = Object.freeze({
'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer,
flights: createFlightsCesiumRenderer,
military: createMilitaryCesiumRenderer,
+2 -1
View File
@@ -56,8 +56,9 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => {
}
});
test('the aircraft, vessels and satellites have extracted Cesium renderers; the others still draw themselves', () => {
test('the aircraft, vessels, satellites and launches have extracted Cesium renderers; the others still draw themselves', () => {
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
'rocket-launches',
'satellites',
'flights',
'military',
+13 -2
View File
@@ -1,12 +1,23 @@
import { rendererFor } from './renderers.js';
import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js';
import { createRocketLaunchesLayer } from '../../layers/launches/index.js';
import * as geometry from '../../celestialRing.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationLaunches({ source, satellites }) {
export function createApplicationLaunches({
source,
satellites,
renderer = {
create: ({ engine = null, ...options }) =>
engine
? rendererFor('rocket-launches', engine, options)
: createLaunchesCesiumRenderer(null, options),
},
}) {
return createRocketLaunchesLayer({
source,
services: { satellites, geometry, overlays, render },
services: { satellites, geometry, overlays, render, renderer },
});
}
+97
View File
@@ -0,0 +1,97 @@
// Deterministic Launch Library 2 records for phase-3 mission/replay acceptance.
import { SATELLITE_FIXTURE_EPOCH } from './satelliteFixture.js';
export const LAUNCH_FIXTURE_EPOCH = SATELLITE_FIXTURE_EPOCH;
export function launchFixture() {
const definitions = [
[
'qa-iss',
'QA ISS supply mission',
'ISS (ZARYA)',
'NASA',
-80.604333,
28.608389,
'Launch Complex 39A',
'Low Earth Orbit',
],
[
'qa-meo',
'QA navigation mission',
'QA MEO payload',
'Rocket Lab',
177.864,
-39.262,
'Launch Complex 1',
'Medium Earth Orbit',
],
[
'qa-gto',
'QA transfer mission',
'QA GTO payload',
'SpaceX',
-52.768,
5.236,
'QA Equatorial Pad',
'Geostationary Transfer Orbit',
],
];
return {
results: definitions.map(
([id, name, payload, provider, lon, lat, pad, orbit], i) => ({
id,
name,
net: new Date(LAUNCH_FIXTURE_EPOCH - (i + 1) * 86400000).toISOString(),
status: { name: 'Launch Successful' },
launch_service_provider: { name: provider },
pad: {
name: pad,
latitude: lat,
longitude: lon,
location: { name: pad },
},
mission: {
name: payload,
description: 'Deterministic QA fixture',
orbit: { name: orbit },
},
timeline: [
{ name: 'MECO', relative_time: '00:02:30' },
{ name: 'SECO', relative_time: '00:09:00' },
{ name: 'Payload deployment', relative_time: '00:10:00' },
],
rocket: {
payloads: [
{
id: `${id}-payload`,
payload: {
name: payload,
type: { name: 'QA spacecraft' },
mass: 500,
},
},
],
launcher_stage:
i === 0
? [
{
id: 'qa-booster',
type: 'First stage',
launcher: { serial_number: 'QA-01' },
landing: {
attempt: true,
success: true,
type: { name: 'Drone Ship' },
landing_location: {
name: 'QA Recovery Platform',
latitude: 28.32,
longitude: -75.12,
},
},
},
]
: [],
},
trajectory: [],
}),
),
};
}
+1 -1
View File
@@ -124,7 +124,7 @@ test('every scene click handler consults ownership before it picks', () => {
'src/layers/installations/selection.js',
'viewer.scene.pick(click.position)',
],
['src/layers/launches/lifecycle.js', 'drillPick(movement.position'],
['src/layers/launches/render/cesium.js', 'drillPick(movement.position'],
['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'],
[
'src/layers/satellites/render/cesium.js',
+235
View File
@@ -0,0 +1,235 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — BoundingSphere.js subset.
// Exact mission framing: Ritter/box fit and the union of two spheres.
import * as Cartesian3 from './cartesian3.js';
import { Check } from './check.js';
import { DEBUG } from './debug.js';
const defined = (value) => value !== null && value !== undefined;
export function create(center = Cartesian3.ZERO, radius = 0) {
return { center: Cartesian3.clone(center), radius };
}
export function clone(sphere, result = create()) {
Cartesian3.clone(sphere.center, result.center);
result.radius = sphere.radius;
return result;
}
const fromPointsXMin = Cartesian3.create();
const fromPointsYMin = Cartesian3.create();
const fromPointsZMin = Cartesian3.create();
const fromPointsXMax = Cartesian3.create();
const fromPointsYMax = Cartesian3.create();
const fromPointsZMax = Cartesian3.create();
const fromPointsCurrentPos = Cartesian3.create();
const fromPointsScratch = Cartesian3.create();
const fromPointsRitterCenter = Cartesian3.create();
const fromPointsMinBoxPt = Cartesian3.create();
const fromPointsMaxBoxPt = Cartesian3.create();
const fromPointsNaiveCenterScratch = Cartesian3.create();
const unionScratch = Cartesian3.create();
const unionScratchCenter = Cartesian3.create();
export function fromPoints(positions, result) {
if (!defined(result)) {
result = create();
}
if (!defined(positions) || positions.length === 0) {
result.center = Cartesian3.clone(Cartesian3.ZERO, result.center);
result.radius = 0.0;
return result;
}
const currentPos = Cartesian3.clone(positions[0], fromPointsCurrentPos);
const xMin = Cartesian3.clone(currentPos, fromPointsXMin);
const yMin = Cartesian3.clone(currentPos, fromPointsYMin);
const zMin = Cartesian3.clone(currentPos, fromPointsZMin);
const xMax = Cartesian3.clone(currentPos, fromPointsXMax);
const yMax = Cartesian3.clone(currentPos, fromPointsYMax);
const zMax = Cartesian3.clone(currentPos, fromPointsZMax);
const numPositions = positions.length;
let i;
for (i = 1; i < numPositions; i++) {
Cartesian3.clone(positions[i], currentPos);
const x = currentPos.x;
const y = currentPos.y;
const z = currentPos.z;
// Store points containing the the smallest and largest components
if (x < xMin.x) {
Cartesian3.clone(currentPos, xMin);
}
if (x > xMax.x) {
Cartesian3.clone(currentPos, xMax);
}
if (y < yMin.y) {
Cartesian3.clone(currentPos, yMin);
}
if (y > yMax.y) {
Cartesian3.clone(currentPos, yMax);
}
if (z < zMin.z) {
Cartesian3.clone(currentPos, zMin);
}
if (z > zMax.z) {
Cartesian3.clone(currentPos, zMax);
}
}
// Compute x-, y-, and z-spans (Squared distances b/n each component's min. and max.).
const xSpan = Cartesian3.magnitudeSquared(
Cartesian3.subtract(xMax, xMin, fromPointsScratch),
);
const ySpan = Cartesian3.magnitudeSquared(
Cartesian3.subtract(yMax, yMin, fromPointsScratch),
);
const zSpan = Cartesian3.magnitudeSquared(
Cartesian3.subtract(zMax, zMin, fromPointsScratch),
);
// Set the diameter endpoints to the largest span.
let diameter1 = xMin;
let diameter2 = xMax;
let maxSpan = xSpan;
if (ySpan > maxSpan) {
maxSpan = ySpan;
diameter1 = yMin;
diameter2 = yMax;
}
if (zSpan > maxSpan) {
maxSpan = zSpan;
diameter1 = zMin;
diameter2 = zMax;
}
// Calculate the center of the initial sphere found by Ritter's algorithm
const ritterCenter = fromPointsRitterCenter;
ritterCenter.x = (diameter1.x + diameter2.x) * 0.5;
ritterCenter.y = (diameter1.y + diameter2.y) * 0.5;
ritterCenter.z = (diameter1.z + diameter2.z) * 0.5;
// Calculate the radius of the initial sphere found by Ritter's algorithm
let radiusSquared = Cartesian3.magnitudeSquared(
Cartesian3.subtract(diameter2, ritterCenter, fromPointsScratch),
);
let ritterRadius = Math.sqrt(radiusSquared);
// Find the center of the sphere found using the Naive method.
const minBoxPt = fromPointsMinBoxPt;
minBoxPt.x = xMin.x;
minBoxPt.y = yMin.y;
minBoxPt.z = zMin.z;
const maxBoxPt = fromPointsMaxBoxPt;
maxBoxPt.x = xMax.x;
maxBoxPt.y = yMax.y;
maxBoxPt.z = zMax.z;
const naiveCenter = Cartesian3.midpoint(
minBoxPt,
maxBoxPt,
fromPointsNaiveCenterScratch,
);
// Begin 2nd pass to find naive radius and modify the ritter sphere.
let naiveRadius = 0;
for (i = 0; i < numPositions; i++) {
Cartesian3.clone(positions[i], currentPos);
// Find the furthest point from the naive center to calculate the naive radius.
const r = Cartesian3.magnitude(
Cartesian3.subtract(currentPos, naiveCenter, fromPointsScratch),
);
if (r > naiveRadius) {
naiveRadius = r;
}
// Make adjustments to the Ritter Sphere to include all points.
const oldCenterToPointSquared = Cartesian3.magnitudeSquared(
Cartesian3.subtract(currentPos, ritterCenter, fromPointsScratch),
);
if (oldCenterToPointSquared > radiusSquared) {
const oldCenterToPoint = Math.sqrt(oldCenterToPointSquared);
// Calculate new radius to include the point that lies outside
ritterRadius = (ritterRadius + oldCenterToPoint) * 0.5;
radiusSquared = ritterRadius * ritterRadius;
// Calculate center of new Ritter sphere
const oldToNew = oldCenterToPoint - ritterRadius;
ritterCenter.x =
(ritterRadius * ritterCenter.x + oldToNew * currentPos.x) /
oldCenterToPoint;
ritterCenter.y =
(ritterRadius * ritterCenter.y + oldToNew * currentPos.y) /
oldCenterToPoint;
ritterCenter.z =
(ritterRadius * ritterCenter.z + oldToNew * currentPos.z) /
oldCenterToPoint;
}
}
if (ritterRadius < naiveRadius) {
Cartesian3.clone(ritterCenter, result.center);
result.radius = ritterRadius;
} else {
Cartesian3.clone(naiveCenter, result.center);
result.radius = naiveRadius;
}
return result;
}
export function union(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
}
if (!defined(result)) {
result = create();
}
const leftCenter = left.center;
const leftRadius = left.radius;
const rightCenter = right.center;
const rightRadius = right.radius;
const toRightCenter = Cartesian3.subtract(
rightCenter,
leftCenter,
unionScratch,
);
const centerSeparation = Cartesian3.magnitude(toRightCenter);
if (leftRadius >= centerSeparation + rightRadius) {
// Left sphere wins.
clone(left, result);
return result;
}
if (rightRadius >= centerSeparation + leftRadius) {
// Right sphere wins.
clone(right, result);
return result;
}
// There are two tangent points, one on far side of each sphere.
const halfDistanceBetweenTangentPoints =
(leftRadius + centerSeparation + rightRadius) * 0.5;
// Compute the center point halfway between the two tangent points.
const center = Cartesian3.multiplyByScalar(
toRightCenter,
(-leftRadius + halfDistanceBetweenTangentPoints) / centerSeparation,
unionScratchCenter,
);
Cartesian3.add(center, leftCenter, center);
Cartesian3.clone(center, result.center);
result.radius = halfDistanceBetweenTangentPoints;
return result;
}
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from './index.js';
const tuple = (s) => [s.center.x, s.center.y, s.center.z, s.radius];
test('mission sphere fit and Earth/orbit union match native Cesium exactly', () => {
for (let i = 0; i < 40; i++) {
const positions = Array.from({ length: i * 3 }, (_, j) =>
geo.cartesian3.fromDegrees(
((j * 73 + i * 11) % 360) - 180,
((j * 29) % 170) - 85,
5e5 + i * 1e6,
),
);
const fit = geo.boundingSphere.fromPoints(positions),
native = Cesium.BoundingSphere.fromPoints(positions);
assert.deepEqual(tuple(fit), tuple(native));
const earth = geo.boundingSphere.create(geo.cartesian3.ZERO, 6378137);
assert.deepEqual(
tuple(geo.boundingSphere.union(earth, fit)),
tuple(
Cesium.BoundingSphere.union(
new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 6378137),
native,
),
),
);
}
});
test('union handles containment, in-place results and empty fits', () => {
const outer = geo.boundingSphere.create(geo.cartesian3.create(1, 2, 3), 20),
inner = geo.boundingSphere.create(geo.cartesian3.create(2, 2, 3), 1);
assert.equal(geo.boundingSphere.union(outer, inner, inner), inner);
assert.deepEqual(tuple(inner), tuple(outer));
assert.deepEqual(tuple(geo.boundingSphere.fromPoints([])), [0, 0, 0, 0]);
});
+1
View File
@@ -15,3 +15,4 @@ export * as quaternion from './quaternion.js';
export * as transforms from './transforms.js';
export * as ellipsoidGeodesic from './ellipsoidGeodesic.js';
export * as boundingSphere from './boundingSphere.js';
+11 -10
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
REPLAY_ASCENT_CAMERA_OFFSET_RAD,
@@ -14,7 +13,13 @@ import {
REPLAY_ORBIT_FRAME_CENTER_BLEND,
} from './policy.js';
export function createCamera({ state: layerState, services, parts, source }) {
export function createCamera({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
function cameraHeadingForPath(path, progress, fallback = Math.PI) {
if (!path?.length) return fallback;
const current = parts.paths.samplePath(path, progress);
@@ -26,7 +31,7 @@ export function createCamera({ state: layerState, services, parts, source }) {
const nextCartographic =
geo.ellipsoid.WGS84.cartesianToCartographic(next);
if (!nextCartographic) continue;
const geodesic = new Cesium.EllipsoidGeodesic(
const geodesic = geo.ellipsoidGeodesic.create(
currentCartographic,
nextCartographic,
);
@@ -308,17 +313,13 @@ export function createCamera({ state: layerState, services, parts, source }) {
*/
function replayOrbitFrameSphere(orbitPath = []) {
const earth = new Cesium.BoundingSphere(
const earth = geo.boundingSphere.create(
geo.cartesian3.ZERO,
geo.ellipsoid.WGS84.maximumRadius,
);
if (!Array.isArray(orbitPath) || orbitPath.length < 2) return earth;
const orbit = Cesium.BoundingSphere.fromPoints(orbitPath);
return Cesium.BoundingSphere.union(
earth,
orbit,
new Cesium.BoundingSphere(),
);
const orbit = geo.boundingSphere.fromPoints(orbitPath);
return geo.boundingSphere.union(earth, orbit, geo.boundingSphere.create());
}
/**
+2 -1
View File
@@ -1,3 +1,4 @@
import { appClock } from '../../time/clock.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
id: 'rocket-launches',
@@ -26,7 +27,7 @@ export function createControls({ state: layerState, services, parts, source }) {
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return [];
const now = Date.now();
const now = appClock.now();
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
const result = [];
for (const launch of layerState._launches || []) {
+92 -1
View File
@@ -1,3 +1,4 @@
import * as geo from '../../geo/index.js';
import { createPolicyHelpers } from './policyHelpers.js';
import { createIngestion } from './ingestion.js';
import { createOrbitRendering } from './orbitRendering.js';
@@ -21,7 +22,15 @@ export function createRocketLaunchesLayer({ services, source }) {
throw new TypeError('A launches source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
const getRenderer = () => {
if (!state._renderer || state._renderer.view !== state._viewer)
state._renderer = services.renderer.create({
viewer: state._viewer,
engine: state._engine,
});
return state._renderer;
};
const context = { state, services, parts, source, getRenderer };
parts.policyHelpers = createPolicyHelpers(context);
parts.ingestion = createIngestion(context);
parts.orbitRendering = createOrbitRendering(context);
@@ -43,6 +52,88 @@ export function createRocketLaunchesLayer({ services, source }) {
parts.lifecycle.methods,
parts.ingestion.methods,
{
qaDrawn() {
if (!state._viewer || !state._dataSource) return null;
const value = (v) =>
typeof v?.getValue === 'function'
? v.getValue(state._declutterTime)
: v;
const plain = (p) => (p ? { x: p.x, y: p.y, z: p.z } : null);
const anchors = (positions) =>
!positions?.length
? []
: Array.from({ length: 8 }, (_, i) =>
plain(positions[Math.floor((i * (positions.length - 1)) / 8)]),
);
const track = state._replayTracks.get(state._selectedLaunchId);
const frame = track?.getReplayState();
return {
enabled: state._enabled,
selected: state._selectedLaunchId,
speed: state._replaySpeed,
paused: state._replayPaused,
records: state._launches.map((l) => ({
id: l.id,
name: l.name,
longitude: l.lon,
latitude: l.lat,
orbit: l.orbit?.name,
})),
points: state._dataSource.entities.values
.filter((e) => e.point && e.position)
.map((e) => {
const color = value(e.point.color);
return {
id: e.id,
position: plain(value(e.position)),
show:
state._dataSource.show &&
e.show &&
value(e.point.show) !== false,
size: value(e.point.pixelSize),
color: color
? [color.red, color.green, color.blue, color.alpha]
: null,
};
}),
paths: state._dataSource.entities.values
.filter((e) => e.polyline)
.map((e) => ({
id: e.id,
show: state._dataSource.show && e.show,
anchors: anchors(value(e.polyline.positions)),
})),
rings: [...state._missionOrbitPrimitives].map(([id, path]) => ({
id,
show: path.primitive.show,
anchors: anchors(path.basePositions).map((p) =>
plain(
geo.matrix4.multiplyByPoint(
path.primitive.modelMatrix,
p,
geo.cartesian3.create(),
),
),
),
})),
replay: frame
? {
...frame,
active: state._replayCameraLaunchId === state._selectedLaunchId,
ascentDurationSec: track.ascentDurationSec,
position: plain(
parts.paths.samplePath(
frame.ascending
? track.ascentPath
: track.animatedOrbitPath,
frame.phaseProgress,
),
),
}
: null,
padVisible: state._launchPadZonePrimitive?.show === true,
};
},
satelliteParamsForSpaceMissions:
parts.policyHelpers.satelliteParamsForSpaceMissions,
satelliteParamsAfterSpaceMissions:
+44 -117
View File
@@ -1,11 +1,12 @@
import * as Cesium from 'cesium';
import { LAUNCH_PAD_ZONE_RADIUS_M } from './policy.js';
import * as geo from '../../geo/index.js';
import { appClock } from '../../time/clock.js';
export function createLaunchPad({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
function hideLaunchPadZone() {
if (layerState._launchPadZonePrimitive)
@@ -13,134 +14,60 @@ export function createLaunchPad({
}
function removeLaunchPadZonePrimitive() {
if (
layerState._launchPadZonePrimitive &&
layerState._viewer?.scene?.primitives
) {
layerState._viewer.scene.primitives.remove(
layerState._launchPadZonePrimitive,
);
}
if (layerState._launchPadZonePrimitive)
getRenderer().removePrimitive(layerState._launchPadZonePrimitive);
layerState._launchPadZonePrimitive = null;
layerState._launchPadZoneLaunchId = null;
}
function createLaunchPadZonePrimitive(launch) {
removeLaunchPadZonePrimitive();
const material = new Cesium.Material({
fabric: {
type: 'GevLaunchPadZone',
uniforms: {
color: Cesium.Color.fromCssColorString('#22e6e6'),
fillAlpha: 0.105,
rimAlpha: 0.72,
},
source: `
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 centered = (materialInput.st - vec2(0.5)) * 2.0;
float radius = length(centered);
float inside = 1.0 - smoothstep(0.985, 1.0, radius);
float rim = smoothstep(0.952, 0.985, radius) * inside;
material.diffuse = color.rgb;
material.emission = color.rgb * rim * 0.35;
material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim);
return material;
}`,
},
});
const geometry = new Cesium.EllipseGeometry({
center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat),
semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M,
semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M,
granularity: Cesium.Math.toRadians(0.08),
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
});
layerState._launchPadZonePrimitive =
layerState._viewer.scene.primitives.add(
new Cesium.GroundPrimitive({
geometryInstances: new Cesium.GeometryInstance({
geometry,
id: `rocket-pad-zone:${launch.id}`,
}),
appearance: new Cesium.MaterialAppearance({
material,
translucent: true,
closed: false,
faceForward: true,
flat: true,
// Keep the zone classified onto the photoreal surface, but bias only its
// rasterized depth toward the camera. This avoids coplanar fragments
// being intermittently buried by the launch-pad mesh at oblique angles
// without adding a world-space height that would make the ring float.
renderState: {
depthTest: {
enabled: true,
},
depthMask: false,
polygonOffset: {
enabled: true,
factor: -1,
units: -4,
},
blending: Cesium.BlendingState.ALPHA_BLEND,
},
}),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
show: true,
}),
);
layerState._launchPadZonePrimitive = getRenderer().createPad(launch);
layerState._launchPadZoneLaunchId = launch.id;
}
function initLaunchPadZonePrimitive() {
if (!layerState._viewer || layerState._launchPadZoneRemover) return;
layerState._launchPadZoneRemover =
layerState._viewer.scene.preRender.addEventListener(() => {
if (!layerState._enabled || !layerState._dataSource?.show) {
hideLaunchPadZone();
return;
}
const nowMs = Date.now();
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
layerState._missionRingDate.setTime(nowMs);
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
layerState._missionRingDate,
);
layerState._lastMissionRingRotationMs = nowMs;
}
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
layerState._launchPadZoneRemover = getRenderer().on('preRender', () => {
if (!layerState._enabled || !layerState._dataSource?.show) {
hideLaunchPadZone();
return;
}
const nowMs = Date.now();
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
layerState._missionRingDate.setTime(appClock.now());
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
layerState._missionRingDate,
);
const camera = layerState._viewer?.camera;
if (!launch || !camera) {
hideLaunchPadZone();
return;
}
const launchPosition = Cesium.Cartesian3.fromDegrees(
launch.lon,
launch.lat,
);
const visible = parts.policyHelpers.launchPadZoneVisible({
layerActive: Boolean(layerState._dataSource?.show),
selectedLaunchId: layerState._selectedLaunchId,
launchId: launch.id,
cameraHeightM: camera.positionCartographic?.height,
cameraDistanceM: Cesium.Cartesian3.distance(
camera.positionWC,
launchPosition,
),
});
if (!visible) {
hideLaunchPadZone();
return;
}
if (layerState._launchPadZoneLaunchId !== launch.id)
createLaunchPadZonePrimitive(launch);
layerState._launchPadZonePrimitive.show = true;
layerState._lastMissionRingRotationMs = nowMs;
}
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
const camera = layerState._viewer?.camera;
if (!launch || !camera) {
hideLaunchPadZone();
return;
}
const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
const visible = parts.policyHelpers.launchPadZoneVisible({
layerActive: Boolean(layerState._dataSource?.show),
selectedLaunchId: layerState._selectedLaunchId,
launchId: launch.id,
cameraHeightM: camera.positionCartographic?.height,
cameraDistanceM: geo.cartesian3.distance(
camera.positionWC,
launchPosition,
),
});
if (!visible) {
hideLaunchPadZone();
return;
}
if (layerState._launchPadZoneLaunchId !== launch.id)
createLaunchPadZonePrimitive(launch);
layerState._launchPadZonePrimitive.show = true;
});
}
function destroyLaunchPadZonePrimitive() {
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { appClock } from '../../time/clock.js';
import { createLaunchPad } from './launchPad.js';
test('selected pad frame uses neutral geometry and the physical orbit epoch', () => {
const launch = { id: 'qa', lon: -80.6, lat: 28.6 };
let tick,
created = 0,
epoch;
const state = {
_viewer: {
camera: {
positionWC: geo.cartesian3.fromDegrees(launch.lon, launch.lat, 500),
positionCartographic: { height: 500 },
},
},
_enabled: true,
_dataSource: { show: true },
_selectedLaunchId: 'qa',
_launches: [launch],
_launchPadZoneRemover: null,
_lastMissionRingRotationMs: 0,
_missionRingDate: new Date(0),
_launchPadZonePrimitive: null,
_launchPadZoneLaunchId: null,
};
const renderer = {
on: (event, fn) => {
assert.equal(event, 'preRender');
tick = fn;
return () => {};
},
createPad: () => {
created++;
return { show: true };
},
removePrimitive: () => {},
};
const parts = {
orbitRendering: {
updateMissionOrbitPrimitiveFrames: (date) => {
epoch = date.getTime();
},
},
policyHelpers: {
launchPadZoneVisible: ({ cameraDistanceM }) => cameraDistanceM < 1000,
},
};
try {
appClock.freeze(123000);
createLaunchPad({
state,
services: {},
parts,
source: {},
getRenderer: () => renderer,
}).initLaunchPadZonePrimitive();
tick();
assert.equal(created, 1);
assert.equal(state._launchPadZonePrimitive.show, true);
assert.equal(epoch, 123000);
} finally {
appClock.reset();
}
});
+34 -38
View File
@@ -1,61 +1,54 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
const methods = {
init(viewer) {
layerState._viewer = viewer;
init(viewer, { engine = null } = {}) {
layerState._engine = engine;
layerState._renderer = services.renderer.create({ viewer, engine });
layerState._viewer = layerState._renderer.view;
layerState._declutterOccluder = getRenderer().occluder();
layerState._enabled = false;
parts.panel.createMissionPanel();
parts.overlays.createReplayVehicleOverlay();
layerState._dataSource = new Cesium.CustomDataSource('rocket-launches');
layerState._dataSource.show = false;
viewer.dataSources.add(layerState._dataSource);
layerState._dataSource = getRenderer().createDataSource();
layerState._count = 0;
layerState._lastUpdate = null;
layerState._lastError = null;
layerState._orbitMatches = 0;
parts.overlays.clearMissionOverlaySources();
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
const enabled = () => layerState._enabled && layerState._dataSource?.show;
layerState._clickHandler = getRenderer().listenClick(
layerState._dataSource,
(entity) => {
const launchId = parts.selection.entityLaunchId(entity);
if (!launchId) return;
parts.selection.setSelectedMission(launchId);
parts.selection.focusMission(
layerState._launches.find((l) => l.id === launchId),
);
},
{ enabled },
);
layerState._clickHandler.setInputAction((movement) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!layerState._enabled || !layerState._dataSource?.show) return;
const entity = viewer.scene
.drillPick(movement.position, 12)
.map((picked) => picked?.id)
.find((candidate) => parts.selection.entityLaunchId(candidate));
const launchId = parts.selection.entityLaunchId(entity);
if (!launchId) return;
parts.selection.setSelectedMission(launchId);
parts.selection.focusMission(
layerState._launches.find((launch) => launch.id === launchId),
);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
layerState._moveHandler = getRenderer().listenMove(
layerState._dataSource,
(entity) => {
layerState._viewer.scene.canvas.style.cursor = entity
? 'pointer'
: '';
},
{ enabled },
);
layerState._moveHandler.setInputAction((movement) => {
if (!layerState._enabled || !layerState._dataSource?.show) return;
const missionEntity = viewer.scene
.drillPick(movement.endPosition, 12)
.map((picked) => picked?.id)
.find((candidate) => parts.selection.entityLaunchId(candidate));
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
// Apply horizon visibility before Cesium traverses and picks the scene.
// A postRender write is one frame late and can remain visibly stale when
// request-on-demand rendering stops after a globe camera move.
layerState._declutterHandler = viewer.scene.preRender.addEventListener(
layerState._declutterHandler = getRenderer().on(
'preRender',
parts.rendering.updateMissionFrame,
);
parts.launchPad.initLaunchPadZonePrimitive();
@@ -153,9 +146,8 @@ export function createLifecycle({
}
layerState._missionRosterPreviewOwnership = null;
layerState._missionRoster = null;
layerState._viewer = null;
if (layerState._dataSource)
viewer.dataSources.remove(layerState._dataSource, true);
getRenderer().removeDataSource(layerState._dataSource);
layerState._dataSource = null;
layerState._count = 0;
layerState._orbitMatches = 0;
@@ -168,6 +160,10 @@ export function createLifecycle({
layerState._satelliteStateBeforeMission = null;
layerState._satelliteActivationPromise = null;
layerState._dataManager = null;
layerState._renderer?.destroy();
layerState._renderer = null;
layerState._engine = null;
layerState._viewer = null;
},
};
+21 -16
View File
@@ -1,7 +1,15 @@
import * as Cesium from 'cesium';
import { rgbaFromCss } from '../shared/color.js';
import * as geo from '../../geo/index.js';
import { appClock } from '../../time/clock.js';
import { WINDOW_DAYS } from './policy.js';
export function createModel({ state: layerState, services, parts, source }) {
export function createModel({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
function finiteCoordinate(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
@@ -137,23 +145,20 @@ export function createModel({ state: layerState, services, parts, source }) {
const identity =
`${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase();
if (/nasa|national aeronautics/.test(identity))
return Cesium.Color.fromCssColorString('#ff9f43');
return rgbaFromCss('#ff9f43');
if (/starlink|spacex|space exploration/.test(identity))
return Cesium.Color.fromCssColorString('#4cc9f0');
if (/rocket lab/.test(identity))
return Cesium.Color.fromCssColorString('#7bed9f');
if (/isro|indian space/.test(identity))
return Cesium.Color.fromCssColorString('#ff66c4');
return rgbaFromCss('#4cc9f0');
if (/rocket lab/.test(identity)) return rgbaFromCss('#7bed9f');
if (/isro|indian space/.test(identity)) return rgbaFromCss('#ff66c4');
if (/cnsa|china national|long march/.test(identity))
return Cesium.Color.fromCssColorString('#ffd166');
if (/blue origin/.test(identity))
return Cesium.Color.fromCssColorString('#a78bfa');
return rgbaFromCss('#ffd166');
if (/blue origin/.test(identity)) return rgbaFromCss('#a78bfa');
if (/ula|united launch alliance/.test(identity))
return Cesium.Color.fromCssColorString('#f97316');
return rgbaFromCss('#f97316');
if (/arianespace|esa|european space/.test(identity))
return Cesium.Color.fromCssColorString('#60a5fa');
if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc');
return Cesium.Color.fromCssColorString('#22e6e6');
return rgbaFromCss('#60a5fa');
if (launch.provider) return rgbaFromCss('#c084fc');
return rgbaFromCss('#22e6e6');
}
/**
@@ -165,7 +170,7 @@ export function createModel({ state: layerState, services, parts, source }) {
* @returns {Array<object>}
*/
function normalizeRocketLaunches(payload, now = new Date()) {
function normalizeRocketLaunches(payload, now = new Date(appClock.now())) {
const launches = Array.isArray(payload) ? payload : payload?.results;
if (!Array.isArray(launches)) return [];
const cutoff = now.getTime() - WINDOW_DAYS * 86400000;
+12 -108
View File
@@ -1,68 +1,12 @@
import * as Cesium from 'cesium';
import {
MISSION_ORBIT_PATTERN_GROUPS,
MISSION_ORBIT_DASHES_PER_GROUP,
} from './policy.js';
import { appClock } from '../../time/clock.js';
import * as geo from '../../geo/index.js';
import { rgbaFromCss } from '../shared/color.js';
export function createOrbitRendering({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
const { orbitFrameModelMatrix } = services.satellites;
/**
* Register the selected-orbit tactical material once. Each group begins with
* one compact round dot followed by one hundred short dashes.
*/
function ensureMissionOrbitPatternRegistered() {
if (layerState._missionOrbitPatternRegistered) return;
new Cesium.Material({
fabric: {
type: 'GevMissionOrbitTactical',
uniforms: {
color: Cesium.Color.CYAN,
groupCount: MISSION_ORBIT_PATTERN_GROUPS,
dashCount: MISSION_ORBIT_DASHES_PER_GROUP,
},
source: `
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
float groupPosition = fract(materialInput.st.s * groupCount);
float markPosition = groupPosition * (dashCount + 1.0);
float markIndex = floor(markPosition);
float localPosition = fract(markPosition);
float centerDistance = abs(localPosition - 0.5);
float edge = max(fwidth(localPosition) * 1.35, 0.012);
float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance);
float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5));
float dash = dashAlong * dashAcross;
float dotAlong = (localPosition - 0.5) / 0.32;
float dotAcross = (materialInput.st.t - 0.5) / 0.5;
float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross)));
float isDot = 1.0 - step(0.5, markIndex);
float visible = mix(dash, dot, isDot);
material.diffuse = color.rgb;
material.emission = color.rgb * mix(0.07, 0.65, isDot);
material.alpha = color.a * visible * mix(0.58, 1.0, isDot);
return material;
}`,
},
});
layerState._missionOrbitPatternRegistered = true;
}
function createMissionOrbitPatternMaterial(color) {
ensureMissionOrbitPatternRegistered();
return Cesium.Material.fromType('GevMissionOrbitTactical', {
color,
groupCount: MISSION_ORBIT_PATTERN_GROUPS,
dashCount: MISSION_ORBIT_DASHES_PER_GROUP,
});
}
function missionOrbitPrimitiveVisible(launchId) {
return Boolean(
layerState._enabled &&
@@ -81,8 +25,8 @@ export function createOrbitRendering({
function removeMissionOrbitPrimitives() {
for (const path of layerState._missionOrbitPrimitives.values()) {
if (path.primitive && layerState._viewer?.scene?.primitives) {
layerState._viewer.scene.primitives.remove(path.primitive);
if (path.primitive && layerState._viewer) {
getRenderer().removePrimitive(path.primitive);
}
}
layerState._missionOrbitPrimitives.clear();
@@ -96,8 +40,9 @@ export function createOrbitRendering({
nowDate,
path.primitive.modelMatrix,
);
getRenderer().commitOrbit(path.primitive);
if (path.labelBakePosition && path.labelPosition) {
Cesium.Matrix4.multiplyByPoint(
geo.matrix4.multiplyByPoint(
path.primitive.modelMatrix,
path.labelBakePosition,
path.labelPosition,
@@ -113,71 +58,30 @@ export function createOrbitRendering({
!Number.isFinite(satelliteTrack.gmstAtBake)
)
return false;
const collection = new Cesium.PolylineCollection();
collection.add({
const collection = getRenderer().createOrbit({
positions: orbitPath,
width: 3,
material: createMissionOrbitPatternMaterial(
Cesium.Color.fromCssColorString('#22e6e6').withAlpha(0.95),
),
color: rgbaFromCss('#22e6e6').withAlpha(0.95),
});
collection.show = missionOrbitPrimitiveVisible(launch.id);
orbitFrameModelMatrix(
satelliteTrack.gmstAtBake,
new Date(),
new Date(appClock.now()),
collection.modelMatrix,
);
layerState._viewer.scene.primitives.add(collection);
layerState._missionOrbitPrimitives.set(launch.id, {
primitive: collection,
basePositions: orbitPath,
gmstAtBake: satelliteTrack.gmstAtBake,
});
return true;
}
function MissionOrbitPatternMaterialProperty(color) {
ensureMissionOrbitPatternRegistered();
this._color = color;
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, {
isConstant: {
get() {
return true;
},
},
definitionChanged: {
get() {
return this._definitionChanged;
},
},
});
MissionOrbitPatternMaterialProperty.prototype.getType = function getType() {
return 'GevMissionOrbitTactical';
};
MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue(
time,
result,
) {
if (!Cesium.defined(result)) result = {};
result.color = this._color;
result.groupCount = MISSION_ORBIT_PATTERN_GROUPS;
result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP;
return result;
};
MissionOrbitPatternMaterialProperty.prototype.equals = function equals(
other,
) {
return this === other;
};
return {
ensureMissionOrbitPatternRegistered,
createMissionOrbitPatternMaterial,
missionOrbitPrimitiveVisible,
syncMissionOrbitPrimitiveVisibility,
removeMissionOrbitPrimitives,
updateMissionOrbitPrimitiveFrames,
addMissionOrbitPrimitive,
MissionOrbitPatternMaterialProperty,
};
}
+18 -22
View File
@@ -1,5 +1,4 @@
import { engineFromViewer } from '../../globe/cesium/engineFromViewer.js';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -12,7 +11,13 @@ import {
ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS,
} from './policy.js';
export function createOverlays({ state: layerState, services, parts, source }) {
export function createOverlays({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
/**
* Resolve whether a surface mission anchor is safely on the camera-facing
* side of Earth. The small positive limb margin prevents labels anchored just
@@ -29,25 +34,24 @@ export function createOverlays({ state: layerState, services, parts, source }) {
limbMargin = 0.012,
) {
if (!cameraPosition || !markerPosition) return false;
const cameraMagnitude = Cesium.Cartesian3.magnitude(cameraPosition);
const cameraMagnitude = geo.cartesian3.magnitude(cameraPosition);
if (
!Number.isFinite(cameraMagnitude) ||
cameraMagnitude <= Cesium.Ellipsoid.WGS84.maximumRadius
cameraMagnitude <= geo.ellipsoid.WGS84.maximumRadius
) {
return false;
}
const cameraDirection = Cesium.Cartesian3.normalize(
const cameraDirection = geo.cartesian3.normalize(
cameraPosition,
new Cesium.Cartesian3(),
geo.cartesian3.create(),
);
const markerDirection = Cesium.Cartesian3.normalize(
const markerDirection = geo.cartesian3.normalize(
markerPosition,
new Cesium.Cartesian3(),
geo.cartesian3.create(),
);
const limbThreshold =
Cesium.Ellipsoid.WGS84.maximumRadius / cameraMagnitude;
const limbThreshold = geo.ellipsoid.WGS84.maximumRadius / cameraMagnitude;
return (
Cesium.Cartesian3.dot(cameraDirection, markerDirection) >
geo.cartesian3.dot(cameraDirection, markerDirection) >
limbThreshold + limbMargin
);
}
@@ -484,15 +488,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
hideReplayVehicleOverlay();
return null;
}
// The engine's projector (engineFromViewer), as the world overlay.
const projector = engineFromViewer(layerState._viewer)?.projector;
const project = (point) =>
projector
? projector.worldToWindow(point)
: Cesium.SceneTransforms.worldToWindowCoordinates(
layerState._viewer.scene,
point,
);
const project = (point) => getRenderer().project(point);
const windowPosition = project(position);
if (!windowPosition) {
hideReplayVehicleOverlay();
@@ -584,7 +580,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
}
layerState._replayVehicleOverlay.style.setProperty(
'--mission-replay-rotation',
`${Cesium.Math.toDegrees(vehicleRotation)}deg`,
`${geo.math.toDegrees(vehicleRotation)}deg`,
);
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
+8 -6
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { appClock } from '../../time/clock.js';
import * as geo from '../../geo/index.js';
import {
@@ -6,7 +5,13 @@ import {
MISSION_GLOBE_VIEW_RANGE_M,
} from './policy.js';
export function createPanel({ state: layerState, services, parts, source }) {
export function createPanel({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
/**
* Preserve the user's globe scale for roster previews while avoiding an
* accidental surface-level fly-to when the list is opened from a close view.
@@ -359,10 +364,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
`rocket-satellite:${layerState._selectedLaunchId}`,
);
const position = satellite?.position?.getValue(
Cesium.JulianDate.fromDate(
new Date(appClock.now()),
layerState._declutterTime,
),
getRenderer().time(appClock.now()),
);
const altitudeM = position
? geo.cartographic.fromCartesian(position)?.height
+4 -4
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
SATELLITE_STANDALONE_DEFAULTS,
@@ -11,6 +10,7 @@ export function createPolicyHelpers({
services,
parts,
source,
getRenderer,
}) {
/**
* Derive the temporary Satellite display mode required by Space Missions.
@@ -229,11 +229,11 @@ export function createPolicyHelpers({
);
const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(position, 0),
geo.boundingSphere.create(position, 0),
{
offset: new Cesium.HeadingPitchRange(0, -geo.math.PI_OVER_TWO, range),
offset: getRenderer().range(0, -geo.math.PI_OVER_TWO, range),
duration: 0.8,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
easingFunction: getRenderer().easing,
},
);
}
+270
View File
@@ -0,0 +1,270 @@
// Native mission graphics stay here; shared mission logic uses plain geometry.
import * as Cesium from 'cesium';
import { isPointerFree } from '../../../data/inputOwnership.js';
import {
MISSION_ORBIT_PATTERN_GROUPS,
MISSION_ORBIT_DASHES_PER_GROUP,
LAUNCH_PAD_ZONE_RADIUS_M,
} from '../policy.js';
export function createLaunchesCesiumRenderer(engine, { viewer } = {}) {
let patternRegistered = false;
const scratchTime = new Cesium.JulianDate();
const color = (value) =>
value?.red !== undefined
? new Cesium.Color(value.red, value.green, value.blue, value.alpha)
: value;
function ensureMissionOrbitPatternRegistered() {
if (patternRegistered) return;
new Cesium.Material({
fabric: {
type: 'GevMissionOrbitTactical',
uniforms: {
color: Cesium.Color.CYAN,
groupCount: MISSION_ORBIT_PATTERN_GROUPS,
dashCount: MISSION_ORBIT_DASHES_PER_GROUP,
},
source: `
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
float groupPosition = fract(materialInput.st.s * groupCount);
float markPosition = groupPosition * (dashCount + 1.0);
float markIndex = floor(markPosition);
float localPosition = fract(markPosition);
float centerDistance = abs(localPosition - 0.5);
float edge = max(fwidth(localPosition) * 1.35, 0.012);
float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance);
float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5));
float dash = dashAlong * dashAcross;
float dotAlong = (localPosition - 0.5) / 0.32;
float dotAcross = (materialInput.st.t - 0.5) / 0.5;
float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross)));
float isDot = 1.0 - step(0.5, markIndex);
float visible = mix(dash, dot, isDot);
material.diffuse = color.rgb;
material.emission = color.rgb * mix(0.07, 0.65, isDot);
material.alpha = color.a * visible * mix(0.58, 1.0, isDot);
return material;
}`,
},
});
patternRegistered = true;
}
function createMissionOrbitPatternMaterial(color) {
ensureMissionOrbitPatternRegistered();
return Cesium.Material.fromType('GevMissionOrbitTactical', {
color,
groupCount: MISSION_ORBIT_PATTERN_GROUPS,
dashCount: MISSION_ORBIT_DASHES_PER_GROUP,
});
}
function MissionOrbitPatternMaterialProperty(color) {
ensureMissionOrbitPatternRegistered();
this._color = color;
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, {
isConstant: {
get() {
return true;
},
},
definitionChanged: {
get() {
return this._definitionChanged;
},
},
});
MissionOrbitPatternMaterialProperty.prototype.getType = function getType() {
return 'GevMissionOrbitTactical';
};
MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue(
time,
result,
) {
if (!Cesium.defined(result)) result = {};
result.color = this._color;
result.groupCount = MISSION_ORBIT_PATTERN_GROUPS;
result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP;
return result;
};
MissionOrbitPatternMaterialProperty.prototype.equals = function equals(
other,
) {
return this === other;
};
function createPad(launch) {
const material = new Cesium.Material({
fabric: {
type: 'GevLaunchPadZone',
uniforms: {
color: Cesium.Color.fromCssColorString('#22e6e6'),
fillAlpha: 0.105,
rimAlpha: 0.72,
},
source: `
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 centered = (materialInput.st - vec2(0.5)) * 2.0;
float radius = length(centered);
float inside = 1.0 - smoothstep(0.985, 1.0, radius);
float rim = smoothstep(0.952, 0.985, radius) * inside;
material.diffuse = color.rgb;
material.emission = color.rgb * rim * 0.35;
material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim);
return material;
}`,
},
});
const geometry = new Cesium.EllipseGeometry({
center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat),
semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M,
semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M,
granularity: Cesium.Math.toRadians(0.08),
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
});
const primitive = viewer.scene.primitives.add(
new Cesium.GroundPrimitive({
geometryInstances: new Cesium.GeometryInstance({
geometry,
id: `rocket-pad-zone:${launch.id}`,
}),
appearance: new Cesium.MaterialAppearance({
material,
translucent: true,
closed: false,
faceForward: true,
flat: true,
// Keep the zone classified onto the photoreal surface, but bias only its
// rasterized depth toward the camera. This avoids coplanar fragments
// being intermittently buried by the launch-pad mesh at oblique angles
// without adding a world-space height that would make the ring float.
renderState: {
depthTest: {
enabled: true,
},
depthMask: false,
polygonOffset: {
enabled: true,
factor: -1,
units: -4,
},
blending: Cesium.BlendingState.ALPHA_BLEND,
},
}),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
show: true,
}),
);
return primitive;
}
return {
kind: 'cesium',
engine,
view: viewer,
time(epochMs) {
return Cesium.JulianDate.fromDate(new Date(epochMs), scratchTime);
},
occluder: () =>
new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
Cesium.Cartesian3.ZERO,
),
callback: (fn) => new Cesium.CallbackProperty(fn, false),
range: (heading, pitch, range) =>
new Cesium.HeadingPitchRange(heading, pitch, range),
easing: Cesium.EasingFunction.CUBIC_IN_OUT,
createDataSource() {
const source = new Cesium.CustomDataSource('rocket-launches');
source.show = false;
viewer.dataSources.add(source);
return source;
},
removeDataSource: (source) => viewer?.dataSources.remove(source, true),
addEntity(source, spec) {
const options = { ...spec };
for (const key of ['point', 'billboard'])
if (spec[key])
options[key] = {
...spec[key],
...(spec[key].color ? { color: color(spec[key].color) } : {}),
...(spec[key].outlineColor
? { outlineColor: color(spec[key].outlineColor) }
: {}),
};
if (spec.polyline) {
const { arc, ...line } = spec.polyline;
options.polyline = {
...line,
material: color(line.material),
...(arc
? {
arcType:
arc === 'none'
? Cesium.ArcType.NONE
: Cesium.ArcType.GEODESIC,
}
: {}),
};
}
return source.entities.add(options);
},
dash: (options) =>
new Cesium.PolylineDashMaterialProperty({
...options,
color: color(options.color),
}),
patternMaterial: (value) =>
new MissionOrbitPatternMaterialProperty(color(value)),
createOrbit({ positions, width, color: rgba }) {
const collection = new Cesium.PolylineCollection();
collection.add({
positions,
width,
material: createMissionOrbitPatternMaterial(color(rgba)),
});
viewer.scene.primitives.add(collection);
return collection;
},
commitOrbit() {},
removePrimitive: (primitive) => viewer?.scene.primitives.remove(primitive),
createPad,
project: (position, result) =>
Cesium.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
position,
result,
),
on: (event, listener) => viewer.scene[event].addEventListener(listener),
listenClick(source, listener, { enabled = () => true } = {}) {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement) => {
if (!isPointerFree()) return;
if (!enabled()) return;
const entity = viewer.scene
.drillPick(movement.position, 12)
.map((p) => p?.id)
.find((e) => e?.id?.startsWith('rocket-'));
listener(entity);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
return { destroy: () => handler.destroy() };
},
listenMove(source, listener, { enabled = () => true } = {}) {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement) => {
if (!enabled()) return;
const entity = viewer.scene
.drillPick(movement.endPosition, 12)
.map((p) => p?.id)
.find((e) => e?.id?.startsWith('rocket-'));
listener(entity);
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
return { destroy: () => handler.destroy() };
},
destroy() {},
};
}
+50 -50
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { rgbaFromCss } from '../shared/color.js';
import * as geo from '../../geo/index.js';
import { appClock } from '../../time/clock.js';
import {
TRAJECTORY_STAGE_COLORS,
@@ -12,6 +13,7 @@ export function createRendering({
services,
parts,
source,
getRenderer,
}) {
const { getSatelliteOrbitTrack, findSatelliteOrbitTrackInTle } =
services.satellites;
@@ -34,10 +36,8 @@ export function createRendering({
}
parts.panel.updateMissionTelemetry();
// The app clock's now, as the JulianDate Cesium's entity properties take.
const time = Cesium.JulianDate.fromDate(
new Date(appClock.now()),
layerState._declutterTime,
);
const time = getRenderer().time(appClock.now());
layerState._declutterTime = time;
layerState._declutterOccluder.cameraPosition =
layerState._viewer.scene.camera.positionWC;
parts.overlays.updateReplayVehicleOverlay(layerState._declutterOccluder);
@@ -77,7 +77,7 @@ export function createRendering({
}
function addLaunchEntity(launch, activeTleText = layerState._activeTleText) {
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat);
const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
const overlayRecord = {
launch,
anchorPosition: position,
@@ -114,7 +114,7 @@ export function createRendering({
);
});
if (satelliteTrack) layerState._orbitMatches++;
const entity = layerState._dataSource.entities.add({
const entity = getRenderer().addEntity(layerState._dataSource, {
id: `rocket-launch:${launch.id}`,
position,
point: {
@@ -129,8 +129,8 @@ export function createRendering({
width: 24,
height: 24,
show: false,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: 0,
verticalOrigin: 0,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: {
@@ -147,6 +147,10 @@ export function createRendering({
noradId: satelliteTrack?.noradId || '',
},
});
// Publish the same anchor representation held by the active renderer.
overlayRecord.anchorPosition = entity.position.getValue(
getRenderer().time(appClock.now()),
);
const points = launch.trajectory
.filter(
(point) =>
@@ -161,7 +165,7 @@ export function createRendering({
point.stageName ||
'trajectory',
),
position: Cesium.Cartesian3.fromDegrees(
position: geo.cartesian3.fromDegrees(
Number(point.longitude),
Number(point.latitude),
Number(point.altitude || 0),
@@ -177,16 +181,16 @@ export function createRendering({
});
segments.forEach((segment, index) => {
if (segment.positions.length < 2) return;
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-trajectory:${launch.id}:${index}`,
polyline: {
positions: parts.paths.surfaceSafePath(segment.positions),
width: 2,
material: Cesium.Color.fromCssColorString(
material: rgbaFromCss(
TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length],
).withAlpha(0.8),
clampToGround: false,
arcType: Cesium.ArcType.NONE,
arc: 'none',
},
properties: {
launchId: launch.id,
@@ -224,7 +228,7 @@ export function createRendering({
new Date(insertionEpochMs),
);
if (propagatedInsertion) {
insertionReference = Cesium.Cartesian3.fromDegrees(
insertionReference = geo.cartesian3.fromDegrees(
propagatedInsertion.longitude,
propagatedInsertion.latitude,
propagatedInsertion.altitude,
@@ -237,7 +241,7 @@ export function createRendering({
// ascent duration, and join the forward orbit tangent. Using the UTC
// epoch as phase sent some ascents toward the far side of Earth and
// forced a visible 180-degree corrective hook before insertion.
const insertionProgress = Cesium.Math.clamp(
const insertionProgress = geo.math.clamp(
insertionDurationSec / orbitPeriodSec,
1 / 96,
0.16,
@@ -259,14 +263,14 @@ export function createRendering({
);
// Cesium can evaluate CallbackProperty values multiple times while it
// traverses one frame. Camera tracking and the HTML replay overlay must
// use the exact same replay epoch as those callbacks; sampling Date.now()
// use the exact same replay epoch as those callbacks; sampling appClock.now()
// independently made the vehicle advance relative to the camera and then
// snap back on the next frame.
let replayStateForFrame = null;
const sampleReplayState = () =>
parts.replay.replayState(
launch,
layerState._animationStarts.get(launch.id) || Date.now(),
layerState._animationStarts.get(launch.id) || appClock.now(),
ascentDurationSec,
REPLAY_ORBIT_DURATION_SEC,
orbitPeriodSec,
@@ -304,22 +308,22 @@ export function createRendering({
if (path.length < 2) return;
const reentryIndex = parts.paths.atmosphericReentryIndex(path);
const reentryPath = path.slice(reentryIndex);
const color = Cesium.Color.fromCssColorString(
const color = rgbaFromCss(
TRAJECTORY_STAGE_COLORS[
(stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length
],
);
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`,
polyline: {
positions: path,
width: 2,
material: new Cesium.PolylineDashMaterialProperty({
material: getRenderer().dash({
color: color.withAlpha(0.82),
dashLength: 12,
dashPattern: 0xaaaa,
}),
arcType: Cesium.ArcType.NONE,
arc: 'none',
},
properties: {
launchId: launch.id,
@@ -330,18 +334,17 @@ export function createRendering({
},
});
if (reentryPath.length > 1) {
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-reentry:${launch.id}:${stageIndex}`,
polyline: {
positions: reentryPath,
width: 2.5,
material: new Cesium.PolylineDashMaterialProperty({
color:
Cesium.Color.fromCssColorString('#ffd166').withAlpha(0.9),
material: getRenderer().dash({
color: rgbaFromCss('#ffd166').withAlpha(0.9),
dashLength: 8,
dashPattern: 0xf0f0,
}),
arcType: Cesium.ArcType.NONE,
arc: 'none',
},
properties: {
launchId: launch.id,
@@ -363,7 +366,7 @@ export function createRendering({
}),
);
}
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-recovery-end:${launch.id}:${stageIndex}`,
position: path.at(-1),
point: {
@@ -394,12 +397,12 @@ export function createRendering({
lastOverlayWindowPosition: null,
lastOverlayMode: null,
});
let livePosition = Cesium.Cartesian3.fromDegrees(
let livePosition = geo.cartesian3.fromDegrees(
orbitCurrent.longitude,
orbitCurrent.latitude,
orbitCurrent.altitude,
);
const liveTime = new Date();
const liveTime = new Date(appClock.now());
const liveTelemetry = { speedMps: null };
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
let liveFrameNumber = -1;
@@ -410,13 +413,13 @@ export function createRendering({
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
liveFrameNumber = frameNumber;
const nowMs = Date.now();
const nowMs = appClock.now();
liveTime.setTime(nowMs);
if (satelliteTrack) {
const propagated =
satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current;
if (propagated) {
Cesium.Cartesian3.fromDegrees(
geo.cartesian3.fromDegrees(
propagated.longitude,
propagated.latitude,
propagated.altitude,
@@ -438,17 +441,15 @@ export function createRendering({
) || livePosition;
}
};
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-satellite:${launch.id}`,
position: new Cesium.CallbackProperty(() => {
position: getRenderer().callback(() => {
updateLiveState();
return livePosition;
}, false),
point: {
pixelSize: 6,
color: Cesium.Color.fromCssColorString(
satelliteTrack ? '#7bed9f' : '#ffd166',
),
color: rgbaFromCss(satelliteTrack ? '#7bed9f' : '#ffd166'),
outlineWidth: 0,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
@@ -488,18 +489,17 @@ export function createRendering({
satelliteTrack,
);
if (!primitiveOrbitAdded) {
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-orbit:${launch.id}`,
polyline: {
positions: orbitPath,
// The 3 px canvas gives the periodic dot room to read as round; the
// shader masks the ten intervening dashes down to a thin center stroke.
width: 3,
material:
new parts.orbitRendering.MissionOrbitPatternMaterialProperty(
Cesium.Color.fromCssColorString('#c084fc').withAlpha(0.95),
),
arcType: Cesium.ArcType.NONE,
material: getRenderer().patternMaterial(
rgbaFromCss('#c084fc').withAlpha(0.95),
),
arc: 'none',
},
properties: {
launchId: launch.id,
@@ -514,10 +514,10 @@ export function createRendering({
const primitivePath = layerState._missionOrbitPrimitives.get(launch.id);
if (primitivePath) {
primitivePath.labelBakePosition = orbitLabelBakePosition;
primitivePath.labelPosition = Cesium.Matrix4.multiplyByPoint(
primitivePath.labelPosition = geo.matrix4.multiplyByPoint(
primitivePath.primitive.modelMatrix,
orbitLabelBakePosition,
new Cesium.Cartesian3(),
geo.cartesian3.create(),
);
orbitLabelPosition = () => primitivePath.labelPosition;
}
@@ -531,13 +531,13 @@ export function createRendering({
gapPx: 8,
}),
);
const replayPosition = new Cesium.CallbackProperty(() => {
const replayPosition = getRenderer().callback(() => {
const state = getReplayState();
return state.ascending
? parts.paths.samplePath(ascentPath, state.phaseProgress)
: parts.paths.samplePath(animatedOrbitPath, state.phaseProgress);
}, false);
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-vehicle:${launch.id}`,
position: replayPosition,
properties: {
@@ -549,7 +549,7 @@ export function createRendering({
: 'Approximate trajectory animation',
},
});
layerState._dataSource.entities.add({
getRenderer().addEntity(layerState._dataSource, {
id: `rocket-transfer:${launch.id}`,
polyline: {
positions: ascentPath,
@@ -557,9 +557,9 @@ export function createRendering({
// The animated vehicle uses Cartesian interpolation between these
// exact samples. Prevent Cesium from replacing each segment with a
// geodesic arc, which would visually separate the dot from the line.
arcType: Cesium.ArcType.NONE,
material: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString('#7bed9f').withAlpha(0.8),
arc: 'none',
material: getRenderer().dash({
color: rgbaFromCss('#7bed9f').withAlpha(0.8),
dashLength: 24,
dashPattern: 0xf0f0,
}),
+24 -19
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium';
import { appClock } from '../../time/clock.js';
import * as geo from '../../geo/index.js';
import {
REPLAY_ASCENT_FALLBACK_SEC,
@@ -14,7 +14,13 @@ import {
REPLAY_INITIAL_RANGE_M,
} from './policy.js';
export function createReplay({ state: layerState, services, parts, source }) {
export function createReplay({
state: layerState,
services,
parts,
source,
getRenderer,
}) {
/**
* Rotate an upright screen-space rocket so its nose follows a projected path.
* @param {{x: number, y: number}} from Current screen point.
@@ -125,7 +131,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
orbitDurationSec,
orbitPeriodSec,
speed = 1,
nowMs = Date.now(),
nowMs = appClock.now(),
preCountdownDurationSec = 0,
loop = true,
) {
@@ -136,7 +142,8 @@ export function createReplay({ state: layerState, services, parts, source }) {
Number(preCountdownDurationSec) || 0,
);
const preCountdownActive =
preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration;
preCountdownDuration > 0 &&
realSecondsSinceStart < -REPLAY_COUNTDOWN_DURATION_SEC;
const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive;
const countdownSeconds = countdownActive
? Math.ceil(-realSecondsSinceStart)
@@ -273,7 +280,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
const now =
layerState._replayPaused && Number.isFinite(layerState._replayPausedAtMs)
? layerState._replayPausedAtMs
: Date.now();
: appClock.now();
for (const [launchId, startedAt] of layerState._animationStarts) {
if (!Number.isFinite(startedAt)) continue;
const elapsedMissionMs = (now - startedAt) * previousSpeed;
@@ -294,13 +301,13 @@ export function createReplay({ state: layerState, services, parts, source }) {
) {
return layerState._replayPausedAtMs;
}
return Date.now();
return appClock.now();
}
function pauseMissionReplay() {
if (!layerState._replayCameraLaunchId || layerState._replayPaused)
return false;
layerState._replayPausedAtMs = Date.now();
layerState._replayPausedAtMs = appClock.now();
layerState._replayPaused = true;
syncReplayButton();
return true;
@@ -314,7 +321,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
) {
return false;
}
const resumedAt = Date.now();
const resumedAt = appClock.now();
const startedAt = layerState._animationStarts.get(
layerState._replayCameraLaunchId,
);
@@ -341,12 +348,12 @@ export function createReplay({ state: layerState, services, parts, source }) {
layerState._replayCameraRemover = null;
layerState._viewer?.camera?.cancelFlight();
if (layerState._viewer?.camera)
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
layerState._viewer.camera.lookAtTransform(geo.matrix4.IDENTITY);
layerState._replayCameraLaunchId = null;
layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
if (stoppedLaunchId)
layerState._animationStarts.set(stoppedLaunchId, Date.now());
layerState._animationStarts.set(stoppedLaunchId, appClock.now());
syncReplayButton();
parts.overlays.syncMissionOverlayEntries();
}
@@ -381,7 +388,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
layerState._viewer.camera.cancelFlight();
layerState._animationStarts.set(
launchId,
Date.now() +
appClock.now() +
(REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000,
);
let cameraReady = false;
@@ -423,7 +430,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
orbitBlend,
);
if (state.ascending) track.orbitCameraWorldFrame = false;
const nowMs = performance.now();
const nowMs = appClock.now();
const frameDurationMs = Number.isFinite(lastCameraUpdateMs)
? geo.math.clamp(nowMs - lastCameraUpdateMs, 4, 50)
: 1000 / 60;
@@ -458,7 +465,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
if (state.ascending) {
layerState._viewer.camera.lookAt(
cameraTarget,
new Cesium.HeadingPitchRange(
getRenderer().range(
track.lastCameraHeading,
cameraView.pitch,
cameraRange,
@@ -488,9 +495,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
);
if (orbitPose) {
if (!track.orbitCameraWorldFrame) {
layerState._viewer.camera.lookAtTransform(
Cesium.Matrix4.IDENTITY,
);
layerState._viewer.camera.lookAtTransform(geo.matrix4.IDENTITY);
track.orbitCameraWorldFrame = true;
}
layerState._viewer.camera.setView({
@@ -511,15 +516,15 @@ export function createReplay({ state: layerState, services, parts, source }) {
});
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(track.ascentPath[0], 0),
geo.boundingSphere.create(track.ascentPath[0], 0),
{
offset: new Cesium.HeadingPitchRange(
offset: getRenderer().range(
initialHeading,
geo.math.toRadians(-20),
REPLAY_INITIAL_RANGE_M,
),
duration: 1.4,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
easingFunction: getRenderer().easing,
complete: () => {
cameraReady = true;
},
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { appClock } from '../../time/clock.js';
import { createReplay } from './replay.js';
function setup() {
const state = {
_missionPanel: null,
_replayCameraLaunchId: 'qa',
_replayPaused: false,
_replayPausedAtMs: null,
_replaySpeed: 1,
_animationStarts: new Map([['qa', 1000]]),
};
const parts = { paths: { orbitInsertionOffsetSeconds: () => 600 } };
return {
state,
replay: createReplay({ state, services: {}, parts, source: {} }),
};
}
test('five seconds of tile preparation are followed by the full ten-second countdown', () => {
const { replay } = setup(),
launch = { launchTime: '2026-10-15T12:00:00Z' };
try {
appClock.freeze(100000);
assert.equal(
replay.replayState(launch, 115000, 12, 28, 5400, 4, undefined, 5)
.preCountdownActive,
true,
);
appClock.freeze(105000);
const countdown = replay.replayState(
launch,
115000,
12,
28,
5400,
4,
undefined,
5,
);
assert.equal(countdown.preCountdownActive, false);
assert.equal(countdown.countdownSeconds, 10);
appClock.freeze(114000);
assert.equal(
replay.replayState(launch, 115000, 12, 28, 5400, 4, undefined, 5)
.countdownSeconds,
1,
);
appClock.freeze(116000);
assert.equal(
replay.replayState(launch, 115000, 12, 28, 5400, 4, undefined, 5)
.elapsedSinceStart,
4,
);
} finally {
appClock.reset();
}
});
test('pause, resume and speed changes use app time without a wall-clock jump', () => {
const { state, replay } = setup();
try {
appClock.freeze(4000);
assert.equal(replay.pauseMissionReplay(), true);
appClock.freeze(9500);
assert.equal(replay.replayClockNow('qa'), 4000);
assert.equal(replay.resumeMissionReplay(), true);
assert.equal(state._animationStarts.get('qa'), 6500);
replay.setReplaySpeed(4);
assert.equal(state._animationStarts.get('qa'), 8750);
} finally {
appClock.reset();
}
});
+9 -13
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { appClock } from '../../time/clock.js';
import * as geo from '../../geo/index.js';
import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js';
@@ -8,6 +7,7 @@ export function createSelection({
services,
parts,
source,
getRenderer,
}) {
const { getKeyholeGeometry } = services.geometry;
@@ -46,7 +46,7 @@ export function createSelection({
roll: 0,
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
easingFunction: getRenderer().easing,
});
}
@@ -70,7 +70,7 @@ export function createSelection({
stopMissionZoomAnchor();
layerState._selectedLaunchId = launchId;
layerState._explicitSelection = Boolean(launchId && isolate);
if (launchId) layerState._animationStarts.set(launchId, Date.now());
if (launchId) layerState._animationStarts.set(launchId, appClock.now());
if (!launchId && layerState._viewer) {
stopMissionZoomAnchor();
layerState._viewer.selectedEntity = undefined;
@@ -93,7 +93,7 @@ export function createSelection({
layerState._missionZoomAnchorRemover = null;
layerState._missionZoomAnchorId = null;
if (layerState._viewer?.camera && !layerState._replayCameraLaunchId) {
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
layerState._viewer.camera.lookAtTransform(geo.matrix4.IDENTITY);
}
}
@@ -109,11 +109,7 @@ export function createSelection({
layerState._missionZoomAnchorId = launch.id;
layerState._viewer.camera.lookAt(
target,
new Cesium.HeadingPitchRange(
0,
parts.camera.missionZoomPitch(range),
range,
),
getRenderer().range(0, parts.camera.missionZoomPitch(range), range),
);
}
@@ -127,7 +123,7 @@ export function createSelection({
`rocket-orbit:${launch.id}`,
);
const orbitPositions = orbitEntity?.polyline?.positions?.getValue(
Cesium.JulianDate.fromDate(new Date(appClock.now())),
getRenderer().time(appClock.now()),
);
const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
let range = 18000000;
@@ -202,15 +198,15 @@ export function createSelection({
`rocket-launch:${launch.id}`,
);
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(launchPosition, 0),
geo.boundingSphere.create(launchPosition, 0),
{
offset: new Cesium.HeadingPitchRange(
offset: getRenderer().range(
0,
geo.math.toRadians(-42),
MISSION_FOCUS_RANGE_M,
),
duration: 1.4,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
easingFunction: getRenderer().easing,
complete: () => {
if (layerState._selectedLaunchId === launch.id) {
startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M);
+4 -9
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
export function createState({ services }) {
@@ -122,17 +121,13 @@ export function createState({ services }) {
state._missionOrbitPrimitives = new Map();
state._declutterTime = new Cesium.JulianDate();
state._declutterOccluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
geo.cartesian3.create(),
);
state._renderer = null;
state._engine = null;
state._declutterTime = null;
state._declutterOccluder = null;
state._missionRingDate = new Date(0);
state._missionOrbitPatternRegistered = false;
state._pathDistanceCache = new WeakMap();
state._sourceController = new AbortController();
return state;
+7
View File
@@ -44,6 +44,13 @@ export class Rgba {
);
}
/** Cesium's CSS form, including its float-to-byte conversion. */
toCssColorString() {
const byte = (v) => (v === 1 ? 255 : (v * 256) | 0);
const rgb = `${byte(this.red)},${byte(this.green)},${byte(this.blue)}`;
return this.alpha === 1 ? `rgb(${rgb})` : `rgba(${rgb},${this.alpha})`;
}
/** `[red, green, blue, alpha]`, 0..1. */
rgba() {
return [this.red, this.green, this.blue, this.alpha];
+13
View File
@@ -75,3 +75,16 @@ test('Cesium billboards and models take an Rgba as their colour', () => {
assert.notEqual(billboard.color, CYAN, 'the billboard keeps its own copy');
collection.destroy();
});
test('CSS serialization keeps native byte rounding and alpha formatting', async () => {
const { constantRgba } = await import('./color.js');
for (const values of [
[0.1, 0.5, 1, 1],
[1, 0, 0.3, 0.42],
[0, 0, 0, 0],
])
assert.equal(
constantRgba(...values).toCssColorString(),
new Cesium.Color(...values).toCssColorString(),
);
});