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.
This commit is contained in:
German Khokhlov committed 2026-10-22 23:41:14 +04:00
1 parent 000ef99464
commit 6bdb995fe3
25 files changed
+727 -53

No files matched your search

+7 -2
View File
@@ -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"
]
+7 -1
View File
@@ -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",
+9 -3
View File
@@ -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);
});
+3
View File
@@ -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,
+5 -2
View File
@@ -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();
}
}
+22
View File
@@ -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();
},
};
}
+1 -1
View File
@@ -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),
};
+3 -5
View File
@@ -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.
+6 -2
View File
@@ -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) {
+10
View File
@@ -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),
+28
View File
@@ -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,
);
});
+4 -1
View File
@@ -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);
}
+40 -7
View File
@@ -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 <color_fragment>
@@ -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 <project_vertex>', VERTEX_PROJECT);
vertex = replace(vertex, '#include <uv_vertex>', 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,
+2 -2
View File
@@ -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,
+31
View File
@@ -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,
+6 -4
View File
@@ -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();
+17 -3
View File
@@ -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) =>
@@ -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 });
});
+355
View File
@@ -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();
},
};
}
@@ -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();
});
+1
View File
@@ -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.
+1
View File
@@ -449,6 +449,7 @@ export function createTracking({
);
layerState._trackedEntity = getRenderer().createTracked({
id: noradId,
position,
viewFrom,
});
@@ -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 },
) {
+2 -2
View File
@@ -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,
+9 -15
View File
@@ -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, {