feat(navara): render CCTV media and calibration handles

Port camera markers, coverage, media planes, volumes and calibration to the
Navara renderer. Preserve shared source/media policy and add exact plane
picking, owned resource cleanup and idle hold handling.

Validate paired keyless dev/preview media and physical drags, native legacy,
engine contracts and full CPU/build gates. Record remaining shell, viewshed
browser and W2 performance gates; defer Dioxus and plan the transit port.
This commit is contained in:
German Khokhlov committed 2026-10-29 16:53:27 +04:00
1 parent 798d6636ab
commit 694616d5c2
22 files changed
+1466 -7

No files matched your search

+7
View File
@@ -542,6 +542,13 @@
"src/layers/cctv/projectionScheduling.test.mjs",
"src/layers/cctv/gizmo.test.mjs",
"src/layers/cctv/render/cesiumGizmo.js",
"src/layers/cctv/render/navara.js",
"src/layers/cctv/render/navara.test.mjs",
"src/layers/cctv/render/navaraMeshes.js",
"src/layers/cctv/render/navaraMeshes.test.mjs",
"src/layers/cctv/render/navaraGizmo.js",
"src/layers/cctv/render/navaraGizmo.test.mjs",
"src/layers/cctv/render/navaraLine.js",
"src/layers/cctv/render/cesium.js",
"src/layers/cctv/render/cesiumViewshed.js",
"src/layers/cctv/render/cesium.test.mjs",
+4
View File
@@ -2705,6 +2705,10 @@
"src/layers/radio/render/cesium.js",
"src/layers/firms/render/navara.js",
"src/layers/radio/render/navara.js",
"src/layers/cctv/render/navara.js",
"src/layers/cctv/render/navaraMeshes.js",
"src/layers/cctv/render/navaraGizmo.js",
"src/layers/cctv/render/navaraLine.js",
"src/layers/alpr/render/navara.js",
"src/layers/alpr/render/navaraOverlay.js",
"src/layers/radio/screenClusters.js",
+5 -1
View File
@@ -3,6 +3,7 @@ import {
bindCctvWorldClickGesture,
} from '../../layers/cctv/render/cesium.js';
import { createCctvLayer } from '../../layers/cctv/index.js';
import { rendererFor } from './renderers.js';
import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js';
@@ -16,7 +17,10 @@ import * as render from '../../renderGovernor.js';
export function createApplicationCctv({
surface,
source,
rendererFactory = createCctvCesiumRenderer,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('cctv', engine, context)
: createCctvCesiumRenderer(context),
}) {
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
const layer = createCctvLayer({
+2
View File
@@ -30,6 +30,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'bikeshare',
'radio',
'alpr-cameras',
'cctv',
])
assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual(
@@ -44,6 +45,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'alpr-cameras',
'satellites',
'rocket-launches',
'cctv',
'radio',
'bikeshare',
'ais-live-vessels',
+9
View File
@@ -65,6 +65,15 @@ const NAVARA_CREDIT_KEYS = new Set([
*/
export async function registerNavaraLayerRenderers() {
const loaders = {
cctv: async () => {
const { createCctvNavaraRenderer } =
await import('../layers/cctv/render/navara.js');
return (engine, options) =>
createCctvNavaraRenderer(engine, {
...options,
...navaraViews.get(engine),
});
},
'alpr-cameras': async () =>
(await import('../layers/alpr/render/navara.js'))
.createAlprNavaraRenderer,
+5
View File
@@ -62,6 +62,11 @@ const toColor = ([r, g, b, a], alphaScale = 1) =>
/** The Cesium material type and uniforms of a line (pure, for tests). */
export function materialSpec(options, color, alphaScale = 1) {
if (options.arrow)
return {
type: 'PolylineArrow',
uniforms: { color: toColor(color, alphaScale) },
};
const { dash } = options;
if (dash?.kind === 'pixels')
return {
+20
View File
@@ -143,3 +143,23 @@ test('a pick of a line primitive names the line', () => {
});
assert.equal(identify({ primitive: {}, id: 'o' }), null);
});
test('arrow lines retain the native arrow material in both depth passes', () => {
const { factories, made } = fakePrimitives();
const { lines, destroy } = createCesiumLines(createFakeViewer(), {
factories,
});
lines
.createLine({
id: 'arrow',
widthPx: 8,
color: [1, 0, 0, 1],
arrow: true,
depthFailAlpha: 0.45,
})
.setPositions(points);
assert.equal(made[0].appearance.material.type, 'PolylineArrow');
assert.equal(made[0].depthFailAppearance.material.type, 'PolylineArrow');
assert.equal(made[0].depthFailAppearance.material.uniforms.color.alpha, 0.45);
destroy();
});
+9 -2
View File
@@ -107,7 +107,13 @@ function transformed(matrix, flat, index, result) {
* @returns {(at: { x: number, y: number }, limit?: number, options?: { includeMovers?: boolean }) => object[]}
* Pick results ({ id, owner, instance? }) under a window position, nearest first.
*/
export function createCpuPicker({ projector, camera, movers, lines }) {
export function createCpuPicker({
projector,
camera,
movers,
lines,
meshes = () => [],
}) {
const depthOf = (position) =>
geo.cartesian3.distance(position, camera.livePosition);
@@ -181,11 +187,12 @@ export function createCpuPicker({ projector, camera, movers, lines }) {
const drill = (
at,
limit = Infinity,
{ includeMovers = true, includeLines = true } = {},
{ includeMovers = true, includeLines = true, includeMeshes = true } = {},
) => {
const hits = [];
if (includeMovers) moverHits(at, hits);
if (includeLines) lineHits(at, hits);
if (includeMeshes) hits.push(...meshes(at));
hits.sort(
(a, b) => b.onTop - a.onTop || b.order - a.order || a.depth - b.depth,
);
+1
View File
@@ -82,6 +82,7 @@ export function normalizeLineOptions(options) {
arc,
depthFailAlpha,
dash: dashOf(options.dash),
...(options.arrow === true ? { arrow: true } : {}),
modelMatrix: modelMatrix ? [...modelMatrix] : null,
pickId: options.pickId,
order: isFiniteNumber(options.order) ? options.order : 0,
+2
View File
@@ -222,6 +222,8 @@ export function createNavaraEngine(
});
const picker = {
// Navara mesh owners supply exact triangle hits for sync/hover/click picks.
registerMeshPicker: (pick) => picking.registerMeshPicker(pick),
// The CPU pick of movers and lines (picking.js); 3D tiles carry no ids.
// Clicks with { pick } get Navara's GPU pick of the gesture instead.
pick: (screen) => picking.pick(screen),
+8
View File
@@ -65,6 +65,7 @@ uniform float uGevAlphaScale;
uniform float uGevPixelRatio;
${OVERLAY_DEPTH_FRAGMENT}
uniform float uGevDash;
uniform bool uGevArrow;
uniform vec2 uGevDashPx;
uniform vec2 uGevTactical;
varying float vGevS;
@@ -93,6 +94,12 @@ if (gevOverlayHidden()) discard;
// sRGB colours (as Cesium takes them) into the linear output.
diffuseColor = sRGBTransferEOTF(uGevColor);
diffuseColor.a *= uGevAlphaScale;
if (uGevArrow) {
float head = max(fwidth(vGevS) * 10.0 * uGevPixelRatio, 1e-6);
float halfWidth = vGevS < 1.0 - head ? 0.15 : 0.5 * (1.0 - vGevS) / head;
float edge = max(fwidth(vGevT), 1e-5);
diffuseColor.a *= 1.0 - smoothstep(halfWidth - edge, halfWidth + edge, abs(vGevT - 0.5));
}
if (uGevDash > 1.5) {
diffuseColor.a *= gevTactical();
} else if (uGevDash > 0.5) {
@@ -201,6 +208,7 @@ export function createLineMesh(
uGevWidthPx: { value: options.widthPx },
uGevColor: { value: [...options.color] },
uGevDash: { value: dash?.kind === 'tactical' ? 2 : dash ? 1 : 0 },
uGevArrow: { value: options.arrow === true },
uGevDashPx: {
value: dash?.kind === 'pixels' ? [dash.lengthPx, dash.gapPx] : [1, 0],
},
+14 -2
View File
@@ -37,7 +37,14 @@ export function createNavaraPicking({
timeoutMs = CLICK_TIMEOUT_MS,
now = () => performance.now(),
}) {
const drillPick = createCpuPicker({ projector, camera, movers, lines });
const meshPickers = new Set();
const drillPick = createCpuPicker({
projector,
camera,
movers,
lines,
meshes: (at) => [...meshPickers].flatMap((pick) => pick(at)),
});
const isModelSet = (id) =>
movers().some(
({ options }) => options.id === id && options.kind === 'model',
@@ -75,7 +82,7 @@ export function createNavaraPicking({
!drillPick.near(hit.set, hit.instance, at, 1) ||
(top !== undefined &&
!(top.owner === hit.set && top.instance === hit.instance) &&
!isModelSet(hit.set));
(!isModelSet(hit.set) || top.instance === undefined));
if (stale) {
stats.stale++;
return drillPick(at, 1)[0];
@@ -102,6 +109,10 @@ export function createNavaraPicking({
view.on('featureClick', onFeatureClick);
return {
registerMeshPicker(pick) {
meshPickers.add(pick);
return () => meshPickers.delete(pick);
},
pick: (at) => drillPick(at, 1)[0],
drillPick: (at, limit) => drillPick(at, limit ?? Infinity),
/** The answer for a click at `at`: the GPU pick of its gesture. */
@@ -132,6 +143,7 @@ export function createNavaraPicking({
/** How clicks were answered: GPU picks, timeouts, recent latencies (ms). */
clickStats: () => ({ ...stats, latencies: [...stats.latencies] }),
destroy() {
meshPickers.clear();
view.off('featureClick', onFeatureClick);
for (const entry of waiting.splice(0)) {
cancel(entry.timer);
+82
View File
@@ -434,3 +434,85 @@ test('stale GPU model hits cannot select hidden or removed instances', async ()
assert.equal(await removed, undefined);
picking.destroy();
});
test('owned mesh triangle hits participate in hover, GPU-empty clicks and disposal', async () => {
const view = fakeView();
const picking = createNavaraPicking({
view,
projector,
camera,
movers: () => [],
lines: () => [],
resolveBatch: () => null,
...timers(),
});
const hit = { id: 'plane', owner: 'plane' };
const release = picking.registerMeshPicker((at) =>
at.x === 10 ? [{ onTop: 0, order: 0, depth: 100, result: hit }] : [],
);
assert.equal(picking.pick({ x: 10, y: 20 }), hit);
assert.equal(picking.pick({ x: 11, y: 20 }), undefined);
const clicked = picking.clickPick({ x: 10, y: 20 });
view.emit('featureClick', null);
assert.equal(await clicked, hit);
release();
assert.equal(picking.pick({ x: 10, y: 20 }), undefined);
picking.registerMeshPicker(() => [
{ onTop: 0, order: 0, depth: 100, result: hit },
]);
picking.destroy();
assert.equal(picking.pick({ x: 10, y: 20 }), undefined);
});
test('mesh picks preserve always-on-top siblings and reject stale sprite GPU answers', async () => {
const view = fakeView();
const sprite = moverSet('points', [[100, 100, 500, 'sprite']]);
const picking = createNavaraPicking({
view,
projector,
camera,
movers: () => [sprite],
lines: () => [],
resolveBatch: () => ({ set: 'points', instance: 0, pickId: 'sprite' }),
...timers(),
});
picking.registerMeshPicker(() => [
{ onTop: 0, order: 0, depth: 10, result: { id: 'plane', owner: 'plane' } },
]);
const pending = picking.clickPick({ x: 100, y: 100 });
view.emit('featureClick', { batchId: 1 });
assert.equal((await pending).id, 'plane');
sprite.options.depthTest = 'always';
assert.equal(picking.pick({ x: 100, y: 100 }).id, 'sprite');
picking.destroy();
});
test('an exact foreground mesh wins over a GPU model pick behind it', async () => {
const view = fakeView();
const model = modelSet('models', [[100, 100, 500, 'jet']], 20);
const picking = createNavaraPicking({
view,
projector,
camera,
movers: () => [model],
lines: () => [],
resolveBatch: () => ({ set: 'models', instance: 0, pickId: 'jet' }),
...timers(),
});
const release = picking.registerMeshPicker(() => [
{
onTop: 0,
order: 0,
depth: 10,
result: { id: 'plane', owner: 'plane' },
},
]);
const pending = picking.clickPick({ x: 100, y: 100 });
view.emit('featureClick', { batchId: 1 });
assert.equal((await pending).id, 'plane');
release();
const uncovered = picking.clickPick({ x: 100, y: 100 });
view.emit('featureClick', { batchId: 1 });
assert.equal((await uncovered).id, 'jet');
picking.destroy();
});
+338
View File
@@ -0,0 +1,338 @@
import * as geo from '../../../geo/index.js';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../../data/trackingClickGesture.js';
import { createCctvNavaraMeshes } from './navaraMeshes.js';
import { createCctvNavaraLine } from './navaraLine.js';
import { createNavaraCctvGizmoRenderer } from './navaraGizmo.js';
import {
CAMERA_ICON,
IDLE_CAMERA_COLOR,
IDLE_COVERAGE_COLOR,
} from '../policy.js';
export function createCctvNavaraRenderer(
engine,
{ state, services, view, MeshDesc },
) {
const meshes = createCctvNavaraMeshes(engine, { view, MeshDesc });
const markers = [];
const coverage = new Set();
const planes = new Set();
const gizmos = new Set();
const removers = new Set();
let initialized = false,
destroyed = false,
visible = true,
dirty = false;
let set = null,
capacity = 0,
credit = null;
const request = () => engine.render.request('cctv');
const markDirty = () => {
if (dirty || destroyed) return;
dirty = true;
request();
};
function syncMarkers() {
if (!dirty || !set || destroyed) return;
dirty = false;
set.setCount(markers.length);
markers.forEach((marker, index) =>
set.set(index, {
position: marker.position,
show: visible && marker.show,
scale: marker.scale,
color: marker.color.rgba(),
cell: 'camera',
pickId: marker.id,
}),
);
set.commit();
}
function allocateMarkers(count) {
if (set && capacity >= count) return;
set?.destroy();
capacity = Math.max(128, count, capacity * 2);
set = engine.movers.createSet({
id: 'cctv',
kind: 'sprite',
capacity,
order: 50,
pickable: true,
depthTest: 'always',
sharpen: true,
sprite: {
sizePx: 24,
scaleByDistance: [350, 1.25, 4_000_000, 0.42],
atlas: { url: CAMERA_ICON, cells: { camera: [0, 0, 1, 1] } },
},
});
markDirty();
}
const ownListener = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
function pickedCameraId(picked) {
if (!picked) return null;
if (picked.owner === 'cctv' && state._recordById.has(picked.id))
return picked.id;
for (const entity of [...coverage, ...planes])
if (picked.id === entity.id && picked.owner === entity.id && entity.show)
return entity.cameraId;
return null;
}
const removeMeshPicker = engine.picker.registerMeshPicker((at) => {
const hit = meshes.pickPlane(engine.camera.pickRay(at));
if (!hit) return [];
// The rendered depth includes terrain, tiles and foreground models.
const surface = engine.picker.pickPosition(at);
if (
surface &&
geo.cartesian3.distance(engine.camera.livePosition, surface) + 0.1 <
hit.distance
)
return [];
return [
{
onTop: 0,
order: 0,
depth: hit.distance,
result: { id: hit.plane.id, owner: hit.plane.id },
},
];
});
const removeFrame = engine.frames.preRender.addEventListener(syncMarkers);
const removePlane = (runtime) => {
const plane = runtime?.planeEntity;
if (planes.delete(plane)) plane.destroy();
runtime?.planeMaterial?.dispose();
};
const points = (p) => [p.tl, p.tr, p.br, p.bl, p.tl];
return {
attach() {
if (destroyed) throw new Error('CCTV renderer is destroyed');
initialized = true;
},
get ready() {
return initialized && !destroyed && !engine.isDestroyed();
},
cameraCartographic: () => engine.camera.pose().cartographic,
following: () => Boolean(engine.camera.following()),
globeVisible: () => !engine.maps.photorealActive(),
tilesReady: () => engine.surface.tilesLoaded(),
project: (position, result) =>
engine.projector.worldToWindow(position, result),
distance: (position) =>
geo.cartesian3.distance(engine.camera.livePosition, position),
horizonTest: () => (position) => engine.projector.horizonVisible(position),
viewport: () => engine.host.size(),
request,
pick(position) {
syncMarkers();
return engine.picker.pick(position);
},
extractPickedCameraId: pickedCameraId,
onMoveEnd: (fn) => ownListener(engine.camera.moveEnd.addEventListener(fn)),
onMoveStart: (fn) =>
ownListener(engine.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) {
const removes = [];
bindTrackingClickGesture(
{
setInputAction(fn, type) {
removes.push(engine.input.on(type, fn));
},
},
(event, gesture) => {
if (isTrackingClickGesture(gesture)) onClick(event);
},
{
...options,
eventTypes: {
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
},
},
);
return ownListener(() => removes.forEach((remove) => remove()));
},
createGizmoRenderer() {
const gizmo = createNavaraCctvGizmoRenderer(engine);
gizmos.add(gizmo);
return gizmo;
},
createMarkers() {
allocateMarkers(1);
return set;
},
createMarker(camera, position) {
const marker = { id: camera.id };
for (const [key, initial] of Object.entries({
position,
color: IDLE_CAMERA_COLOR,
scale: 1,
show: true,
})) {
const copy = (item) => (key === 'position' ? { ...item } : item);
const equal = (a, b) =>
key === 'position'
? a.x === b.x && a.y === b.y && a.z === b.z
: key === 'color'
? a.red === b.red &&
a.green === b.green &&
a.blue === b.blue &&
a.alpha === b.alpha
: a === b;
let value = copy(initial);
Object.defineProperty(marker, key, {
enumerable: true,
get: () => value,
set(next) {
if (!equal(next, value)) {
value = copy(next);
markDirty();
}
},
});
}
markers.push(marker);
allocateMarkers(markers.length);
markDirty();
return marker;
},
setMarkersVisible(value) {
if (visible !== Boolean(value)) {
visible = Boolean(value);
markDirty();
}
},
restoreOrder: syncMarkers,
showCredit() {
credit ??= engine.credits.addStatic(
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors',
);
},
hideCredit() {
credit?.();
credit = null;
},
sampleMeshFloorCells: (p, options) =>
services.mesh.sampleMeshFloorCells(engine, p, options),
obstructionRay: (origin, direction, exclude, far) =>
engine.surface.raycast({ origin, direction, exclude, far }),
createPlaneMaterial: meshes.createMaterial,
createTestPlaneMaterial: () => meshes.createMaterial(null),
setPlaneImage: (runtime, image) => runtime.planeMaterial.setImage(image),
createPlane(record, runtime, geometry, p, orientation) {
const plane = meshes.createPlane(
record.camera.id,
runtime.planeMaterial,
geometry,
p,
orientation,
);
planes.add(plane);
return plane;
},
updatePlane: (runtime, geometry, p, orientation) =>
meshes.updatePlane(runtime.planeEntity, geometry, p, orientation),
setPlaneVisible(runtime, value) {
if (runtime.planeEntity) runtime.planeEntity.show = value;
},
removePlane,
createCoverage(cameraId, p) {
const created = [];
try {
for (const [role, positions] of [
['ray-tl', [p.mount, p.tl]],
['ray-tr', [p.mount, p.tr]],
['ray-br', [p.mount, p.br]],
['ray-bl', [p.mount, p.bl]],
['cap', points(p)],
]) {
const line = createCctvNavaraLine(
engine,
`cctv-${cameraId}-${role}`,
{ width: 1.2, tint: IDLE_COVERAGE_COLOR },
positions,
);
line.cameraId = cameraId;
line._coverageRole = role === 'cap' ? 'cap' : 'edge';
coverage.add(line);
created.push(line);
}
return created;
} catch (error) {
for (const line of created) {
coverage.delete(line);
line.destroy();
}
throw error;
}
},
updateCoverage(record, p) {
const lines = record.coverageEntities;
if (!lines || lines.length < 5) return;
['tl', 'tr', 'br', 'bl'].forEach((role, i) => {
lines[i].polyline.positions = [p.mount, p[role]];
});
lines[4].polyline.positions = points(p);
},
removeCoverageEntity(entity) {
if (coverage.delete(entity)) entity.destroy();
},
styleLine: (entity, style) => entity.style(style),
hideCoverage(record) {
for (const entity of record.coverageEntities || []) entity.show = false;
},
removeCoverage(record) {
for (const entity of record.coverageEntities || [])
if (coverage.delete(entity)) entity.destroy();
},
createVolume: (record, tint) =>
meshes.createVolume(record.frustumPositions, tint),
removeVolume: (volume) => volume?.destroy(),
focus(record, duration) {
engine.camera.flyToSphere(
{
center: record.position,
radius: Math.max(40, record.camera.rangeM * 0.36),
},
{
heading: geo.math.toRadians(record.camera.headingDeg),
pitch: geo.math.toRadians(-22),
range: Math.max(280, record.camera.rangeM * 1.18),
duration: Math.max(0.2, duration || 0),
easing: 'cubicInOut',
},
);
},
destroy() {
if (destroyed) return;
destroyed = true;
initialized = false;
removeFrame();
removeMeshPicker();
for (const remove of [...removers]) remove();
for (const gizmo of gizmos) gizmo.destroy();
for (const line of coverage) line.destroy();
meshes.destroy();
set?.destroy();
set = null;
credit?.();
credit = null;
markers.length = 0;
coverage.clear();
planes.clear();
gizmos.clear();
},
};
}
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCctvNavaraRenderer } from './navara.js';
function fixture(t) {
const engine = createFakeEngine({ kind: 'navara' });
const pickers = new Set();
engine.picker.registerMeshPicker = (pick) => {
pickers.add(pick);
return () => pickers.delete(pick);
};
const descriptors = new Map();
const view = {
registerMesh: (key, value) => descriptors.set(key, value),
addMesh(config) {
const key = Object.keys(config)[0];
const desc = new (descriptors.get(key))(view, {}, config);
desc.createMesh();
return { delete: () => desc.onDestroy() };
},
};
class MeshDesc {
onDestroy() {}
}
const state = { _recordById: new Map() };
const renderer = createCctvNavaraRenderer(engine, {
state,
services: {},
view,
MeshDesc,
});
renderer.attach();
t.after(() => renderer.destroy());
return { engine, renderer, state, pickers };
}
test('CCTV focus uses the engine sphere-flight fields and native range/pitch', (t) => {
const { engine, renderer } = fixture(t);
let flight;
engine.camera.flyToSphere = (sphere, options) => {
flight = { sphere, options };
};
const position = { x: 100, y: 200, z: 300 };
renderer.focus({ position, camera: { headingDeg: 84, rangeM: 126 } }, 0.2);
assert.deepEqual(flight, {
sphere: { center: position, radius: 45.36 },
options: {
heading: (84 * Math.PI) / 180,
pitch: (-22 * Math.PI) / 180,
range: 280,
duration: 0.2,
easing: 'cubicInOut',
},
});
});
test('CCTV markers own position snapshots and batch mutations without idle writes', (t) => {
const { engine, renderer } = fixture(t);
renderer.createMarkers();
const position = { x: 6378137, y: 10, z: 20 };
const marker = renderer.createMarker({ id: 'camera' }, position);
renderer.restoreOrder();
const store = engine.fake.moverSet('cctv').store;
position.y = 35;
marker.position = position;
engine.fake.frame();
assert.equal(store.positions[1], 35);
position.y = 42;
assert.equal(
marker.position.y,
35,
'a caller cannot mutate the retained position',
);
engine.fake.resetCalls();
marker.position = { ...marker.position };
marker.color = marker.color.withAlpha(marker.color.alpha);
renderer.restoreOrder();
assert.equal(engine.fake.callsOf('render.request').length, 0);
renderer.setMarkersVisible(false);
renderer.restoreOrder();
assert.equal(store.shows[0], 0);
});
test('CCTV picking requires actual owned primitives, and destroy removes its picker', (t) => {
const { engine, renderer, state, pickers } = fixture(t);
renderer.createMarkers();
const position = { x: 6378137, y: 10, z: 20 };
renderer.createMarker({ id: 'a' }, position);
state._recordById.set('a', {});
assert.equal(
renderer.extractPickedCameraId({ id: 'a', owner: 'sibling' }),
null,
);
assert.equal(renderer.extractPickedCameraId({ id: 'a', owner: 'cctv' }), 'a');
const p = {
mount: position,
tl: position,
tr: position,
br: position,
bl: position,
};
const lines = renderer.createCoverage('a', p);
lines[0].show = true;
assert.equal(
renderer.extractPickedCameraId({ id: lines[0].id, owner: lines[0].id }),
'a',
);
renderer.removeCoverageEntity(lines[0]);
assert.equal(
renderer.extractPickedCameraId({ id: lines[0].id, owner: lines[0].id }),
null,
);
renderer.destroy();
renderer.destroy();
assert.equal(pickers.size, 0);
assert.equal(engine.lines.list().length, 0);
assert.equal(engine.movers.list().length, 0);
});
+150
View File
@@ -0,0 +1,150 @@
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js';
import { createCctvNavaraLine } from './navaraLine.js';
export function createNavaraCctvGizmoRenderer(engine) {
const entities = new Map();
const removers = [];
let destroyed = false;
const pointSets = new Set();
const makePoint = (part, color, size) => {
const id = `${GIZMO_ID_PREFIX}${part}`;
const set = engine.movers.createSet({
id,
kind: 'point',
capacity: 1,
pickable: true,
depthTest: 'always',
order: 100,
point: { sizePx: size, outlinePx: 2, outlineColor: [0, 0, 0, 0.7] },
});
pointSets.add(set);
let position = { x: 0, y: 0, z: 0 };
let visible = false;
let pixelSize = size;
const sync = () => {
if (destroyed) return;
set.setCount(1);
set.set(0, {
position,
show: visible,
scale: pixelSize / size,
color: color.rgba(),
pickId: id,
});
set.commit();
};
return {
id,
get position() {
return position;
},
set position(value) {
position = value;
sync();
},
get show() {
return visible;
},
set show(value) {
if (visible !== Boolean(value)) {
visible = Boolean(value);
sync();
}
},
point: {
get pixelSize() {
return pixelSize;
},
set pixelSize(value) {
if (value !== pixelSize) {
pixelSize = value;
sync();
}
},
},
};
};
const add = (part, entity) => {
entities.set(part, entity);
return entity;
};
const assertNew = (part) => {
if (destroyed) throw new Error('CCTV gizmo is destroyed');
if (entities.has(part))
throw new Error(`CCTV handle is already owned: ${part}`);
};
return {
createPoint(part, color, size) {
assertNew(part);
return add(part, makePoint(part, color, size));
},
createLine(part, color, width, arrow = false) {
assertNew(part);
const line = createCctvNavaraLine(engine, `${GIZMO_ID_PREFIX}${part}`, {
tint: color.withAlpha(arrow ? 1 : 0.9),
depthFail: color.withAlpha(0.45),
width,
arrow,
order: 100,
});
if (!arrow) return add(part, line);
let point;
try {
point = makePoint(part, color, 14);
} catch (error) {
line.destroy();
throw error;
}
const visibility = Object.getOwnPropertyDescriptor(point, 'show');
Object.defineProperty(point, 'show', {
get: visibility.get,
set(value) {
visibility.set(value);
line.show = value;
},
});
point.polyline = line.polyline;
point.destroy = () => line.destroy();
return add(part, point);
},
pickPart(position) {
for (const hit of engine.picker.drillPick(position, 6)) {
if (typeof hit?.id !== 'string' || !hit.id.startsWith(GIZMO_ID_PREFIX))
continue;
const part = hit.id.slice(GIZMO_ID_PREFIX.length);
if (hit.owner === hit.id && entities.get(part)?.show) return part;
}
return null;
},
pickRay: (position) => engine.camera.pickRay(position),
setCursor(value) {
if (engine.host.canvas.style) engine.host.canvas.style.cursor = value;
},
suspendCameraInputs: () => engine.input.suspendCameraInputs(),
on(type, listener) {
if (type !== 'cancel') {
removers.push(engine.input.on(type, listener));
return;
}
const canvas = engine.host.canvas;
for (const [target, event] of [
[canvas, 'pointercancel'],
[canvas.ownerDocument?.defaultView, 'blur'],
]) {
if (!target?.addEventListener) continue;
target.addEventListener(event, listener);
removers.push(() => target.removeEventListener(event, listener));
}
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const remove of removers.splice(0)) remove();
for (const entity of entities.values()) entity.destroy?.();
for (const set of pointSets) set.destroy();
entities.clear();
pointSets.clear();
if (engine.host.canvas.style) engine.host.canvas.style.cursor = '';
},
};
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
import { createNavaraCctvGizmoRenderer } from './navaraGizmo.js';
const color = viewshedColors(30).fill;
test('Navara gizmo releases arrow resources, input and cancellation listeners', () => {
const engine = createFakeEngine({ kind: 'navara' });
const canvas = new EventTarget();
const window = new EventTarget();
canvas.style = {};
canvas.ownerDocument = { defaultView: window };
engine.host.canvas = canvas;
const renderer = createNavaraCctvGizmoRenderer(engine);
renderer.createLine('east', color, 8, true);
renderer.createLine('heading', color, 3);
renderer.createPoint('range', color, 10);
let cancels = 0;
let moves = 0;
renderer.on('cancel', () => cancels++);
renderer.on('move', () => moves++);
canvas.dispatchEvent(new Event('pointercancel'));
window.dispatchEvent(new Event('blur'));
engine.fake.input('move', { position: { x: 1, y: 1 } });
assert.equal(cancels, 2);
assert.equal(moves, 1);
assert.throws(
() => renderer.createLine('east', color, 8, true),
/already owned/,
);
assert.equal(engine.lines.list().length, 2);
assert.equal(engine.movers.list().length, 2);
renderer.setCursor('grabbing');
renderer.destroy();
renderer.destroy();
canvas.dispatchEvent(new Event('pointercancel'));
window.dispatchEvent(new Event('blur'));
engine.fake.input('move', { position: { x: 1, y: 1 } });
assert.equal(cancels, 2);
assert.equal(moves, 1);
assert.equal(engine.lines.list().length, 0);
assert.equal(engine.movers.list().length, 0);
assert.equal(canvas.style.cursor, '');
assert.throws(() => renderer.createPoint('new', color, 10), /destroyed/);
});
test('Navara gizmo rolls back the arrow shaft if its tip cannot be allocated', () => {
const engine = createFakeEngine({ kind: 'navara' });
engine.movers.createSet = () => {
throw new Error('allocation failed');
};
const renderer = createNavaraCctvGizmoRenderer(engine);
assert.throws(
() => renderer.createLine('east', color, 8, true),
/allocation failed/,
);
assert.equal(engine.lines.list().length, 0);
renderer.destroy();
});
+68
View File
@@ -0,0 +1,68 @@
/** Mutable CCTV line backed by the existing engine ribbon renderer. */
export function createCctvNavaraLine(engine, id, initial, points = []) {
let style = { ...initial };
let positions = points;
let visible = false;
let destroyed = false;
let line = null;
const build = () => {
line?.destroy();
line = engine.lines.createLine({
id,
pickId: id,
widthPx: style.width,
color: style.tint.rgba(),
depthFailAlpha: style.depthFail
? style.depthFail.alpha / Math.max(style.tint.alpha, 1e-6)
: 0,
dash: style.dashed ? { lengthPx: 8, gapPx: 8 } : null,
arrow: Boolean(style.arrow),
order: style.order ?? 0,
});
line.setPositions(positions);
line.show(visible);
};
build();
return {
id,
get show() {
return !destroyed && visible;
},
set show(value) {
if (destroyed || visible === Boolean(value)) return;
visible = Boolean(value);
line.show(visible);
},
polyline: {
get positions() {
return positions;
},
set positions(value) {
if (destroyed) return;
positions = value;
line.setPositions(value);
},
},
style(next) {
if (destroyed) return;
const depth = (s) =>
s.depthFail ? s.depthFail.alpha / Math.max(s.tint.alpha, 1e-6) : 0;
const rebuild =
next.width !== style.width ||
Boolean(next.dashed) !== Boolean(style.dashed) ||
depth(next) !== depth(style);
const colorChanged = next.tint
.rgba()
.some((v, i) => v !== style.tint.rgba()[i]);
style = { ...style, ...next };
if (rebuild) build();
else if (colorChanged) line.setColor(style.tint.rgba());
this.show = next.visible;
},
destroy() {
if (destroyed) return;
destroyed = true;
line.destroy();
},
};
}
+298
View File
@@ -0,0 +1,298 @@
import {
BufferAttribute,
BufferGeometry,
CanvasTexture,
DoubleSide,
Group,
LinearFilter,
Mesh,
MeshBasicMaterial,
Quaternion,
Raycaster,
SRGBColorSpace,
Vector3,
VideoTexture,
} from 'three';
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
import { PLANE_OUTLINE_COLOR } from '../policy.js';
const KIND = 'gevCctvMesh';
const registered = new WeakSet();
/** Local mesh coordinates keep ECEF precision in the double world matrix. */
function placeGeometry(mesh, positions, indices, origin) {
const local = new Float32Array(positions.length);
for (let i = 0; i < positions.length; i += 3) {
local[i] = positions[i] - origin.x;
local[i + 1] = positions[i + 1] - origin.y;
local[i + 2] = positions[i + 2] - origin.z;
}
mesh.geometry.setAttribute('position', new BufferAttribute(local, 3));
mesh.geometry.setIndex(Array.from(indices));
mesh.geometry.computeBoundingSphere();
mesh.matrix.makeTranslation(origin.x, origin.y, origin.z);
mesh.matrixWorldNeedsUpdate = true;
}
/** CCTV owns these resources; media playback remains in shared projection. */
export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
if (!view?.addMesh || !MeshDesc)
throw new TypeError('CCTV requires Navara MeshDesc support');
if (!registered.has(view)) {
view.registerMesh(
KIND,
class extends MeshDesc {
constructor(owner, ctx, config) {
super(owner, ctx, config);
this.cctv = config[KIND];
}
getPassKey() {
return 'transparent';
}
createMesh() {
return this.cctv.group;
}
onDestroy() {
this.cctv.dispose();
super.onDestroy();
}
},
);
registered.add(view);
}
const owned = new Set();
const materials = new Set();
const planes = new Set();
const raycaster = new Raycaster();
let destroyed = false;
const request = () => engine.render.request('cctv-mesh');
const alive = () => {
if (destroyed) throw new Error('CCTV meshes are destroyed');
};
function createMaterial(image) {
alive();
const material = new MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.95,
side: DoubleSide,
depthWrite: false,
toneMapped: false,
});
const resource = {
material,
image: null,
texture: null,
disposed: false,
setImage(next) {
if (resource.disposed) return;
if (next === resource.image) {
if (resource.texture && !resource.texture.isVideoTexture)
resource.texture.needsUpdate = true;
request();
return;
}
const texture =
next?.tagName === 'VIDEO'
? new VideoTexture(next)
: new CanvasTexture(next);
texture.colorSpace = SRGBColorSpace;
texture.minFilter = texture.magFilter = LinearFilter;
texture.generateMipmaps = false;
const previous = resource.texture;
resource.image = next;
resource.texture = texture;
material.map = texture;
material.needsUpdate = true;
previous?.dispose();
request();
},
dispose() {
if (resource.disposed) return;
resource.disposed = true;
resource.texture?.dispose();
resource.texture = resource.image = null;
material.map = null;
material.dispose();
materials.delete(resource);
},
};
materials.add(resource);
try {
if (image) resource.setImage(image);
return resource;
} catch (error) {
resource.dispose();
throw error;
}
}
function ownMesh(mesh, disposeExtra = () => {}) {
alive();
const group = new Group();
group.frustumCulled = mesh.frustumCulled = false;
mesh.matrixAutoUpdate = false;
group.add(mesh);
let freed = false;
let handle;
const dispose = () => {
if (freed) return;
freed = true;
mesh.geometry.dispose();
disposeExtra();
owned.delete(resource);
};
const resource = {
group,
mesh,
get show() {
return !freed && group.visible;
},
set show(value) {
if (freed || group.visible === Boolean(value)) return;
group.visible = Boolean(value);
request();
},
destroy() {
if (freed) return;
try {
handle?.delete();
} finally {
dispose();
request();
}
},
};
owned.add(resource);
try {
handle = view.addMesh({ [KIND]: { group, dispose } });
return resource;
} catch (error) {
dispose();
throw error;
}
}
function updatePlane(plane, geometry, positions, orientation) {
if (!planes.has(plane)) return;
// Match the native plane's quaternion and dimensions, including its UVs.
const q = new Quaternion(
orientation.x,
orientation.y,
orientation.z,
orientation.w,
);
const flat = [];
for (const [x, y] of [
[-1, 1],
[1, 1],
[-1, -1],
[1, -1],
]) {
const p = new Vector3(x * geometry.halfW, y * geometry.halfH, 0)
.applyQuaternion(q)
.add(positions.capCenter);
flat.push(p.x, p.y, p.z);
}
placeGeometry(plane.mesh, flat, [0, 2, 1, 2, 3, 1], positions.capCenter);
plane.outline.setPositions(
[0, 1, 3, 2, 0].map((i) => ({
x: flat[i * 3],
y: flat[i * 3 + 1],
z: flat[i * 3 + 2],
})),
);
request();
}
return {
createMaterial,
createPlane(cameraId, material, geometry, positions, orientation) {
alive();
if (!materials.has(material) || material.disposed)
throw new Error('CCTV plane material is not owned');
const mesh = new Mesh(new BufferGeometry(), material.material);
mesh.geometry.setAttribute(
'uv',
new BufferAttribute(new Float32Array([0, 1, 1, 1, 0, 0, 1, 0]), 2),
);
let outline;
let plane;
let transferred = false;
try {
outline = engine.lines.createLine({
id: `cctv-${cameraId}-plane-outline`,
widthPx: 1,
color: PLANE_OUTLINE_COLOR.rgba(),
});
transferred = true;
plane = ownMesh(mesh, () => {
outline.destroy();
material.dispose();
planes.delete(plane);
});
plane.id = `cctv-${cameraId}-plane`;
plane.cameraId = cameraId;
plane.outline = outline;
const visibility = Object.getOwnPropertyDescriptor(plane, 'show');
Object.defineProperty(plane, 'show', {
get: visibility.get,
set(value) {
visibility.set(value);
if (planes.has(plane)) outline.show(plane.show);
},
});
planes.add(plane);
updatePlane(plane, geometry, positions, orientation);
plane.show = false;
return plane;
} catch (error) {
plane?.destroy();
if (!transferred) {
mesh.geometry.dispose();
outline?.destroy();
material.dispose();
}
throw error;
}
},
updatePlane,
createVolume(positions, tint) {
alive();
const data = frustumVolumeGeometryData(positions);
const material = new MeshBasicMaterial({
transparent: true,
opacity: tint.alpha,
side: DoubleSide,
depthWrite: false,
toneMapped: false,
});
material.color.setRGB(tint.red, tint.green, tint.blue, SRGBColorSpace);
const mesh = new Mesh(new BufferGeometry(), material);
placeGeometry(mesh, data.positions, data.indices, positions.mount);
return ownMesh(mesh, () => material.dispose());
},
/** Actual plane triangles, not a projected rectangle or marker proxy. */
pickPlane(ray) {
if (destroyed || !ray) return undefined;
raycaster.ray.origin.copy(ray.origin);
raycaster.ray.direction.copy(ray.direction).normalize();
let nearest;
for (const plane of planes) {
if (!plane.show) continue;
plane.group.updateWorldMatrix(true, true);
const hit = raycaster.intersectObject(plane.mesh, false)[0];
if (hit && (!nearest || hit.distance < nearest.distance))
nearest = { ...hit, plane };
}
return nearest;
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const resource of [...owned]) resource.destroy();
for (const material of [...materials]) material.dispose();
},
};
}
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Group, Vector3 } from 'three';
import * as Cesium from 'cesium';
import { createCctvNavaraMeshes } from './navaraMeshes.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
function harness() {
const scene = new Group();
const kinds = new Map();
const handles = new Set();
const lines = new Set();
let requests = 0;
let fail = false;
let failedGeometryDisposals = 0;
class MeshDesc {
onDestroy() {}
}
const view = {
registerMesh: (kind, desc) => kinds.set(kind, desc),
addMesh(config) {
if (fail) {
config.gevCctvMesh.group.children[0].geometry.addEventListener(
'dispose',
() => failedGeometryDisposals++,
);
throw new Error('descriptor failed');
}
const kind = Object.keys(config)[0];
const desc = new (kinds.get(kind))(view, {}, config);
assert.equal(desc.getPassKey(), 'transparent');
const object = desc.createMesh();
scene.add(object);
const handle = {
delete() {
handles.delete(handle);
scene.remove(object);
desc.onDestroy();
},
};
handles.add(handle);
return handle;
},
};
const engine = {
render: { request: () => requests++ },
lines: {
createLine(options) {
const line = {
options,
setPositions: (value) => (line.positions = value),
show: (value) => (line.visible = value),
destroy: () => lines.delete(line),
};
lines.add(line);
return line;
},
},
};
const owner = createCctvNavaraMeshes(engine, { view, MeshDesc });
return {
owner,
scene,
handles,
lines,
requests: () => requests,
failedGeometryDisposals: () => failedGeometryDisposals,
fail: () => (fail = true),
};
}
const shape = { halfW: 80, halfH: 45 };
const positions = { capCenter: { x: 6378137, y: 1234, z: 5678 } };
const identity = { x: 0, y: 0, z: 0, w: 1 };
const canvas = () => ({ width: 8, height: 8, tagName: 'CANVAS' });
test('Navara CCTV plane preserves native rotated geometry, UVs and metre precision', (t) => {
const h = harness();
t.after(() => h.owner.destroy());
const q = Cesium.Quaternion.fromAxisAngle(
Cesium.Cartesian3.normalize(
new Cesium.Cartesian3(1, 2, 3),
new Cesium.Cartesian3(),
),
0.75,
);
const material = h.owner.createMaterial(canvas());
const plane = h.owner.createPlane('a', material, shape, positions, q);
const matrix = Cesium.Matrix3.fromQuaternion(q);
plane.group.updateWorldMatrix(true, true);
const attribute = plane.mesh.geometry.getAttribute('position');
const uv = plane.mesh.geometry.getAttribute('uv');
for (const [i, [x, y]] of [
[-1, 1],
[1, 1],
[-1, -1],
[1, -1],
].entries()) {
const native = Cesium.Matrix3.multiplyByVector(
matrix,
new Cesium.Cartesian3(x * shape.halfW, y * shape.halfH, 0),
new Cesium.Cartesian3(),
);
Cesium.Cartesian3.add(native, positions.capCenter, native);
const actual = new Vector3()
.fromBufferAttribute(attribute, i)
.applyMatrix4(plane.mesh.matrixWorld);
assert.ok(Cesium.Cartesian3.distance(native, actual) < 0.00001);
assert.equal(uv.getX(i), (x + 1) / 2);
assert.equal(uv.getY(i), (y + 1) / 2);
}
assert.equal(plane.show, false);
assert.equal(plane.outline.visible, false);
plane.show = true;
assert.equal(plane.outline.visible, true);
assert.equal(material.material.opacity, 0.95);
assert.equal(material.material.depthWrite, false);
});
test('Navara CCTV picks actual visible plane triangles from either side, nearest first', (t) => {
const h = harness();
t.after(() => h.owner.destroy());
const make = (id, z) => {
const plane = h.owner.createPlane(
id,
h.owner.createMaterial(canvas()),
shape,
{ capCenter: { ...positions.capCenter, z } },
identity,
);
plane.show = true;
return plane;
};
const far = make('far', 5600);
const near = make('near', 5700);
const ray = {
origin: { ...positions.capCenter, z: 5800 },
direction: { x: 0, y: 0, z: -1 },
};
assert.equal(h.owner.pickPlane(ray).plane, near);
assert.equal(h.owner.pickPlane(ray).distance, 100);
near.show = false;
assert.equal(h.owner.pickPlane(ray).plane, far);
assert.equal(
h.owner.pickPlane({
...ray,
origin: { ...ray.origin, x: ray.origin.x + 81 },
}),
undefined,
);
assert.equal(
h.owner.pickPlane({
origin: { ...ray.origin, z: 5500 },
direction: { x: 0, y: 0, z: 1 },
}).plane,
far,
);
far.destroy();
assert.equal(h.owner.pickPlane(ray), undefined);
});
test('Navara CCTV releases replaced video textures and ignores retired image updates', () => {
const h = harness();
const first = canvas();
const material = h.owner.createMaterial(first);
let firstDisposed = 0;
material.texture.addEventListener('dispose', () => firstDisposed++);
const version = material.texture.version;
material.setImage(first);
assert.ok(material.texture.version > version);
let cancelled = 0;
const video = {
tagName: 'VIDEO',
requestVideoFrameCallback: () => 17,
cancelVideoFrameCallback: (id) => {
assert.equal(id, 17);
cancelled++;
},
};
material.setImage(video);
assert.equal(firstDisposed, 1);
assert.equal(material.texture.isVideoTexture, true);
material.setImage(canvas());
assert.equal(cancelled, 1);
let finalDisposed = 0;
material.texture.addEventListener('dispose', () => finalDisposed++);
const plane = h.owner.createPlane('a', material, shape, positions, identity);
plane.destroy();
plane.destroy();
const requests = h.requests();
material.setImage(video);
assert.equal(h.requests(), requests);
assert.equal(finalDisposed, 1);
assert.equal(h.handles.size, 0);
assert.equal(h.lines.size, 0);
h.owner.destroy();
h.owner.destroy();
assert.equal(finalDisposed, 1);
});
test('Navara CCTV rolls back a failed descriptor and rejects new resources after destroy', () => {
const h = harness();
const material = h.owner.createMaterial(canvas());
let releases = 0;
material.material.addEventListener('dispose', () => releases++);
h.fail();
assert.throws(
() => h.owner.createPlane('a', material, shape, positions, identity),
/descriptor failed/,
);
assert.equal(releases, 1);
assert.equal(h.failedGeometryDisposals(), 1);
assert.equal(h.handles.size, 0);
assert.equal(h.lines.size, 0);
h.owner.destroy();
assert.throws(() => h.owner.createMaterial(canvas()), /destroyed/);
});
test('Navara CCTV volume preserves native world vertices and releases geometry/material', () => {
const h = harness();
const mount = positions.capCenter;
const p = (x, y, z) => ({ x: mount.x + x, y: mount.y + y, z: mount.z + z });
const volume = h.owner.createVolume(
{
mount,
tl: p(-5, 5, 100),
tr: p(5, 5, 100),
br: p(5, -5, 100),
bl: p(-5, -5, 100),
},
viewshedColors(30).fill,
);
assert.equal(volume.mesh.geometry.index.count, 18);
assert.equal(volume.mesh.geometry.getAttribute('position').count, 5);
assert.equal(volume.mesh.matrix.elements[12], mount.x);
let geometryReleases = 0,
materialReleases = 0;
volume.mesh.geometry.addEventListener('dispose', () => geometryReleases++);
volume.mesh.material.addEventListener('dispose', () => materialReleases++);
h.owner.destroy();
h.owner.destroy();
assert.equal(geometryReleases, 1);
assert.equal(materialReleases, 1);
assert.equal(h.handles.size, 0);
});
+2 -2
View File
@@ -58,8 +58,8 @@ function applyMode() {
if (!_installed) return;
const continuous = _holds.size > 0;
if (_engine) {
if (_engine.render.isContinuous() !== continuous)
_engine.render.setContinuous(continuous);
if (_engine.render.isContinuous() === continuous) return;
_engine.render.setContinuous(continuous);
if (!continuous) _engine.render.request('governor-idle');
return;
}
+17
View File
@@ -136,3 +136,20 @@ test('on a non-Cesium globe engine the governor drives engine.render with owner-
uninstallRenderGovernor(engine);
_resetRenderGovernorForTest();
});
test('releasing an absent Navara hold does not wake an already idle engine', async () => {
const { createFakeEngine } = await import('./globe/testing/fakeEngine.js');
_resetRenderGovernorForTest();
const engine = createFakeEngine({ kind: 'navara' });
installRenderGovernor(engine);
engine.fake.resetCalls();
for (let i = 0; i < 22; i++) releaseContinuousRender('cctv-projection');
assert.equal(engine.fake.callsOf('render.request').length, 0);
holdContinuousRender('video');
releaseContinuousRender('video');
assert.equal(engine.fake.callsOf('render.request').length, 1);
releaseContinuousRender('video');
assert.equal(engine.fake.callsOf('render.request').length, 1);
uninstallRenderGovernor(engine);
_resetRenderGovernorForTest();
});