feat(launches): render on Navara

This commit is contained in:
German Khokhlov committed 2026-10-23 01:19:57 +04:00
1 parent 4baefeb8f9
commit 766c786093
20 files changed
+1050 -33

No files matched your search

+6 -1
View File
@@ -1155,5 +1155,10 @@
"scripts/qa-launches.mjs", "scripts/qa-launches.mjs",
"src/geo/boundingSphere.test.mjs", "src/geo/boundingSphere.test.mjs",
"src/layers/launches/render/cesium.js", "src/layers/launches/render/cesium.js",
"src/layers/launches/launchPadFrame.test.mjs" "src/layers/launches/launchPadFrame.test.mjs",
"src/layers/launches/render/navara.js",
"src/layers/launches/render/navara.test.mjs",
"scripts/qa-launches-compare.mjs",
"src/layers/satellites/interaction.test.mjs",
"src/layers/launches/lifecycleSnapshot.test.mjs"
] ]
+1
View File
@@ -2652,6 +2652,7 @@
"src/layers/vessels/render/cesium.js", "src/layers/vessels/render/cesium.js",
"src/layers/localAdsb/render/navara.js", "src/layers/localAdsb/render/navara.js",
"src/layers/vessels/render/navara.js", "src/layers/vessels/render/navara.js",
"src/layers/launches/render/navara.js",
"src/layers/satellites/render/navara.js", "src/layers/satellites/render/navara.js",
"src/layers/shared/render/engineView.js", "src/layers/shared/render/engineView.js",
"src/celestialRing/render/cesium.js", "src/celestialRing/render/cesium.js",
+9 -1
View File
@@ -21,6 +21,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
'flights', 'flights',
'military', 'military',
'local-adsb', 'local-adsb',
'rocket-launches',
'satellites', 'satellites',
'ais-live-vessels', 'ais-live-vessels',
]) ])
@@ -29,7 +30,14 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
createLayerAvailability('navara') createLayerAvailability('navara')
.available() .available()
.map(({ id }) => id), .map(({ id }) => id),
['flights', 'military', 'local-adsb', 'satellites', 'ais-live-vessels'], [
'flights',
'military',
'local-adsb',
'satellites',
'rocket-launches',
'ais-live-vessels',
],
); );
assert.equal(layerAvailableOn('satellites', 'navara'), true); assert.equal(layerAvailableOn('satellites', 'navara'), true);
}); });
+14
View File
@@ -18,6 +18,9 @@ import {
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js';
// Native mesh capability stays at the composition boundary.
const navaraViews = new WeakMap();
/** The Navara globe module, loaded on demand. */ /** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () => const loadNavaraGlobeModule = async () =>
(await import('../globe/navara/index.js')).createNavaraGlobe; (await import('../globe/navara/index.js')).createNavaraGlobe;
@@ -55,6 +58,15 @@ const SHELL_CREDIT_KEYS = new Set([
*/ */
export async function registerNavaraLayerRenderers() { export async function registerNavaraLayerRenderers() {
const loaders = { const loaders = {
'rocket-launches': async () => {
const { createLaunchesNavaraRenderer } =
await import('../layers/launches/render/navara.js');
return (engine, options) =>
createLaunchesNavaraRenderer(engine, {
...options,
...navaraViews.get(engine),
});
},
satellites: async () => satellites: async () =>
(await import('../layers/satellites/render/navara.js')) (await import('../layers/satellites/render/navara.js'))
.createSatellitesNavaraRenderer, .createSatellitesNavaraRenderer,
@@ -112,6 +124,8 @@ export async function createNavaraScene({
}); });
defer(() => globe.destroy()); defer(() => globe.destroy());
const { engine } = globe; const { engine } = globe;
navaraViews.set(engine, { view: globe.view, MeshDesc: globe.MeshDesc });
defer(() => navaraViews.delete(engine));
// The layers drawn on Navara (their render/navara.js), before the catalog // The layers drawn on Navara (their render/navara.js), before the catalog
// asks the registry which layers this engine shows. // asks the registry which layers this engine shows.
await registerNavaraLayerRenderers(); await registerNavaraLayerRenderers();
+29 -1
View File
@@ -90,7 +90,35 @@ export function launchFixture() {
] ]
: [], : [],
}, },
trajectory: [], trajectory:
i === 2
? [
{
latitude: lat,
longitude: lon,
altitude: 0,
stage: 'Stage 1',
},
{
latitude: lat + 0.2,
longitude: lon + 0.5,
altitude: 40000,
stage: 'Stage 1',
},
{
latitude: lat + 0.4,
longitude: lon + 1,
altitude: 80000,
stage: 'Stage 2',
},
{
latitude: lat + 0.8,
longitude: lon + 2,
altitude: 180000,
stage: 'Stage 2',
},
]
: [],
}), }),
), ),
}; };
+13 -3
View File
@@ -85,12 +85,17 @@ export function orientationVectors(position, heading, pitch, roll) {
* Cesium's camera heading/pitch/roll getters: heading and roll in [0, 2π); * Cesium's camera heading/pitch/roll getters: heading and roll in [0, 2π);
* within 1e-3 of straight up or down roll is 0 and heading follows `up`. * within 1e-3 of straight up or down roll is 0 and heading follows `up`.
*/ */
export function headingPitchRoll(position, direction, up) { export function headingPitchRoll(
position,
direction,
up,
verticalEpsilon = 1e-3,
) {
const axes = localAxes(position); const axes = localAxes(position);
const d = toLocal(axes, direction); const d = toLocal(axes, direction);
const u = toLocal(axes, up); const u = toLocal(axes, up);
const r = toLocal(axes, cross(direction, up)); const r = toLocal(axes, cross(direction, up));
const vertical = Math.abs(Math.abs(d.u) - 1) <= 1e-3; const vertical = Math.abs(Math.abs(d.u) - 1) <= verticalEpsilon;
const raw = vertical const raw = vertical
? Math.atan2(u.n, u.e) - math.PI_OVER_TWO ? Math.atan2(u.n, u.e) - math.PI_OVER_TWO
: Math.atan2(d.n, d.e) - math.PI_OVER_TWO; : Math.atan2(d.n, d.e) - math.PI_OVER_TWO;
@@ -109,7 +114,12 @@ export function headingPitchRoll(position, direction, up) {
*/ */
export function navaraPose(position, direction, up) { export function navaraPose(position, direction, up) {
const place = cartographic.fromCartesian(position, WGS84); const place = cartographic.fromCartesian(position, WGS84);
const angles = headingPitchRoll(position, direction, up); // Navara orients its camera in the surface ENU frame at lng/lat. Cesium's
// Cartesian ENU normal at a high-altitude eye differs from that frame.
const surface = cartesian3.fromRadians(place.longitude, place.latitude, 0);
// Keep near-nadir tilt when serializing; the public pose keeps Cesium's
// wider singularity tolerance, which would discard it here.
const angles = headingPitchRoll(surface, direction, up, 1e-14);
let roll = math.toDegrees(angles.roll); let roll = math.toDegrees(angles.roll);
if (roll > 180) roll -= 360; if (roll > 180) roll -= 360;
return { return {
+25 -5
View File
@@ -116,12 +116,11 @@ test('navaraPose gives Navara setCamera degrees in the same convention', () => {
near(pose.lng, -70, 1e-9, 'lng'); near(pose.lng, -70, 1e-9, 'lng');
near(pose.lat, 45, 1e-9, 'lat'); near(pose.lat, 45, 1e-9, 'lat');
near(pose.height, 25000, 1e-6, 'height'); near(pose.height, 25000, 1e-6, 'height');
near(pose.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)'); assert.ok(pose.heading >= 0 && pose.heading <= 360);
near(pose.pitch, math.toDegrees(-0.6), 1e-9, 'pitch (deg)'); assert.ok(pose.roll > -180 && pose.roll <= 180);
near(pose.roll, math.toDegrees(-0.05), 1e-9, 'roll (deg) in (−180, 180]'); // Navara uses the surface frame at lng/lat to reconstruct the basis.
// Back through the same convention, the basis is unchanged.
const back = orientationVectors( const back = orientationVectors(
camera.position, cartesian3.fromDegrees(pose.lng, pose.lat, 0),
math.toRadians(pose.heading), math.toRadians(pose.heading),
math.toRadians(pose.pitch), math.toRadians(pose.pitch),
math.toRadians(pose.roll), math.toRadians(pose.roll),
@@ -358,3 +357,24 @@ test("horizonVisible agrees with Cesium's EllipsoidalOccluder, also at the limb"
assert.equal(horizonVisible(eye, buried), false); assert.equal(horizonVisible(eye, buried), false);
assert.equal(horizonVisible(eye, lifted), true); assert.equal(horizonVisible(eye, lifted), true);
}); });
test('Navara high-orbit angles reconstruct the exact requested world basis', () => {
for (const height of [0, 25000, 420000, 2e7, 4.5e7])
for (const lat of [-70, -45, 15, 45, 70]) {
const camera = cameraAt(-70, lat, height, 1.2, -1.4, -0.05);
const pose = navaraPose(camera.position, camera.direction, camera.up);
const back = orientationVectors(
cartesian3.fromDegrees(pose.lng, pose.lat, 0),
math.toRadians(pose.heading),
math.toRadians(pose.pitch),
math.toRadians(pose.roll),
);
near(
cartesian3.distance(back.direction, camera.direction),
0,
1e-12,
'high orbit direction',
);
near(cartesian3.distance(back.up, camera.up), 0, 1e-12, 'high orbit up');
}
});
+9 -3
View File
@@ -51,7 +51,7 @@ function createFakePort() {
port.drawn = { port.drawn = {
position, position,
...orientationVectors( ...orientationVectors(
position, cartesian3.fromDegrees(pose.lng, pose.lat, 0),
math.toRadians(pose.heading), math.toRadians(pose.heading),
math.toRadians(pitch), math.toRadians(pitch),
math.toRadians(pose.roll), math.toRadians(pose.roll),
@@ -127,8 +127,14 @@ test('setView updates the pose at once and pushes the Navara pose in degrees', (
near(pose.roll, 0.05, 1e-12, 'roll'); near(pose.roll, 0.05, 1e-12, 'roll');
const pushed = port.applied.at(-1); const pushed = port.applied.at(-1);
near(pushed.lng, -70, 1e-9, 'lng'); near(pushed.lng, -70, 1e-9, 'lng');
near(pushed.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)'); port.update();
near(pushed.roll, math.toDegrees(0.05), 1e-9, 'roll (deg)'); near(
cartesian3.distance(port.drawn.direction, pose.direction),
0,
1e-12,
'drawn direction',
);
near(cartesian3.distance(port.drawn.up, pose.up), 0, 1e-12, 'drawn up');
assert.ok(port.frames > 0, 'no frame was requested'); assert.ok(port.frames > 0, 'no frame was requested');
}); });
+14 -1
View File
@@ -214,7 +214,20 @@ test("the camera starts from Navara's drawn camera and pushes poses with setCame
engine.camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) }); engine.camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) });
const [, pose] = view.calls.findLast(([name]) => name === 'setCamera'); const [, pose] = view.calls.findLast(([name]) => name === 'setCamera');
near(pose.lng, 20, 1e-9, 'lng'); near(pose.lng, 20, 1e-9, 'lng');
near(pose.pitch, -90, 1e-9, 'pitch'); const drawn = orientationVectors(
cartesian3.fromDegrees(pose.lng, pose.lat, 0),
math.toRadians(pose.heading),
math.toRadians(pose.pitch),
math.toRadians(pose.roll),
);
const desired = engine.camera.pose();
near(
cartesian3.distance(drawn.direction, desired.direction),
0,
1e-12,
'world direction',
);
near(cartesian3.distance(drawn.up, desired.up), 0, 1e-12, 'world up');
}); });
test("Navara's fov follows Cesium's vertical fov, also after a resize", () => { test("Navara's fov follows Cesium's vertical fov, also after a resize", () => {
+2 -1
View File
@@ -2,7 +2,7 @@
// the product way (viewOptions.js) and the GlobeEngine on it. Load it with a // the product way (viewOptions.js) and the GlobeEngine on it. Load it with a
// dynamic import() so Navara stays out of the Cesium-mode bundle. // dynamic import() so Navara stays out of the Cesium-mode bundle.
import ThreeView from '@navaramap/three'; import ThreeView, { MeshDesc } from '@navaramap/three';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { createNavaraEngine } from './createNavaraEngine.js'; import { createNavaraEngine } from './createNavaraEngine.js';
@@ -179,6 +179,7 @@ export async function createNavaraGlobe({
return { return {
view, view,
engine, engine,
MeshDesc,
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
+1
View File
@@ -132,6 +132,7 @@ export function createRocketLaunchesLayer({ services, source }) {
} }
: null, : null,
padVisible: state._launchPadZonePrimitive?.show === true, padVisible: state._launchPadZonePrimitive?.show === true,
padHeightM: state._launchPadZonePrimitive?.heightM ?? null,
}; };
}, },
satelliteParamsForSpaceMissions: satelliteParamsForSpaceMissions:
+2 -1
View File
@@ -80,6 +80,8 @@ export function createLifecycle({
layerState._updateDirty = false; layerState._updateDirty = false;
if (layerState._dataSource) layerState._dataSource.show = true; if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
// Snapshot the existing follow before the mission flight releases it.
await parts.ingestion.captureSatelliteDependency();
parts.selection.focusFullGlobe(layerState._viewer); parts.selection.focusFullGlobe(layerState._viewer);
document.getElementById('cockpit-context')?.setAttribute('hidden', ''); document.getElementById('cockpit-context')?.setAttribute('hidden', '');
if (layerState._selectedLaunchId) if (layerState._selectedLaunchId)
@@ -88,7 +90,6 @@ export function createLifecycle({
layerState._explicitSelection, layerState._explicitSelection,
); );
else parts.selection.setSelectedMission(null, false); else parts.selection.setSelectedMission(null, false);
await parts.ingestion.captureSatelliteDependency();
}, },
async disable() { async disable() {
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLifecycle } from './lifecycle.js';
test('missions preserve the satellite target before their focus flight releases it', async () => {
const previous = globalThis.document;
globalThis.document = { getElementById: () => null };
try {
let target = 25544,
snapshot = null;
const lifecycle = createLifecycle({
state: { _dataSource: { show: false }, _viewer: {} },
services: { render: {} },
parts: {
orbitRendering: { syncMissionOrbitPrimitiveVisibility() {} },
selection: {
focusFullGlobe() {
target = null;
},
setSelectedMission() {},
},
ingestion: {
async captureSatelliteDependency() {
snapshot = target;
},
},
},
});
await lifecycle.methods._enableBody();
assert.equal(target, null);
assert.equal(
snapshot,
25544,
'mission camera must not erase the restoration target',
);
} finally {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
}
});
+1
View File
@@ -69,6 +69,7 @@ export function createOrbitRendering({
new Date(appClock.now()), new Date(appClock.now()),
collection.modelMatrix, collection.modelMatrix,
); );
getRenderer().commitOrbit(collection);
layerState._missionOrbitPrimitives.set(launch.id, { layerState._missionOrbitPrimitives.set(launch.id, {
primitive: collection, primitive: collection,
basePositions: orbitPath, basePositions: orbitPath,
+565
View File
@@ -0,0 +1,565 @@
// Mission points, paths and the documented flat pad disc on Navara.
import * as geo from '../../../geo/index.js';
import {
Group,
Mesh,
CircleGeometry,
RingGeometry,
MeshBasicMaterial,
DoubleSide,
} from 'three';
import { createMoverBank } from '../../aircraft/render/navara.js';
import { createEngineLayerView } from '../../shared/render/engineView.js';
import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js';
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
import { LAUNCH_PAD_ZONE_RADIUS_M } from '../policy.js';
const PAD_KIND = 'gevLaunchPad';
const registered = new WeakSet();
const read = (v, time) =>
typeof v?.getValue === 'function' ? v.getValue(time) : v;
const property = (v) =>
typeof v?.getValue === 'function' ? v : { getValue: () => v };
const rgba = (c) => (c ? [c.red, c.green, c.blue, c.alpha] : [1, 1, 1, 1]);
export function createLaunchesNavaraRenderer(
engine,
{ view: nativeView, MeshDesc } = {},
) {
let destroyed = false,
sequence = 0,
inFrame = false,
queued = false,
bankVisibility = false;
const sources = new Set(),
banks = new Map(),
dirty = new Set(),
primitives = new Set();
const flush = () => {
queued = false;
for (const bank of dirty) bank.commit();
dirty.clear();
};
const touch = (bank) => {
dirty.add(bank);
if (!inFrame && !queued) {
queued = true;
queueMicrotask(flush);
}
};
const fields = { position: null, color: null, show: true };
const pointBank = (record, graphic, sprite) => {
const time = engine.clock.now(),
size =
read(sprite ? graphic.width : graphic.pixelSize, time) ??
(sprite ? 24 : 5);
const image = sprite ? read(graphic.image, time) : null;
const outline = rgba(
read(graphic.outlineColor, time) ?? {
red: 0,
green: 0,
blue: 0,
alpha: 1,
},
);
const outlineWidth = read(graphic.outlineWidth, time) ?? 0;
const key = [
sprite ? 'sprite' : 'point',
size,
outlineWidth,
...outline,
image ?? '',
].join(':');
let bank = banks.get(key);
if (!bank) {
const id = ++sequence;
bank = createMoverBank(engine, {
options: (capacity, generation) => ({
id: `launches-${id}-${generation}`,
kind: sprite ? 'sprite' : 'point',
capacity,
pickable: true,
depthTest: 'always',
...(sprite
? {
sprite: {
sizePx: size,
atlas: {
...(typeof image === 'string'
? { url: image }
: { canvas: image }),
cells: { reticle: [0, 0, 1, 1] },
},
},
}
: {
point: {
sizePx: size,
outlinePx: outlineWidth,
outlineColor: outline,
},
}),
}),
write: (point, set, index) =>
set.set(index, {
position: point.position,
color: point.color,
show: point.show,
pickId: point.record.entity.id,
...(sprite ? { cell: 'reticle' } : {}),
}),
onCommit: touch,
});
banks.set(key, bank);
bank.setVisible(bankVisibility);
}
return bank;
};
const syncPoint = (point, graphic, record) => {
const time = engine.clock.now();
if (
!record.source.show ||
!record.entity.show ||
read(graphic.show, time) === false
) {
if (point.show) {
point.show = false;
fields.position = point.position;
fields.color = point.color;
fields.show = false;
point.gevBank.update(point, fields);
}
return;
}
const p = read(record.entity.position, time),
c = read(graphic.color, time);
const shown = Boolean(p);
const red = c?.red ?? 1,
green = c?.green ?? 1,
blue = c?.blue ?? 1,
alpha = c?.alpha ?? 1;
if (
p &&
(point.position.x !== p.x ||
point.position.y !== p.y ||
point.position.z !== p.z ||
point.show !== shown ||
red !== point.color[0] ||
green !== point.color[1] ||
blue !== point.color[2] ||
alpha !== point.color[3])
) {
geo.cartesian3.clone(p, point.position);
point.color[0] = red;
point.color[1] = green;
point.color[2] = blue;
point.color[3] = alpha;
point.show = shown;
fields.position = point.position;
fields.color = point.color;
fields.show = shown;
point.gevBank.update(point, fields);
} else if (point.show !== shown) {
point.show = shown;
fields.position = point.position;
fields.color = point.color;
fields.show = shown;
point.gevBank.update(point, fields);
}
};
const syncEntity = (record) => {
for (const point of record.points) syncPoint(point, point.graphic, record);
if (record.line) {
const show = Boolean(
record.source.show &&
record.entity.show &&
read(record.entity.polyline.show, engine.clock.now()) !== false,
);
if (show !== record.lineVisible) {
record.lineVisible = show;
record.line.show(show);
}
}
};
const sync = () => {
let visible = false;
for (const source of sources)
if (source.show) {
visible = true;
break;
}
if (visible !== bankVisibility) {
bankVisibility = visible;
for (const bank of banks.values()) bank.setVisible(visible);
}
for (const source of sources)
for (const record of source.records.values()) syncEntity(record);
};
const frame = (run) => {
inFrame = true;
try {
return run();
} finally {
sync();
inFrame = false;
flush();
}
};
const adapter = createEngineLayerView(engine, {
layerId: 'rocket-launches',
wrap: frame,
isDestroyed: () => destroyed,
});
const view = adapter.view;
view.container = engine.host.container;
view.scene.canvas = engine.host.canvas;
view.scene.preUpdate = engine.frames.preUpdate;
view.scene.preRender = engine.frames.preRender;
view.scene.postRender = engine.frames.postRender;
view.scene.requestRender = () => engine.render.request('rocket-launches');
Object.defineProperty(view, 'trackedEntity', {
get: () => engine.camera.following(),
set: (value) => {
if (!value) engine.camera.following()?.stop();
},
});
view.camera.cancelFlight = () => engine.camera.cancelFlight();
view.camera.lookAt = (target, offset) => engine.camera.lookAt(target, offset);
view.camera.lookAtTransform = (matrix, offset) =>
engine.camera.lookAtTransform(matrix, offset);
view.camera.setView = (options) => engine.camera.setView(options);
view.camera.flyTo = ({ easingFunction, ...options }) =>
engine.camera.flyTo({ ...options, easing: 'cubicInOut' });
view.camera.flyToBoundingSphere = (
sphere,
{ offset, easingFunction, ...options } = {},
) =>
engine.camera.flyToSphere(sphere, {
...options,
...offset,
easing: 'cubicInOut',
});
const unregister = registerViewerEngine(view, engine);
const clearSource = (source) => {
for (const record of source.records.values()) {
for (const point of record.points) point.gevBank?.remove(point);
record.line?.destroy();
}
source.records.clear();
source.entitiesArray.length = 0;
};
const addEntity = (source, spec) => {
const record = {
source,
entity: null,
points: [],
line: null,
lineVisible: null,
};
let shown = spec.show !== false;
const entity = {
...spec,
position: spec.position ? property(spec.position) : undefined,
};
record.entity = entity;
Object.defineProperty(entity, 'show', {
get: () => shown,
set: (value) => {
shown = Boolean(value);
syncEntity(record);
},
});
for (const name of ['point', 'billboard', 'polyline'])
if (spec[name]) {
const values = Object.fromEntries(
Object.entries(spec[name]).map(([k, v]) => [k, property(v)]),
);
entity[name] = new Proxy(values, {
set: (target, key, value) => {
target[key] = property(value);
syncEntity(record);
return true;
},
});
}
source.records.set(entity.id, record);
source.entitiesArray.push(entity);
for (const name of ['point', 'billboard'])
if (entity[name]) {
const graphic = entity[name],
p = read(entity.position, engine.clock.now());
const point = {
record,
graphic,
position: geo.cartesian3.clone(p ?? geo.cartesian3.ZERO),
color: new Float64Array(
rgba(read(graphic.color, engine.clock.now())),
),
show: false,
gevBank: null,
gevIndex: -1,
};
record.points.push(point);
pointBank(record, graphic, name === 'billboard').add(point);
}
if (spec.polyline) {
const material = read(entity.polyline.material, engine.clock.now());
const color = material?.kind ? material.color : material;
const dash =
material?.kind === 'tactical'
? 'tactical'
: material?.kind === 'dash'
? {
lengthPx:
material.dashLength /
(material.dashPattern === 0xaaaa ? 16 : 4),
gapPx:
material.dashLength /
(material.dashPattern === 0xaaaa ? 16 : 4),
}
: null;
record.line = engine.lines.createLine({
id: `launch-entity-${++sequence}`,
pickId: entity.id,
widthPx: read(entity.polyline.width, engine.clock.now()) ?? 2,
color: rgba(color),
dash,
arc: 'none',
});
record.line.setPositions(
read(entity.polyline.positions, engine.clock.now()),
);
}
syncEntity(record);
return entity;
};
const createDataSource = () => {
let visible = false;
const source = {
records: new Map(),
entitiesArray: [],
get show() {
return visible;
},
set show(value) {
visible = Boolean(value);
sync();
},
entities: {},
};
Object.defineProperty(source.entities, 'values', {
get: () => source.entitiesArray,
});
source.entities.getById = (id) => source.records.get(id)?.entity;
source.entities.removeAll = () => clearSource(source);
sources.add(source);
return source;
};
const createPad = (launch) => {
const group = new Group();
group.matrixAutoUpdate = false;
group.frustumCulled = false;
const fill = new Mesh(
new CircleGeometry(LAUNCH_PAD_ZONE_RADIUS_M, 96),
new MeshBasicMaterial({
color: 0x22e6e6,
transparent: true,
opacity: 0.105,
side: DoubleSide,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -4,
}),
);
const rim = new Mesh(
new RingGeometry(
LAUNCH_PAD_ZONE_RADIUS_M * 0.952,
LAUNCH_PAD_ZONE_RADIUS_M * 0.985,
96,
),
new MeshBasicMaterial({
color: 0x22e6e6,
transparent: true,
opacity: 0.687,
side: DoubleSide,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -4,
}),
);
fill.frustumCulled = false;
rim.frustumCulled = false;
rim.renderOrder = 1;
group.add(fill, rim);
let padHeightM = 0;
let dead = false,
freed = false;
const dispose = () => {
if (freed) return;
freed = true;
for (const mesh of [fill, rim]) {
mesh.geometry.dispose();
mesh.material.dispose();
}
};
const place = (height) => {
if (dead) return;
padHeightM = Number.isFinite(height) ? height : 0;
group.matrix.fromArray(
geo.transforms.eastNorthUpToFixedFrame(
geo.cartesian3.fromDegrees(
launch.lon,
launch.lat,
Number.isFinite(height) ? height : 0,
),
geo.ellipsoid.WGS84,
),
);
group.matrixWorldNeedsUpdate = true;
engine.render.request('launch-pad');
};
// W1 parity decision: a flat ellipsoid disc, not terrain classification.
// A coarse tile height sampled during the focus flight can be kilometres off.
place(0);
let handle;
if (nativeView) {
if (!registered.has(nativeView)) {
registered.add(nativeView);
nativeView.registerMesh(
PAD_KIND,
class extends MeshDesc {
constructor(view, ctx, config) {
super(view, ctx, config);
this.pad = config[PAD_KIND];
}
createMesh() {
return this.pad.group;
}
onDestroy() {
this.pad.dispose();
super.onDestroy();
}
},
);
}
handle = nativeView.addMesh({ [PAD_KIND]: { group, dispose } });
}
const pad = {
get heightM() {
return padHeightM;
},
get show() {
return group.visible;
},
set show(value) {
if (group.visible === Boolean(value)) return;
group.visible = Boolean(value);
engine.render.request('launch-pad');
},
destroy() {
if (dead) return;
dead = true;
handle?.delete();
dispose();
primitives.delete(pad);
engine.render.request('launch-pad');
},
};
primitives.add(pad);
return pad;
};
const listenClick = (source, listener, { enabled = () => true } = {}) => {
const remove = selectionDispatcherFor(engine).register('rocket-launches', {
owns: (id) => source.records.has(id),
onClick: (event) => {
if (!enabled()) return;
const entity = [event.picked, ...event.drilled]
.map((p) => source.entities.getById(p?.id))
.find(Boolean);
listener(entity);
},
});
return { destroy: remove };
};
return {
kind: 'navara',
engine,
view,
time: (epoch) => epoch,
occluder: () => ({
cameraPosition: null,
isPointVisible: (p) => engine.projector.horizonVisible(p),
}),
callback: (fn) => ({ getValue: fn }),
range: (heading, pitch, range) => ({ heading, pitch, range }),
easing: undefined,
createDataSource,
addEntity,
removeDataSource(source) {
clearSource(source);
sources.delete(source);
sync();
},
dash: (options) => ({ kind: 'dash', ...options }),
patternMaterial: (color) => ({ kind: 'tactical', color }),
createOrbit({ positions, width, color }) {
const line = engine.lines.createLine({
id: `launch-orbit-${++sequence}`,
widthPx: width,
color: rgba(color),
dash: 'tactical',
arc: 'none',
});
line.setPositions(positions);
let visible = true;
const primitive = {
modelMatrix: geo.matrix4.clone(geo.matrix4.IDENTITY),
get show() {
return visible;
},
set show(value) {
if (visible === Boolean(value)) return;
visible = Boolean(value);
line.show(visible);
},
destroy() {
line.destroy();
primitives.delete(primitive);
},
line,
};
primitives.add(primitive);
return primitive;
},
commitOrbit: (primitive) =>
primitive.line.setModelMatrix(primitive.modelMatrix),
createPad,
removePrimitive: (primitive) => primitive.destroy(),
project: (p, r) => engine.projector.worldToWindow(p, r),
on: (event, listener) => view.on(event, listener),
listenClick,
listenMove: (source, listener, { enabled = () => true } = {}) => ({
destroy: engine.input.on('move', (event) => {
if (enabled())
listener(
engine.picker
.drillPick(event.endPosition, 12)
.map((p) => source.entities.getById(p.id))
.find(Boolean),
);
}),
}),
destroy() {
destroyed = true;
for (const source of sources) clearSource(source);
sources.clear();
for (const primitive of [...primitives]) primitive.destroy();
for (const bank of banks.values()) bank.destroy();
banks.clear();
dirty.clear();
unregister();
adapter.destroy();
},
};
}
+170
View File
@@ -0,0 +1,170 @@
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 } from '../../shared/color.js';
import { createLaunchesNavaraRenderer } from './navara.js';
const position = geo.cartesian3.fromDegrees(0, 0, 1000);
test('mission bank growth, callbacks and source visibility write actual mover stores', async () => {
const engine = createFakeEngine();
const renderer = createLaunchesNavaraRenderer(engine);
const source = renderer.createDataSource();
let sample = position;
for (let i = 0; i < 1100; i++)
renderer.addEntity(source, {
id: `rocket-launch:${i}`,
position: renderer.callback(() => sample),
point: { pixelSize: 6, color: WHITE, show: true },
});
const values = source.entities.values;
source.show = true;
await Promise.resolve();
const listed = engine.movers.list();
assert.equal(listed.length, 1);
assert.equal(listed[0].count, 1100);
const set = engine.fake.moverSet(listed[0].id);
assert.equal(set.store.pickIds[1000], 'rocket-launch:1000');
const remove = renderer.on('preRender', () => {});
sample = { x: 1, y: 2, z: 3 };
engine.fake.frame();
assert.deepEqual([...set.store.positions.slice(0, 3)], [1, 2, 3]);
assert.equal(source.entities.values, values);
source.entities.getById('rocket-launch:0').point.show = false;
await Promise.resolve();
assert.equal(set.store.shows[0], 0);
source.show = false;
await Promise.resolve();
assert.equal(engine.movers.list()[0].count, 0);
const commits = engine.fake.moverSet(listed[0].id).commits;
sample = { x: 7, y: 8, z: 9 };
engine.fake.resetCalls();
engine.fake.frames(3);
assert.equal(engine.fake.moverSet(listed[0].id).commits, commits);
assert.equal(
engine.fake.renderRequests,
0,
'hidden motion must not sustain rendering',
);
source.show = true;
await Promise.resolve();
assert.equal(engine.movers.list()[0].count, 1100);
source.entities.removeAll();
await Promise.resolve();
assert.equal(values.length, 0);
assert.equal(engine.movers.list()[0].count, 0);
remove();
renderer.destroy();
assert.equal(engine.movers.list().length, 0);
engine.destroy();
});
test('mission lines retain dash styles and orbit matrices and release on removal', () => {
const engine = createFakeEngine();
const renderer = createLaunchesNavaraRenderer(engine);
const source = renderer.createDataSource();
source.show = true;
const entity = renderer.addEntity(source, {
id: 'rocket-transfer:x',
polyline: {
positions: [position, { x: 0, y: 6778137, z: 0 }],
width: 2,
material: renderer.dash({
color: WHITE,
dashLength: 24,
dashPattern: 0xf0f0,
}),
},
});
const first = engine.lines.list()[0];
assert.deepEqual(engine.fake.line(first.id).options.dash, {
kind: 'pixels',
lengthPx: 6,
gapPx: 6,
});
entity.show = false;
assert.equal(engine.lines.list()[0].shown, false);
const orbit = renderer.createOrbit({
positions: [position, { x: 0, y: 6778137, z: 0 }],
width: 3,
color: WHITE,
});
assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY);
orbit.modelMatrix[12] = 42;
renderer.commitOrbit(orbit);
assert.equal(engine.fake.line(engine.lines.list()[1].id).modelMatrix[12], 42);
renderer.removePrimitive(orbit);
renderer.removeDataSource(source);
assert.equal(engine.lines.list().length, 0);
renderer.destroy();
engine.destroy();
});
test('mission hover uses engine drillPick and obeys disabled state', () => {
const engine = createFakeEngine();
const renderer = createLaunchesNavaraRenderer(engine);
const source = renderer.createDataSource();
const entity = renderer.addEntity(source, {
id: 'rocket-launch:x',
position,
point: { pixelSize: 6, color: WHITE },
});
engine.fake.script('drillPick', [{ id: entity.id }]);
let enabled = true,
hovered = null;
const handler = renderer.listenMove(
source,
(e) => {
hovered = e;
},
{ enabled: () => enabled },
);
engine.fake.input('move', { endPosition: { x: 1, y: 2 } });
assert.equal(hovered, entity);
enabled = false;
hovered = null;
engine.fake.input('move', { endPosition: { x: 1, y: 2 } });
assert.equal(hovered, null);
handler.destroy();
renderer.destroy();
engine.destroy();
});
test('flat pad uses a native mesh description, ellipsoid ENU placement and idempotent disposal', () => {
const engine = createFakeEngine();
engine.fake.script('sampleHeight', 125);
let config,
removed = 0;
const view = {
registerMesh() {},
addMesh(value) {
config = value;
return {
delete() {
removed++;
},
};
},
};
const renderer = createLaunchesNavaraRenderer(engine, {
view,
MeshDesc: class {},
});
const pad = renderer.createPad({ id: 'x', lon: 0, lat: 0 });
assert.equal(
engine.fake.callsOf('surface.sampleHeight').length,
0,
'flat pad must not sample a coarse tile',
);
const group = config.gevLaunchPad.group;
assert.equal(group.children.length, 2);
assert.equal(group.matrix.elements[12], 6378137);
assert.equal(group.children[0].material.polygonOffsetUnits, -4);
pad.show = false;
assert.equal(group.visible, false);
pad.destroy();
pad.destroy();
assert.equal(removed, 1);
renderer.destroy();
engine.destroy();
});
+1 -2
View File
@@ -33,8 +33,7 @@ export function createInteraction({
layerState._viewer && layerState._viewer &&
((getRenderer().follow.current() && ((getRenderer().follow.current() &&
getRenderer().follow.current() !== layerState._trackedEntity) || getRenderer().follow.current() !== layerState._trackedEntity) ||
(getRenderer().kind === 'navara' && (!getRenderer().follow.current() &&
!getRenderer().follow.current() &&
!getRenderer().follow.releasing())) !getRenderer().follow.releasing()))
) { ) {
parts.tracking._clearTracking(true, { parts.tracking._clearTracking(true, {
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createEngineEvent } from '../../globe/contract.js';
import { createInteraction } from './interaction.js';
test('external null follow clears satellite state on Cesium; own release stays guarded', () => {
const previous = globalThis.document;
globalThis.document = new EventTarget();
try {
const changed = createEngineEvent();
let current = null,
releasing = false;
const calls = [];
const renderer = {
kind: 'cesium',
follow: {
onChanged: (fn) => changed.addEventListener(fn),
current: () => current,
releasing: () => releasing,
},
listenClick: () => ({ destroy() {} }),
};
const state = {
_enabled: true,
_trackedNorad: 25544,
_viewer: {},
_trackedEntity: { id: 'own' },
};
const interaction = createInteraction({
state,
services: { picking: {} },
parts: { tracking: { _clearTracking: (...args) => calls.push(args) } },
getRenderer: () => renderer,
});
interaction._installClickHandler(state._viewer);
releasing = true;
changed.raiseEvent();
assert.equal(calls.length, 0);
releasing = false;
changed.raiseEvent();
assert.equal(calls.length, 1);
assert.equal(calls[0][0], true);
current = state._trackedEntity;
changed.raiseEvent();
assert.equal(calls.length, 1);
current = { id: 'foreign' };
changed.raiseEvent();
assert.equal(calls.length, 2);
state._trackedEntityChangedRemove();
} finally {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
}
});
+30 -14
View File
@@ -1,8 +1,10 @@
// Satellite drawing and native follow, extracted unchanged from the layer. // Satellite drawing and guarded native follow.
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { isPointerFree } from '../../../data/inputOwnership.js'; import { isPointerFree } from '../../../data/inputOwnership.js';
export function createSatellitesCesiumRenderer(engine, { viewer } = {}) { export function createSatellitesCesiumRenderer(engine, { viewer } = {}) {
let releasing = false;
let starting = null;
return { return {
kind: 'cesium', kind: 'cesium',
engine, engine,
@@ -70,23 +72,37 @@ export function createSatellitesCesiumRenderer(engine, { viewer } = {}) {
removeTracked: (entity) => viewer?.entities.remove(entity), removeTracked: (entity) => viewer?.entities.remove(entity),
follow: { follow: {
start: (entity) => { start: (entity) => {
if (engine) { starting = entity;
engine.camera.follow({ try {
native: entity, if (engine) {
position: () => entity.position.getValue(viewer.clock.currentTime), engine.camera.follow({
viewFrom: () => entity.viewFrom.getValue(viewer.clock.currentTime), native: entity,
}); position: () =>
} else { entity.position.getValue(viewer.clock.currentTime),
viewer.camera?.cancelFlight(); viewFrom: () =>
viewer.trackedEntity = entity; entity.viewFrom.getValue(viewer.clock.currentTime),
});
} else {
viewer.camera?.cancelFlight();
viewer.trackedEntity = entity;
}
} finally {
starting = null;
} }
}, },
release: () => { release: () => {
const current = engine?.camera.following(); releasing = true;
if (current && current.native === viewer?.trackedEntity) current.stop(); try {
else if (viewer) viewer.trackedEntity = undefined; const current = engine?.camera.following();
if (current && current.native === viewer?.trackedEntity)
current.stop();
else if (viewer) viewer.trackedEntity = undefined;
} finally {
releasing = false;
}
}, },
current: () => viewer?.trackedEntity, current: () => starting ?? viewer?.trackedEntity,
releasing: () => releasing,
onChanged: (listener) => onChanged: (listener) =>
viewer.trackedEntityChanged.addEventListener(listener), viewer.trackedEntityChanged.addEventListener(listener),
}, },
@@ -52,3 +52,69 @@ test('native satellite follow goes through the shared camera contract', () => {
assert.equal(options.position(), position); assert.equal(options.position(), position);
assert.deepEqual({ ...options.viewFrom() }, { ...offset }); assert.deepEqual({ ...options.viewFrom() }, { ...offset });
}); });
test('native own follow release is guarded without hiding later external release', () => {
const event = new Cesium.Event();
let entity = { id: 'satellite' };
const viewer = {
trackedEntityChanged: event,
get trackedEntity() {
return entity;
},
set trackedEntity(value) {
entity = value;
event.raiseEvent(value);
},
};
const renderer = createSatellitesCesiumRenderer(null, { viewer });
const guards = [];
renderer.follow.onChanged(() => guards.push(renderer.follow.releasing()));
renderer.follow.release();
viewer.trackedEntity = { id: 'other' };
viewer.trackedEntity = undefined;
assert.deepEqual(guards, [true, false, false]);
assert.equal(renderer.follow.releasing(), false);
});
test('native follow takeover reports its starting entity through an intermediate null', () => {
const event = new Cesium.Event();
let current = { id: 'foreign' };
const viewer = {
clock: { currentTime: Cesium.JulianDate.now() },
trackedEntityChanged: event,
get trackedEntity() {
return current;
},
set trackedEntity(v) {
current = v;
event.raiseEvent();
},
};
const engine = {
camera: {
follow: ({ native }) => {
viewer.trackedEntity = undefined;
viewer.trackedEntity = native;
},
},
};
const renderer = createSatellitesCesiumRenderer(engine, { viewer });
const entity = {
id: 'own',
position: {
getValue() {
return geo.cartesian3.ZERO;
},
},
viewFrom: {
getValue() {
return geo.cartesian3.ZERO;
},
},
};
const seen = [];
renderer.follow.onChanged(() => seen.push(renderer.follow.current()));
renderer.follow.start(entity);
assert.deepEqual(seen, [entity, entity]);
assert.equal(renderer.follow.current(), entity);
});