From 000ef99464eed33bb282bef2414ed3171436bedc Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 22:37:09 +0400 Subject: [PATCH] refactor(satellites): Cesium rendering behind a renderer interface Move native points, synchronous orbit primitives, tracked entities, follow and picking into the Cesium renderer. Use appClock for satellite physical epochs and isolate the unchanged CelestialRing ephemeris. Synthetic core/dense and ISS/MEO/GEO snapshots match the baseline exactly; all gates pass. The pointer-ownership source assertion follows scene.pick into render/cesium.js; registry and import-boundary assertions now reflect the migrated satellite layer. --- scripts/check-import-directions.mjs | 3 + scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 14 ++-- src/app/layers/qaSources.js | 11 ++- src/app/layers/renderers.js | 2 + src/app/layers/renderers.test.mjs | 5 +- src/app/layers/satellites.js | 13 +++- src/bench/satelliteFixture.js | 50 ++++++++++++++ src/bench/satelliteFixture.test.mjs | 46 +++++++++++++ src/celestialRing.js | 84 +++++++++-------------- src/celestialRing/render/cesium.js | 34 +++++++++ src/data/inputOwnership.test.mjs | 2 +- src/layers/satellites/catalog.js | 14 ++-- src/layers/satellites/controls.js | 6 +- src/layers/satellites/index.js | 10 ++- src/layers/satellites/ingestion.js | 13 ++-- src/layers/satellites/interaction.js | 81 +++++++++++----------- src/layers/satellites/lifecycle.js | 25 ++++--- src/layers/satellites/orbits.js | 7 +- src/layers/satellites/policy.js | 12 ++-- src/layers/satellites/records.js | 3 +- src/layers/satellites/render/cesium.js | 95 ++++++++++++++++++++++++++ src/layers/satellites/rendering.js | 54 +++++---------- src/layers/satellites/state.js | 2 + src/layers/satellites/testing.js | 16 ++++- src/layers/satellites/tracking.js | 44 ++++++------ src/tooling/importDirections.test.mjs | 2 +- 27 files changed, 459 insertions(+), 196 deletions(-) create mode 100644 src/bench/satelliteFixture.js create mode 100644 src/bench/satelliteFixture.test.mjs create mode 100644 src/celestialRing/render/cesium.js create mode 100644 src/layers/satellites/render/cesium.js diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index b7d5f622f..57d1a385d 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -38,10 +38,13 @@ const RENDERED_LAYER_DIRS = [ 'src/layers/military/', 'src/layers/localAdsb/', 'src/layers/vessels/', + 'src/layers/satellites/', 'src/layers/aircraft/', 'src/layers/shared/', ]; const renderedLayer = (file) => + file === 'src/celestialRing.js' || + file.startsWith('src/celestialRing/') || RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir)); const cesiumLayerRenderer = (file) => /\/render\/cesium[^/]*\.js$/.test(file); const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0a2a652db..6f7e48557 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1138,5 +1138,10 @@ "scripts/qa-flights-engines.mjs", "src/bench/w1Feeds.js", "scripts/qa-map-switch.mjs", - "scripts/qa-engine-notice.mjs" + "scripts/qa-engine-notice.mjs", + "src/bench/satelliteFixture.js", + "src/bench/satelliteFixture.test.mjs", + "src/layers/satellites/render/cesium.js", + "src/celestialRing/render/cesium.js", + "scripts/qa-satellites.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4679199b2..7ac715c1d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1146,7 +1146,8 @@ "src/geo/check.js", "src/geo/debug.js", "src/geo/developerError.js", - "src/time/clock.js" + "src/time/clock.js", + "src/layers/shared/color.js" ], "external": ["cesium", "satellite.js"] }, @@ -2608,7 +2609,9 @@ "src/layers/vessels/render/cesium.js", "src/layers/localAdsb/render/navara.js", "src/layers/vessels/render/navara.js", - "src/layers/shared/render/navaraView.js" + "src/layers/shared/render/navaraView.js", + "src/celestialRing/render/cesium.js", + "src/layers/satellites/render/cesium.js" ], "external": [ "hls.js", @@ -3188,7 +3191,9 @@ "src/layers/military/render/cesium.js", "src/layers/aircraft/qaDrawn.js", "src/layers/localAdsb/render/cesium.js", - "src/layers/vessels/render/cesium.js" + "src/layers/vessels/render/cesium.js", + "src/celestialRing/render/cesium.js", + "src/layers/satellites/render/cesium.js" ], "external": [ "hls.js", @@ -3753,7 +3758,8 @@ "src/geo/debug.js", "src/geo/developerError.js", "src/time/clock.js", - "src/globe/surfaceDetail.js" + "src/globe/surfaceDetail.js", + "src/celestialRing/render/cesium.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js index 25f5bd6df..925d6da19 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 +// satellites:fixture 839 core TLEs + 6000 dense points // local-adsb:fixture the local receivers' answer (server format, // /api/local-receivers/aircraft): 40 aircraft // near JFK, 1090 MHz and 978 MHz UAT @@ -23,12 +24,15 @@ import { syntheticMilitary, syntheticVessels, } from '../../bench/w1Feeds.js'; +import { satelliteFixture } from '../../bench/satelliteFixture.js'; +import { createSatelliteSource } from '../../layers/satellites/source.js'; import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js'; const KINDS = Object.freeze({ flights: new Set(['synthetic']), military: new Set(['synthetic']), vessels: new Set(['fixture']), + satellites: new Set(['fixture']), 'local-adsb': new Set(['fixture']), }); @@ -44,7 +48,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, local-adsb:fixture)`, + `qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, local-adsb:fixture)`, ); return { layer, kind, count }; }); @@ -60,6 +64,11 @@ const json = (payload) => export function createQaSources(entries, { now = () => Date.now() } = {}) { const sources = {}; for (const { layer, count } of entries) { + if (layer === 'satellites') + sources.satellites = createSatelliteSource({ + fetchImpl: async (url) => + new Response(satelliteFixture(String(url).split('/').at(-1))), + }); if (layer === 'flights') sources.flights = createFlightSource({ now, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 9d9c8a5c3..247ffd293 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -27,6 +27,7 @@ import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js'; import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js'; +import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js'; import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -87,6 +88,7 @@ export const layerAvailableOn = (layerId, engineKind) => /** Cesium renderers extracted from their layers (render/cesium.js). */ export const CESIUM_LAYER_RENDERERS = Object.freeze({ + satellites: createSatellitesCesiumRenderer, flights: createFlightsCesiumRenderer, military: createMilitaryCesiumRenderer, 'local-adsb': createLocalAdsbCesiumRenderer, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index e8529c734..9d1f3d3e7 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 layers and vessels have extracted Cesium renderers; the others still draw themselves', () => { +test('the aircraft, vessels and satellites have extracted Cesium renderers; the others still draw themselves', () => { assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [ + 'satellites', 'flights', 'military', 'local-adsb', @@ -78,7 +79,7 @@ test('the aircraft layers and vessels have extracted Cesium renderers; the other const military = rendererFor('military', { kind: 'cesium' }, { viewer }); assert.equal(military.kind, 'cesium'); assert.equal(military.viewer, viewer); - assert.equal(rendererFor('satellites', { kind: 'cesium' }), null); + assert.equal(rendererFor('satellites', { kind: 'cesium' }).kind, 'cesium'); }); test('the layer availability API: engine, isAvailable, available, unavailable', () => { diff --git a/src/app/layers/satellites.js b/src/app/layers/satellites.js index 10eccb39f..109029d0a 100644 --- a/src/app/layers/satellites.js +++ b/src/app/layers/satellites.js @@ -1,3 +1,5 @@ +import { rendererFor } from './renderers.js'; +import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js'; import { createSatellitesLayer } from '../../layers/satellites/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; @@ -8,10 +10,19 @@ import * as render from '../../renderGovernor.js'; import * as layerState from '../../data/layerState.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationSatellites({ source }) { +export function createApplicationSatellites({ + source, + renderer = { + create: ({ engine = null, ...options }) => + engine + ? rendererFor('satellites', engine, options) + : createSatellitesCesiumRenderer(null, options), + }, +}) { return createSatellitesLayer({ source, services: { + renderer, picking, focus, readout, diff --git a/src/bench/satelliteFixture.js b/src/bench/satelliteFixture.js new file mode 100644 index 000000000..7e46f3572 --- /dev/null +++ b/src/bench/satelliteFixture.js @@ -0,0 +1,50 @@ +// Deterministic TLE catalogs for phase 3 browser QA; no live feed requests. +import { W1_FROZEN_MS } from './w1Feeds.js'; + +const checksum = (line) => { + const body = line.padEnd(68).slice(0, 68); + let sum = 0; + for (const c of body) sum += /[0-9]/.test(c) ? Number(c) : c === '-' ? 1 : 0; + return body + (sum % 10); +}; +const tle = (id, name, index, inclination, motion) => { + const number = String(id).padStart(5, '0'); + const angle = (value) => (value % 360).toFixed(4).padStart(8); + return [ + name, + checksum( + `1 ${number}U 26001A 26289.50000000 .00000000 00000-0 00000-0 0 999`, + ), + checksum( + `2 ${number} ${angle(inclination)} ${angle(index * 137.508)} 0003000 ${angle(0)} ${angle(index * 47.3)} ${motion.toFixed(8).padStart(11)}00001`, + ), + ].join('\n'); +}; +const GROUPS = { + stations: [25544, 1, 51.64, 15.5, 'ISS (ZARYA)'], + visual: [30000, 720, 53, 15.2, 'QA VISUAL'], + 'gps-ops': [40000, 32, 55, 2.0056, 'QA GPS'], + 'glo-ops': [41000, 24, 64.8, 2.131, 'QA GLONASS'], + galileo: [42000, 30, 56, 1.7047, 'QA GALILEO'], + geo: [45000, 32, 0.1, 1.0027, 'QA GEO'], + starlink: [50000, 6000, 53, 15.2, 'QA STARLINK'], +}; +export const SATELLITE_FIXTURE_EPOCH = W1_FROZEN_MS; +export const SATELLITE_FIXTURE_CORE_COUNT = 839; +export const SATELLITE_FIXTURE_DENSE_COUNT = 6839; +export function satelliteFixture(group) { + const spec = GROUPS[group]; + if (!spec) throw new TypeError('Unknown fixture group'); + const [first, count, inclination, motion, prefix] = spec; + return ( + Array.from({ length: count }, (_, i) => + tle( + first + i, + count === 1 ? prefix : `${prefix} ${i + 1}`, + i, + inclination, + motion, + ), + ).join('\n') + '\n' + ); +} diff --git a/src/bench/satelliteFixture.test.mjs b/src/bench/satelliteFixture.test.mjs new file mode 100644 index 000000000..d70c3b965 --- /dev/null +++ b/src/bench/satelliteFixture.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { twoline2satrec, propagate } from 'satellite.js'; +import { + satelliteFixture, + SATELLITE_FIXTURE_EPOCH, +} from './satelliteFixture.js'; + +test('satellite fixture has distinct, valid SGP4 catalogs including LEO, MEO and GEO', () => { + const ids = new Set(); + const counts = { + stations: 1, + visual: 720, + 'gps-ops': 32, + 'glo-ops': 24, + galileo: 30, + geo: 32, + starlink: 6000, + }; + for (const [group, count] of Object.entries(counts)) { + const lines = satelliteFixture(group).trim().split('\n'); + assert.equal(lines.length, count * 3); + for (let i = 0; i < lines.length; i += 3) { + for (const line of [lines[i + 1], lines[i + 2]]) { + assert.equal(line.length, 69); + const sum = [...line.slice(0, 68)].reduce( + (n, c) => n + (/\d/.test(c) ? Number(c) : c === '-' ? 1 : 0), + 0, + ); + assert.equal(Number(line[68]), sum % 10); + } + const rec = twoline2satrec(lines[i + 1], lines[i + 2]); + assert.equal(rec.error, 0); + assert.ok(!ids.has(rec.satnum)); + ids.add(rec.satnum); + const position = propagate( + rec, + new Date(SATELLITE_FIXTURE_EPOCH), + ).position; + assert.ok(position && Number.isFinite(position.x)); + const radius = Math.hypot(position.x, position.y, position.z); + if (group === 'geo') assert.ok(radius > 41000 && radius < 43000); + if (group === 'gps-ops') assert.ok(radius > 26000 && radius < 27000); + } + } +}); diff --git a/src/celestialRing.js b/src/celestialRing.js index 0d368605d..93c8e5e91 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,4 +1,10 @@ -import * as Cesium from 'cesium'; +import * as geo from './geo/index.js'; +import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; +import { + createCelestialEphemeris, + projectCelestialPoint, + flyCelestialViewer, +} from './celestialRing/render/cesium.js'; import { appClock } from './time/clock.js'; import { governorRequestRender } from './renderGovernor.js'; @@ -32,7 +38,7 @@ export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; const RING_INSET_PX = 11; const MARKER_INSET_PX = 36; const COLLIDING_MARKER_EXTRA_INSET_PX = 32; -const MARKER_COLLISION_ANGLE = Cesium.Math.toRadians(6); +const MARKER_COLLISION_ANGLE = geo.math.toRadians(6); const FULL_GLOBE_RADIUS_RATIO = 0.61; const TAU = Math.PI * 2; // This overlay is a secondary HUD treatment. Keep it smooth while reserving @@ -212,7 +218,7 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) { /** Return the Earth-disc radius in CSS pixels for a perspective camera. */ export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { - const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius; + const earthRadiusM = geo.ellipsoid.WGS84.maximumRadius; if ( !Number.isFinite(cameraDistance) || cameraDistance <= earthRadiusM || @@ -250,24 +256,23 @@ export function projectEarthDiscToViewport( const scene = viewer?.scene; if (!camera || !scene || !(width > 0) || !(height > 0)) return null; - const distance = Cesium.Cartesian3.magnitude(camera.positionWC); + const distance = geo.cartesian3.magnitude(camera.positionWC); if ( !Number.isFinite(distance) || - distance <= Cesium.Ellipsoid.WGS84.maximumRadius + distance <= geo.ellipsoid.WGS84.maximumRadius ) return null; - const toCenter = Cesium.Cartesian3.negate( + const toCenter = geo.cartesian3.negate( camera.positionWC, - scratchToCenter || new Cesium.Cartesian3(), + scratchToCenter || geo.cartesian3.create(), ); - if (Cesium.Cartesian3.dot(camera.directionWC, toCenter) <= 0) return null; + if (geo.cartesian3.dot(camera.directionWC, toCenter) <= 0) return null; - const center = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - Cesium.Cartesian3.ZERO, - scratchCenter, - ); + const projector = engineFromViewer(viewer)?.projector; + const center = projector + ? projector.worldToWindow(geo.cartesian3.ZERO, scratchCenter) + : projectCelestialPoint(scene, geo.cartesian3.ZERO, scratchCenter); if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null; @@ -408,13 +413,11 @@ export class CelestialRing { this._moonRenderKey = ''; this._outlineRenderKey = ''; - this._sunInertial = new Cesium.Cartesian3(); - this._moonInertial = new Cesium.Cartesian3(); - this._sunFixed = new Cesium.Cartesian3(); - this._moonFixed = new Cesium.Cartesian3(); - this._toCenter = new Cesium.Cartesian3(); - this._screenCenter = new Cesium.Cartesian2(); - this._fixedMatrix = new Cesium.Matrix3(); + this._sunFixed = geo.cartesian3.create(); + this._moonFixed = geo.cartesian3.create(); + this._toCenter = geo.cartesian3.create(); + this._screenCenter = geo.cartesian2.create(); + this._ephemeris = createCelestialEphemeris(); this._buildDOM(); this._removePostRender = viewer.scene.postRender.addEventListener(() => @@ -531,7 +534,7 @@ export class CelestialRing { return false; } - const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; + const earthRadius = geo.ellipsoid.WGS84.maximumRadius; const keyholeRadius = getKeyholeGeometry( canvas.clientWidth || canvas.width, height, @@ -548,19 +551,18 @@ export class CelestialRing { this._focusInProgress = false; this.viewer.scene.requestRender?.(); }; - this.viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians( + flyCelestialViewer(this.viewer, { + destination: geo.cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, altitude, ), orientation: { heading: this.viewer.camera.heading, - pitch: -Cesium.Math.PI_OVER_TWO, + pitch: -geo.math.PI_OVER_TWO, roll: 0, }, duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, complete: finishFocus, cancel: () => { finishFocus(); @@ -622,27 +624,7 @@ export class CelestialRing { _updateEphemeris(time) { if (!this._ephemerisDirty) return true; - Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( - time, - this._sunInertial, - ); - Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame( - time, - this._moonInertial, - ); - const matrix = - Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) || - Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix); - if (!matrix) return false; - - Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed); - Cesium.Matrix3.multiplyByVector( - matrix, - this._moonInertial, - this._moonFixed, - ); - Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed); - Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed); + if (!this._ephemeris(time, this._sunFixed, this._moonFixed)) return false; this._ephemerisDirty = false; this._ephemerisUpdateCount += 1; return true; @@ -755,18 +737,18 @@ export class CelestialRing { // The sun and moon at the app clock's now (the wall clock unless QA // moves it), as Navara's sun. - const time = Cesium.JulianDate.fromDate(new Date(appClock.now())); + const time = appClock.now(); if (!this._updateEphemeris(time)) return; this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount); const camera = this.viewer.camera; const sunProjection = celestialScreenAngle( - Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC), - Cesium.Cartesian3.dot(this._sunFixed, camera.upWC), + geo.cartesian3.dot(this._sunFixed, camera.rightWC), + geo.cartesian3.dot(this._sunFixed, camera.upWC), this._sunAngle, ); const moonProjection = celestialScreenAngle( - Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC), - Cesium.Cartesian3.dot(this._moonFixed, camera.upWC), + geo.cartesian3.dot(this._moonFixed, camera.rightWC), + geo.cartesian3.dot(this._moonFixed, camera.upWC), this._moonAngle, ); if (sunProjection.stable) this._sunAngle = sunProjection.angle; diff --git a/src/celestialRing/render/cesium.js b/src/celestialRing/render/cesium.js new file mode 100644 index 000000000..d4ff8b432 --- /dev/null +++ b/src/celestialRing/render/cesium.js @@ -0,0 +1,34 @@ +// Exact legacy planetary model and bare-viewer fallback for CelestialRing. +import * as Cesium from 'cesium'; +export function createCelestialEphemeris() { + const sunInertial = new Cesium.Cartesian3(); + const moonInertial = new Cesium.Cartesian3(); + const fixedMatrix = new Cesium.Matrix3(); + return (epochMs, sunFixed, moonFixed) => { + const time = Cesium.JulianDate.fromDate(new Date(epochMs)); + Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( + time, + sunInertial, + ); + Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame( + time, + moonInertial, + ); + const matrix = + Cesium.Transforms.computeIcrfToFixedMatrix(time, fixedMatrix) || + Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, fixedMatrix); + if (!matrix) return false; + Cesium.Matrix3.multiplyByVector(matrix, sunInertial, sunFixed); + Cesium.Matrix3.multiplyByVector(matrix, moonInertial, moonFixed); + Cesium.Cartesian3.normalize(sunFixed, sunFixed); + Cesium.Cartesian3.normalize(moonFixed, moonFixed); + return true; + }; +} +export const projectCelestialPoint = (scene, position, result) => + Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result); +export const flyCelestialViewer = (viewer, options) => + viewer.camera.flyTo({ + ...options, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }); diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index e3faf3b05..2efca8806 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -127,7 +127,7 @@ test('every scene click handler consults ownership before it picks', () => { ['src/layers/launches/lifecycle.js', 'drillPick(movement.position'], ['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'], [ - 'src/layers/satellites/interaction.js', + 'src/layers/satellites/render/cesium.js', 'viewer.scene.pick(click.position)', ], [ diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 25eaf9b2c..061e09919 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -1,4 +1,6 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; +import { nearFarScale } from '../shared/color.js'; import { twoline2satrec } from 'satellite.js'; import { DENSE_REFRESH_FRAMES, @@ -31,7 +33,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { 1, Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), ); - const now = new Date(); + const now = new Date(appClock.now()); for (let i = 0; i < perFrame; i++) { if (layerState._denseCursor >= layerState._denseIds.length) layerState._denseCursor = 0; @@ -42,7 +44,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { if (!sat || !point) continue; const pos = parts.orbits.propagatePosition(sat.satrec, now); if (pos) { - point.position = Cesium.Cartesian3.fromDegrees( + point.position = geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -96,7 +98,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entries = parts.orbits.parseTLE(text); const style = POINT_STYLES.dense; - const now = new Date(); + const now = new Date(appClock.now()); let added = 0; for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { @@ -124,7 +126,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { group: 'dense', }); const point = layerState._pointCollection.add({ - position: Cesium.Cartesian3.fromDegrees( + position: geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -133,7 +135,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { color: style.color, outlineColor: style.outlineColor, outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + scaleByDistance: nearFarScale(1e6, 1.5, 2e7, 0.6), id: noradId, }); layerState._points.set(noradId, point); diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 9101668a3..4479e6ced 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -1,3 +1,4 @@ +import { appClock } from '../../time/clock.js'; import { tallySatelliteClasses, satelliteClassLabel, @@ -189,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) { if (noradId === null) return null; const sat = layerState._catalog.get(noradId); - const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const pos = parts.orbits.propagatePosition( + sat.satrec, + new Date(appClock.now()), + ); if (!pos) return null; return { diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js index 16e1de512..b3c508381 100644 --- a/src/layers/satellites/index.js +++ b/src/layers/satellites/index.js @@ -17,7 +17,15 @@ export function createSatellitesLayer({ services, source }) { throw new TypeError('A satellites 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.controls = createControls(context); parts.catalog = createCatalog(context); parts.labels = createLabels(context); diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7fb36bb00..5f5c4f1db 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -1,5 +1,7 @@ import { twoline2satrec } from 'satellite.js'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; +import { nearFarScale } from '../shared/color.js'; import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; export function createIngestion({ @@ -7,6 +9,7 @@ export function createIngestion({ services, parts, source, + getRenderer, }) { const methods = { async update(viewer, { signal = null } = {}) { @@ -78,7 +81,7 @@ export function createIngestion({ layerState._pointCollection.removeAll(); layerState._points.clear(); for (const path of layerState._orbitPaths.values()) - viewer.scene.primitives.remove(path.primitive); + getRenderer().removeOrbit(path.primitive); layerState._orbitPaths.clear(); layerState._catalog.clear(); // The detection overlay caches one record per satellite and stamps its @@ -93,7 +96,7 @@ export function createIngestion({ layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog parts.labels._syncIssOverlay(); - const now = new Date(); + const now = new Date(appClock.now()); // Process all TLE entries in CATALOG_GROUPS order const allEntries = []; @@ -123,7 +126,7 @@ export function createIngestion({ const pos = parts.orbits.propagatePosition(satrec, now); if (!pos) continue; - const cartesian = Cesium.Cartesian3.fromDegrees( + const cartesian = geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -137,7 +140,7 @@ export function createIngestion({ color: style.color, outlineColor: style.outlineColor, outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + scaleByDistance: nearFarScale(1e6, 1.5, 2e7, 0.6), id: noradId, }); diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 7ef8816c6..e293da20b 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; export function createInteraction({ @@ -6,6 +5,7 @@ export function createInteraction({ services, parts, source, + getRenderer, }) { const { resolvePickId, isOwnedByOtherLayer } = services.picking; @@ -25,63 +25,64 @@ export function createInteraction({ // new owner controls it). Guarded so our OWN switch (viewer.trackedEntity // briefly undefined mid-_trackSatellite) doesn't self-clear. if (!layerState._trackedEntityChangedRemove) { - layerState._trackedEntityChangedRemove = - viewer.trackedEntityChanged.addEventListener(() => { + layerState._trackedEntityChangedRemove = getRenderer().follow.onChanged( + () => { if (!layerState._enabled) return; if ( layerState._trackedNorad && layerState._viewer && - layerState._viewer.trackedEntity && - layerState._viewer.trackedEntity !== layerState._trackedEntity + getRenderer().follow.current() && + getRenderer().follow.current() !== layerState._trackedEntity ) { parts.tracking._clearTracking(true, { origin: - layerState._viewer.trackedEntity?.gevSelectionOrigin || + getRenderer().follow.current()?.gevSelectionOrigin || 'programmatic', }); } - }); + }, + ); } - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - layerState._clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; - if (!layerState._enabled) return; - const picked = viewer.scene.pick(click.position); + layerState._clickHandler = getRenderer().listenClick( + ({ picked }) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; + if (!layerState._enabled) return; - if (picked) { - // Clicking tracked entity itself — ignore - if (picked.id === layerState._trackedEntity) return; - - // Check if it's a satellite point (id is NORAD catalog number) - const prim = picked.primitive; - if (prim && prim.id != null) { - const noradId = Number(prim.id); - if (!isNaN(noradId) && layerState._catalog.has(noradId)) { - parts.tracking._cancelPendingTrackingRestore(); - parts.tracking._trackSatellite(noradId, { origin: 'user' }); + if (picked) { + // Clicking tracked entity itself — ignore + if (getRenderer().isTrackedPick(picked, layerState._trackedEntity)) return; + + // Check if it's a satellite point (id is NORAD catalog number) + const prim = picked.primitive; + if (prim && prim.id != null) { + const noradId = getRenderer().pickedNorad(picked); + if (!isNaN(noradId) && layerState._catalog.has(noradId)) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._trackSatellite(noradId, { origin: 'user' }); + return; + } } } - } - // A pick that belongs to a sibling layer (plane, vessel, station, CCTV - // camera…) is not "empty space" — leave OUR tracking (and crucially - // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; - } + // A pick that belongs to a sibling layer (plane, vessel, station, CCTV + // camera…) is not "empty space" — leave OUR tracking (and crucially + // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; + } - // Clicked empty space — deselect - if (layerState._trackedNorad) { - parts.tracking._cancelPendingTrackingRestore(); - parts.tracking._clearTracking(false, { origin: 'user' }); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + // Clicked empty space — deselect + if (layerState._trackedNorad) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin: 'user' }); + } + }, + { enabled: () => layerState._enabled }, + ); document.addEventListener('keydown', _onKeyDown); } diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 7c0d36670..16ed95e51 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; export function createLifecycle({ @@ -6,6 +5,7 @@ export function createLifecycle({ services, parts, source, + getRenderer, }) { const { clearFocusTarget } = services.focus; const { holdContinuousRender, releaseContinuousRender } = services.render; @@ -14,10 +14,13 @@ export function createLifecycle({ const methods = { // Catalog data refresh; propagation remains preRender-owned. - async init(viewer) { + async init(viewer, { engine = null } = {}) { parts.catalog._abortActiveUpdates(); clearFocusTarget('satellites'); - layerState._viewer = viewer; + layerState._engine = engine; + layerState._renderer?.destroy(); + layerState._renderer = services.renderer.create({ viewer, engine }); + layerState._viewer = layerState._renderer.view; layerState._catalog = new Map(); layerState._points = new Map(); layerState._detectionObjects = new Map(); @@ -46,14 +49,13 @@ export function createLifecycle({ layerState._catalogRevision++; // Point primitives for satellite dots - layerState._pointCollection = new Cesium.PointPrimitiveCollection(); - viewer.scene.primitives.add(layerState._pointCollection); + layerState._pointCollection = getRenderer().createPoints(); parts.interaction._installClickHandler(viewer); // Pre-render listener for real-time position updates // (fleet propagation + tracked per-frame dot + orbit ring GMST rotation) - layerState._preRenderListener = viewer.scene.preRender.addEventListener( + layerState._preRenderListener = getRenderer().onFrame( parts.rendering._preRenderTick, ); @@ -84,8 +86,8 @@ export function createLifecycle({ const norad = Number(pickedId); return Number.isFinite(norad) && layerState._points.has(norad); }); - if (!layerState._preRenderListener && viewer) { - layerState._preRenderListener = viewer.scene.preRender.addEventListener( + if (!layerState._preRenderListener && layerState._viewer) { + layerState._preRenderListener = getRenderer().onFrame( parts.rendering._preRenderTick, ); } @@ -142,12 +144,12 @@ export function createLifecycle({ layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); if (layerState._pointCollection) { - viewer.scene.primitives.remove(layerState._pointCollection); + getRenderer().removePoints(layerState._pointCollection); layerState._pointCollection = null; } // Orbit ring primitives are removed (and destroyed) here for (const path of layerState._orbitPaths.values()) { - viewer.scene.primitives.remove(path.primitive); + getRenderer().removeOrbit(path.primitive); } layerState._points.clear(); layerState._detectionObjects.clear(); @@ -171,6 +173,9 @@ export function createLifecycle({ status: 'destroyed', failedGroups: [], }; + layerState._renderer?.destroy(); + layerState._renderer = null; + layerState._engine = null; layerState._viewer = null; }, }; diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 4d97dbed8..6464ea036 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -1,3 +1,4 @@ +import { appClock } from '../../time/clock.js'; import * as geo from '../../geo/index.js'; import { gstime, @@ -133,7 +134,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = appClock.now(), }) { return getNextSatellitePass(ISS_NORAD, { latDeg, @@ -163,7 +164,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = appClock.now(), }, ) { const id = Number(noradId); @@ -280,7 +281,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { } function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); + const referenceDate = new Date(appClock.now()); const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 656374fc6..301edb998 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { WHITE, rgbaFromCss } from '../shared/color.js'; import { satelliteClassColor } from '../../data/satelliteClass.js'; /** @@ -71,7 +72,7 @@ export const DENSE_CREATE_CHUNK = 1500; * High orbits (MEO nav / GEO belt) scale this up so the ring stays in frame. */ -export const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3( +export const TRACK_VIEW_FROM_LEO = geo.cartesian3.create( -450000, -450000, 350000, @@ -94,10 +95,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4; * attribute, so classification costs nothing per frame. */ -export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); +export const POINT_OUTLINE = WHITE.withAlpha(0.3); -export const _classColor = (group) => - Cesium.Color.fromCssColorString(satelliteClassColor(group)); +export const _classColor = (group) => rgbaFromCss(satelliteClassColor(group)); export const POINT_STYLES = { // The ISS keeps its own long-standing red hero styling rather than the @@ -106,7 +106,7 @@ export const POINT_STYLES = { // Its card still reads "STATION · ISS", so the class stays legible. iss: { pixelSize: 12, - color: Cesium.Color.fromCssColorString('#ff4444'), + color: rgbaFromCss('#ff4444'), outlineColor: POINT_OUTLINE, outlineWidth: 2, }, diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index c3fd9b470..44a58a09f 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -1,3 +1,4 @@ +import { appClock } from '../../time/clock.js'; import * as geo from '../../geo/index.js'; import { satelliteClassLabel } from '../../data/satelliteClass.js'; import { ISS_NORAD } from './policy.js'; @@ -77,7 +78,7 @@ export function createRecords({ state, parts }) { const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const now = new Date(); + const now = new Date(appClock.now()); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; diff --git a/src/layers/satellites/render/cesium.js b/src/layers/satellites/render/cesium.js new file mode 100644 index 000000000..217069620 --- /dev/null +++ b/src/layers/satellites/render/cesium.js @@ -0,0 +1,95 @@ +// Satellite drawing and native follow, extracted unchanged from the layer. +import * as Cesium from 'cesium'; +import { isPointerFree } from '../../../data/inputOwnership.js'; + +export function createSatellitesCesiumRenderer(engine, { viewer } = {}) { + return { + kind: 'cesium', + engine, + view: viewer, + createPoints() { + const points = new Cesium.PointPrimitiveCollection(); + viewer.scene.primitives.add(points); + return points; + }, + removePoints(points) { + viewer?.scene.primitives.remove(points); + }, + onFrame: (listener) => viewer?.scene.preRender.addEventListener(listener), + project: (position, result) => + Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + result, + ), + createOrbit({ positions, width, color }) { + const primitive = new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry({ + positions, + width, + vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + color.withAlpha(0.6), + ), + depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( + color.withAlpha(0.35), + ), + }, + }), + appearance: new Cesium.PolylineColorAppearance({ translucent: true }), + depthFailAppearance: new Cesium.PolylineColorAppearance({ + translucent: true, + }), + asynchronous: false, + allowPicking: false, + }); + viewer.scene.primitives.add(primitive); + return primitive; + }, + commitOrbit() {}, + removeOrbit: (primitive) => viewer?.scene.primitives.remove(primitive), + createTracked({ position, viewFrom }) { + return viewer.entities.add({ + position: new Cesium.CallbackProperty(position, false), + viewFrom, + point: { + pixelSize: 14, + color: Cesium.Color.YELLOW, + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + }, + removeTracked: (entity) => viewer?.entities.remove(entity), + follow: { + start: (entity) => { + viewer.trackedEntity = entity; + }, + release: () => { + if (viewer) viewer.trackedEntity = undefined; + }, + current: () => viewer?.trackedEntity, + onChanged: (listener) => + viewer.trackedEntityChanged.addEventListener(listener), + }, + listenClick(listener, { enabled = () => true } = {}) { + const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + handler.setInputAction((click) => { + if (!isPointerFree()) return; + if (!enabled()) return; + listener({ + picked: viewer.scene.pick(click.position), + position: click.position, + }); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + return { destroy: () => handler.destroy() }; + }, + pickedNorad: (picked) => Number(picked?.primitive?.id), + isTrackedPick: (picked, entity) => Boolean(entity && picked?.id === entity), + destroy() {}, + }; +} diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index 08e4396bc..ed435d0ee 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -1,4 +1,6 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; +import { CYAN } from '../shared/color.js'; import { gstime } from 'satellite.js'; import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js'; @@ -7,6 +9,7 @@ export function createRendering({ services, parts, source, + getRenderer, }) { const { focusNowMs, @@ -42,36 +45,17 @@ export function createRendering({ const sat = layerState._catalog.get(noradId); if (!sat || !layerState._viewer) return; - const bakeDate = new Date(); + const bakeDate = new Date(appClock.now()); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); if (basePositions.length < 2) return; - const pathColor = color || Cesium.Color.CYAN; + const pathColor = color || CYAN; - const primitive = new Cesium.Primitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry: new Cesium.PolylineGeometry({ - positions: basePositions, - width: noradId === ISS_NORAD ? 2.5 : 2.0, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), - attributes: { - color: Cesium.ColorGeometryInstanceAttribute.fromColor( - pathColor.withAlpha(0.6), - ), - depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( - pathColor.withAlpha(0.35), - ), - }, - }), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - depthFailAppearance: new Cesium.PolylineColorAppearance({ - translucent: true, - }), - asynchronous: false, // build this frame — no async-rebuild blink window - allowPicking: false, // ring clicks fall through to satellites/deselect + const primitive = getRenderer().createOrbit({ + positions: basePositions, + width: noradId === ISS_NORAD ? 2.5 : 2.0, + color: pathColor, }); - layerState._viewer.scene.primitives.add(primitive); layerState._orbitPaths.set(noradId, { primitive, @@ -120,6 +104,7 @@ export function createRendering({ nowDate, path.primitive.modelMatrix, ); + getRenderer().commitOrbit(path.primitive); } } @@ -130,8 +115,7 @@ export function createRendering({ function _hideOrbitPath(noradId) { const path = layerState._orbitPaths.get(noradId); if (path) { - if (layerState._viewer) - layerState._viewer.scene.primitives.remove(path.primitive); // remove() destroys + if (layerState._viewer) getRenderer().removeOrbit(path.primitive); // remove() destroys layerState._orbitPaths.delete(noradId); } } @@ -143,7 +127,7 @@ export function createRendering({ */ function _propagateAll() { - const now = new Date(); + const now = new Date(appClock.now()); let updated = 0; for (const [noradId, sat] of layerState._catalog) { @@ -151,7 +135,7 @@ export function createRendering({ const pos = parts.orbits.propagatePosition(sat.satrec, now); if (!pos) continue; - const cartesian = Cesium.Cartesian3.fromDegrees( + const cartesian = geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -211,7 +195,7 @@ export function createRendering({ layerState._params.showOrbits && now - layerState._lastRingRotation >= RING_ROTATION_MS ) { - _updateOrbitPathRotations(new Date(now)); + _updateOrbitPathRotations(new Date(appClock.now())); layerState._lastRingRotation = now; } } @@ -236,13 +220,9 @@ export function createRendering({ previousActiveCount: layerState._activeFocusCount, nowMs, screenPositionFor: (position) => - Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - position, - layerState._scratchFocusScreen, - ), + getRenderer().project(position, layerState._scratchFocusScreen), cameraDistanceFor: (position) => - Cesium.Cartesian3.distance(camera.positionWC, position), + geo.cartesian3.distance(camera.positionWC, position), baseColorFor: (noradId) => parts.controls._pointStyleFor( noradId, diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index f2ce08d9e..30f484969 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -45,6 +45,8 @@ export function createState({ services }) { state._denseLoadController = null; state._viewer = null; + state._renderer = null; + state._engine = null; state._preRenderListener = null; diff --git a/src/layers/satellites/testing.js b/src/layers/satellites/testing.js index e0f394a50..a58c48643 100644 --- a/src/layers/satellites/testing.js +++ b/src/layers/satellites/testing.js @@ -1,7 +1,13 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; -export function createTesting({ state: layerState, services, parts, source }) { +export function createTesting({ + state: layerState, + services, + parts, + source, + getRenderer, +}) { /** * Seed a single tracked satellite so tests can invoke the exact production * pre-render callback without constructing a WebGL viewer. @@ -27,6 +33,8 @@ export function createTesting({ state: layerState, services, parts, source }) { // control satellite that must NOT be treated as part of it). neighbours = [], }) { + layerState._renderer = null; + layerState._engine = null; layerState._viewer = viewer; layerState._catalog = new Map([ [noradId, { name, satrec, group: 'stations' }], @@ -168,6 +176,8 @@ export function createTesting({ state: layerState, services, parts, source }) { overlayHost, preservePending = false, }) { + layerState._renderer = null; + layerState._engine = null; layerState._viewer = viewer; layerState._catalog = new Map([ [ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }], @@ -177,7 +187,7 @@ export function createTesting({ state: layerState, services, parts, source }) { [ ISS_NORAD, { - primitive: { show: true, modelMatrix: new Cesium.Matrix4() }, + primitive: { show: true, modelMatrix: geo.matrix4.create() }, gmstAtBake: 0, }, ], diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 485466aa2..306329dc3 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { constantRgba } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; import { satelliteClassLabel } from '../../data/satelliteClass.js'; import { @@ -9,7 +10,15 @@ import { TRACK_VIEW_FROM_LEO, } from './policy.js'; -export function createTracking({ state: layerState, services, parts, source }) { +const YELLOW = constantRgba(1, 1, 0); + +export function createTracking({ + state: layerState, + services, + parts, + source, + getRenderer, +}) { const { clearFocusTarget, publishFocusTargetFromCachedPosition } = services.focus; const { @@ -111,9 +120,9 @@ export function createTracking({ state: layerState, services, parts, source }) { parts.rendering._hideOrbitPath(layerState._trackedNorad); } if (layerState._viewer && !skipViewerUntrack) - layerState._viewer.trackedEntity = undefined; + getRenderer().follow.release(); if (layerState._trackedEntity) { - layerState._viewer.entities.remove(layerState._trackedEntity); + getRenderer().removeTracked(layerState._trackedEntity); layerState._trackedEntity = null; } layerState._trackedNorad = null; @@ -159,7 +168,7 @@ export function createTracking({ state: layerState, services, parts, source }) { ); if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample layerState._trackedFrameGeo = pos; - Cesium.Cartesian3.fromDegrees( + geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -410,15 +419,15 @@ export function createTracking({ state: layerState, services, parts, source }) { point.show = false; // Show orbital path - parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW); + parts.rendering._showOrbitPath(noradId, YELLOW); // Tracked entity position propagates per evaluation through the per-frame // cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame. // Falls back to the point primitive's 1s-throttled position if SGP4 fails. - const positionProperty = new Cesium.CallbackProperty(() => { + const position = () => { const pos = _getTrackedFramePosition(); return pos ? layerState._trackedFrameCartesian : point.position; - }, false); + }; const name = sat.name.trim(); @@ -433,22 +442,15 @@ export function createTracking({ state: layerState, services, parts, source }) { initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M ? TRACK_VIEW_FROM_HIGH_SCALE : 1; - const viewFrom = Cesium.Cartesian3.multiplyByScalar( + const viewFrom = geo.cartesian3.multiplyByScalar( TRACK_VIEW_FROM_LEO, viewScale, - new Cesium.Cartesian3(), + geo.cartesian3.create(), ); - layerState._trackedEntity = layerState._viewer.entities.add({ - position: positionProperty, + layerState._trackedEntity = getRenderer().createTracked({ + position, viewFrom, - point: { - pixelSize: 14, - color: Cesium.Color.YELLOW, - outlineColor: Cesium.Color.WHITE, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, }); layerState._trackedEntity.gevSelectionOrigin = origin; layerState._trackedEntity.gevTrackedId = `satellites:${noradId}`; @@ -459,13 +461,13 @@ export function createTracking({ state: layerState, services, parts, source }) { layerId: 'satellites', id: noradId, label: name, - position: Cesium.Cartesian3.clone(point.position), + position: geo.cartesian3.clone(point.position), origin, }); selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); layerState._contextRefreshedAtMs = Date.now(); - layerState._viewer.trackedEntity = layerState._trackedEntity; + getRenderer().follow.start(layerState._trackedEntity); console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); } return { diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 9a45fc8c8..c36b4f56c 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -311,7 +311,7 @@ test('layers moved onto renderers keep each engine in its render file', (t) => { "export const load = () => import('../layers/flights/render/navara.js');", ); // A layer not moved yet still imports cesium anywhere (until its *a task). - write('src/layers/satellites/rendering.js', "import 'cesium';"); + write('src/layers/earthquakes/rendering.js', "import 'cesium';"); assert.doesNotThrow(() => checkImportDirections(root)); write('src/layers/flights/tracking.js', "import 'cesium';");