From 694616d5c25fd836707f657dd9693e0637ee225c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 16:53:27 +0400 Subject: [PATCH] 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. --- scripts/format-scope.json | 7 + scripts/package-boundaries.json | 4 + src/app/layers/cctv.js | 6 +- src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 9 + src/globe/cesium/lines.js | 5 + src/globe/cesium/lines.test.mjs | 20 ++ src/globe/cpuPicks.js | 11 +- src/globe/lines.js | 1 + src/globe/navara/createNavaraEngine.js | 2 + src/globe/navara/lines/lineMesh.js | 8 + src/globe/navara/picking.js | 16 +- src/globe/navara/picking.test.mjs | 82 +++++ src/layers/cctv/render/navara.js | 338 +++++++++++++++++++ src/layers/cctv/render/navara.test.mjs | 119 +++++++ src/layers/cctv/render/navaraGizmo.js | 150 ++++++++ src/layers/cctv/render/navaraGizmo.test.mjs | 61 ++++ src/layers/cctv/render/navaraLine.js | 68 ++++ src/layers/cctv/render/navaraMeshes.js | 298 ++++++++++++++++ src/layers/cctv/render/navaraMeshes.test.mjs | 245 ++++++++++++++ src/renderGovernor.js | 4 +- src/renderGovernor.test.mjs | 17 + 22 files changed, 1466 insertions(+), 7 deletions(-) create mode 100644 src/layers/cctv/render/navara.js create mode 100644 src/layers/cctv/render/navara.test.mjs create mode 100644 src/layers/cctv/render/navaraGizmo.js create mode 100644 src/layers/cctv/render/navaraGizmo.test.mjs create mode 100644 src/layers/cctv/render/navaraLine.js create mode 100644 src/layers/cctv/render/navaraMeshes.js create mode 100644 src/layers/cctv/render/navaraMeshes.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eb802e4b4..f29f8662e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b2510ed6f..a03e9eecf 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 711ff20c0..4be0968b8 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.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({ diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 6383bd597..8675c0bfd 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 1d6b31189..8fdb1b235 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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, diff --git a/src/globe/cesium/lines.js b/src/globe/cesium/lines.js index 159e81ec2..ea518d31d 100644 --- a/src/globe/cesium/lines.js +++ b/src/globe/cesium/lines.js @@ -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 { diff --git a/src/globe/cesium/lines.test.mjs b/src/globe/cesium/lines.test.mjs index 786ea7d67..229f56813 100644 --- a/src/globe/cesium/lines.test.mjs +++ b/src/globe/cesium/lines.test.mjs @@ -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(); +}); diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js index 9fdb0db64..61ab015ad 100644 --- a/src/globe/cpuPicks.js +++ b/src/globe/cpuPicks.js @@ -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, ); diff --git a/src/globe/lines.js b/src/globe/lines.js index 470a7de98..f5998991f 100644 --- a/src/globe/lines.js +++ b/src/globe/lines.js @@ -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, diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index a22b59925..a6442923b 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -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), diff --git a/src/globe/navara/lines/lineMesh.js b/src/globe/navara/lines/lineMesh.js index a4ac7d5de..98413212c 100644 --- a/src/globe/navara/lines/lineMesh.js +++ b/src/globe/navara/lines/lineMesh.js @@ -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], }, diff --git a/src/globe/navara/picking.js b/src/globe/navara/picking.js index 9804e7037..8fcc99596 100644 --- a/src/globe/navara/picking.js +++ b/src/globe/navara/picking.js @@ -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); diff --git a/src/globe/navara/picking.test.mjs b/src/globe/navara/picking.test.mjs index fa60d037d..26865754d 100644 --- a/src/globe/navara/picking.test.mjs +++ b/src/globe/navara/picking.test.mjs @@ -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(); +}); diff --git a/src/layers/cctv/render/navara.js b/src/layers/cctv/render/navara.js new file mode 100644 index 000000000..1dd59e11b --- /dev/null +++ b/src/layers/cctv/render/navara.js @@ -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( + '© OpenStreetMap 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(); + }, + }; +} diff --git a/src/layers/cctv/render/navara.test.mjs b/src/layers/cctv/render/navara.test.mjs new file mode 100644 index 000000000..fb058183b --- /dev/null +++ b/src/layers/cctv/render/navara.test.mjs @@ -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); +}); diff --git a/src/layers/cctv/render/navaraGizmo.js b/src/layers/cctv/render/navaraGizmo.js new file mode 100644 index 000000000..326342eb3 --- /dev/null +++ b/src/layers/cctv/render/navaraGizmo.js @@ -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 = ''; + }, + }; +} diff --git a/src/layers/cctv/render/navaraGizmo.test.mjs b/src/layers/cctv/render/navaraGizmo.test.mjs new file mode 100644 index 000000000..5d41c39bd --- /dev/null +++ b/src/layers/cctv/render/navaraGizmo.test.mjs @@ -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(); +}); diff --git a/src/layers/cctv/render/navaraLine.js b/src/layers/cctv/render/navaraLine.js new file mode 100644 index 000000000..336249f9e --- /dev/null +++ b/src/layers/cctv/render/navaraLine.js @@ -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(); + }, + }; +} diff --git a/src/layers/cctv/render/navaraMeshes.js b/src/layers/cctv/render/navaraMeshes.js new file mode 100644 index 000000000..c1d1a26b1 --- /dev/null +++ b/src/layers/cctv/render/navaraMeshes.js @@ -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(); + }, + }; +} diff --git a/src/layers/cctv/render/navaraMeshes.test.mjs b/src/layers/cctv/render/navaraMeshes.test.mjs new file mode 100644 index 000000000..aaec1a2dc --- /dev/null +++ b/src/layers/cctv/render/navaraMeshes.test.mjs @@ -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); +}); diff --git a/src/renderGovernor.js b/src/renderGovernor.js index 4fd1c17a9..e8ce96918 100644 --- a/src/renderGovernor.js +++ b/src/renderGovernor.js @@ -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; } diff --git a/src/renderGovernor.test.mjs b/src/renderGovernor.test.mjs index d9bdcc574..e0856a8a8 100644 --- a/src/renderGovernor.test.mjs +++ b/src/renderGovernor.test.mjs @@ -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(); +});