mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
798d6636ab
commit
694616d5c2
22 files changed
+1466
-7
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user