From 133ca141197e34e1630e0e5f1d5fb0106dd5921c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 04:25:02 +0400 Subject: [PATCH] Keep CCTV playback and calibration while replacing globe projection surfaces --- scripts/qa-cctv-engine-switch.mjs | 137 ++++++++++++ src/app/constructCatalog.js | 2 +- src/data/cctv.test.mjs | 20 +- src/layers/cctv/geometry.js | 14 +- src/layers/cctv/lifecycle.js | 318 ++++++++++++++++++---------- src/layers/cctv/projection.js | 19 +- src/layers/cctv/rendering.js | 1 + src/voice/gevActions.js | 1 - src/voice/gevActionsEngine.test.mjs | 1 - 9 files changed, 378 insertions(+), 135 deletions(-) create mode 100644 scripts/qa-cctv-engine-switch.mjs diff --git a/scripts/qa-cctv-engine-switch.mjs b/scripts/qa-cctv-engine-switch.mjs new file mode 100644 index 000000000..08bc07b9a --- /dev/null +++ b/scripts/qa-cctv-engine-switch.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { promisify } from 'node:util'; +import { execFile } from 'node:child_process'; +import puppeteer from 'puppeteer'; +import { installCctvFixture } from './fixtures/w2/cctv.mjs'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +import { switchGlobe } from './qa-switch-globe.mjs'; +const url = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + url.port, + ) +) + throw Error('Supply local keyless preview URL'); +url.search = '?engine=navara&welcome=0'; +url.hash = + 'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l='; +const { stdout: video } = await promisify(execFile)( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'lavfi', + '-i', + 'testsrc2=size=320x180:rate=12', + '-t', + '60', + '-an', + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-preset', + 'ultrafast', + '-threads', + '1', + '-movflags', + 'frag_keyframe+empty_moov', + '-f', + 'mp4', + 'pipe:1', + ], + { encoding: 'buffer', maxBuffer: 16000000 }, +); +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message); + }); + await installCctvFixture(p, { video }); + let catalogs = 0; + p.on('request', (req) => { + if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++; + }); + await p.goto(url.href); + await p.waitForFunction( + () => + window.__godsEyeView?.engine.kind === 'navara' && + document.querySelector('#loading-screen.hidden'), + ); + await p.evaluate(async () => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Expected keyless preview'); + await styleManager.initialRestorePromise; + await dataManager.setEnabled('cctv', true, { origin: 'user' }); + window.qaCctv = dataManager.layers.get('cctv').module; + window.qaCctv.setParams({ + selectedCameraId: 'w2-cctv-video', + showProjection: true, + coverageMode: 'viewshed', + calibration: { patch: { heightM: 31, pitchDeg: 20 } }, + }); + }); + await p.waitForFunction( + () => + window.qaCctv.getActiveVideoElement()?.videoWidth === 320 && + window.qaCctv.getActiveVideoElement().currentTime > 0.1, + { timeout: 40000 }, + ); + const first = await p.evaluate(() => { + window.qaVideo = window.qaCctv.getActiveVideoElement(); + return window.qaVideo.getVideoPlaybackQuality().totalVideoFrames; + }); + let previous = first; + const count = catalogs; + for (const kind of ['cesium', 'navara']) { + await switchGlobe(p, kind); + await p.waitForFunction( + (n) => + window.qaCctv.getActiveVideoElement()?.getVideoPlaybackQuality() + .totalVideoFrames > n, + {}, + previous, + ); + const v = await p.evaluate(() => ({ + same: + window.qaCctv === + window.__godsEyeView.dataManager.layers.get('cctv').module, + media: window.qaCctv.getActiveVideoElement() === window.qaVideo, + frames: window.qaVideo.getVideoPlaybackQuality().totalVideoFrames, + paused: window.qaVideo.paused, + params: window.qaCctv.getParams(), + plane: !!window.qaCctv + ._cctvRecordsForTest() + .find((r) => r.camera.id === 'w2-cctv-video').projection?.planeEntity, + })); + console.log( + kind, + v.same, + v.media, + v.frames, + v.plane, + v.params.selectedCameraId, + v.params.coverageMode, + ); + assert.ok(v.same && v.media && v.plane && !v.paused); + assert.equal(v.params.selectedCameraId, 'w2-cctv-video'); + assert.equal(v.params.coverageMode, 'viewshed'); + assert.equal(catalogs, count); + previous = v.frames; + } + assert.deepEqual(errors, []); + console.log('PASS retained CCTV catalog, decoder and textured projection'); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 4bf1ebd06..a454465d1 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -246,7 +246,7 @@ export function createApplicationCatalog({ source: sources.traffic, surface, nativeRenderer: engine => rendererFor('traffic',engine), })), ], - ['cctv', () => createApplicationCctv({ surface, source: sources.cctv })], + ['cctv', () => sessionModel('cctv', () => createApplicationCctv({ surface, source: sources.cctv }))], [ 'radio', () => sessionModel('radio', () => createApplicationRadio({ surface, source: sources.radio })), diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index ea81bf4c7..9d478ef15 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -14,6 +14,7 @@ // computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under // plain node:test. import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; // Preserve structural pins across component qualification and formatter wrapping. function componentFunctionSource(fn) { return fn @@ -233,7 +234,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig 'building a 100-camera catalog must insert zero coverage entities', ); assert.doesNotMatch( - componentFunctionSource(cctvLayer.init), + sceneBindings, /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/, ); @@ -968,6 +969,11 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => { assert.equal(flyCalls, 0); }); +// Both initial mount and live engine switches install the same click handler. +const sceneBindings = componentFunctionSource( + readFileSync(new URL('../layers/cctv/lifecycle.js', import.meta.url), 'utf8') + .split('function bindScene(viewer, options) {')[1].split('function createRenderer(')[0], +); test('CCTV repeated in-world clicks dispatch focus only for the one real activation', () => { const target = new EventTarget(); const activated = []; @@ -997,23 +1003,23 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat assert.deepEqual(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']); assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]); assert.match( - componentFunctionSource(cctvLayer.init), + sceneBindings, /_removeClick = bindClick\(/, ); assert.match( - componentFunctionSource(cctvLayer.init), + sceneBindings, /_cctvOverlayHost\.hitTest/, ); assert.match( - componentFunctionSource(cctvLayer.init), + sceneBindings, /sourceId: CCTV_OVERLAY_SOURCE_ID/, ); assert.match( - componentFunctionSource(cctvLayer.init), + sceneBindings, /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/, ); assert.match( - componentFunctionSource(cctvLayer.init), + sceneBindings, /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/, ); }); @@ -1126,7 +1132,7 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and 'an ID-less surface pick remains true empty space', ); - const initSource = componentFunctionSource(cctvLayer.init); + const initSource = sceneBindings; assert.ok( initSource.indexOf('if (pickedId !== null) return') < initSource.indexOf('_cctvOverlayHost.hitTest'), diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index c12f0c450..61243d90e 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -748,10 +748,9 @@ export function createGeometry({ state: layerState, services, parts, source }) { * Removes all coverage entities and projection runtimes from the scene. */ - function destroyCoverageEntities() { - if (!parts.renderer?.ready) return; + function destroyCoverageEntities({ retainMedia = false } = {}) { for (const entity of layerState._coverageEntities) { - parts.renderer.removeCoverageEntity(entity); + parts.renderer?.removeCoverageEntity(entity); } layerState._coverageEntities = []; @@ -760,9 +759,14 @@ export function createGeometry({ state: layerState, services, parts, source }) { } for (const runtime of layerState._projectionEntities) { - parts.projection.destroyProjectionRuntime(runtime); + if (retainMedia) { + if (runtime.planeEntity) parts.renderer.removePlane(runtime); + runtime.planeEntity = runtime.planeMaterial = null; + runtime.planeVisible = false; + runtime.needsPlane = true; + } else parts.projection.destroyProjectionRuntime(runtime); } - layerState._projectionEntities = []; + if (!retainMedia) layerState._projectionEntities = []; } return { computeFrustumGeometry, diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 50bbd442b..5b377f1de 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -32,6 +32,7 @@ export function createLifecycle({ * set changed since they were last told, including by motion passes. */ function settleView() { + if (layerState._restoringViewport || !parts.renderer?.ready) return; const changed = parts.rendering.refreshHorizonCulling(); noteViewPose(); parts.geometryQueue.enqueueViewGeometry(); @@ -124,6 +125,126 @@ export function createLifecycle({ // recomputes it against the then-current scene. layerState._lastAppliedRegime = null; } + function bindScene(viewer, options) { + // Task 5: track the surface regime the initial geometry was computed for + // and listen for map-stack changes (main.js re-dispatches + // MapStackController.onChange as this CustomEvent). The handler compares + // regimes itself, so 'switching'/'error' emissions and same-regime stack + // swaps (bing→osm) no-op. + layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime(); + if (!layerState._mapStackListener && typeof window !== 'undefined') { + layerState._mapStackListener = () => + parts.ground.handleMapStackChanged(); + window.addEventListener( + 'gev:map-stack-changed', + layerState._mapStackListener, + ); + } + + // Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with + // the billboards' always-on-top depth setting) + one initial pass so the + // first paint is already culled. + if (!layerState._horizonCullListener) { + // Ambient cards piggyback the same settle event: moveEnd-driven + // reselection only, never per frame (refreshAmbientCards no-ops while + // the layer is disabled). + layerState._horizonCullListener = parts.renderer.onMoveEnd(() => { + layerState._cameraMoving = false; + stopViewMotionTimer(); + if (layerState._enabled) settleView(); + }); + } + if (!layerState._moveStartListener) { + // Item B: hover picking pauses while the camera is in motion. + layerState._moveStartListener = parts.renderer.onMoveStart(() => { + layerState._cameraMoving = true; + if (layerState._enabled) startViewMotionTimer(); + }); + } + const canvas = options.engine?.host.canvas ?? viewer?.scene?.canvas; + if (canvas && typeof ResizeObserver === 'function') { + layerState._viewResizeObserver = new ResizeObserver(() => { + if (layerState._enabled) settleView(); + }); + layerState._viewResizeObserver.observe(canvas); + } + parts.rendering.refreshHorizonCulling(); + + layerState._removeClick = parts.renderer.bindClick( + (click) => { + if (!layerState._enabled) return; + const picked = parts.renderer.pick(click.position); + const cameraId = parts.selection.extractPickedCameraId(picked); + if (cameraId) { + activateCctvCameraFromWorldClick( + cameraId, + parts.selection.setActiveCamera, + ); + return; + } + // Any identified scene object owns this click even if its layer does not + // register a shared pick predicate. This keeps selectable siblings ahead + // of an overlapping CCTV card while ID-less globe/terrain/tile surfaces + // remain eligible for true empty-space deselection. + const pickedId = resolvePickId(picked); + if (pickedId !== null) return; + // Item A (owner round 2): the scene pick found no camera — try the + // painted ambient cards. The cards canvas is pointer-events:none (this + // handler owns the events), so a click landing on a card's rect selects + // its camera exactly like a click on the icon. Cesium click positions + // and the recorded rects are both CSS px — direct comparison. + const cardId = layerState._cctvOverlayHost.hitTest( + click.position.x, + click.position.y, + { sourceId: CCTV_OVERLAY_SOURCE_ID }, + )?.entryId; + if (cardId && layerState._recordById.has(cardId)) { + activateCctvCameraFromWorldClick( + cardId, + parts.selection.setActiveCamera, + ); + return; + } + if ( + parts.selection.cctvEmptyClickDeselects(picked, { + activeCameraId: layerState._activeCameraId, + calibrationMode: layerState._calibrationMode, + }) + ) { + parts.selection.deactivateActiveCamera(); + } + }, + { + // Item B: hover summons a card on a cardless camera icon. The gesture + // classifier owns MOUSE_MOVE too, so chain hover work through its seam + // instead of replacing the travel accumulator's handler. + onMouseMove: (movement) => + parts.hover.handleHoverMove(movement?.endPosition), + }, + ); + + } + function createRenderer(viewer, options) { + if (parts.renderer) throw new Error('CCTV layer is already initialized'); + if (typeof rendererFactory !== 'function') + throw new TypeError('A CCTV renderer factory is required'); + const renderer = rendererFactory({ + state: layerState, + services, + parts, + source, + engine: options.engine, + }); + parts.renderer = renderer; + try { + renderer.attach(viewer); + return renderer; + } catch (error) { + renderer.destroy(); + parts.renderer = null; + throw error; + } + } const methods = { /** * Initializes the CCTV layer: loads camera sources, builds the catalog, @@ -132,19 +253,8 @@ export function createLifecycle({ * @param {Cesium.Viewer} viewer - The Cesium viewer instance. */ async init(viewer, options = {}) { - if (parts.renderer) throw new Error('CCTV layer is already initialized'); - if (typeof rendererFactory !== 'function') - throw new TypeError('A CCTV renderer factory is required'); - const renderer = rendererFactory({ - state: layerState, - services, - parts, - source, - engine: options.engine, - }); - parts.renderer = renderer; + const renderer = createRenderer(viewer, options); try { - renderer.attach(viewer); layerState._sourceAbort?.abort(); const sourceAbort = new AbortController(); layerState._sourceAbort = sourceAbort; @@ -301,103 +411,7 @@ export function createLifecycle({ .catch(() => {}); } - // Task 5: track the surface regime the initial geometry was computed for - // and listen for map-stack changes (main.js re-dispatches - // MapStackController.onChange as this CustomEvent). The handler compares - // regimes itself, so 'switching'/'error' emissions and same-regime stack - // swaps (bing→osm) no-op. - layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime(); - if (!layerState._mapStackListener && typeof window !== 'undefined') { - layerState._mapStackListener = () => - parts.ground.handleMapStackChanged(); - window.addEventListener( - 'gev:map-stack-changed', - layerState._mapStackListener, - ); - } - - // Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with - // the billboards' always-on-top depth setting) + one initial pass so the - // first paint is already culled. - if (!layerState._horizonCullListener) { - // Ambient cards piggyback the same settle event: moveEnd-driven - // reselection only, never per frame (refreshAmbientCards no-ops while - // the layer is disabled). - layerState._horizonCullListener = parts.renderer.onMoveEnd(() => { - layerState._cameraMoving = false; - stopViewMotionTimer(); - if (layerState._enabled) settleView(); - }); - } - if (!layerState._moveStartListener) { - // Item B: hover picking pauses while the camera is in motion. - layerState._moveStartListener = parts.renderer.onMoveStart(() => { - layerState._cameraMoving = true; - if (layerState._enabled) startViewMotionTimer(); - }); - } - const canvas = options.engine?.host.canvas ?? viewer?.scene?.canvas; - if (canvas && typeof ResizeObserver === 'function') { - layerState._viewResizeObserver = new ResizeObserver(() => { - if (layerState._enabled) settleView(); - }); - layerState._viewResizeObserver.observe(canvas); - } - parts.rendering.refreshHorizonCulling(); - - layerState._removeClick = parts.renderer.bindClick( - (click) => { - if (!layerState._enabled) return; - const picked = parts.renderer.pick(click.position); - const cameraId = parts.selection.extractPickedCameraId(picked); - if (cameraId) { - activateCctvCameraFromWorldClick( - cameraId, - parts.selection.setActiveCamera, - ); - return; - } - // Any identified scene object owns this click even if its layer does not - // register a shared pick predicate. This keeps selectable siblings ahead - // of an overlapping CCTV card while ID-less globe/terrain/tile surfaces - // remain eligible for true empty-space deselection. - const pickedId = resolvePickId(picked); - if (pickedId !== null) return; - // Item A (owner round 2): the scene pick found no camera — try the - // painted ambient cards. The cards canvas is pointer-events:none (this - // handler owns the events), so a click landing on a card's rect selects - // its camera exactly like a click on the icon. Cesium click positions - // and the recorded rects are both CSS px — direct comparison. - const cardId = layerState._cctvOverlayHost.hitTest( - click.position.x, - click.position.y, - { sourceId: CCTV_OVERLAY_SOURCE_ID }, - )?.entryId; - if (cardId && layerState._recordById.has(cardId)) { - activateCctvCameraFromWorldClick( - cardId, - parts.selection.setActiveCamera, - ); - return; - } - if ( - parts.selection.cctvEmptyClickDeselects(picked, { - activeCameraId: layerState._activeCameraId, - calibrationMode: layerState._calibrationMode, - }) - ) { - parts.selection.deactivateActiveCamera(); - } - }, - { - // Item B: hover summons a card on a cardless camera icon. The gesture - // classifier owns MOUSE_MOVE too, so chain hover work through its seam - // instead of replacing the travel accumulator's handler. - onMouseMove: (movement) => - parts.hover.handleHoverMove(movement?.endPosition), - }, - ); - + bindScene(viewer, options); await parts.health.syncHealthState(true); sourceAbort.signal.throwIfAborted(); parts.rendering.refreshCoverageStyles(); @@ -412,6 +426,76 @@ export function createLifecycle({ } }, + attachRenderer(viewer, options = {}) { + const renderer = createRenderer(viewer, options); + layerState._restoringViewport = Boolean(options.waitForCameraRestore); + renderer.createMarkers(); + for (const record of layerState._records) { + record.billboard = renderer.createMarker(record.camera, record.position); + record.groundResolved = {}; + record.groundSamples = {}; + record.probeClampRangeM = null; + } + bindScene(viewer, options); + if (!layerState._restoringViewport && layerState._enabled) this.resumeDisplay(); + }, + + resumeViewport() { + if (!layerState._restoringViewport) return; + layerState._restoringViewport = false; + if (layerState._enabled && parts.renderer) this.resumeDisplay(); + }, + + resumeDisplay() { + this.showRenderer(); + if (layerState._calibrationMode) { + parts.calibration.ensureGizmo(); + layerState._gizmo?.setEnabled(true); + services.render.holdContinuousRender('cctv-adjust'); + } + settleView(); + }, + + detachRenderer() { + if (!parts.renderer) return; + layerState._restoringViewport = true; + parts.renderer.hideCredit(); + unregisterPickOwner('cctv'); + if (layerState._mapStackListener) { + window.removeEventListener('gev:map-stack-changed', layerState._mapStackListener); + layerState._mapStackListener = null; + } + layerState._viewResizeObserver?.disconnect(); + layerState._viewResizeObserver = null; + layerState._horizonCullListener?.(); + layerState._moveStartListener?.(); + layerState._removeFocusAppearListener?.(); + layerState._horizonCullListener = layerState._moveStartListener = layerState._removeFocusAppearListener = null; + layerState._gizmo?.destroy(); + layerState._gizmo = null; + releaseContinuousRender('cctv-adjust'); + layerState._removeClick?.(); + layerState._removeClick = null; + parts.projection.stopProjectionLoop(); + parts.geometryQueue.stopGeometryLoadQueue(); + stopViewMotionTimer(); + parts.cards.stopCardFrameLoop(); + parts.hover.clearHoverCard(); + parts.projection.clearProjectionOverlay(); + layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID); + parts.geometry.destroyCoverageEntities({retainMedia:true}); + for (const record of layerState._records) { + record.billboard = null; + record.coverageEntities = []; + } + layerState._viewRecords = []; + layerState._cameraIndexCache = null; + layerState._viewPose = null; + layerState._cameraMoving = false; + parts.renderer.destroy(); + parts.renderer = null; + }, + /** * Enables the layer: shows entities, starts the projection loop, and kicks * the staggered geometry-load queue. Heavy work (per-camera ground @@ -420,14 +504,20 @@ export function createLifecycle({ */ enable() { layerState._enabled = true; - if (layerState._cameraMoving) startViewMotionTimer(); + layerState._lastUpdate = Date.now(); + if (!layerState._activeCameraId && layerState._records.length) { + layerState._activeCameraId = layerState._records[0].camera.id; + layerState._autoHopSuspended = false; + } + this.showRenderer(true); + }, + showRenderer(refreshImage = false) { if ( layerState._records.some( (record) => record.camera.cityId === 'warendorf', ) ) parts.renderer?.showCredit(); - layerState._lastUpdate = Date.now(); // Pick-ownership (H2): camera billboards use the camera id directly; // coverage polyline entities use `cctv--` entity ids. registerPickOwner('cctv', (pickedId) => { @@ -443,14 +533,10 @@ export function createLifecycle({ ); return Boolean(coverage && layerState._recordById.has(coverage[1])); }); - if (!layerState._activeCameraId && layerState._records.length) { - layerState._activeCameraId = layerState._records[0].camera.id; - layerState._autoHopSuspended = false; - } const activeRecord = parts.selection.getActiveRecord(); if (activeRecord) { parts.projection.ensureProjectionRuntime(activeRecord); - parts.frames.refreshProjectionImage(activeRecord, true); + if (refreshImage) parts.frames.refreshProjectionImage(activeRecord, true); } parts.rendering.refreshHorizonCulling(); // Re-enabled mid-flight: moveStart has already fired. diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js index 43af31f9a..034c5d3fb 100644 --- a/src/layers/cctv/projection.js +++ b/src/layers/cctv/projection.js @@ -103,7 +103,7 @@ export function createProjection({ function setPlaneVisible(runtime, visible) { if (!runtime) return; runtime.planeVisible = !!visible; - parts.renderer.setPlaneVisible(runtime, !!visible); + if (runtime.planeEntity) parts.renderer?.setPlaneVisible(runtime, !!visible); if (visible && runtime.overlayEntry && runtime.cameraId) { if (layerState._projectionOverlayOwnerId !== runtime.cameraId) { layerState._cctvOverlayHost.setEntries( @@ -256,7 +256,7 @@ export function createProjection({ runtime.imageLoading = false; runtime.imageReady = false; }; - parts.renderer.setPlaneImage(runtime, runtime.canvas); + if (runtime.planeMaterial) parts.renderer?.setPlaneImage(runtime, runtime.canvas); parts.frames.refreshProjectionImage(record, true); parts.presentation.notifyListeners(); }, @@ -311,8 +311,19 @@ export function createProjection({ function ensureProjectionRuntime(record) { if (!record) return null; - if (record.projection && !record.projection.disposed) - return record.projection; + if (record.projection && !record.projection.disposed) { + const runtime = record.projection; + if (runtime.needsPlane && parts.renderer?.ready) { + const geometry = record.frustumGeometry || parts.geometry.computeFrustumGeometry(record.camera, parts.ground.groundAltFor(record), record.probeClampRangeM); + const positions = record.frustumPositions || parts.geometry.frustumCartesians(geometry); + runtime.planeMaterial = parts.renderer.createPlaneMaterial(runtime.canvas); + createProjectionPlane(record, runtime, geometry, positions); + if (runtime.video?.videoWidth) parts.renderer.setPlaneImage(runtime, runtime.video); + runtime.needsPlane = false; + runtime.lastSwappedCanvasStamp = -1; + } + return runtime; + } const runtime = createProjectionRuntime(record); record.projection = runtime; if (runtime) { diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index b5e082b4b..34bd425d5 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -311,6 +311,7 @@ export function createRendering({ /** Applies coverage visibility/style state and lazily builds eligible sets. */ function refreshCoverageStyles() { + if (!parts.renderer?.ready) return; const activeRecord = parts.selection.getActiveRecord(); parts.geometry.ensureActiveCoverageEntities(activeRecord); const activeId = activeRecord?.camera.id || null; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d999018cd..3dd7068bd 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -237,7 +237,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([ 'fly_route', 'move_camera', 'set_context_mode', - 'control_cctv', ]); /** The refusal code of a viewer-only tool on an engine without a viewer. */ export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE'; diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index 0777bd76c..ccbbe2b81 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -94,7 +94,6 @@ test('the viewer-only actions are one exported list of real tools', () => { 'fly_route', 'move_camera', 'set_context_mode', - 'control_cctv', ]); assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS)); const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));