diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 57d1a385d..0d1652527 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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/', ]; diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3278f4678..35d1a9a14 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 366b5cbd1..44906e625 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js index 925d6da19..d86b5e38b 100644 --- a/src/app/layers/qaSources.js +++ b/src/app/layers/qaSources.js @@ -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) => diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 247ffd293..68b570fb1 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 9d1f3d3e7..ac10199df 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -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', diff --git a/src/app/layers/rocketLaunches.js b/src/app/layers/rocketLaunches.js index af60bd3b6..6e1f877c7 100644 --- a/src/app/layers/rocketLaunches.js +++ b/src/app/layers/rocketLaunches.js @@ -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 }, }); } diff --git a/src/bench/launchFixture.js b/src/bench/launchFixture.js new file mode 100644 index 000000000..0f38460c0 --- /dev/null +++ b/src/bench/launchFixture.js @@ -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: [], + }), + ), + }; +} diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 2efca8806..a0c34c201 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -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', diff --git a/src/geo/boundingSphere.js b/src/geo/boundingSphere.js new file mode 100644 index 000000000..a7581f20e --- /dev/null +++ b/src/geo/boundingSphere.js @@ -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; +} diff --git a/src/geo/boundingSphere.test.mjs b/src/geo/boundingSphere.test.mjs new file mode 100644 index 000000000..e871afe76 --- /dev/null +++ b/src/geo/boundingSphere.test.mjs @@ -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]); +}); diff --git a/src/geo/index.js b/src/geo/index.js index 75309996f..a5652dae6 100644 --- a/src/geo/index.js +++ b/src/geo/index.js @@ -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'; diff --git a/src/layers/launches/camera.js b/src/layers/launches/camera.js index 6a1f37e8f..4e7b8038a 100644 --- a/src/layers/launches/camera.js +++ b/src/layers/launches/camera.js @@ -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()); } /** diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index d55394bcc..53f7d467c 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -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 || []) { diff --git a/src/layers/launches/index.js b/src/layers/launches/index.js index 8df45903e..4286325d5 100644 --- a/src/layers/launches/index.js +++ b/src/layers/launches/index.js @@ -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: diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js index f0a87ce6a..1fc24becf 100644 --- a/src/layers/launches/launchPad.js +++ b/src/layers/launches/launchPad.js @@ -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() { diff --git a/src/layers/launches/launchPadFrame.test.mjs b/src/layers/launches/launchPadFrame.test.mjs new file mode 100644 index 000000000..cd8f4bf09 --- /dev/null +++ b/src/layers/launches/launchPadFrame.test.mjs @@ -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(); + } +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 58d70d34b..f33560fac 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -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; }, }; diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 641b8d157..79da0e615 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -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} */ - 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; diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index d907904eb..10d5191b1 100644 --- a/src/layers/launches/orbitRendering.js +++ b/src/layers/launches/orbitRendering.js @@ -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, }; } diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index fa7ccb88f..2c52f1169 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -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(); diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index eb75c46c8..c1e77fe39 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -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 diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js index 3af60771a..8a41f258a 100644 --- a/src/layers/launches/policyHelpers.js +++ b/src/layers/launches/policyHelpers.js @@ -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, }, ); } diff --git a/src/layers/launches/render/cesium.js b/src/layers/launches/render/cesium.js new file mode 100644 index 000000000..e23edf9e2 --- /dev/null +++ b/src/layers/launches/render/cesium.js @@ -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() {}, + }; +} diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index a76aae224..57eee9abc 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -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, }), diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 0faf7d28e..dac5535f9 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -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; }, diff --git a/src/layers/launches/replayClock.test.mjs b/src/layers/launches/replayClock.test.mjs new file mode 100644 index 000000000..b25a23a40 --- /dev/null +++ b/src/layers/launches/replayClock.test.mjs @@ -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(); + } +}); diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 761b31aa6..3ab865e86 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -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); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 2cb1fd26d..1210e9ae8 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -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; diff --git a/src/layers/shared/color.js b/src/layers/shared/color.js index 393874a28..a5fa2e410 100644 --- a/src/layers/shared/color.js +++ b/src/layers/shared/color.js @@ -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]; diff --git a/src/layers/shared/color.test.mjs b/src/layers/shared/color.test.mjs index 9ccaf0f45..b0f40492a 100644 --- a/src/layers/shared/color.test.mjs +++ b/src/layers/shared/color.test.mjs @@ -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(), + ); +});