mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Keep CCTV playback and calibration while replacing globe projection surfaces
This commit is contained in:
1 parent
24190a3e35
commit
133ca14119
9 files changed
+378
-135
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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 })),
|
||||
|
||||
+13
-7
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+202
-116
@@ -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-<cameraId>-<role>` 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user