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, {