mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(launches): render on Navara
This commit is contained in:
1 parent
4baefeb8f9
commit
766c786093
20 files changed
+1050
-33
No files matched your search
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user