From 6bdb995fe376dfd862612ba9becc59609ad308c9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 23:41:14 +0400 Subject: [PATCH] feat(satellites): render on Navara Render satellite fleets and selected targets through mover banks and orbit lines. Enable the celestial HUD on Navara and keep native follow explicitly in ENU. Add deterministic interaction, share, orbit, motion and cleanup acceptance QA. Record complete targeted acceptance and remaining shared cross-shell tile failures. --- scripts/format-scope.json | 9 +- scripts/package-boundaries.json | 8 +- src/app/navaraLayers.test.mjs | 12 +- src/app/navaraMode.js | 3 + src/celestialRing.js | 7 +- src/celestialRing/view.js | 22 ++ src/globe/cesium/movers.js | 2 +- src/globe/contract.js | 8 +- src/globe/cpuPicks.js | 8 +- src/globe/movers.js | 10 + src/globe/movers.test.mjs | 28 ++ src/globe/navara/movers/index.js | 5 +- src/globe/navara/movers/moverMesh.js | 47 ++- src/layers/aircraft/render/navara.js | 4 +- src/layers/satellites/index.js | 31 ++ src/layers/satellites/interaction.js | 10 +- src/layers/satellites/render/cesium.js | 20 +- src/layers/satellites/render/cesium.test.mjs | 54 +++ src/layers/satellites/render/navara.js | 355 ++++++++++++++++++ src/layers/satellites/render/navara.test.mjs | 101 +++++ src/layers/satellites/rendering.js | 1 + src/layers/satellites/tracking.js | 1 + .../render/{navaraView.js => engineView.js} | 6 +- src/layers/vessels/render/navara.js | 4 +- src/ui/applicationShell.js | 24 +- 25 files changed, 727 insertions(+), 53 deletions(-) create mode 100644 src/celestialRing/view.js create mode 100644 src/layers/satellites/render/cesium.test.mjs create mode 100644 src/layers/satellites/render/navara.js create mode 100644 src/layers/satellites/render/navara.test.mjs rename src/layers/shared/render/{navaraView.js => engineView.js} (96%) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6f7e48557..6a1b853dc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -366,7 +366,7 @@ "src/layers/vessels/render/contract.js", "src/layers/vessels/renderer.test.mjs", "src/layers/vessels/render/navara.js", - "src/layers/shared/render/navaraView.js", + "src/layers/shared/render/engineView.js", "scripts/qa-vessels-engines.mjs", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -1143,5 +1143,10 @@ "src/bench/satelliteFixture.test.mjs", "src/layers/satellites/render/cesium.js", "src/celestialRing/render/cesium.js", - "scripts/qa-satellites.mjs" + "scripts/qa-satellites.mjs", + "scripts/qa-satellites-compare.mjs", + "src/celestialRing/view.js", + "src/layers/satellites/render/navara.js", + "src/layers/satellites/render/navara.test.mjs", + "src/layers/satellites/render/cesium.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7ac715c1d..7aa10025d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2609,8 +2609,10 @@ "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/satellites/render/navara.js", + "src/layers/shared/render/engineView.js", "src/celestialRing/render/cesium.js", + "src/celestialRing/view.js", "src/layers/satellites/render/cesium.js" ], "external": [ @@ -3193,6 +3195,8 @@ "src/layers/localAdsb/render/cesium.js", "src/layers/vessels/render/cesium.js", "src/celestialRing/render/cesium.js", + "src/celestialRing/view.js", + "src/layers/shared/render/engineView.js", "src/layers/satellites/render/cesium.js" ], "external": [ @@ -3690,6 +3694,8 @@ "src/cyberSonar.js", "src/app/sourceSlot.js", "src/celestialRing.js", + "src/celestialRing/view.js", + "src/layers/shared/render/engineView.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", "src/data/contactTrailRenderer.js", diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index e4327ea87..634ec1849 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -17,13 +17,19 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers, registerNavaraLayerRenderers(), ]); await registerNavaraLayerRenderers(); - for (const id of ['flights', 'military', 'local-adsb', 'ais-live-vessels']) + for (const id of [ + 'flights', + 'military', + 'local-adsb', + 'satellites', + 'ais-live-vessels', + ]) assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.deepEqual( createLayerAvailability('navara') .available() .map(({ id }) => id), - ['flights', 'military', 'local-adsb', 'ais-live-vessels'], + ['flights', 'military', 'local-adsb', 'satellites', 'ais-live-vessels'], ); - assert.equal(rendererFor('satellites', { kind: 'navara' }), null); + assert.equal(layerAvailableOn('satellites', 'navara'), true); }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 9800b4945..2c296e598 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -55,6 +55,9 @@ const SHELL_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { + satellites: async () => + (await import('../layers/satellites/render/navara.js')) + .createSatellitesNavaraRenderer, flights: async () => (await import('../layers/flights/render/navara.js')) .createFlightsNavaraRenderer, diff --git a/src/celestialRing.js b/src/celestialRing.js index 93c8e5e91..c86e89a08 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,4 +1,5 @@ import * as geo from './geo/index.js'; +import { createCelestialView } from './celestialRing/view.js'; import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { createCelestialEphemeris, @@ -394,7 +395,8 @@ export class CelestialRing { * @param {{enabled?:boolean,onAutoDisable?:Function}} [options] */ constructor(viewer, { enabled = true, onAutoDisable = null } = {}) { - this.viewer = viewer; + this._engineView = viewer?.kind ? createCelestialView(viewer) : null; + this.viewer = this._engineView?.view ?? viewer; this.enabled = !!enabled; this.visible = false; this._onAutoDisable = @@ -420,7 +422,7 @@ export class CelestialRing { this._ephemeris = createCelestialEphemeris(); this._buildDOM(); - this._removePostRender = viewer.scene.postRender.addEventListener(() => + this._removePostRender = this.viewer.scene.postRender.addEventListener(() => this._draw(), ); // Pre-existing staleness fix (perf wave 2 review): the ephemeris was @@ -825,5 +827,6 @@ export class CelestialRing { if (this._removePostRender) this._removePostRender(); this._removePostRender = null; this._root?.remove(); + this._engineView?.destroy(); } } diff --git a/src/celestialRing/view.js b/src/celestialRing/view.js new file mode 100644 index 000000000..2aca415a2 --- /dev/null +++ b/src/celestialRing/view.js @@ -0,0 +1,22 @@ +// The shared engine view has no renderer-library imports. Reuse its cached +// camera basis for the celestial canvas, with flight and host ownership here. +import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; +import { createEngineLayerView } from '../layers/shared/render/engineView.js'; +export function createCelestialView(engine) { + const adapter = createEngineLayerView(engine, { layerId: 'celestial-ring' }); + const { view } = adapter; + view.container = engine.host.container; + view.scene.canvas = engine.host.canvas; + view.scene.postRender = engine.frames.postRender; + view.scene.requestRender = () => engine.render.request('celestial-ring'); + view.camera.flyTo = ({ easingFunction, ...options }) => + engine.camera.flyTo({ ...options, easing: 'cubicInOut' }); + const unregister = registerViewerEngine(view, engine); + return { + view, + destroy() { + unregister(); + adapter.destroy(); + }, + }; +} diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 9d81c15c6..3b51c4763 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -158,7 +158,7 @@ function pointPart(options, store, factories, requestRender, owners) { const { point, depthTest } = options; const style = { outlineWidth: point.outlinePx, - outlineColor: Cesium.Color.BLACK, + outlineColor: new Cesium.Color(...point.outlineColor), scaleByDistance: nearFar(point.scaleByDistance), disableDepthTestDistance: depthTestDistance(depthTest), }; diff --git a/src/globe/contract.js b/src/globe/contract.js index 931f478a0..a0f640752 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -285,9 +285,6 @@ * - launches: the replay timeline (replay.js, Date.now/performance.now), * the mission orbit's GMST frame (orbitRendering.js new Date()), launch * pad and panel throttles (phase 3 Task 15a); - * - satellites: the point cloud's SGP4 propagation and orbit bake - * (rendering.js new Date()/Date.now) (Task 14a); only the tracked - * satellite reads the app clock; * - wall-clock throttles and feed receipt stamps everywhere (they measure * real elapsed time, not scene time). * @property {() => number} now The current app time. @@ -321,8 +318,9 @@ * each sprite is a square of sizePx CSS px times its scale, times the * NearFarScalar [nearM, nearScale, farM, farScale] when given (Cesium's * interpolation: over the squared camera distance, eased by ^0.2). - * @property {{ sizePx: number, outlinePx?: number, scaleByDistance?: number[] }} [point] - * A filled disc of sizePx CSS px diameter with a dark outline. + * @property {{ sizePx: number, outlinePx?: number, outlineColor?: number[], scaleByDistance?: number[] }} [point] + * A filled disc of sizePx CSS px diameter plus outlinePx on each side; + * outlineColor is rgba in 0..1 (opaque black by default). * @property {{ url: string, minimumPixelSize?: number, maximumScale?: number, * colorBlend?: { mode: 'mix'|'replace', amount: number }, unlit?: boolean }} [model] * A glTF drawn per instance in metres, at least minimumPixelSize px. diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js index 7f0bc0cb6..9fdb0db64 100644 --- a/src/globe/cpuPicks.js +++ b/src/globe/cpuPicks.js @@ -77,9 +77,13 @@ function moverRadius(options, scale, distance, modelRadiusPx = null) { ? options.point : null; const size = style - ? style.sizePx * distanceScale(style.scaleByDistance, distance) + ? (style.sizePx * scale + + (options.kind === 'point' ? 2 * style.outlinePx : 0)) * + distanceScale(style.scaleByDistance, distance) : Math.max(options.model.minimumPixelSize, 24); - return Math.max((size * scale) / 2, PICK_RADIUS_PX) + PICK_RADIUS_PX; + return ( + Math.max((style ? size : size * scale) / 2, PICK_RADIUS_PX) + PICK_RADIUS_PX + ); } function transformed(matrix, flat, index, result) { diff --git a/src/globe/movers.js b/src/globe/movers.js index 711fedbe7..1b4004b16 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -104,7 +104,17 @@ export function normalizeMoverSetOptions(options) { } else if (kind === 'point') { const point = options.point; if (!isObject(point)) fail('a point set needs point options'); + const outlineColor = point.outlineColor ?? [0, 0, 0, 1]; + if ( + !Array.isArray(outlineColor) || + outlineColor.length !== 4 || + !outlineColor.every( + (value) => isFiniteNumber(value) && value >= 0 && value <= 1, + ) + ) + fail('point.outlineColor must be [r, g, b, a] in 0..1'); normalized.point = { + outlineColor: Object.freeze([...outlineColor]), sizePx: positive(point.sizePx, 'point.sizePx'), outlinePx: nonNegative(point.outlinePx, 'point.outlinePx', 0), scaleByDistance: scaleByDistanceOf(point.scaleByDistance), diff --git a/src/globe/movers.test.mjs b/src/globe/movers.test.mjs index 38b1af9e8..72b81f98c 100644 --- a/src/globe/movers.test.mjs +++ b/src/globe/movers.test.mjs @@ -236,3 +236,31 @@ test('a set is ready() once what it draws has loaded, never once destroyed', () 'an engine part without ready() draws at once', ); }); + +test('point outline RGBA is validated and copied independently of the caller', () => { + const color = [1, 0.5, 0, 0.3]; + const options = normalizeMoverSetOptions({ + id: 'outlined', + kind: 'point', + capacity: 1, + point: { sizePx: 14, outlinePx: 2, outlineColor: color }, + }); + color[0] = 0; + assert.deepEqual(options.point.outlineColor, [1, 0.5, 0, 0.3]); + assert.ok(Object.isFrozen(options.point.outlineColor)); + for (const outlineColor of [ + [1, 0, 0], + [1, 0, 0, 2], + [NaN, 0, 0, 1], + ]) + assert.throws( + () => + normalizeMoverSetOptions({ + id: 'bad', + kind: 'point', + capacity: 1, + point: { sizePx: 14, outlineColor }, + }), + TypeError, + ); +}); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index 2e6128c4b..446873b5e 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -76,10 +76,12 @@ const frameCamera = { cameraHeight: 0, fovDeg: 60, viewportHeightPx: 0, + pixelRatio: 1, }; function updateMover(view, ctx, mover) { const raw = view.camera.raw; - const height = ctx?.getRenderer().domElement.clientHeight; + const renderer = ctx?.getRenderer(); + const height = renderer?.domElement.clientHeight; if (!raw || !(height > 0)) return; raw.updateMatrixWorld(); const e = raw.matrixWorld.elements; @@ -97,6 +99,7 @@ function updateMover(view, ctx, mover) { Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M; frameCamera.fovDeg = raw.fov; frameCamera.viewportHeightPx = height; + frameCamera.pixelRatio = renderer.getPixelRatio?.() ?? 1; mover.update(frameCamera); } diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index aeae1d3bd..718735fab 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -40,15 +40,18 @@ uniform vec4 uGevScaleByDistance; uniform float uGevHasScaleByDistance; uniform float uGevDisableWithinM; uniform float uGevOutlinePx; +uniform float uGevPixelRatio; varying vec4 vGevColor; varying vec2 vGevLocal; varying float vGevOutline; +varying float vGevPixelDistance; ${OVERLAY_DEPTH_VERTEX} `; const VERTEX_PROJECT = /* glsl */ ` vec4 mvCenter = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0); float gevScale = gevStyle.y; +float gevDistanceScale = 1.0; float gevDistance = length(mvCenter.xyz); if (uGevHasScaleByDistance > 0.5) { // Cesium's czm_nearFarScalar: over the squared distance, eased by ^0.2. @@ -60,9 +63,21 @@ if (uGevHasScaleByDistance > 0.5) { 1.0 ); t = pow(t, 0.2); - gevScale *= mix(uGevScaleByDistance.y, uGevScaleByDistance.w, t); + gevDistanceScale = mix(uGevScaleByDistance.y, uGevScaleByDistance.w, t); + gevScale *= gevDistanceScale; } float gevSizePx = uGevSizePx * gevScale; +vGevOutline = 0.0; +vGevPixelDistance = 0.0; +#ifdef GEV_POINT +float gevBorderPx = 2.0 * uGevOutlinePx * gevDistanceScale; +float gevOutlinedSize = gevSizePx + gevBorderPx; +vGevOutline = gevBorderPx / max(gevOutlinedSize, 1e-3); +// Cesium reserves three physical pixels for the point's smooth edge. +gevSizePx = gevOutlinedSize + 3.0 / uGevPixelRatio; +vGevPixelDistance = 2.0 / max(gevSizePx * uGevPixelRatio, 1e-3); +if (gevStyle.y <= 0.0) gevSizePx = 0.0; +#endif float gevCos = cos(gevStyle.x); float gevSin = sin(gevStyle.x); vec2 gevCorner = position.xy; @@ -75,7 +90,6 @@ mvPosition.xy += gevTurned * gevSizePx * uGevFootprint * max(-mvCenter.z, 0.0); gl_Position = projectionMatrix * mvPosition; vGevColor = gevColor; vGevLocal = gevCorner; -vGevOutline = uGevOutlinePx / max(gevSizePx, 1e-3); vGevDepthFree = gevDistance < uGevDisableWithinM ? 1.0 : 0.0; `; @@ -87,9 +101,11 @@ vMapUv = mix(gevUv.xy, gevUv.zw, uv); `; const FRAGMENT_HEAD = /* glsl */ ` +uniform vec4 uGevOutlineColor; varying vec4 vGevColor; varying vec2 vGevLocal; varying float vGevOutline; +varying float vGevPixelDistance; ${OVERLAY_DEPTH_FRAGMENT} `; @@ -103,8 +119,14 @@ const fragmentColor = (point) => if (gevOverlayHidden()) discard; float gevRadius = length(vGevLocal) * 2.0; if (gevRadius > 1.0) discard; -diffuseColor *= sRGBTransferEOTF(vGevColor); -if (gevRadius > 1.0 - vGevOutline) diffuseColor.rgb = vec3(0.0); +float gevMaxDistance = max(0.0, 0.5 - vGevPixelDistance); +float gevDistanceToCenter = gevRadius * 0.5; +float gevWholeAlpha = 1.0 - smoothstep(gevMaxDistance, 0.5, gevDistanceToCenter); +float gevInner = 1.0 - vGevOutline; +float gevInnerAlpha = 1.0 - smoothstep(gevMaxDistance * gevInner, 0.5 * gevInner, gevDistanceToCenter); +diffuseColor *= sRGBTransferEOTF(mix(uGevOutlineColor, vGevColor, gevInnerAlpha)); +diffuseColor.a *= gevWholeAlpha; +if (diffuseColor.a < 0.005) discard; ` : /* glsl */ ` #include @@ -129,7 +151,7 @@ export function patchMoverShader(shader, uniforms, { point = false } = {}) { let vertex = replace( shader.vertexShader, 'void main() {', - `${VERTEX_HEAD}\nvoid main() {`, + `${point ? '#define GEV_POINT\n' : ''}${VERTEX_HEAD}\nvoid main() {`, ); vertex = replace(vertex, '#include ', VERTEX_PROJECT); vertex = replace(vertex, '#include ', VERTEX_UV); @@ -212,6 +234,10 @@ export function createMoverMesh( value: typeof depthTest === 'object' ? depthTest.disableWithinM : -1, }, uGevOutlinePx: { value: point ? options.point.outlinePx : 0 }, + uGevOutlineColor: { + value: point ? [...options.point.outlineColor] : [0, 0, 0, 1], + }, + uGevPixelRatio: { value: 1 }, uGevDepthMode: { value: depthTest === 'always' @@ -232,7 +258,7 @@ export function createMoverMesh( onTextureLoad(); }), transparent: true, - alphaTest: 0.02, + alphaTest: point ? 0.005 : 0.02, // Drawn after tone mapping, with no depth buffer: the shader tests the // scene depth itself (overlayDepth.js). depthTest: false, @@ -299,7 +325,14 @@ export function createMoverMesh( * origin moved to the camera when it has gone too far (a full rewrite). * Returns true when it rewrote the instances. */ - update({ cameraPosition, cameraHeight, fovDeg, viewportHeightPx }) { + update({ + cameraPosition, + cameraHeight, + fovDeg, + viewportHeightPx, + pixelRatio = 1, + }) { + uniforms.uGevPixelRatio.value = pixelRatio; uniforms.uGevFootprint.value = footprintPerDepth( fovDeg, viewportHeightPx, diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index a8830dddc..05fe7ad6b 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -22,7 +22,7 @@ import { aircraftIconCells, drawAircraftIconAtlas, } from '../../../data/aircraftIcons.js'; -import { createNavaraLayerView } from '../../shared/render/navaraView.js'; +import { createEngineLayerView } from '../../shared/render/engineView.js'; const BASE_SIZE_PX = 20; @@ -251,7 +251,7 @@ export function createAircraftNavaraRenderer({ }; // --- the view ------------------------------------------------------------ - const layerView = createNavaraLayerView(engine, { + const layerView = createEngineLayerView(engine, { layerId, wrap: (run) => inFrameDo(run), isDestroyed: () => destroyed, diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js index b3c508381..64f733c46 100644 --- a/src/layers/satellites/index.js +++ b/src/layers/satellites/index.js @@ -1,3 +1,4 @@ +import * as geo from '../../geo/index.js'; import { createControls } from './controls.js'; import { createCatalog } from './catalog.js'; import { createLabels } from './labels.js'; @@ -43,6 +44,36 @@ export function createSatellitesLayer({ services, source }) { parts.lifecycle.methods, parts.ingestion.methods, { + qaDrawn() { + if (!state._renderer) return null; + const plain = (p) => (p ? { x: p.x, y: p.y, z: p.z } : null); + return { + enabled: state._enabled, + points: [...state._points].map(([id, point]) => ({ + id, + position: plain(point.position), + show: state._enabled && state._params.showPoints && point.show, + size: point.pixelSize, + alpha: point.color.alpha, + })), + tracked: plain(getRenderer().trackedPosition?.(state._trackedEntity)), + rings: [...state._orbitPaths].map(([id, path]) => ({ + id, + show: path.primitive.show, + anchors: Array.from({ length: 8 }, (_, i) => + plain( + geo.matrix4.multiplyByPoint( + path.primitive.modelMatrix, + path.basePositions[ + Math.floor((i * (path.basePositions.length - 1)) / 8) + ], + geo.cartesian3.create(), + ), + ), + ), + })), + }; + }, satelliteVisualsVisible: parts.controls.satelliteVisualsVisible, satelliteCatalogModeChanged: parts.controls.satelliteCatalogModeChanged, createIssOverlayEntry: parts.labels.createIssOverlayEntry, diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index e293da20b..87c80ac1e 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -31,8 +31,11 @@ export function createInteraction({ if ( layerState._trackedNorad && layerState._viewer && - getRenderer().follow.current() && - getRenderer().follow.current() !== layerState._trackedEntity + ((getRenderer().follow.current() && + getRenderer().follow.current() !== layerState._trackedEntity) || + (getRenderer().kind === 'navara' && + !getRenderer().follow.current() && + !getRenderer().follow.releasing())) ) { parts.tracking._clearTracking(true, { origin: @@ -56,8 +59,7 @@ export function createInteraction({ return; // Check if it's a satellite point (id is NORAD catalog number) - const prim = picked.primitive; - if (prim && prim.id != null) { + if (Number.isFinite(getRenderer().pickedNorad(picked))) { const noradId = getRenderer().pickedNorad(picked); if (!isNaN(noradId) && layerState._catalog.has(noradId)) { parts.tracking._cancelPendingTrackingRestore(); diff --git a/src/layers/satellites/render/cesium.js b/src/layers/satellites/render/cesium.js index 217069620..5582d8a15 100644 --- a/src/layers/satellites/render/cesium.js +++ b/src/layers/satellites/render/cesium.js @@ -54,7 +54,8 @@ export function createSatellitesCesiumRenderer(engine, { viewer } = {}) { createTracked({ position, viewFrom }) { return viewer.entities.add({ position: new Cesium.CallbackProperty(position, false), - viewFrom, + viewFrom: new Cesium.Cartesian3(viewFrom.x, viewFrom.y, viewFrom.z), + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, point: { pixelSize: 14, color: Cesium.Color.YELLOW, @@ -64,13 +65,26 @@ export function createSatellitesCesiumRenderer(engine, { viewer } = {}) { }, }); }, + trackedPosition: (entity) => + entity?.position?.getValue(viewer.clock.currentTime), removeTracked: (entity) => viewer?.entities.remove(entity), follow: { start: (entity) => { - viewer.trackedEntity = entity; + if (engine) { + engine.camera.follow({ + native: entity, + position: () => entity.position.getValue(viewer.clock.currentTime), + viewFrom: () => entity.viewFrom.getValue(viewer.clock.currentTime), + }); + } else { + viewer.camera?.cancelFlight(); + viewer.trackedEntity = entity; + } }, release: () => { - if (viewer) viewer.trackedEntity = undefined; + const current = engine?.camera.following(); + if (current && current.native === viewer?.trackedEntity) current.stop(); + else if (viewer) viewer.trackedEntity = undefined; }, current: () => viewer?.trackedEntity, onChanged: (listener) => diff --git a/src/layers/satellites/render/cesium.test.mjs b/src/layers/satellites/render/cesium.test.mjs new file mode 100644 index 000000000..1499863a5 --- /dev/null +++ b/src/layers/satellites/render/cesium.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { createSatellitesCesiumRenderer } from './cesium.js'; + +test('native satellite viewFrom fills the scratch vector used by EntityView', () => { + const viewer = { entities: new Cesium.EntityCollection() }; + const renderer = createSatellitesCesiumRenderer(null, { viewer }); + const viewFrom = geo.cartesian3.create(-450000, -450000, 350000); + const entity = renderer.createTracked({ + position: () => geo.cartesian3.fromDegrees(0, 0, 420000), + viewFrom, + }); + assert.equal( + entity.trackingReferenceFrame, + Cesium.TrackingReferenceFrame.ENU, + ); + const scratch = new Cesium.Cartesian3(); + const value = entity.viewFrom.getValue(Cesium.JulianDate.now(), scratch); + assert.equal(value, scratch); + assert.deepEqual( + [scratch.x, scratch.y, scratch.z], + [viewFrom.x, viewFrom.y, viewFrom.z], + ); + renderer.removeTracked(entity); + assert.equal(viewer.entities.values.length, 0); +}); + +test('native satellite follow goes through the shared camera contract', () => { + let options; + const viewer = { + entities: new Cesium.EntityCollection(), + clock: { currentTime: Cesium.JulianDate.now() }, + }; + const engine = { + camera: { + follow: (spec) => { + options = spec; + }, + }, + }; + const renderer = createSatellitesCesiumRenderer(engine, { viewer }); + const position = geo.cartesian3.fromDegrees(0, 0, 20200000); + const offset = geo.cartesian3.create(-1800000, -1800000, 1400000); + const entity = renderer.createTracked({ + position: () => position, + viewFrom: offset, + }); + renderer.follow.start(entity); + assert.equal(options.native, entity); + assert.equal(options.position(), position); + assert.deepEqual({ ...options.viewFrom() }, { ...offset }); +}); diff --git a/src/layers/satellites/render/navara.js b/src/layers/satellites/render/navara.js new file mode 100644 index 000000000..4c761cf3a --- /dev/null +++ b/src/layers/satellites/render/navara.js @@ -0,0 +1,355 @@ +// Satellite points, rigid orbit rings and follow through the globe contract. +import * as geo from '../../../geo/index.js'; +import { createMoverBank } from '../../aircraft/render/navara.js'; +import { createEngineLayerView } from '../../shared/render/engineView.js'; +import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; +import { WHITE, constantRgba } from '../../shared/color.js'; + +const YELLOW = constantRgba(1, 1, 0); +export function createSatellitesNavaraRenderer(engine) { + const banks = new Map(); + const points = new Map(); + const tracked = new Set(); + const orbits = new Set(); + const dirty = new Set(); + let destroyed = false; + let inFrame = false; + let queued = false; + let visible = true; + let sequence = 0; + let releasing = false; + let starting = null; + const rgba = new Float64Array(4); + const fields = { + position: null, + color: rgba, + scale: 1, + show: true, + pickId: null, + }; + const positionField = { position: null }; + const colorField = { color: rgba }; + const showField = { show: true }; + const flush = () => { + queued = false; + for (const bank of dirty) bank.commit(); + dirty.clear(); + }; + const onCommit = (bank) => { + dirty.add(bank); + if (!inFrame && !queued) { + queued = true; + queueMicrotask(flush); + } + }; + const frame = (run) => { + inFrame = true; + try { + return run(); + } finally { + inFrame = false; + flush(); + } + }; + const layerView = createEngineLayerView(engine, { + layerId: 'satellites', + wrap: frame, + isDestroyed: () => destroyed, + }); + const copyColor = (color) => { + rgba[0] = color.red; + rgba[1] = color.green; + rgba[2] = color.blue; + rgba[3] = color.alpha; + }; + const write = (point, set, index) => { + copyColor(point.values.color); + fields.position = point.values.position; + fields.show = point.values.show; + fields.pickId = point.id; + set.set(index, fields); + }; + const bankFor = (point) => { + const v = point.values; + const scale = v.scaleByDistance; + const outline = v.outlineColor; + const depth = + v.disableDepthTestDistance === Infinity + ? 'always' + : v.disableDepthTestDistance > 0 + ? { disableWithinM: v.disableDepthTestDistance } + : 'normal'; + const key = [ + point.family, + v.pixelSize, + v.outlineWidth, + outline.red, + outline.green, + outline.blue, + outline.alpha, + ...Object.values(scale ?? {}), + v.disableDepthTestDistance, + ].join(':'); + let entry = banks.get(key); + if (!entry) { + const id = ++sequence; + const bank = createMoverBank(engine, { + options: (capacity, generation) => ({ + id: `satellites-points-${id}-${generation}`, + kind: 'point', + capacity, + pickable: true, + depthTest: depth, + order: point.family === 'tracked' ? 100 : 0, + point: { + sizePx: v.pixelSize, + outlinePx: v.outlineWidth, + outlineColor: [ + outline.red, + outline.green, + outline.blue, + outline.alpha, + ], + scaleByDistance: scale + ? [scale.near, scale.nearValue, scale.far, scale.farValue] + : null, + }, + }), + write, + onCommit, + }); + entry = { bank, family: point.family }; + banks.set(key, entry); + bank.setVisible(point.family === 'tracked' || visible); + } + return entry.bank; + }; + const createPoint = (spec, family = 'fleet') => { + const point = { + id: spec.id, + family, + gevBank: null, + gevIndex: -1, + values: { + position: geo.cartesian3.clone(spec.position), + color: spec.color ?? WHITE, + show: spec.show !== false, + pixelSize: spec.pixelSize ?? 1, + outlineColor: spec.outlineColor ?? WHITE, + outlineWidth: spec.outlineWidth ?? 0, + scaleByDistance: spec.scaleByDistance ?? null, + disableDepthTestDistance: spec.disableDepthTestDistance ?? 0, + }, + }; + Object.defineProperties(point, { + position: { + get: () => point.values.position, + set(value) { + geo.cartesian3.clone(value, point.values.position); + positionField.position = point.values.position; + point.gevBank?.update(point, positionField); + }, + }, + color: { + get: () => point.values.color, + set(value) { + point.values.color = value; + copyColor(value); + point.gevBank?.update(point, colorField); + }, + }, + show: { + get: () => point.values.show, + set(value) { + if (point.values.show === Boolean(value)) return; + point.values.show = Boolean(value); + showField.show = point.values.show; + point.gevBank?.update(point, showField); + }, + }, + }); + for (const key of [ + 'pixelSize', + 'outlineWidth', + 'outlineColor', + 'scaleByDistance', + 'disableDepthTestDistance', + ]) + Object.defineProperty(point, key, { + get: () => point.values[key], + set(value) { + if (point.values[key] === value) return; + point.values[key] = value; + bankFor(point).add(point); + }, + }); + bankFor(point).add(point); + return point; + }; + const collection = { + get show() { + return visible; + }, + set show(value) { + visible = Boolean(value); + for (const entry of banks.values()) + if (entry.family === 'fleet') entry.bank.setVisible(visible); + }, + add(spec) { + const point = createPoint(spec); + points.set(point.id, point); + return point; + }, + remove(point) { + if (!points.delete(point.id)) return false; + point.gevBank?.remove(point); + return true; + }, + removeAll() { + for (const point of points.values()) point.gevBank?.remove(point); + points.clear(); + }, + }; + const follow = { + start(entity) { + starting = entity; + try { + const followLabel = { + id: entity.gevTrackedId, + model: () => entity.gevLabelModel, + position: () => entity.gevDisplayPosition?.() ?? entity.sample(), + }; + entity.follow = engine.camera.follow({ + position: entity.sample, + viewFrom: () => entity.viewFrom, + label: followLabel, + }); + } finally { + starting = null; + } + }, + release() { + releasing = true; + try { + engine.camera.following()?.stop(); + } finally { + releasing = false; + } + }, + releasing: () => releasing, + current() { + if (starting) return starting; + const current = engine.camera.following(); + for (const entity of tracked) + if (entity.follow === current && current) return entity; + return current; + }, + onChanged: (listener) => + engine.camera.followChanged.addEventListener(listener), + }; + return { + kind: 'navara', + engine, + view: layerView.view, + createPoints: () => collection, + removePoints: () => collection.removeAll(), + project: (position, result) => + engine.projector.worldToWindow(position, result), + onFrame: (listener) => + layerView.view.on('preRender', () => { + listener(); + for (const entity of tracked) { + const position = entity.sample(); + if (position) entity.point.position = position; + } + }), + createOrbit({ positions, width, color }) { + const line = engine.lines.createLine({ + id: `satellites-orbit-${++sequence}`, + widthPx: width, + color: [color.red, color.green, color.blue, 0.6], + depthFailAlpha: 0.35 / 0.6, + arc: 'geodesic', + }); + line.setPositions(positions); + const primitive = { + line, + modelMatrix: geo.matrix4.clone(geo.matrix4.IDENTITY), + visible: true, + get show() { + return this.visible; + }, + set show(value) { + this.visible = Boolean(value); + line.show(this.visible); + }, + }; + orbits.add(primitive); + return primitive; + }, + commitOrbit: (primitive) => + primitive.line.setModelMatrix(primitive.modelMatrix), + removeOrbit(primitive) { + primitive.line.destroy(); + orbits.delete(primitive); + }, + createTracked({ id, position, viewFrom }) { + const entity = { + id: `satellites:${id}`, + sample: position, + viewFrom, + follow: null, + position: { getValue: position }, + point: createPoint( + { + id, + position: position(), + pixelSize: 14, + color: YELLOW, + outlineColor: WHITE, + outlineWidth: 2, + disableDepthTestDistance: Infinity, + }, + 'tracked', + ), + }; + tracked.add(entity); + return entity; + }, + trackedPosition: (entity) => entity?.point.position, + removeTracked(entity) { + entity.point.gevBank?.remove(entity.point); + tracked.delete(entity); + if (entity.follow && engine.camera.following() === entity.follow) + follow.release(); + }, + follow, + listenClick(listener) { + const dispatcher = selectionDispatcherFor(engine); + if (!dispatcher) + throw new Error('Satellite selection dispatcher unavailable'); + const remove = dispatcher.register('satellites', { + owns: (id) => points.has(Number(id)), + onClick: listener, + }); + return { destroy: remove }; + }, + pickedNorad: (picked) => Number(picked?.id ?? picked?.primitive?.id), + isTrackedPick: (picked, entity) => + Boolean( + entity && + Number(picked?.id ?? picked?.primitive?.id) === entity.point.id, + ), + destroy() { + destroyed = true; + for (const orbit of orbits) orbit.line.destroy(); + orbits.clear(); + for (const entry of banks.values()) entry.bank.destroy(); + banks.clear(); + dirty.clear(); + points.clear(); + tracked.clear(); + layerView.destroy(); + }, + }; +} diff --git a/src/layers/satellites/render/navara.test.mjs b/src/layers/satellites/render/navara.test.mjs new file mode 100644 index 000000000..b8a7b2128 --- /dev/null +++ b/src/layers/satellites/render/navara.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { WHITE, nearFarScale } from '../../shared/color.js'; +import { createSatellitesNavaraRenderer } from './navara.js'; + +const position = geo.cartesian3.fromDegrees(0, 0, 420000); +const spec = (id) => ({ + id, + position, + color: WHITE, + pixelSize: 12, + outlineColor: WHITE.withAlpha(0.3), + outlineWidth: 1, + scaleByDistance: nearFarScale(1e6, 1.5, 2e7, 0.6), +}); + +test('satellite banks retain styles, survive growth and update the real store', async () => { + const engine = createFakeEngine(); + const renderer = createSatellitesNavaraRenderer(engine); + const points = renderer.createPoints(); + const all = Array.from({ length: 1500 }, (_, i) => points.add(spec(i))); + await Promise.resolve(); + const [listed] = engine.movers.list(); + const set = engine.fake.moverSet(listed.id); + assert.equal(engine.movers.list().length, 1); + assert.equal(set.store.count, 1500); + assert.equal(set.store.pickIds[699], 699); + assert.deepEqual(set.options.point.outlineColor, [1, 1, 1, 0.3]); + const before = set.commits; + const remove = renderer.onFrame(() => { + all[0].position = { x: 1, y: 2, z: 3 }; + all[0].show = false; + }); + engine.fake.frame(); + remove(); + assert.equal(engine.fake.moverSet(listed.id).commits, before + 1); + assert.deepEqual([...set.store.positions.slice(0, 3)], [1, 2, 3]); + assert.equal(set.store.shows[0], 0); + all[1].pixelSize = 14; + await Promise.resolve(); + assert.equal(set.store.count, 1499); + assert.equal(engine.movers.list().length, 2); + points.removeAll(); + await Promise.resolve(); + assert.ok(engine.movers.list().every((s) => s.count === 0)); + renderer.destroy(); + assert.equal(engine.movers.list().length, 0); + engine.destroy(); +}); + +test('tracked glyph uses the follow sample and stays visible independently of fleet', async () => { + const engine = createFakeEngine(); + const renderer = createSatellitesNavaraRenderer(engine); + const points = renderer.createPoints(); + points.add(spec(25544)); + let sample = position; + const entity = renderer.createTracked({ + id: 25544, + position: () => sample, + viewFrom: { x: -450000, y: -450000, z: 350000 }, + }); + entity.gevTrackedId = '25544'; + renderer.follow.start(entity); + points.show = false; + const remove = renderer.onFrame(() => {}); + sample = { x: position.x + 7600, y: 100, z: 0 }; + engine.fake.frame(); + remove(); + assert.deepEqual({ ...renderer.trackedPosition(entity) }, sample); + assert.equal(renderer.follow.current(), entity); + const sets = engine.movers.list().map((s) => engine.fake.moverSet(s.id)); + assert.equal(sets.find((s) => s.options.order === 100).store.shows[0], 1); + assert.equal(sets.find((s) => s.options.order === 0).store.count, 0); + renderer.removeTracked(entity); + assert.equal(renderer.follow.current(), null); + renderer.destroy(); + engine.destroy(); +}); + +test('orbit starts at identity, rotates without rebuilding and releases lines', () => { + const engine = createFakeEngine(); + const renderer = createSatellitesNavaraRenderer(engine); + const orbit = renderer.createOrbit({ + positions: [position, { x: 0, y: 6778137, z: 0 }], + width: 2.5, + color: WHITE, + }); + assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY); + const before = engine.lines.list()[0]; + orbit.modelMatrix[12] = 123; + renderer.commitOrbit(orbit); + assert.equal(engine.lines.list()[0].id, before.id); + orbit.show = false; + assert.equal(engine.lines.list()[0].shown, false); + renderer.removeOrbit(orbit); + assert.equal(engine.lines.list().length, 0); + renderer.destroy(); + engine.destroy(); +}); diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index ed435d0ee..358c7b5c5 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -59,6 +59,7 @@ export function createRendering({ layerState._orbitPaths.set(noradId, { primitive, + basePositions, // Same Date object as computeOrbitPath's internal fixedGmst → identical // GMST value (gstime is pure), so delta starts at exactly 0 and the // initial identity modelMatrix is correct until the first ring tick. diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 306329dc3..925ebe1fc 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -449,6 +449,7 @@ export function createTracking({ ); layerState._trackedEntity = getRenderer().createTracked({ + id: noradId, position, viewFrom, }); diff --git a/src/layers/shared/render/navaraView.js b/src/layers/shared/render/engineView.js similarity index 96% rename from src/layers/shared/render/navaraView.js rename to src/layers/shared/render/engineView.js index 172fde1d3..d9ced61da 100644 --- a/src/layers/shared/render/navaraView.js +++ b/src/layers/shared/render/engineView.js @@ -1,5 +1,5 @@ -// The view a layer renderer on the Navara engine gives its logic (shared by -// the aircraft and vessel renderers): `camera` camera-shaped and `scene` +// An engine-contract view shared by layer renderers and the celestial HUD: +// `camera` camera-shaped and `scene` // scene-shaped adapters over engine.camera.pose() (refreshed once per frame, // and whenever the camera moved), with the engine registered for the scene // (engineFromViewer) so the shared helpers (iconOrientation, focus) project @@ -16,7 +16,7 @@ import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js' * (the renderer's frame commit scope). * @returns {{ view: object, freshCamera: () => object, destroy: () => void }} */ -export function createNavaraLayerView( +export function createEngineLayerView( engine, { layerId, wrap = (run) => run(), isDestroyed = () => false }, ) { diff --git a/src/layers/vessels/render/navara.js b/src/layers/vessels/render/navara.js index 4aa505eb6..454984e72 100644 --- a/src/layers/vessels/render/navara.js +++ b/src/layers/vessels/render/navara.js @@ -15,7 +15,7 @@ import * as geo from '../../../geo/index.js'; import { createMoverBank } from '../../aircraft/render/navara.js'; -import { createNavaraLayerView } from '../../shared/render/navaraView.js'; +import { createEngineLayerView } from '../../shared/render/engineView.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; import { allVesselChevronIcons, VESSEL_ICON_PX } from '../icons.js'; @@ -125,7 +125,7 @@ export function createVesselsNavaraRenderer( }; // --- the view -------------------------------------------------------------- - const layerView = createNavaraLayerView(engine, { + const layerView = createEngineLayerView(engine, { layerId: LAYER_ID, wrap: inFrameDo, isDestroyed: () => destroyed, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 68fd61358..213609995 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -414,21 +414,15 @@ export class StyleManager extends ShellFacade { exitPanels: () => this._panelChrome.exitCockpit(), }); - // Full-globe sun/moon ring. It is a crisp screen-space overlay above the - // Cesium canvas but below the HUD/detection/readout z ladder. - // Viewer-bound modules (the ring, cockpit) are not created without a - // viewer (the experimental Navara engine); detection and the tracked - // readout are the engine's. - this.celestialRing = viewer - ? new CelestialRing(viewer, { - enabled: false, - onAutoDisable: () => - this.setCelestialRingEnabled(false, { - syncShare: !!this.shareLinkManager, - focus: false, - }), - }) - : null; + // Full-globe sun/moon ring on either engine, below the HUD/readout. + this.celestialRing = new CelestialRing(viewer ?? engine, { + enabled: false, + onAutoDisable: () => + this.setCelestialRingEnabled(false, { + syncShare: !!this.shareLinkManager, + focus: false, + }), + }); // Share Link Manager this.shareLinkManager = new ShareLinkManager(engine, {