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",
|
||||
"src/geo/boundingSphere.test.mjs",
|
||||
"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/localAdsb/render/navara.js",
|
||||
"src/layers/vessels/render/navara.js",
|
||||
"src/layers/launches/render/navara.js",
|
||||
"src/layers/satellites/render/navara.js",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/celestialRing/render/cesium.js",
|
||||
|
||||
@@ -21,6 +21,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
'ais-live-vessels',
|
||||
])
|
||||
@@ -29,7 +30,14 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
|
||||
createLayerAvailability('navara')
|
||||
.available()
|
||||
.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);
|
||||
});
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
} from '../renderGovernor.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. */
|
||||
const loadNavaraGlobeModule = async () =>
|
||||
(await import('../globe/navara/index.js')).createNavaraGlobe;
|
||||
@@ -55,6 +58,15 @@ const SHELL_CREDIT_KEYS = new Set([
|
||||
*/
|
||||
export async function registerNavaraLayerRenderers() {
|
||||
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 () =>
|
||||
(await import('../layers/satellites/render/navara.js'))
|
||||
.createSatellitesNavaraRenderer,
|
||||
@@ -112,6 +124,8 @@ export async function createNavaraScene({
|
||||
});
|
||||
defer(() => globe.destroy());
|
||||
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
|
||||
// asks the registry which layers this engine shows.
|
||||
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π);
|
||||
* 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 d = toLocal(axes, direction);
|
||||
const u = toLocal(axes, 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
|
||||
? Math.atan2(u.n, u.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) {
|
||||
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);
|
||||
if (roll > 180) roll -= 360;
|
||||
return {
|
||||
|
||||
@@ -116,12 +116,11 @@ test('navaraPose gives Navara setCamera degrees in the same convention', () => {
|
||||
near(pose.lng, -70, 1e-9, 'lng');
|
||||
near(pose.lat, 45, 1e-9, 'lat');
|
||||
near(pose.height, 25000, 1e-6, 'height');
|
||||
near(pose.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)');
|
||||
near(pose.pitch, math.toDegrees(-0.6), 1e-9, 'pitch (deg)');
|
||||
near(pose.roll, math.toDegrees(-0.05), 1e-9, 'roll (deg) in (−180, 180]');
|
||||
// Back through the same convention, the basis is unchanged.
|
||||
assert.ok(pose.heading >= 0 && pose.heading <= 360);
|
||||
assert.ok(pose.roll > -180 && pose.roll <= 180);
|
||||
// Navara uses the surface frame at lng/lat to reconstruct the basis.
|
||||
const back = orientationVectors(
|
||||
camera.position,
|
||||
cartesian3.fromDegrees(pose.lng, pose.lat, 0),
|
||||
math.toRadians(pose.heading),
|
||||
math.toRadians(pose.pitch),
|
||||
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, 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 = {
|
||||
position,
|
||||
...orientationVectors(
|
||||
position,
|
||||
cartesian3.fromDegrees(pose.lng, pose.lat, 0),
|
||||
math.toRadians(pose.heading),
|
||||
math.toRadians(pitch),
|
||||
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');
|
||||
const pushed = port.applied.at(-1);
|
||||
near(pushed.lng, -70, 1e-9, 'lng');
|
||||
near(pushed.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)');
|
||||
near(pushed.roll, math.toDegrees(0.05), 1e-9, 'roll (deg)');
|
||||
port.update();
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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) });
|
||||
const [, pose] = view.calls.findLast(([name]) => name === 'setCamera');
|
||||
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", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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.
|
||||
|
||||
import ThreeView from '@navaramap/three';
|
||||
import ThreeView, { MeshDesc } from '@navaramap/three';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { DefaultPlugin } from '@navaramap/three-default-plugin';
|
||||
import { createNavaraEngine } from './createNavaraEngine.js';
|
||||
@@ -179,6 +179,7 @@ export async function createNavaraGlobe({
|
||||
return {
|
||||
view,
|
||||
engine,
|
||||
MeshDesc,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
|
||||
@@ -132,6 +132,7 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
}
|
||||
: null,
|
||||
padVisible: state._launchPadZonePrimitive?.show === true,
|
||||
padHeightM: state._launchPadZonePrimitive?.heightM ?? null,
|
||||
};
|
||||
},
|
||||
satelliteParamsForSpaceMissions:
|
||||
|
||||
@@ -80,6 +80,8 @@ export function createLifecycle({
|
||||
layerState._updateDirty = false;
|
||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
// Snapshot the existing follow before the mission flight releases it.
|
||||
await parts.ingestion.captureSatelliteDependency();
|
||||
parts.selection.focusFullGlobe(layerState._viewer);
|
||||
document.getElementById('cockpit-context')?.setAttribute('hidden', '');
|
||||
if (layerState._selectedLaunchId)
|
||||
@@ -88,7 +90,6 @@ export function createLifecycle({
|
||||
layerState._explicitSelection,
|
||||
);
|
||||
else parts.selection.setSelectedMission(null, false);
|
||||
await parts.ingestion.captureSatelliteDependency();
|
||||
},
|
||||
|
||||
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()),
|
||||
collection.modelMatrix,
|
||||
);
|
||||
getRenderer().commitOrbit(collection);
|
||||
layerState._missionOrbitPrimitives.set(launch.id, {
|
||||
primitive: collection,
|
||||
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 &&
|
||||
((getRenderer().follow.current() &&
|
||||
getRenderer().follow.current() !== layerState._trackedEntity) ||
|
||||
(getRenderer().kind === 'navara' &&
|
||||
!getRenderer().follow.current() &&
|
||||
(!getRenderer().follow.current() &&
|
||||
!getRenderer().follow.releasing()))
|
||||
) {
|
||||
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 { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
|
||||
export function createSatellitesCesiumRenderer(engine, { viewer } = {}) {
|
||||
let releasing = false;
|
||||
let starting = null;
|
||||
return {
|
||||
kind: 'cesium',
|
||||
engine,
|
||||
@@ -70,23 +72,37 @@ export function createSatellitesCesiumRenderer(engine, { viewer } = {}) {
|
||||
removeTracked: (entity) => viewer?.entities.remove(entity),
|
||||
follow: {
|
||||
start: (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;
|
||||
starting = entity;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
} finally {
|
||||
starting = null;
|
||||
}
|
||||
},
|
||||
release: () => {
|
||||
const current = engine?.camera.following();
|
||||
if (current && current.native === viewer?.trackedEntity) current.stop();
|
||||
else if (viewer) viewer.trackedEntity = undefined;
|
||||
releasing = true;
|
||||
try {
|
||||
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) =>
|
||||
viewer.trackedEntityChanged.addEventListener(listener),
|
||||
},
|
||||
|
||||
@@ -52,3 +52,69 @@ test('native satellite follow goes through the shared camera contract', () => {
|
||||
assert.equal(options.position(), position);
|
||||
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