mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +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),
|
source: sources.traffic, surface, nativeRenderer: engine => rendererFor('traffic',engine),
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
['cctv', () => createApplicationCctv({ surface, source: sources.cctv })],
|
['cctv', () => sessionModel('cctv', () => createApplicationCctv({ surface, source: sources.cctv }))],
|
||||||
[
|
[
|
||||||
'radio',
|
'radio',
|
||||||
() => sessionModel('radio', () => createApplicationRadio({ surface, source: sources.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
|
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
|
||||||
// plain node:test.
|
// plain node:test.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
// Preserve structural pins across component qualification and formatter wrapping.
|
// Preserve structural pins across component qualification and formatter wrapping.
|
||||||
function componentFunctionSource(fn) {
|
function componentFunctionSource(fn) {
|
||||||
return 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',
|
'building a 100-camera catalog must insert zero coverage entities',
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
componentFunctionSource(cctvLayer.init),
|
sceneBindings,
|
||||||
/buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/,
|
/buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -968,6 +969,11 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => {
|
|||||||
assert.equal(flyCalls, 0);
|
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', () => {
|
test('CCTV repeated in-world clicks dispatch focus only for the one real activation', () => {
|
||||||
const target = new EventTarget();
|
const target = new EventTarget();
|
||||||
const activated = [];
|
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(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']);
|
||||||
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
||||||
assert.match(
|
assert.match(
|
||||||
componentFunctionSource(cctvLayer.init),
|
sceneBindings,
|
||||||
/_removeClick = bindClick\(/,
|
/_removeClick = bindClick\(/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
componentFunctionSource(cctvLayer.init),
|
sceneBindings,
|
||||||
/_cctvOverlayHost\.hitTest/,
|
/_cctvOverlayHost\.hitTest/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
componentFunctionSource(cctvLayer.init),
|
sceneBindings,
|
||||||
/sourceId: CCTV_OVERLAY_SOURCE_ID/,
|
/sourceId: CCTV_OVERLAY_SOURCE_ID/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
componentFunctionSource(cctvLayer.init),
|
sceneBindings,
|
||||||
/activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/,
|
/activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
componentFunctionSource(cctvLayer.init),
|
sceneBindings,
|
||||||
/activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/,
|
/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',
|
'an ID-less surface pick remains true empty space',
|
||||||
);
|
);
|
||||||
|
|
||||||
const initSource = componentFunctionSource(cctvLayer.init);
|
const initSource = sceneBindings;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
initSource.indexOf('if (pickedId !== null) return') <
|
initSource.indexOf('if (pickedId !== null) return') <
|
||||||
initSource.indexOf('_cctvOverlayHost.hitTest'),
|
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.
|
* Removes all coverage entities and projection runtimes from the scene.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function destroyCoverageEntities() {
|
function destroyCoverageEntities({ retainMedia = false } = {}) {
|
||||||
if (!parts.renderer?.ready) return;
|
|
||||||
for (const entity of layerState._coverageEntities) {
|
for (const entity of layerState._coverageEntities) {
|
||||||
parts.renderer.removeCoverageEntity(entity);
|
parts.renderer?.removeCoverageEntity(entity);
|
||||||
}
|
}
|
||||||
layerState._coverageEntities = [];
|
layerState._coverageEntities = [];
|
||||||
|
|
||||||
@@ -760,9 +759,14 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const runtime of layerState._projectionEntities) {
|
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 {
|
return {
|
||||||
computeFrustumGeometry,
|
computeFrustumGeometry,
|
||||||
|
|||||||
+202
-116
@@ -32,6 +32,7 @@ export function createLifecycle({
|
|||||||
* set changed since they were last told, including by motion passes.
|
* set changed since they were last told, including by motion passes.
|
||||||
*/
|
*/
|
||||||
function settleView() {
|
function settleView() {
|
||||||
|
if (layerState._restoringViewport || !parts.renderer?.ready) return;
|
||||||
const changed = parts.rendering.refreshHorizonCulling();
|
const changed = parts.rendering.refreshHorizonCulling();
|
||||||
noteViewPose();
|
noteViewPose();
|
||||||
parts.geometryQueue.enqueueViewGeometry();
|
parts.geometryQueue.enqueueViewGeometry();
|
||||||
@@ -124,6 +125,126 @@ export function createLifecycle({
|
|||||||
// recomputes it against the then-current scene.
|
// recomputes it against the then-current scene.
|
||||||
layerState._lastAppliedRegime = null;
|
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 = {
|
const methods = {
|
||||||
/**
|
/**
|
||||||
* Initializes the CCTV layer: loads camera sources, builds the catalog,
|
* 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.
|
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
|
||||||
*/
|
*/
|
||||||
async init(viewer, options = {}) {
|
async init(viewer, options = {}) {
|
||||||
if (parts.renderer) throw new Error('CCTV layer is already initialized');
|
const renderer = createRenderer(viewer, options);
|
||||||
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 {
|
try {
|
||||||
renderer.attach(viewer);
|
|
||||||
layerState._sourceAbort?.abort();
|
layerState._sourceAbort?.abort();
|
||||||
const sourceAbort = new AbortController();
|
const sourceAbort = new AbortController();
|
||||||
layerState._sourceAbort = sourceAbort;
|
layerState._sourceAbort = sourceAbort;
|
||||||
@@ -301,103 +411,7 @@ export function createLifecycle({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 5: track the surface regime the initial geometry was computed for
|
bindScene(viewer, options);
|
||||||
// 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),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await parts.health.syncHealthState(true);
|
await parts.health.syncHealthState(true);
|
||||||
sourceAbort.signal.throwIfAborted();
|
sourceAbort.signal.throwIfAborted();
|
||||||
parts.rendering.refreshCoverageStyles();
|
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
|
* Enables the layer: shows entities, starts the projection loop, and kicks
|
||||||
* the staggered geometry-load queue. Heavy work (per-camera ground
|
* the staggered geometry-load queue. Heavy work (per-camera ground
|
||||||
@@ -420,14 +504,20 @@ export function createLifecycle({
|
|||||||
*/
|
*/
|
||||||
enable() {
|
enable() {
|
||||||
layerState._enabled = true;
|
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 (
|
if (
|
||||||
layerState._records.some(
|
layerState._records.some(
|
||||||
(record) => record.camera.cityId === 'warendorf',
|
(record) => record.camera.cityId === 'warendorf',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
parts.renderer?.showCredit();
|
parts.renderer?.showCredit();
|
||||||
layerState._lastUpdate = Date.now();
|
|
||||||
// Pick-ownership (H2): camera billboards use the camera id directly;
|
// Pick-ownership (H2): camera billboards use the camera id directly;
|
||||||
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
|
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
|
||||||
registerPickOwner('cctv', (pickedId) => {
|
registerPickOwner('cctv', (pickedId) => {
|
||||||
@@ -443,14 +533,10 @@ export function createLifecycle({
|
|||||||
);
|
);
|
||||||
return Boolean(coverage && layerState._recordById.has(coverage[1]));
|
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();
|
const activeRecord = parts.selection.getActiveRecord();
|
||||||
if (activeRecord) {
|
if (activeRecord) {
|
||||||
parts.projection.ensureProjectionRuntime(activeRecord);
|
parts.projection.ensureProjectionRuntime(activeRecord);
|
||||||
parts.frames.refreshProjectionImage(activeRecord, true);
|
if (refreshImage) parts.frames.refreshProjectionImage(activeRecord, true);
|
||||||
}
|
}
|
||||||
parts.rendering.refreshHorizonCulling();
|
parts.rendering.refreshHorizonCulling();
|
||||||
// Re-enabled mid-flight: moveStart has already fired.
|
// Re-enabled mid-flight: moveStart has already fired.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function createProjection({
|
|||||||
function setPlaneVisible(runtime, visible) {
|
function setPlaneVisible(runtime, visible) {
|
||||||
if (!runtime) return;
|
if (!runtime) return;
|
||||||
runtime.planeVisible = !!visible;
|
runtime.planeVisible = !!visible;
|
||||||
parts.renderer.setPlaneVisible(runtime, !!visible);
|
if (runtime.planeEntity) parts.renderer?.setPlaneVisible(runtime, !!visible);
|
||||||
if (visible && runtime.overlayEntry && runtime.cameraId) {
|
if (visible && runtime.overlayEntry && runtime.cameraId) {
|
||||||
if (layerState._projectionOverlayOwnerId !== runtime.cameraId) {
|
if (layerState._projectionOverlayOwnerId !== runtime.cameraId) {
|
||||||
layerState._cctvOverlayHost.setEntries(
|
layerState._cctvOverlayHost.setEntries(
|
||||||
@@ -256,7 +256,7 @@ export function createProjection({
|
|||||||
runtime.imageLoading = false;
|
runtime.imageLoading = false;
|
||||||
runtime.imageReady = 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.frames.refreshProjectionImage(record, true);
|
||||||
parts.presentation.notifyListeners();
|
parts.presentation.notifyListeners();
|
||||||
},
|
},
|
||||||
@@ -311,8 +311,19 @@ export function createProjection({
|
|||||||
|
|
||||||
function ensureProjectionRuntime(record) {
|
function ensureProjectionRuntime(record) {
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
if (record.projection && !record.projection.disposed)
|
if (record.projection && !record.projection.disposed) {
|
||||||
return record.projection;
|
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);
|
const runtime = createProjectionRuntime(record);
|
||||||
record.projection = runtime;
|
record.projection = runtime;
|
||||||
if (runtime) {
|
if (runtime) {
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export function createRendering({
|
|||||||
/** Applies coverage visibility/style state and lazily builds eligible sets. */
|
/** Applies coverage visibility/style state and lazily builds eligible sets. */
|
||||||
|
|
||||||
function refreshCoverageStyles() {
|
function refreshCoverageStyles() {
|
||||||
|
if (!parts.renderer?.ready) return;
|
||||||
const activeRecord = parts.selection.getActiveRecord();
|
const activeRecord = parts.selection.getActiveRecord();
|
||||||
parts.geometry.ensureActiveCoverageEntities(activeRecord);
|
parts.geometry.ensureActiveCoverageEntities(activeRecord);
|
||||||
const activeId = activeRecord?.camera.id || null;
|
const activeId = activeRecord?.camera.id || null;
|
||||||
|
|||||||
@@ -237,7 +237,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([
|
|||||||
'fly_route',
|
'fly_route',
|
||||||
'move_camera',
|
'move_camera',
|
||||||
'set_context_mode',
|
'set_context_mode',
|
||||||
'control_cctv',
|
|
||||||
]);
|
]);
|
||||||
/** The refusal code of a viewer-only tool on an engine without a viewer. */
|
/** The refusal code of a viewer-only tool on an engine without a viewer. */
|
||||||
export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE';
|
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',
|
'fly_route',
|
||||||
'move_camera',
|
'move_camera',
|
||||||
'set_context_mode',
|
'set_context_mode',
|
||||||
'control_cctv',
|
|
||||||
]);
|
]);
|
||||||
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
|
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
|
||||||
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
|
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
|
||||||
|
|||||||
Reference in new issue
Block a user