mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
refactor(cctv): isolate native rendering resources
Keep shared CCTV logic independent of Cesium, release the original renderer's resources, and guard retired initialization/media work. Preserve native marker, plane, volume, calibration and input behavior. Record keyless dev/preview validation and retain unresolved shell idle/terrain and photoreal acceptance gaps. Routine QA builds use empty Google and Cesium credentials and block paid maps before navigation.
This commit is contained in:
1 parent
f72bf6a774
commit
93c71ffc86
32 files changed
+1536
-740
No files matched your search
@@ -542,6 +542,11 @@
|
|||||||
"src/layers/cctv/projectionScheduling.test.mjs",
|
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||||
"src/layers/cctv/gizmo.test.mjs",
|
"src/layers/cctv/gizmo.test.mjs",
|
||||||
"src/layers/cctv/render/cesiumGizmo.js",
|
"src/layers/cctv/render/cesiumGizmo.js",
|
||||||
|
"src/layers/cctv/render/cesium.js",
|
||||||
|
"src/layers/cctv/render/cesiumViewshed.js",
|
||||||
|
"src/layers/cctv/render/cesium.test.mjs",
|
||||||
|
"src/layers/cctv/lifecycle.test.mjs",
|
||||||
|
"src/layers/cctv/projectionMedia.test.mjs",
|
||||||
"src/layers/cctv/cards.js",
|
"src/layers/cctv/cards.js",
|
||||||
"src/layers/cctv/rendering.js",
|
"src/layers/cctv/rendering.js",
|
||||||
"src/layers/cctv/testing.js",
|
"src/layers/cctv/testing.js",
|
||||||
|
|||||||
@@ -1449,10 +1449,6 @@
|
|||||||
"src/layers/shared/color.js",
|
"src/layers/shared/color.js",
|
||||||
"src/data/cctvLod.js",
|
"src/data/cctvLod.js",
|
||||||
"src/data/cctvViewshed.js",
|
"src/data/cctvViewshed.js",
|
||||||
"src/data/iconOrientation.js",
|
|
||||||
"src/data/render/cesiumProjection.js",
|
|
||||||
"src/data/trackingClickGesture.js",
|
|
||||||
"src/data/render/cesiumConstants.js",
|
|
||||||
"src/layers/cctv/calibration.js",
|
"src/layers/cctv/calibration.js",
|
||||||
"src/layers/cctv/cards.js",
|
"src/layers/cctv/cards.js",
|
||||||
"src/layers/cctv/catalog.js",
|
"src/layers/cctv/catalog.js",
|
||||||
@@ -1497,11 +1493,9 @@
|
|||||||
"src/geo/scaleToGeodeticSurface.js",
|
"src/geo/scaleToGeodeticSurface.js",
|
||||||
"src/geo/check.js",
|
"src/geo/check.js",
|
||||||
"src/geo/debug.js",
|
"src/geo/debug.js",
|
||||||
"src/geo/developerError.js",
|
"src/geo/developerError.js"
|
||||||
"src/globe/contract.js",
|
|
||||||
"src/globe/cesium/engineFromViewer.js"
|
|
||||||
],
|
],
|
||||||
"external": ["hls.js", "cesium"]
|
"external": ["hls.js"]
|
||||||
},
|
},
|
||||||
"street-level-layer": {
|
"street-level-layer": {
|
||||||
"exports": [
|
"exports": [
|
||||||
@@ -1835,6 +1829,8 @@
|
|||||||
"src/app/layers/bikeshare.js",
|
"src/app/layers/bikeshare.js",
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
"src/layers/cctv/render/cesiumGizmo.js",
|
"src/layers/cctv/render/cesiumGizmo.js",
|
||||||
|
"src/layers/cctv/render/cesium.js",
|
||||||
|
"src/layers/cctv/render/cesiumViewshed.js",
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/flights.js",
|
"src/app/layers/flights.js",
|
||||||
@@ -2793,6 +2789,8 @@
|
|||||||
"src/app/layers/bikeshare.js",
|
"src/app/layers/bikeshare.js",
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
"src/layers/cctv/render/cesiumGizmo.js",
|
"src/layers/cctv/render/cesiumGizmo.js",
|
||||||
|
"src/layers/cctv/render/cesium.js",
|
||||||
|
"src/layers/cctv/render/cesiumViewshed.js",
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/flights.js",
|
"src/app/layers/flights.js",
|
||||||
|
|||||||
+77
-3
@@ -68,6 +68,8 @@
|
|||||||
* is no longer needed to avoid a plane/wireframe disagreement.
|
* is no longer needed to avoid a plane/wireframe disagreement.
|
||||||
*
|
*
|
||||||
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
|
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
|
||||||
|
* Routine keyless builds also pass --terrain-globe: assert zero scene floor
|
||||||
|
* samples instead of claiming mesh-floor coverage on a globe-only map.
|
||||||
*
|
*
|
||||||
* Exit 0 = all asserts passed. Non-zero = a hard failure.
|
* Exit 0 = all asserts passed. Non-zero = a hard failure.
|
||||||
*/
|
*/
|
||||||
@@ -99,6 +101,9 @@ const output = getOpt('--out');
|
|||||||
if (!explicitUrl || !output)
|
if (!explicitUrl || !output)
|
||||||
throw new Error('Explicit --url and --out are required');
|
throw new Error('Explicit --url and --out are required');
|
||||||
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
|
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
|
||||||
|
const terrainGlobe = getFlag('--terrain-globe');
|
||||||
|
if (terrainGlobe && mapPolicy.allowPaidMaps)
|
||||||
|
throw new Error('Terrain-globe QA must keep paid maps blocked');
|
||||||
const CCTV_URL = new URL(explicitUrl);
|
const CCTV_URL = new URL(explicitUrl);
|
||||||
if (
|
if (
|
||||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||||
@@ -404,6 +409,17 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await blockPaidQaMaps(page, mapPolicy);
|
await blockPaidQaMaps(page, mapPolicy);
|
||||||
|
const terrainRequests = new Set();
|
||||||
|
let terrainActivityAt = 0;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (new URL(request.url()).pathname !== '/api/terrain/heights') return;
|
||||||
|
terrainRequests.add(request);
|
||||||
|
terrainActivityAt = Date.now();
|
||||||
|
});
|
||||||
|
for (const event of ['requestfinished', 'requestfailed'])
|
||||||
|
page.on(event, (request) => {
|
||||||
|
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
|
||||||
|
});
|
||||||
await page.setViewport({ width: 1280, height: 800 });
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
watchW1Page(page, evidence, {
|
watchW1Page(page, evidence, {
|
||||||
name: 'cctv-legacy',
|
name: 'cctv-legacy',
|
||||||
@@ -435,6 +451,27 @@ async function main() {
|
|||||||
if (layer.enabled) await app.dataManager.setEnabled(id, false);
|
if (layer.enabled) await app.dataManager.setEnabled(id, false);
|
||||||
app.styleManager._setDetectionMode('OFF');
|
app.styleManager._setDetectionMode('OFF');
|
||||||
});
|
});
|
||||||
|
evidence.surface = await page.evaluate(() => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
map: app.mapStackController.getActiveId(),
|
||||||
|
globeVisible: app.viewer.scene.globe.show,
|
||||||
|
tileset: Boolean(app.tileset),
|
||||||
|
googleKeyPresent: Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||||
|
photorealAvailable:
|
||||||
|
app.mapStackController.isStackAvailable('photoreal'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
evidence.surfaceMode = terrainGlobe ? 'terrain-globe' : 'mesh-floor';
|
||||||
|
if (terrainGlobe) {
|
||||||
|
const keyless =
|
||||||
|
evidence.surface.globeVisible === true &&
|
||||||
|
!evidence.surface.tileset &&
|
||||||
|
!evidence.surface.googleKeyPresent &&
|
||||||
|
!evidence.surface.photorealAvailable;
|
||||||
|
record('keyless terrain-globe startup', keyless);
|
||||||
|
if (!keyless) throw new Error('Expected a keyless globe before CCTV QA');
|
||||||
|
}
|
||||||
// Let the initial fly-to Austin and first tiles settle.
|
// Let the initial fly-to Austin and first tiles settle.
|
||||||
await sleep(4000);
|
await sleep(4000);
|
||||||
|
|
||||||
@@ -619,7 +656,7 @@ async function main() {
|
|||||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||||
let sampleFloorOk = false;
|
let sampleFloorOk = false;
|
||||||
if (tilesSeenLoaded) {
|
if (tilesSeenLoaded && !terrainGlobe) {
|
||||||
sampleFloorOk = await page
|
sampleFloorOk = await page
|
||||||
.waitForFunction(
|
.waitForFunction(
|
||||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||||
@@ -631,8 +668,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const cFloor = await readCounters();
|
const cFloor = await readCounters();
|
||||||
record(
|
record(
|
||||||
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
terrainGlobe
|
||||||
tilesSeenLoaded && sampleFloorOk,
|
? 'terrain-globe resolves from priors with zero scene ground samples'
|
||||||
|
: '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||||
|
terrainGlobe
|
||||||
|
? cFloor.sampleHeight - c0.sampleHeight === 0
|
||||||
|
: tilesSeenLoaded && sampleFloorOk,
|
||||||
tilesSeenLoaded
|
tilesSeenLoaded
|
||||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||||
@@ -1588,6 +1629,24 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cBeforeViewshed = await readCounters();
|
const cBeforeViewshed = await readCounters();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||||
|
const events = [];
|
||||||
|
window.__qaViewshedTrace = events;
|
||||||
|
for (const method of ['add', 'remove']) {
|
||||||
|
const original = primitives[method].bind(primitives);
|
||||||
|
primitives[method] = (primitive, ...args) => {
|
||||||
|
if (primitive?._gevViewshed)
|
||||||
|
events.push({
|
||||||
|
method,
|
||||||
|
camera: primitive._gevViewshed,
|
||||||
|
at: performance.now(),
|
||||||
|
stack: new Error().stack,
|
||||||
|
});
|
||||||
|
return original(primitive, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
const modeAfterSet = await page.evaluate(() => {
|
const modeAfterSet = await page.evaluate(() => {
|
||||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||||
mod.setParams({ coverageMode: 'viewshed' });
|
mod.setParams({ coverageMode: 'viewshed' });
|
||||||
@@ -1638,6 +1697,18 @@ async function main() {
|
|||||||
mod.setParams({ coverageMode: 'viewshed' });
|
mod.setParams({ coverageMode: 'viewshed' });
|
||||||
});
|
});
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
|
// NEXT starts an asynchronous footprint-height lookup. Its completion is
|
||||||
|
// allowed to update geometry; the unchanged 8s idle window starts after it.
|
||||||
|
const terrainDeadline = Date.now() + 35000;
|
||||||
|
while (
|
||||||
|
(terrainRequests.size || Date.now() - terrainActivityAt < 250) &&
|
||||||
|
Date.now() < terrainDeadline
|
||||||
|
)
|
||||||
|
await sleep(50);
|
||||||
|
const terrainSettled =
|
||||||
|
terrainRequests.size === 0 && Date.now() - terrainActivityAt >= 250;
|
||||||
|
record('footprint requests settle before viewshed idle', terrainSettled);
|
||||||
|
if (!terrainSettled) throw new Error('Footprint requests did not settle');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||||
window.__qaViewshedRefs = [];
|
window.__qaViewshedRefs = [];
|
||||||
@@ -1659,6 +1730,9 @@ async function main() {
|
|||||||
return { sameCount, sameIdentity, count: now.length };
|
return { sameCount, sameIdentity, count: now.length };
|
||||||
});
|
});
|
||||||
const cAfterViewshed = await readCounters();
|
const cAfterViewshed = await readCounters();
|
||||||
|
evidence.viewshedTrace = await page.evaluate(
|
||||||
|
() => window.__qaViewshedTrace,
|
||||||
|
);
|
||||||
const lateFloorSamples =
|
const lateFloorSamples =
|
||||||
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
||||||
record(
|
record(
|
||||||
|
|||||||
+23
-4
@@ -1,4 +1,7 @@
|
|||||||
import { createCesiumCctvGizmoRenderer } from '../../layers/cctv/render/cesiumGizmo.js';
|
import {
|
||||||
|
createCctvCesiumRenderer,
|
||||||
|
bindCctvWorldClickGesture,
|
||||||
|
} from '../../layers/cctv/render/cesium.js';
|
||||||
import { createCctvLayer } from '../../layers/cctv/index.js';
|
import { createCctvLayer } from '../../layers/cctv/index.js';
|
||||||
import * as credits from '../../data/dataCredits.js';
|
import * as credits from '../../data/dataCredits.js';
|
||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
@@ -10,12 +13,16 @@ import * as focus from '../../data/focusDeemphasis.js';
|
|||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationCctv({ surface, source }) {
|
export function createApplicationCctv({
|
||||||
|
surface,
|
||||||
|
source,
|
||||||
|
rendererFactory = createCctvCesiumRenderer,
|
||||||
|
}) {
|
||||||
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
|
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
|
||||||
return createCctvLayer({
|
const layer = createCctvLayer({
|
||||||
source,
|
source,
|
||||||
|
rendererFactory,
|
||||||
services: {
|
services: {
|
||||||
createGizmoRenderer: createCesiumCctvGizmoRenderer,
|
|
||||||
credits,
|
credits,
|
||||||
sprites,
|
sprites,
|
||||||
activation,
|
activation,
|
||||||
@@ -29,4 +36,16 @@ export function createApplicationCctv({ surface, source }) {
|
|||||||
render,
|
render,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
...layer,
|
||||||
|
bindCctvWorldClickGesture,
|
||||||
|
// Legacy native helper; production navigation uses the initialized owner.
|
||||||
|
focusCctvRecord(viewer, record, duration) {
|
||||||
|
const renderer = viewer
|
||||||
|
? createCctvCesiumRenderer({ services: {}, state: {} })
|
||||||
|
: null;
|
||||||
|
renderer?.attach(viewer);
|
||||||
|
return layer.focusCctvRecord(renderer, record, duration);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+10
-1
@@ -305,6 +305,15 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
|
|||||||
inserted.push(entity);
|
inserted.push(entity);
|
||||||
return entity;
|
return entity;
|
||||||
},
|
},
|
||||||
|
getById(id) {
|
||||||
|
return inserted.find((entity) => entity.id === id);
|
||||||
|
},
|
||||||
|
remove(entity) {
|
||||||
|
const index = inserted.indexOf(entity);
|
||||||
|
if (index < 0) return false;
|
||||||
|
inserted.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const activeRecord = records.at(-1);
|
const activeRecord = records.at(-1);
|
||||||
@@ -966,7 +975,7 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
|
|||||||
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
||||||
assert.match(
|
assert.match(
|
||||||
componentFunctionSource(cctvLayer.init),
|
componentFunctionSource(cctvLayer.init),
|
||||||
/bindCctvWorldClickGesture\(_clickHandler/,
|
/_removeClick = bindClick\(/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
componentFunctionSource(cctvLayer.init),
|
componentFunctionSource(cctvLayer.init),
|
||||||
|
|||||||
+35
-55
@@ -6,17 +6,17 @@
|
|||||||
*
|
*
|
||||||
* Two responsibilities, both pure of layer state:
|
* Two responsibilities, both pure of layer state:
|
||||||
* - Color identity: a stable per-camera hue (golden-angle spaced over the
|
* - Color identity: a stable per-camera hue (golden-angle spaced over the
|
||||||
* id-sorted catalog index) and the derived fill/line Cesium colors.
|
* id-sorted catalog index) and the derived fill/line RGBA colors.
|
||||||
* - Volume geometry: a translucent frustum pyramid primitive built FROM the
|
* - Volume geometry: frustum pyramid buffers built FROM the
|
||||||
* exact frustumCartesians positions the wireframe uses — welded by
|
* exact frustumCartesians positions the wireframe uses — welded by
|
||||||
* construction, never recomputed independently. 5 vertices, 6 triangles,
|
* construction, never recomputed independently. 5 vertices, 6 triangles.
|
||||||
* synchronous build (trivially small), no picking.
|
* The renderer owns primitive creation and picking policy.
|
||||||
*
|
*
|
||||||
* Nothing here queries the scene or persists anything: colors are
|
* Nothing here queries the scene or persists anything: colors are
|
||||||
* presentation-only and volumes are rebuilt only at the moments the frustum
|
* presentation-only and volumes are rebuilt only at the moments the frustum
|
||||||
* wireframe already rewrites (pose edits / visible-set changes).
|
* wireframe already rewrites (pose edits / visible-set changes).
|
||||||
*/
|
*/
|
||||||
import * as Cesium from 'cesium';
|
import { Rgba } from '../layers/shared/color.js';
|
||||||
|
|
||||||
/** Golden angle in degrees — maximally spreads consecutive indices around the hue wheel. */
|
/** Golden angle in degrees — maximally spreads consecutive indices around the hue wheel. */
|
||||||
const GOLDEN_ANGLE_DEG = 137.50776405003785;
|
const GOLDEN_ANGLE_DEG = 137.50776405003785;
|
||||||
@@ -39,19 +39,42 @@ export function cameraHue(index) {
|
|||||||
return (i * GOLDEN_ANGLE_DEG) % 360;
|
return (i * GOLDEN_ANGLE_DEG) % 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same HSL arithmetic as the native reference, including operation order.
|
||||||
|
function viewshedHsl(hue, saturation, lightness, alpha) {
|
||||||
|
const high =
|
||||||
|
lightness < 0.5
|
||||||
|
? lightness * (1 + saturation)
|
||||||
|
: lightness + saturation - lightness * saturation;
|
||||||
|
const low = 2 * lightness - high;
|
||||||
|
const channel = (h) => {
|
||||||
|
if (h < 0) h += 1;
|
||||||
|
if (h > 1) h -= 1;
|
||||||
|
if (h * 6 < 1) return low + (high - low) * 6 * h;
|
||||||
|
if (h * 2 < 1) return high;
|
||||||
|
if (h * 3 < 2) return low + (high - low) * (2 / 3 - h) * 6;
|
||||||
|
return low;
|
||||||
|
};
|
||||||
|
return new Rgba(
|
||||||
|
channel(hue + 1 / 3),
|
||||||
|
channel(hue),
|
||||||
|
channel(hue - 1 / 3),
|
||||||
|
alpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derived viewshed colors for a hue: translucent volume fills (idle/active)
|
* Derived viewshed colors for a hue: translucent volume fills (idle/active)
|
||||||
* and wireframe line tints (idle/active), all in the same hue family.
|
* and wireframe line tints (idle/active), all in the same hue family.
|
||||||
* @param {number} hueDeg - Hue in degrees.
|
* @param {number} hueDeg - Hue in degrees.
|
||||||
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
|
* @returns {{fill: Rgba, fillActive: Rgba, line: Rgba, lineActive: Rgba}}
|
||||||
*/
|
*/
|
||||||
export function viewshedColors(hueDeg) {
|
export function viewshedColors(hueDeg) {
|
||||||
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
|
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
|
||||||
return {
|
return {
|
||||||
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
fill: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||||
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
fillActive: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||||
line: Cesium.Color.fromHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
|
line: viewshedHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
|
||||||
lineActive: Cesium.Color.fromHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE),
|
lineActive: viewshedHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +84,8 @@ export function viewshedColors(hueDeg) {
|
|||||||
* the apex + the far cap split into 2 triangles. Pure — this is the ONLY
|
* the apex + the far cap split into 2 triangles. Pure — this is the ONLY
|
||||||
* geometry definition (weld-by-construction with the wireframe, which draws
|
* geometry definition (weld-by-construction with the wireframe, which draws
|
||||||
* its rays/cap from the same 5 Cartesians).
|
* its rays/cap from the same 5 Cartesians).
|
||||||
* @param {{mount: Cesium.Cartesian3, tl: Cesium.Cartesian3, tr: Cesium.Cartesian3,
|
* @param {{mount: Object, tl: Object, tr: Object,
|
||||||
* br: Cesium.Cartesian3, bl: Cesium.Cartesian3}} positions - frustumCartesians shape.
|
* br: Object, bl: Object}} positions - ECEF frustumCartesians shape.
|
||||||
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
||||||
*/
|
*/
|
||||||
export function frustumVolumeGeometryData(positions) {
|
export function frustumVolumeGeometryData(positions) {
|
||||||
@@ -85,46 +108,3 @@ export function frustumVolumeGeometryData(positions) {
|
|||||||
]);
|
]);
|
||||||
return { positions: flat, indices };
|
return { positions: flat, indices };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the translucent frustum-volume primitive for one camera.
|
|
||||||
* Synchronous (6 triangles — cheaper than the async geometry pipeline's
|
|
||||||
* bookkeeping), unlit flat color, both faces visible (the viewer is routinely
|
|
||||||
* inside or behind a cone), never pickable (clicks fall through to the
|
|
||||||
* billboard/wireframe/plane, whose pick semantics are established).
|
|
||||||
* @param {Object} positions - frustumCartesians shape (see frustumVolumeGeometryData).
|
|
||||||
* @param {Cesium.Color} color - Per-camera fill color (already alpha'd).
|
|
||||||
* @returns {Cesium.Primitive}
|
|
||||||
*/
|
|
||||||
export function createFrustumVolumePrimitive(positions, color) {
|
|
||||||
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
|
|
||||||
const geometry = new Cesium.Geometry({
|
|
||||||
attributes: {
|
|
||||||
position: new Cesium.GeometryAttribute({
|
|
||||||
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
|
|
||||||
componentsPerAttribute: 3,
|
|
||||||
values: flat,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
indices,
|
|
||||||
primitiveType: Cesium.PrimitiveType.TRIANGLES,
|
|
||||||
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
|
|
||||||
});
|
|
||||||
return new Cesium.Primitive({
|
|
||||||
geometryInstances: new Cesium.GeometryInstance({
|
|
||||||
geometry,
|
|
||||||
attributes: {
|
|
||||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
appearance: new Cesium.PerInstanceColorAppearance({
|
|
||||||
flat: true,
|
|
||||||
translucent: true,
|
|
||||||
renderState: {
|
|
||||||
cull: { enabled: false },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asynchronous: false,
|
|
||||||
allowPicking: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -62,7 +62,7 @@ test('cameraHue: consecutive indices separated by >= 80 degrees', () => {
|
|||||||
test('viewshedColors: fill/line pairs with the designed alphas', () => {
|
test('viewshedColors: fill/line pairs with the designed alphas', () => {
|
||||||
const colors = viewshedColors(210);
|
const colors = viewshedColors(210);
|
||||||
for (const key of ['fill', 'fillActive', 'line', 'lineActive']) {
|
for (const key of ['fill', 'fillActive', 'line', 'lineActive']) {
|
||||||
assert.ok(colors[key] instanceof Cesium.Color, `${key} must be a Cesium.Color`);
|
assert.ok(['red', 'green', 'blue', 'alpha'].every((field) => Number.isFinite(colors[key][field])), `${key} has finite RGBA channels`);
|
||||||
}
|
}
|
||||||
assert.ok(Math.abs(colors.fill.alpha - 0.12) < 1e-6);
|
assert.ok(Math.abs(colors.fill.alpha - 0.12) < 1e-6);
|
||||||
assert.ok(Math.abs(colors.fillActive.alpha - 0.22) < 1e-6);
|
assert.ok(Math.abs(colors.fillActive.alpha - 0.22) < 1e-6);
|
||||||
@@ -72,6 +72,20 @@ test('viewshedColors: fill/line pairs with the designed alphas', () => {
|
|||||||
assert.ok(colors.line.red !== colors.line.green || colors.line.green !== colors.line.blue);
|
assert.ok(colors.line.red !== colors.line.green || colors.line.green !== colors.line.blue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('portable viewshed colours exactly match the native palette across hue wraps', () => {
|
||||||
|
const palette = { fill: [0.85, 0.6, 0.12], fillActive: [0.85, 0.6, 0.22], line: [0.9, 0.65, 0.85], lineActive: [0.9, 0.7, 1] };
|
||||||
|
for (let i = -512; i <= 512; i++) {
|
||||||
|
const degrees = i * 137.50776405003785;
|
||||||
|
const hue = (((degrees % 360) + 360) % 360) / 360;
|
||||||
|
const actual = viewshedColors(degrees);
|
||||||
|
for (const [key, values] of Object.entries(palette)) {
|
||||||
|
const expected = Cesium.Color.fromHsl(hue, ...values);
|
||||||
|
for (const field of ['red', 'green', 'blue', 'alpha']) assert.equal(actual[key][field], expected[field], `${degrees}/${key}/${field}`);
|
||||||
|
assert.equal(actual[key].withAlpha(0.3).alpha, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Minimal frustumCartesians-shaped fixture. */
|
/** Minimal frustumCartesians-shaped fixture. */
|
||||||
function positionsFixture() {
|
function positionsFixture() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
|||||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||||
import { createDirectionsSource } from '../layers/directions/source.js';
|
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||||
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||||
|
import { createCctvCesiumRenderer } from '../layers/cctv/render/cesium.js';
|
||||||
import * as input from './inputOwnership.js';
|
import * as input from './inputOwnership.js';
|
||||||
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
||||||
|
|
||||||
@@ -83,9 +84,12 @@ test('Directions introduces the shared OSM credit when the requested route draws
|
|||||||
|
|
||||||
test('Warendorf webcam display introduces the shared OSM credit', (t) => {
|
test('Warendorf webcam display introduces the shared OSM credit', (t) => {
|
||||||
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
|
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
|
||||||
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
|
const renderer = createCctvCesiumRenderer({ state: {}, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty } });
|
||||||
|
renderer.attach(v);
|
||||||
|
t.after(() => renderer.destroy());
|
||||||
|
const lifecycle = createCctvLifecycle({ state: { _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
|
||||||
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
|
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
|
||||||
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
|
parts: { renderer, selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
|
||||||
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
|
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
|
||||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export function createCalibration({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function ensureGizmo() {
|
function ensureGizmo() {
|
||||||
if (layerState._gizmo || !layerState._viewer) return;
|
if (layerState._gizmo || !parts.renderer?.ready) return;
|
||||||
// Both patch callbacks receive the gizmo's PINNED drag record — never
|
// Both patch callbacks receive the gizmo's PINNED drag record — never
|
||||||
// re-resolve the active camera here: a mid-drag voice select or auto-hop
|
// re-resolve the active camera here: a mid-drag voice select or auto-hop
|
||||||
// would route the captured offsets onto a camera with a different basePose.
|
// would route the captured offsets onto a camera with a different basePose.
|
||||||
@@ -306,7 +306,7 @@ export function createCalibration({
|
|||||||
? record
|
? record
|
||||||
: null;
|
: null;
|
||||||
layerState._gizmo = createCalibrationGizmo({
|
layerState._gizmo = createCalibrationGizmo({
|
||||||
renderer: services.createGizmoRenderer(layerState._viewer),
|
renderer: parts.renderer.createGizmoRenderer(),
|
||||||
getActiveRecord: () =>
|
getActiveRecord: () =>
|
||||||
layerState._enabled && layerState._calibrationMode
|
layerState._enabled && layerState._calibrationMode
|
||||||
? parts.selection.getActiveRecord()
|
? parts.selection.getActiveRecord()
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
applyFrameResult,
|
applyFrameResult,
|
||||||
} from '../../data/cctvCards.js';
|
} from '../../data/cctvCards.js';
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
|
||||||
import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js';
|
import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js';
|
||||||
import {
|
import {
|
||||||
CCTV_OVERLAY_SOURCE_OPTIONS,
|
CCTV_OVERLAY_SOURCE_OPTIONS,
|
||||||
@@ -35,7 +34,7 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
layerState._activeCameraCardEnabled = activeCameraCardEnabled === true;
|
layerState._activeCameraCardEnabled = activeCameraCardEnabled === true;
|
||||||
if (layerState._enabled) pushAmbientCardEntries();
|
if (layerState._enabled) pushAmbientCardEntries();
|
||||||
layerState._viewer?.scene?.requestRender?.();
|
parts.renderer?.request();
|
||||||
|
|
||||||
return { activeCameraCardEnabled: layerState._activeCameraCardEnabled };
|
return { activeCameraCardEnabled: layerState._activeCameraCardEnabled };
|
||||||
}
|
}
|
||||||
@@ -72,8 +71,7 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
function refreshAmbientCards() {
|
function refreshAmbientCards() {
|
||||||
if (
|
if (
|
||||||
!layerState._enabled ||
|
!layerState._enabled ||
|
||||||
!layerState._viewer ||
|
!parts.renderer?.ready ||
|
||||||
layerState._viewer.isDestroyed() ||
|
|
||||||
!layerState._records.length
|
!layerState._records.length
|
||||||
) {
|
) {
|
||||||
layerState._cctvOverlayHost.setEntries(
|
layerState._cctvOverlayHost.setEntries(
|
||||||
@@ -83,17 +81,15 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scene = layerState._viewer.scene;
|
const carto = parts.renderer.cameraCartographic();
|
||||||
const carto = layerState._viewer.camera.positionCartographic;
|
|
||||||
const viewerLat = carto ? geo.math.toDegrees(carto.latitude) : 0;
|
const viewerLat = carto ? geo.math.toDegrees(carto.latitude) : 0;
|
||||||
const viewerLon = carto ? geo.math.toDegrees(carto.longitude) : 0;
|
const viewerLon = carto ? geo.math.toDegrees(carto.longitude) : 0;
|
||||||
// The active camera is excluded from ambient selection ALWAYS (not just
|
// The active camera is excluded from ambient selection ALWAYS (not just
|
||||||
// after its async activation settles). It is published separately in the
|
// after its async activation settles). It is published separately in the
|
||||||
// protected lane below, and grace never applies to it.
|
// protected lane below, and grace never applies to it.
|
||||||
const activeId = layerState._activeCameraId;
|
const activeId = layerState._activeCameraId;
|
||||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
const horizonVisible = parts.renderer.horizonTest();
|
||||||
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
|
const { width, height } = parts.renderer.viewport();
|
||||||
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
|
|
||||||
const marginX = width * CARD_VIEW_MARGIN;
|
const marginX = width * CARD_VIEW_MARGIN;
|
||||||
const marginY = height * CARD_VIEW_MARGIN;
|
const marginY = height * CARD_VIEW_MARGIN;
|
||||||
|
|
||||||
@@ -105,8 +101,8 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
let inView = false;
|
let inView = false;
|
||||||
let sx = NaN;
|
let sx = NaN;
|
||||||
let sy = NaN;
|
let sy = NaN;
|
||||||
if (occluder.isPointVisible(record.position)) {
|
if (horizonVisible(record.position)) {
|
||||||
const screen = scene.cartesianToCanvasCoordinates(record.position);
|
const screen = parts.renderer.project(record.position);
|
||||||
if (
|
if (
|
||||||
screen &&
|
screen &&
|
||||||
Number.isFinite(screen.x) &&
|
Number.isFinite(screen.x) &&
|
||||||
@@ -325,7 +321,7 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
slot,
|
slot,
|
||||||
applyFrameResult(slot, { ok: !!frame, frame }, Date.now()),
|
applyFrameResult(slot, { ok: !!frame, frame }, Date.now()),
|
||||||
);
|
);
|
||||||
layerState._viewer?.scene?.requestRender?.();
|
parts.renderer?.request();
|
||||||
};
|
};
|
||||||
image.onload = () => settle(true);
|
image.onload = () => settle(true);
|
||||||
image.onerror = () => settle(false);
|
image.onerror = () => settle(false);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
|
const planeShowing = !!(runtime.planeVisible && runtime.planeMaterial);
|
||||||
if (!planeShowing) return;
|
if (!planeShowing) return;
|
||||||
|
|
||||||
// Only swap when the canvas content actually changed since the last swap.
|
// Only swap when the canvas content actually changed since the last swap.
|
||||||
@@ -141,7 +141,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
if (!buffer) return;
|
if (!buffer) return;
|
||||||
runtime.lastTextureSwapAt = now;
|
runtime.lastTextureSwapAt = now;
|
||||||
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
||||||
runtime.planeMaterial.image = buffer;
|
parts.renderer.setPlaneImage(runtime, buffer);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-80
@@ -1,6 +1,4 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
|
|
||||||
import {
|
import {
|
||||||
PROJECTION_VERT_ASPECT,
|
PROJECTION_VERT_ASPECT,
|
||||||
FRUSTUM_GROUND_CLEARANCE_M,
|
FRUSTUM_GROUND_CLEARANCE_M,
|
||||||
@@ -8,7 +6,6 @@ import {
|
|||||||
PROBE_CLEARANCE_M,
|
PROBE_CLEARANCE_M,
|
||||||
COVERAGE_NEIGHBOR_RADIUS_KM,
|
COVERAGE_NEIGHBOR_RADIUS_KM,
|
||||||
COVERAGE_NEIGHBOR_LIMIT,
|
COVERAGE_NEIGHBOR_LIMIT,
|
||||||
IDLE_COVERAGE_COLOR,
|
|
||||||
PLANE_FOOTPRINT_LIFT_CAP_M,
|
PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
@@ -20,7 +17,6 @@ import {
|
|||||||
|
|
||||||
export function createGeometry({ state: layerState, services, parts, source }) {
|
export function createGeometry({ state: layerState, services, parts, source }) {
|
||||||
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
||||||
const { sampleMeshFloorCells } = services.mesh;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ground measured under the plane's support points for the record's
|
* Ground measured under the plane's support points for the record's
|
||||||
@@ -346,31 +342,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
if (record.billboard) {
|
if (record.billboard) {
|
||||||
record.billboard.position = positions.mount;
|
record.billboard.position = positions.mount;
|
||||||
}
|
}
|
||||||
if (record.coverageEntities?.length >= 5) {
|
parts.renderer?.updateCoverage(record, positions);
|
||||||
record.coverageEntities[0].polyline.positions = [
|
|
||||||
positions.mount,
|
|
||||||
positions.tl,
|
|
||||||
];
|
|
||||||
record.coverageEntities[1].polyline.positions = [
|
|
||||||
positions.mount,
|
|
||||||
positions.tr,
|
|
||||||
];
|
|
||||||
record.coverageEntities[2].polyline.positions = [
|
|
||||||
positions.mount,
|
|
||||||
positions.br,
|
|
||||||
];
|
|
||||||
record.coverageEntities[3].polyline.positions = [
|
|
||||||
positions.mount,
|
|
||||||
positions.bl,
|
|
||||||
];
|
|
||||||
record.coverageEntities[4].polyline.positions = [
|
|
||||||
positions.tl,
|
|
||||||
positions.tr,
|
|
||||||
positions.br,
|
|
||||||
positions.bl,
|
|
||||||
positions.tl,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
|
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
|
||||||
// positions (weld invariant). Only records currently showing a volume pay
|
// positions (weld invariant). Only records currently showing a volume pay
|
||||||
// this (6 triangles, synchronous — trivial even during slider/gizmo drags).
|
// this (6 triangles, synchronous — trivial even during slider/gizmo drags).
|
||||||
@@ -451,12 +423,12 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
if (sampleGround && parts.model.projectionTilesReady()) {
|
if (sampleGround && parts.model.projectionTilesReady()) {
|
||||||
record.groundMeshSampleRequestCount =
|
record.groundMeshSampleRequestCount =
|
||||||
(record.groundMeshSampleRequestCount || 0) + 1;
|
(record.groundMeshSampleRequestCount || 0) + 1;
|
||||||
const viewerCarto = layerState._viewer?.camera?.positionCartographic;
|
const viewerCarto = parts.renderer?.cameraCartographic();
|
||||||
const excludeObjects = [...(record.coverageEntities || [])];
|
const excludeObjects = [...(record.coverageEntities || [])];
|
||||||
if (record.billboard) excludeObjects.push(record.billboard);
|
if (record.billboard) excludeObjects.push(record.billboard);
|
||||||
if (record.projection?.planeEntity)
|
if (record.projection?.planeEntity)
|
||||||
excludeObjects.push(record.projection.planeEntity);
|
excludeObjects.push(record.projection.planeEntity);
|
||||||
sampleMeshFloorCells(layerState._viewer?.scene, [point], {
|
parts.renderer.sampleMeshFloorCells([point], {
|
||||||
excludeObjects: excludeObjects.filter(Boolean),
|
excludeObjects: excludeObjects.filter(Boolean),
|
||||||
viewerLat: viewerCarto
|
viewerLat: viewerCarto
|
||||||
? geo.math.toDegrees(viewerCarto.latitude)
|
? geo.math.toDegrees(viewerCarto.latitude)
|
||||||
@@ -530,9 +502,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function destroyViewshedVolume(record) {
|
function destroyViewshedVolume(record) {
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (record.viewshedPrimitive && layerState._viewer) {
|
if (record.viewshedPrimitive)
|
||||||
layerState._viewer.scene.primitives.remove(record.viewshedPrimitive);
|
parts.renderer?.removeVolume(record.viewshedPrimitive);
|
||||||
}
|
|
||||||
record.viewshedPrimitive = null;
|
record.viewshedPrimitive = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +521,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
function rebuildViewshedVolume(record, isActive) {
|
function rebuildViewshedVolume(record, isActive) {
|
||||||
destroyViewshedVolume(record);
|
destroyViewshedVolume(record);
|
||||||
if (
|
if (
|
||||||
!layerState._viewer ||
|
!parts.renderer?.ready ||
|
||||||
!record?.frustumPositions ||
|
!record?.frustumPositions ||
|
||||||
!record.viewshedColors
|
!record.viewshedColors
|
||||||
)
|
)
|
||||||
@@ -558,14 +529,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
const color = isActive
|
const color = isActive
|
||||||
? record.viewshedColors.fillActive
|
? record.viewshedColors.fillActive
|
||||||
: record.viewshedColors.fill;
|
: record.viewshedColors.fill;
|
||||||
const primitive = createFrustumVolumePrimitive(
|
record.viewshedPrimitive = parts.renderer.createVolume(record, color);
|
||||||
record.frustumPositions,
|
|
||||||
color,
|
|
||||||
);
|
|
||||||
// QA tag: the harness counts viewshed volumes by this marker.
|
|
||||||
primitive._gevViewshed = record.camera.id;
|
|
||||||
record.viewshedPrimitive =
|
|
||||||
layerState._viewer.scene.primitives.add(primitive);
|
|
||||||
record.viewshedActiveTint = !!isActive;
|
record.viewshedActiveTint = !!isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,8 +575,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function runActivationObstructionProbe(record) {
|
function runActivationObstructionProbe(record) {
|
||||||
record.probeClampRangeM = null;
|
record.probeClampRangeM = null;
|
||||||
const scene = layerState._viewer?.scene;
|
if (!parts.renderer?.ready) return;
|
||||||
if (!scene || typeof scene.pickFromRay !== 'function') return;
|
|
||||||
const camera = record.camera;
|
const camera = record.camera;
|
||||||
const rangeScale = parts.calibration.normalizeCalibration(
|
const rangeScale = parts.calibration.normalizeCalibration(
|
||||||
camera.calibration,
|
camera.calibration,
|
||||||
@@ -628,11 +591,19 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
const { dir } = frustumFrameEcef(camera, mountPos);
|
const { dir } = frustumFrameEcef(camera, mountPos);
|
||||||
// Exclude everything the layer itself draws so the probe can only hit the
|
// Exclude everything the layer itself draws so the probe can only hit the
|
||||||
// world (3D tiles), not our own billboards/polylines/planes.
|
// world (3D tiles), not our own billboards/polylines/planes.
|
||||||
const exclude = [layerState._billboards, ...layerState._coverageEntities];
|
const exclude = [...layerState._coverageEntities];
|
||||||
for (const runtime of layerState._projectionEntities) {
|
for (const runtime of layerState._projectionEntities) {
|
||||||
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
|
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
|
||||||
}
|
}
|
||||||
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude);
|
for (const other of layerState._records) {
|
||||||
|
if (other.viewshedPrimitive) exclude.push(other.viewshedPrimitive);
|
||||||
|
}
|
||||||
|
const hit = parts.renderer.obstructionRay(
|
||||||
|
mountPos,
|
||||||
|
dir,
|
||||||
|
exclude,
|
||||||
|
camera.rangeM,
|
||||||
|
);
|
||||||
if (!hit?.position) return;
|
if (!hit?.position) return;
|
||||||
const dist = geo.cartesian3.distance(mountPos, hit.position);
|
const dist = geo.cartesian3.distance(mountPos, hit.position);
|
||||||
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
|
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
|
||||||
@@ -684,37 +655,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
record.position = positions.mount;
|
record.position = positions.mount;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPolyline = (role, linePositions) =>
|
return parts.renderer.createCoverage(camera.id, positions);
|
||||||
layerState._viewer.entities.add({
|
|
||||||
id: `cctv-${camera.id}-${role}`,
|
|
||||||
properties: { cctvCameraId: camera.id },
|
|
||||||
polyline: {
|
|
||||||
positions: linePositions,
|
|
||||||
width: 1.2,
|
|
||||||
material: IDLE_COVERAGE_COLOR,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const entities = [
|
|
||||||
addPolyline('ray-tl', [positions.mount, positions.tl]),
|
|
||||||
addPolyline('ray-tr', [positions.mount, positions.tr]),
|
|
||||||
addPolyline('ray-br', [positions.mount, positions.br]),
|
|
||||||
addPolyline('ray-bl', [positions.mount, positions.bl]),
|
|
||||||
addPolyline('cap', [
|
|
||||||
positions.tl,
|
|
||||||
positions.tr,
|
|
||||||
positions.br,
|
|
||||||
positions.bl,
|
|
||||||
positions.tl,
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
entities[0]._coverageRole = 'edge';
|
|
||||||
entities[1]._coverageRole = 'edge';
|
|
||||||
entities[2]._coverageRole = 'edge';
|
|
||||||
entities[3]._coverageRole = 'edge';
|
|
||||||
entities[4]._coverageRole = 'cap';
|
|
||||||
return entities;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -797,9 +738,9 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function destroyCoverageEntities() {
|
function destroyCoverageEntities() {
|
||||||
if (!layerState._viewer) return;
|
if (!parts.renderer?.ready) return;
|
||||||
for (const entity of layerState._coverageEntities) {
|
for (const entity of layerState._coverageEntities) {
|
||||||
layerState._viewer.entities.remove(entity);
|
parts.renderer.removeCoverageEntity(entity);
|
||||||
}
|
}
|
||||||
layerState._coverageEntities = [];
|
layerState._coverageEntities = [];
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export function createGeometryQueue({
|
|||||||
|
|
||||||
function processGeometryBatch() {
|
function processGeometryBatch() {
|
||||||
layerState._geoQueueTimer = 0;
|
layerState._geoQueueTimer = 0;
|
||||||
if (!layerState._viewer) {
|
if (!parts.renderer?.ready) {
|
||||||
stopGeometryLoadQueue();
|
stopGeometryLoadQueue();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ export function createGeometryQueue({
|
|||||||
const batchResult = processCctvGeometryDrainBatch({
|
const batchResult = processCctvGeometryDrainBatch({
|
||||||
queue: layerState._geoQueue,
|
queue: layerState._geoQueue,
|
||||||
readOwnership: () => ({
|
readOwnership: () => ({
|
||||||
trackedEntity: layerState._viewer.trackedEntity,
|
trackedEntity: parts.renderer.following(),
|
||||||
cockpitActive:
|
cockpitActive:
|
||||||
typeof document !== 'undefined' &&
|
typeof document !== 'undefined' &&
|
||||||
document.body?.classList.contains('cockpit-mode'),
|
document.body?.classList.contains('cockpit-mode'),
|
||||||
@@ -296,7 +296,7 @@ export function createGeometryQueue({
|
|||||||
layerState._tilesReadyReenqueued = false;
|
layerState._tilesReadyReenqueued = false;
|
||||||
if (!layerState._records.length) return;
|
if (!layerState._records.length) return;
|
||||||
const active = parts.selection.getActiveRecord();
|
const active = parts.selection.getActiveRecord();
|
||||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
const carto = parts.renderer?.cameraCartographic();
|
||||||
const refLat = carto
|
const refLat = carto
|
||||||
? geo.math.toDegrees(carto.latitude)
|
? geo.math.toDegrees(carto.latitude)
|
||||||
: (active?.camera.lat ?? 0);
|
: (active?.camera.lat ?? 0);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function currentSurfaceRegime() {
|
function currentSurfaceRegime() {
|
||||||
return surfaceRegimeKey(layerState._viewer?.scene?.globe?.show);
|
return surfaceRegimeKey(parts.renderer?.globeVisible());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,7 +244,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function handleMapStackChanged() {
|
function handleMapStackChanged() {
|
||||||
if (!layerState._viewer || !layerState._records.length) return;
|
if (!parts.renderer?.ready || !layerState._records.length) return;
|
||||||
const regime = currentSurfaceRegime();
|
const regime = currentSurfaceRegime();
|
||||||
if (regime === layerState._lastAppliedRegime) return;
|
if (regime === layerState._lastAppliedRegime) return;
|
||||||
layerState._lastAppliedRegime = regime;
|
layerState._lastAppliedRegime = regime;
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ export function createHover({ state: layerState, services, parts, source }) {
|
|||||||
!position
|
!position
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (!layerState._viewer || layerState._viewer.isDestroyed()) return;
|
if (!parts.renderer?.ready) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return;
|
if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return;
|
||||||
layerState._hoverLastPickAt = now;
|
layerState._hoverLastPickAt = now;
|
||||||
let picked = null;
|
let picked = null;
|
||||||
try {
|
try {
|
||||||
picked = layerState._viewer.scene.pick(position);
|
picked = parts.renderer.pick(position);
|
||||||
} catch {
|
} catch {
|
||||||
picked = null;
|
picked = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { createIngestion } from './ingestion.js';
|
|||||||
import { createState } from './state.js';
|
import { createState } from './state.js';
|
||||||
|
|
||||||
/** Construct one layer with its own scene state and supplied application services. */
|
/** Construct one layer with its own scene state and supplied application services. */
|
||||||
export function createCctvLayer({ services, source }) {
|
export function createCctvLayer({ services, source, rendererFactory }) {
|
||||||
if (
|
if (
|
||||||
!['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every(
|
!['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every(
|
||||||
(key) => typeof source?.[key] === 'function',
|
(key) => typeof source?.[key] === 'function',
|
||||||
@@ -29,7 +29,7 @@ export function createCctvLayer({ services, source }) {
|
|||||||
throw new TypeError('A camera source is required');
|
throw new TypeError('A camera source is required');
|
||||||
const state = createState({ services });
|
const state = createState({ services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const context = { state, services, parts, source };
|
const context = { state, services, parts, source, rendererFactory };
|
||||||
parts.calibration = createCalibration(context);
|
parts.calibration = createCalibration(context);
|
||||||
parts.testing = createTesting(context);
|
parts.testing = createTesting(context);
|
||||||
parts.projection = createProjection(context);
|
parts.projection = createProjection(context);
|
||||||
@@ -94,7 +94,6 @@ export function createCctvLayer({ services, source }) {
|
|||||||
clearProbeClampOnDeactivation:
|
clearProbeClampOnDeactivation:
|
||||||
parts.geometry.clearProbeClampOnDeactivation,
|
parts.geometry.clearProbeClampOnDeactivation,
|
||||||
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
||||||
bindCctvWorldClickGesture: parts.selection.bindCctvWorldClickGesture,
|
|
||||||
setActiveCamera: parts.selection.setActiveCamera,
|
setActiveCamera: parts.selection.setActiveCamera,
|
||||||
deactivateActiveCamera: parts.selection.deactivateActiveCamera,
|
deactivateActiveCamera: parts.selection.deactivateActiveCamera,
|
||||||
cctvEmptyClickDeselects: parts.selection.cctvEmptyClickDeselects,
|
cctvEmptyClickDeselects: parts.selection.cctvEmptyClickDeselects,
|
||||||
|
|||||||
+278
-301
@@ -1,11 +1,9 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as geo from '../../geo/index.js';
|
||||||
import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js';
|
import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js';
|
||||||
import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js';
|
import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js';
|
||||||
import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js';
|
import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js';
|
||||||
import {
|
import {
|
||||||
GROUND_PRIOR_INIT_WAIT_MS,
|
GROUND_PRIOR_INIT_WAIT_MS,
|
||||||
CAMERA_ICON,
|
|
||||||
IDLE_CAMERA_COLOR,
|
|
||||||
CALIBRATION_RANGE_FLOOR_M,
|
CALIBRATION_RANGE_FLOOR_M,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
@@ -14,8 +12,8 @@ export function createLifecycle({
|
|||||||
services,
|
services,
|
||||||
parts,
|
parts,
|
||||||
source,
|
source,
|
||||||
|
rendererFactory,
|
||||||
}) {
|
}) {
|
||||||
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
|
|
||||||
const { activateCctvCameraFromWorldClick } = services.activation;
|
const { activateCctvCameraFromWorldClick } = services.activation;
|
||||||
const { resolvePickId, registerPickOwner, unregisterPickOwner } =
|
const { resolvePickId, registerPickOwner, unregisterPickOwner } =
|
||||||
services.picking;
|
services.picking;
|
||||||
@@ -38,9 +36,6 @@ export function createLifecycle({
|
|||||||
layerState._lastError = null;
|
layerState._lastError = null;
|
||||||
layerState._lastFocusStyleAt = 0;
|
layerState._lastFocusStyleAt = 0;
|
||||||
layerState._activeFocusStyleCount = 0;
|
layerState._activeFocusStyleCount = 0;
|
||||||
// FIX ①/③: the discovered tileset handle is scene-scoped — drop it so a fresh
|
|
||||||
// init re-discovers against the current scene primitives.
|
|
||||||
layerState._activeTileset = null;
|
|
||||||
// Task 5: the applied-regime tracker is record-set-scoped — a fresh init
|
// Task 5: the applied-regime tracker is record-set-scoped — a fresh init
|
||||||
// recomputes it against the then-current scene.
|
// recomputes it against the then-current scene.
|
||||||
layerState._lastAppliedRegime = null;
|
layerState._lastAppliedRegime = null;
|
||||||
@@ -52,283 +47,277 @@ export function createLifecycle({
|
|||||||
* handling, and performs initial health sync. Coverage entities stay lazy.
|
* handling, and performs initial health sync. Coverage entities stay lazy.
|
||||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
|
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
|
||||||
*/
|
*/
|
||||||
async init(viewer) {
|
async init(viewer, options = {}) {
|
||||||
layerState._sourceAbort?.abort();
|
if (parts.renderer) throw new Error('CCTV layer is already initialized');
|
||||||
const sourceAbort = new AbortController();
|
if (typeof rendererFactory !== 'function')
|
||||||
layerState._sourceAbort = sourceAbort;
|
throw new TypeError('A CCTV renderer factory is required');
|
||||||
if (typeof document !== 'undefined')
|
const renderer = rendererFactory({
|
||||||
document.addEventListener(
|
state: layerState,
|
||||||
'visibilitychange',
|
services,
|
||||||
parts.cards.handleVisibilityChange,
|
parts,
|
||||||
|
source,
|
||||||
|
engine: options.engine,
|
||||||
|
});
|
||||||
|
parts.renderer = renderer;
|
||||||
|
try {
|
||||||
|
renderer.attach(viewer);
|
||||||
|
layerState._sourceAbort?.abort();
|
||||||
|
const sourceAbort = new AbortController();
|
||||||
|
layerState._sourceAbort = sourceAbort;
|
||||||
|
if (typeof document !== 'undefined')
|
||||||
|
document.addEventListener(
|
||||||
|
'visibilitychange',
|
||||||
|
parts.cards.handleVisibilityChange,
|
||||||
|
);
|
||||||
|
clearRuntimeState();
|
||||||
|
layerState._enabled = false;
|
||||||
|
layerState._activeCameraId = null;
|
||||||
|
layerState._autoHopSuspended = false;
|
||||||
|
layerState._lastHopAt = 0;
|
||||||
|
layerState._lastViewContext = '';
|
||||||
|
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
||||||
|
|
||||||
|
parts.renderer.createMarkers();
|
||||||
|
|
||||||
|
const sources = await parts.catalog.loadCameraSources();
|
||||||
|
sourceAbort.signal.throwIfAborted();
|
||||||
|
// prettier-ignore
|
||||||
|
const catalogFromSources = parts.catalog.buildCatalogFromSources(sources);
|
||||||
|
const catalog = catalogFromSources.length
|
||||||
|
? catalogFromSources
|
||||||
|
: parts.catalog.seedCatalog();
|
||||||
|
|
||||||
|
// Viewshed color identity (design §3a): golden-angle hue over the
|
||||||
|
// id-SORTED catalog index — deterministic across sessions for a stable
|
||||||
|
// catalog, maximally separated for neighboring cameras.
|
||||||
|
const hueIndexById = new Map(
|
||||||
|
catalog
|
||||||
|
.map((camera) => camera.id)
|
||||||
|
.sort()
|
||||||
|
.map((id, index) => [id, index]),
|
||||||
);
|
);
|
||||||
layerState._viewer = viewer;
|
|
||||||
clearRuntimeState();
|
|
||||||
layerState._enabled = false;
|
|
||||||
layerState._activeCameraId = null;
|
|
||||||
layerState._autoHopSuspended = false;
|
|
||||||
layerState._lastHopAt = 0;
|
|
||||||
layerState._lastViewContext = '';
|
|
||||||
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
|
||||||
|
|
||||||
layerState._billboards = new Cesium.BillboardCollection();
|
for (const camera of catalog) {
|
||||||
layerState._viewer.scene.primitives.add(layerState._billboards);
|
const savedEntry = layerState._calibrationById.get(camera.id);
|
||||||
registerSpriteCollection('cctv', layerState._billboards);
|
if (savedEntry) {
|
||||||
|
// Entries saved before the range floor dropped (no rangeFloorM)
|
||||||
const sources = await parts.catalog.loadCameraSources();
|
// keep their effective range by re-basing rangeScale once.
|
||||||
const catalogFromSources = parts.catalog.buildCatalogFromSources(sources);
|
const values =
|
||||||
const catalog = catalogFromSources.length
|
savedEntry.rangeFloorM === CALIBRATION_RANGE_FLOOR_M
|
||||||
? catalogFromSources
|
? savedEntry.values
|
||||||
: parts.catalog.seedCatalog();
|
: parts.calibration.migrateRangeScaleForFloor(
|
||||||
|
savedEntry.values,
|
||||||
// Viewshed color identity (design §3a): golden-angle hue over the
|
camera.rangeM,
|
||||||
// id-SORTED catalog index — deterministic across sessions for a stable
|
);
|
||||||
// catalog, maximally separated for neighboring cameras.
|
savedEntry.values = values;
|
||||||
const hueIndexById = new Map(
|
savedEntry.rangeFloorM = CALIBRATION_RANGE_FLOOR_M;
|
||||||
catalog
|
camera.calibration = parts.calibration.normalizeCalibration(values);
|
||||||
.map((camera) => camera.id)
|
camera.calSource = savedEntry.source;
|
||||||
.sort()
|
}
|
||||||
.map((id, index) => [id, index]),
|
parts.model.ensureCameraPose(camera);
|
||||||
);
|
|
||||||
|
|
||||||
for (const camera of catalog) {
|
|
||||||
const savedEntry = layerState._calibrationById.get(camera.id);
|
|
||||||
if (savedEntry) {
|
|
||||||
// Entries saved before the range floor dropped (no rangeFloorM)
|
|
||||||
// keep their effective range by re-basing rangeScale once.
|
|
||||||
const values =
|
|
||||||
savedEntry.rangeFloorM === CALIBRATION_RANGE_FLOOR_M
|
|
||||||
? savedEntry.values
|
|
||||||
: parts.calibration.migrateRangeScaleForFloor(
|
|
||||||
savedEntry.values,
|
|
||||||
camera.rangeM,
|
|
||||||
);
|
|
||||||
savedEntry.values = values;
|
|
||||||
savedEntry.rangeFloorM = CALIBRATION_RANGE_FLOOR_M;
|
|
||||||
camera.calibration = parts.calibration.normalizeCalibration(values);
|
|
||||||
camera.calSource = savedEntry.source;
|
|
||||||
}
|
}
|
||||||
parts.model.ensureCameraPose(camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
|
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
|
||||||
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
|
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
|
||||||
// the catalog's orthometric groundElevationM feeds the geoid fallback
|
// the catalog's orthometric groundElevationM feeds the geoid fallback
|
||||||
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
|
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
|
||||||
// records are normally built WITH their prior (correct first paint in
|
// records are normally built WITH their prior (correct first paint in
|
||||||
// every regime); a cold/slow upstream loses the race and the batch
|
// every regime); a cold/slow upstream loses the race and the batch
|
||||||
// applies post-hoc via applyLateGroundPriors instead of hanging init.
|
// applies post-hoc via applyLateGroundPriors instead of hanging init.
|
||||||
const priorsPromise = parts.ground.resolveGroundPriors(catalog);
|
const priorsPromise = parts.ground.resolveGroundPriors(catalog);
|
||||||
const priors = await Promise.race([
|
const priors = await Promise.race([
|
||||||
priorsPromise,
|
priorsPromise,
|
||||||
new Promise((resolve) =>
|
new Promise((resolve) =>
|
||||||
setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS),
|
setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS),
|
||||||
),
|
|
||||||
]);
|
|
||||||
sourceAbort.signal.throwIfAborted();
|
|
||||||
|
|
||||||
for (let i = 0; i < catalog.length; i++) {
|
|
||||||
const camera = catalog[i];
|
|
||||||
// Ellipsoidal ground prior (or null while the batch is still in
|
|
||||||
// flight). Geometry falls back to the catalog value only until the
|
|
||||||
// batch lands.
|
|
||||||
const groundPrior = priors?.[i] || null;
|
|
||||||
// Cheap first-pass altitude from the ellipsoidal prior (catalog value
|
|
||||||
// only as the pre-prior fallback) — the staggered geometry queue
|
|
||||||
// refines with sampled tile heights after enable so the init path
|
|
||||||
// never raycasts the scene once per camera.
|
|
||||||
const priorGround = Number.isFinite(groundPrior?.ellipsoid)
|
|
||||||
? groundPrior.ellipsoid
|
|
||||||
: Number(camera.groundElevationM) || 0;
|
|
||||||
camera.absoluteHeightM = priorGround + camera.mountHeightM;
|
|
||||||
const position = Cesium.Cartesian3.fromDegrees(
|
|
||||||
camera.lon,
|
|
||||||
camera.lat,
|
|
||||||
camera.absoluteHeightM,
|
|
||||||
);
|
|
||||||
const billboard = layerState._billboards.add({
|
|
||||||
id: camera.id,
|
|
||||||
image: CAMERA_ICON,
|
|
||||||
position,
|
|
||||||
color: IDLE_CAMERA_COLOR,
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
// Field-test fix (2026-07-06): always-on-top. The old finite value
|
|
||||||
// (1800 m) re-engaged the depth test at far zoom, where the COARSE
|
|
||||||
// far-LOD Google-3D mesh sits above the true ground and swallowed
|
|
||||||
// ground-anchored icons ("submerged" pills over SF). Far-side-of-globe
|
|
||||||
// icons are handled by refreshHorizonCulling() (the flights-layer
|
|
||||||
// EllipsoidalOccluder pattern), not by the depth test.
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42),
|
|
||||||
});
|
|
||||||
|
|
||||||
const record = {
|
|
||||||
camera,
|
|
||||||
position,
|
|
||||||
billboard,
|
|
||||||
coverageEntities: [],
|
|
||||||
projection: null,
|
|
||||||
// Task 5 (height-datum fix): regime-aware ground resolution state.
|
|
||||||
// groundPrior — { ellipsoid, source } from the Re:Earth batch
|
|
||||||
// (null until a late batch lands). The prior applies in EVERY
|
|
||||||
// regime and is the terrain-globe resolution outright.
|
|
||||||
// groundResolved — PER-REGIME one-shot latch (regime key →
|
|
||||||
// boolean): true once this record's resolution completed for that
|
|
||||||
// regime; such records are excluded from the completion pass so
|
|
||||||
// their geometry freezes. Re-armed only on a genuine pose change,
|
|
||||||
// explicit user select/move, or a surface-regime change — never
|
|
||||||
// on the 10s timer.
|
|
||||||
// groundSamples — PER-REGIME resolved ground (regime key →
|
|
||||||
// metres): the accepted one-shot scene sample in google-3d, the
|
|
||||||
// mirrored prior in terrain-globe. Kept across re-arms as the
|
|
||||||
// "has ever resolved" memory for the B9c mid-stream guard.
|
|
||||||
// frustumPositions — cached Cartesians for pure recomputes (so
|
|
||||||
// plane placement never re-derives geometry it already has).
|
|
||||||
groundPrior,
|
|
||||||
groundResolved: {},
|
|
||||||
groundSamples: {},
|
|
||||||
frustumGeometry: null,
|
|
||||||
frustumPositions: null,
|
|
||||||
// §9.1 activation obstruction probe result: effective-range clamp so
|
|
||||||
// the far-cap plane never clips into the tiles. Null = unclamped.
|
|
||||||
// Reset + re-probed on every activation; cleared when the user takes
|
|
||||||
// the range slider (slider overrides the clamp).
|
|
||||||
probeClampRangeM: null,
|
|
||||||
// Viewshed (design §3a/§3b): per-camera color identity + the volume
|
|
||||||
// primitive handle (exists only in viewshed mode for the visible set).
|
|
||||||
viewshedColors: viewshedColors(
|
|
||||||
cameraHue(hueIndexById.get(camera.id) ?? 0),
|
|
||||||
),
|
),
|
||||||
viewshedPrimitive: null,
|
]);
|
||||||
viewshedActiveTint: false,
|
sourceAbort.signal.throwIfAborted();
|
||||||
};
|
|
||||||
layerState._records.push(record);
|
|
||||||
layerState._recordById.set(camera.id, record);
|
|
||||||
}
|
|
||||||
|
|
||||||
layerState._count = layerState._records.length;
|
for (let i = 0; i < catalog.length; i++) {
|
||||||
if (layerState._records.length > 0) {
|
const camera = catalog[i];
|
||||||
// Projection runtime + first frame fetch are deferred to enable() so
|
// Ellipsoidal ground prior (or null while the batch is still in
|
||||||
// initializing the catalog stays render-cheap.
|
// flight). Geometry falls back to the catalog value only until the
|
||||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
// batch lands.
|
||||||
}
|
const groundPrior = priors?.[i] || null;
|
||||||
|
// Cheap first-pass altitude from the ellipsoidal prior (catalog value
|
||||||
|
// only as the pre-prior fallback) — the staggered geometry queue
|
||||||
|
// refines with sampled tile heights after enable so the init path
|
||||||
|
// never raycasts the scene once per camera.
|
||||||
|
const priorGround = Number.isFinite(groundPrior?.ellipsoid)
|
||||||
|
? groundPrior.ellipsoid
|
||||||
|
: Number(camera.groundElevationM) || 0;
|
||||||
|
camera.absoluteHeightM = priorGround + camera.mountHeightM;
|
||||||
|
const position = geo.cartesian3.fromDegrees(
|
||||||
|
camera.lon,
|
||||||
|
camera.lat,
|
||||||
|
camera.absoluteHeightM,
|
||||||
|
);
|
||||||
|
const billboard = parts.renderer.createMarker(camera, position);
|
||||||
|
|
||||||
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
|
const record = {
|
||||||
// when it lands (pure recomputes — applyLateGroundPriors guards against
|
camera,
|
||||||
// a torn-down/re-inited catalog).
|
position,
|
||||||
if (!priors) {
|
billboard,
|
||||||
const initRecords = layerState._records.slice();
|
coverageEntities: [],
|
||||||
priorsPromise
|
projection: null,
|
||||||
.then((late) => {
|
// Task 5 (height-datum fix): regime-aware ground resolution state.
|
||||||
if (late) parts.ground.applyLateGroundPriors(initRecords, late);
|
// groundPrior — { ellipsoid, source } from the Re:Earth batch
|
||||||
})
|
// (null until a late batch lands). The prior applies in EVERY
|
||||||
.catch(() => {});
|
// regime and is the terrain-globe resolution outright.
|
||||||
}
|
// groundResolved — PER-REGIME one-shot latch (regime key →
|
||||||
|
// boolean): true once this record's resolution completed for that
|
||||||
|
// regime; such records are excluded from the completion pass so
|
||||||
|
// their geometry freezes. Re-armed only on a genuine pose change,
|
||||||
|
// explicit user select/move, or a surface-regime change — never
|
||||||
|
// on the 10s timer.
|
||||||
|
// groundSamples — PER-REGIME resolved ground (regime key →
|
||||||
|
// metres): the accepted one-shot scene sample in google-3d, the
|
||||||
|
// mirrored prior in terrain-globe. Kept across re-arms as the
|
||||||
|
// "has ever resolved" memory for the B9c mid-stream guard.
|
||||||
|
// frustumPositions — cached Cartesians for pure recomputes (so
|
||||||
|
// plane placement never re-derives geometry it already has).
|
||||||
|
groundPrior,
|
||||||
|
groundResolved: {},
|
||||||
|
groundSamples: {},
|
||||||
|
frustumGeometry: null,
|
||||||
|
frustumPositions: null,
|
||||||
|
// §9.1 activation obstruction probe result: effective-range clamp so
|
||||||
|
// the far-cap plane never clips into the tiles. Null = unclamped.
|
||||||
|
// Reset + re-probed on every activation; cleared when the user takes
|
||||||
|
// the range slider (slider overrides the clamp).
|
||||||
|
probeClampRangeM: null,
|
||||||
|
// Viewshed (design §3a/§3b): per-camera color identity + the volume
|
||||||
|
// primitive handle (exists only in viewshed mode for the visible set).
|
||||||
|
viewshedColors: viewshedColors(
|
||||||
|
cameraHue(hueIndexById.get(camera.id) ?? 0),
|
||||||
|
),
|
||||||
|
viewshedPrimitive: null,
|
||||||
|
viewshedActiveTint: false,
|
||||||
|
};
|
||||||
|
layerState._records.push(record);
|
||||||
|
layerState._recordById.set(camera.id, record);
|
||||||
|
}
|
||||||
|
|
||||||
// Task 5: track the surface regime the initial geometry was computed for
|
layerState._count = layerState._records.length;
|
||||||
// and listen for map-stack changes (main.js re-dispatches
|
if (layerState._records.length > 0) {
|
||||||
// MapStackController.onChange as this CustomEvent). The handler compares
|
// Projection runtime + first frame fetch are deferred to enable() so
|
||||||
// regimes itself, so 'switching'/'error' emissions and same-regime stack
|
// initializing the catalog stays render-cheap.
|
||||||
// swaps (bing→osm) no-op.
|
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||||
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
|
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
|
||||||
// the billboards' always-on-top depth setting) + one initial pass so the
|
// when it lands (pure recomputes — applyLateGroundPriors guards against
|
||||||
// first paint is already culled.
|
// a torn-down/re-inited catalog).
|
||||||
if (!layerState._horizonCullListener) {
|
if (!priors) {
|
||||||
// Ambient cards piggyback the same settle event: moveEnd-driven
|
const initRecords = layerState._records.slice();
|
||||||
// reselection only, never per frame (refreshAmbientCards no-ops while
|
priorsPromise
|
||||||
// the layer is disabled).
|
.then((late) => {
|
||||||
layerState._horizonCullListener = () => {
|
if (late) parts.ground.applyLateGroundPriors(initRecords, late);
|
||||||
layerState._cameraMoving = false;
|
|
||||||
parts.rendering.refreshHorizonCulling();
|
|
||||||
parts.cards.refreshAmbientCards();
|
|
||||||
};
|
|
||||||
layerState._viewer.camera.moveEnd.addEventListener(
|
|
||||||
layerState._horizonCullListener,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!layerState._moveStartListener) {
|
|
||||||
// Item B: hover picking pauses while the camera is in motion.
|
|
||||||
layerState._moveStartListener = () => {
|
|
||||||
layerState._cameraMoving = true;
|
|
||||||
};
|
|
||||||
layerState._viewer.camera.moveStart.addEventListener(
|
|
||||||
layerState._moveStartListener,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
parts.rendering.refreshHorizonCulling();
|
|
||||||
|
|
||||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
|
||||||
layerState._viewer.scene.canvas,
|
|
||||||
);
|
|
||||||
parts.selection.bindCctvWorldClickGesture(
|
|
||||||
layerState._clickHandler,
|
|
||||||
(click) => {
|
|
||||||
if (!layerState._enabled) return;
|
|
||||||
const picked = layerState._viewer.scene.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,
|
|
||||||
})
|
})
|
||||||
) {
|
.catch(() => {});
|
||||||
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);
|
// Task 5: track the surface regime the initial geometry was computed for
|
||||||
parts.rendering.refreshCoverageStyles();
|
// and listen for map-stack changes (main.js re-dispatches
|
||||||
parts.presentation.notifyListeners();
|
// MapStackController.onChange as this CustomEvent). The handler compares
|
||||||
restoreSpriteOrder(layerState._viewer);
|
// regimes itself, so 'switching'/'error' emissions and same-regime stack
|
||||||
console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras');
|
// 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;
|
||||||
|
parts.rendering.refreshHorizonCulling();
|
||||||
|
parts.cards.refreshAmbientCards();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!layerState._moveStartListener) {
|
||||||
|
// Item B: hover picking pauses while the camera is in motion.
|
||||||
|
layerState._moveStartListener = parts.renderer.onMoveStart(() => {
|
||||||
|
layerState._cameraMoving = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
sourceAbort.signal.throwIfAborted();
|
||||||
|
parts.rendering.refreshCoverageStyles();
|
||||||
|
parts.presentation.notifyListeners();
|
||||||
|
parts.renderer.restoreOrder();
|
||||||
|
// prettier-ignore
|
||||||
|
console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras');
|
||||||
|
} catch (error) {
|
||||||
|
if (parts.renderer === renderer) methods.destroy();
|
||||||
|
else renderer.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -344,7 +333,7 @@ export function createLifecycle({
|
|||||||
(record) => record.camera.cityId === 'warendorf',
|
(record) => record.camera.cityId === 'warendorf',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
|
parts.renderer?.showCredit();
|
||||||
layerState._lastUpdate = Date.now();
|
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.
|
||||||
@@ -386,12 +375,12 @@ export function createLifecycle({
|
|||||||
parts.cards.startCardFrameLoop();
|
parts.cards.startCardFrameLoop();
|
||||||
parts.cards.refreshAmbientCards();
|
parts.cards.refreshAmbientCards();
|
||||||
parts.presentation.notifyListeners();
|
parts.presentation.notifyListeners();
|
||||||
restoreSpriteOrder(layerState._viewer);
|
parts.renderer.restoreOrder();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Disables the layer: hides entities, stops the projection loop and load queue. */
|
/** Disables the layer: hides entities, stops the projection loop and load queue. */
|
||||||
disable() {
|
disable() {
|
||||||
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
|
parts.renderer?.hideCredit();
|
||||||
layerState._enabled = false;
|
layerState._enabled = false;
|
||||||
unregisterPickOwner('cctv');
|
unregisterPickOwner('cctv');
|
||||||
// ADJUST mode does not survive a layer toggle — predictable re-entry.
|
// ADJUST mode does not survive a layer toggle — predictable re-entry.
|
||||||
@@ -412,10 +401,10 @@ export function createLifecycle({
|
|||||||
/**
|
/**
|
||||||
* Tears down the layer: destroys click handler, projection loop, coverage
|
* Tears down the layer: destroys click handler, projection loop, coverage
|
||||||
* entities, billboards, and clears all runtime state and subscribers.
|
* entities, billboards, and clears all runtime state and subscribers.
|
||||||
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref).
|
* Releases resources through the renderer that originally acquired them.
|
||||||
*/
|
*/
|
||||||
destroy(viewer) {
|
destroy() {
|
||||||
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
|
parts.renderer?.hideCredit();
|
||||||
layerState._sourceAbort?.abort();
|
layerState._sourceAbort?.abort();
|
||||||
if (typeof document !== 'undefined')
|
if (typeof document !== 'undefined')
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
@@ -430,39 +419,27 @@ export function createLifecycle({
|
|||||||
);
|
);
|
||||||
layerState._mapStackListener = null;
|
layerState._mapStackListener = null;
|
||||||
}
|
}
|
||||||
const teardownViewer = viewer || layerState._viewer;
|
layerState._horizonCullListener?.();
|
||||||
if (layerState._horizonCullListener && teardownViewer?.camera?.moveEnd) {
|
layerState._horizonCullListener = null;
|
||||||
teardownViewer.camera.moveEnd.removeEventListener(
|
layerState._moveStartListener?.();
|
||||||
layerState._horizonCullListener,
|
layerState._moveStartListener = null;
|
||||||
);
|
layerState._removeFocusAppearListener?.();
|
||||||
layerState._horizonCullListener = null;
|
layerState._removeFocusAppearListener = null;
|
||||||
}
|
|
||||||
if (layerState._moveStartListener && teardownViewer?.camera?.moveStart) {
|
|
||||||
teardownViewer.camera.moveStart.removeEventListener(
|
|
||||||
layerState._moveStartListener,
|
|
||||||
);
|
|
||||||
layerState._moveStartListener = null;
|
|
||||||
}
|
|
||||||
if (layerState._gizmo) {
|
if (layerState._gizmo) {
|
||||||
layerState._gizmo.destroy();
|
layerState._gizmo.destroy();
|
||||||
layerState._gizmo = null;
|
layerState._gizmo = null;
|
||||||
}
|
}
|
||||||
layerState._calibrationMode = false;
|
layerState._calibrationMode = false;
|
||||||
releaseContinuousRender('cctv-adjust');
|
releaseContinuousRender('cctv-adjust');
|
||||||
if (layerState._clickHandler) {
|
layerState._removeClick?.();
|
||||||
layerState._clickHandler.destroy();
|
layerState._removeClick = null;
|
||||||
layerState._clickHandler = null;
|
|
||||||
}
|
|
||||||
parts.projection.stopProjectionLoop();
|
parts.projection.stopProjectionLoop();
|
||||||
parts.geometryQueue.stopGeometryLoadQueue();
|
parts.geometryQueue.stopGeometryLoadQueue();
|
||||||
parts.cards.teardownAmbientCards();
|
parts.cards.teardownAmbientCards();
|
||||||
parts.geometry.destroyCoverageEntities();
|
parts.geometry.destroyCoverageEntities();
|
||||||
if (layerState._billboards && teardownViewer) {
|
parts.renderer?.destroy();
|
||||||
teardownViewer.scene.primitives.remove(layerState._billboards);
|
parts.renderer = null;
|
||||||
layerState._billboards = null;
|
|
||||||
}
|
|
||||||
clearRuntimeState();
|
clearRuntimeState();
|
||||||
layerState._viewer = null;
|
|
||||||
layerState._enabled = false;
|
layerState._enabled = false;
|
||||||
layerState._activeCameraId = null;
|
layerState._activeCameraId = null;
|
||||||
layerState._autoHopSuspended = false;
|
layerState._autoHopSuspended = false;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { createLifecycle } from './lifecycle.js';
|
||||||
|
import { createState } from './state.js';
|
||||||
|
|
||||||
|
function setup(t, { failMarkers = false } = {}) {
|
||||||
|
const noop = () => {};
|
||||||
|
const services = {
|
||||||
|
overlays: {
|
||||||
|
clearOverlaySource: noop,
|
||||||
|
hitTestWorldOverlay: noop,
|
||||||
|
setOverlayEntries: noop,
|
||||||
|
setOverlaySourceVisible: noop,
|
||||||
|
},
|
||||||
|
activation: {},
|
||||||
|
picking: { unregisterPickOwner: noop },
|
||||||
|
focus: {},
|
||||||
|
render: { releaseContinuousRender: noop },
|
||||||
|
};
|
||||||
|
const state = createState({ services });
|
||||||
|
const pending = [];
|
||||||
|
const renderers = [];
|
||||||
|
const parts = {
|
||||||
|
geometryQueue: { stopGeometryLoadQueue: noop },
|
||||||
|
cards: { teardownAmbientCards: noop, handleVisibilityChange: noop },
|
||||||
|
projection: { clearProjectionOverlay: noop, stopProjectionLoop: noop },
|
||||||
|
geometry: { destroyCoverageEntities: noop },
|
||||||
|
calibration: { loadCalibrationStore: () => new Map() },
|
||||||
|
catalog: {
|
||||||
|
loadCameraSources: () => new Promise((resolve) => pending.push(resolve)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rendererFactory = () => {
|
||||||
|
const renderer = {
|
||||||
|
owner: null,
|
||||||
|
released: false,
|
||||||
|
releases: 0,
|
||||||
|
hideCredit() {},
|
||||||
|
attach(owner) {
|
||||||
|
this.owner = owner;
|
||||||
|
},
|
||||||
|
createMarkers() {
|
||||||
|
if (failMarkers) throw new Error('marker allocation failed');
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (!this.released) {
|
||||||
|
this.released = true;
|
||||||
|
this.releases++;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderers.push(renderer);
|
||||||
|
return renderer;
|
||||||
|
};
|
||||||
|
const layer = createLifecycle({
|
||||||
|
state,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
rendererFactory,
|
||||||
|
}).methods;
|
||||||
|
t.after(() => layer.destroy());
|
||||||
|
return { state, pending, parts, layer, renderers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('repeated initialization rejects without replacing the current CCTV owner', async (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const owner = {};
|
||||||
|
const starting = h.layer.init(owner);
|
||||||
|
const stopped = assert.rejects(starting, { name: 'AbortError' });
|
||||||
|
await assert.rejects(h.layer.init({}), /already initialized/);
|
||||||
|
assert.equal(h.renderers.length, 1);
|
||||||
|
assert.equal(h.parts.renderer.owner, owner);
|
||||||
|
assert.equal(h.parts.renderer.released, false);
|
||||||
|
h.layer.destroy({});
|
||||||
|
h.pending[0]([]);
|
||||||
|
await stopped;
|
||||||
|
assert.equal(h.renderers[0].releases, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a retired init rejection cannot destroy the newer CCTV owner', async (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const first = assert.rejects(h.layer.init({}), { name: 'AbortError' });
|
||||||
|
h.layer.destroy();
|
||||||
|
const owner = {};
|
||||||
|
const second = assert.rejects(h.layer.init(owner), { name: 'AbortError' });
|
||||||
|
const current = h.parts.renderer;
|
||||||
|
h.pending[0]([]);
|
||||||
|
await first;
|
||||||
|
assert.equal(h.parts.renderer, current);
|
||||||
|
assert.equal(current.owner, owner);
|
||||||
|
assert.equal(current.released, false);
|
||||||
|
assert.equal(h.state._sourceAbort.signal.aborted, false);
|
||||||
|
h.layer.destroy();
|
||||||
|
h.pending[1]([]);
|
||||||
|
await second;
|
||||||
|
assert.deepEqual(
|
||||||
|
h.renderers.map((renderer) => renderer.releases),
|
||||||
|
[1, 1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial resource failure disposes its owner and leaves initialization retryable', async (t) => {
|
||||||
|
const h = setup(t, { failMarkers: true });
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
await assert.rejects(h.layer.init({}), /marker allocation failed/);
|
||||||
|
assert.equal(h.parts.renderer, null);
|
||||||
|
assert.equal(h.state._sourceAbort.signal.aborted, true);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
h.renderers.map((renderer) => renderer.releases),
|
||||||
|
[1, 1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy releases focus-appearance callbacks even without prior disable', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
let released = 0;
|
||||||
|
h.state._removeFocusAppearListener = () => released++;
|
||||||
|
h.layer.destroy();
|
||||||
|
h.layer.destroy();
|
||||||
|
assert.equal(released, 1);
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
||||||
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
||||||
@@ -327,7 +326,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function currentViewContext() {
|
function currentViewContext() {
|
||||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
const carto = parts.renderer?.cameraCartographic();
|
||||||
if (!carto) return 'none';
|
if (!carto) return 'none';
|
||||||
const lat = geo.math.toDegrees(carto.latitude);
|
const lat = geo.math.toDegrees(carto.latitude);
|
||||||
const lon = geo.math.toDegrees(carto.longitude);
|
const lon = geo.math.toDegrees(carto.longitude);
|
||||||
@@ -355,8 +354,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
* Reports whether the active Google Photorealistic 3D Tileset (if any) has
|
* Reports whether the active Google Photorealistic 3D Tileset (if any) has
|
||||||
* finished loading the tiles in view. Shared mesh-floor sampling is gated on
|
* finished loading the tiles in view. Shared mesh-floor sampling is gated on
|
||||||
* this so a one-shot cell never bakes in a miss from still-streaming tiles.
|
* this so a one-shot cell never bakes in a miss from still-streaming tiles.
|
||||||
* Discovers + caches the tileset lazily from scene
|
* The renderer discovers and caches its active tileset. When no
|
||||||
* primitives (the CCTV module holds only a `_viewer` reference). When no
|
|
||||||
* tileset is present (OSM fallback) this returns true so ground sampling is
|
* tileset is present (OSM fallback) this returns true so ground sampling is
|
||||||
* not permanently blocked.
|
* not permanently blocked.
|
||||||
*
|
*
|
||||||
@@ -370,25 +368,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function projectionTilesReady() {
|
function projectionTilesReady() {
|
||||||
if (
|
return parts.renderer?.tilesReady() ?? true;
|
||||||
!layerState._activeTileset ||
|
|
||||||
layerState._activeTileset.isDestroyed?.()
|
|
||||||
) {
|
|
||||||
layerState._activeTileset = null;
|
|
||||||
const primitives = layerState._viewer?.scene?.primitives;
|
|
||||||
if (primitives && typeof primitives.get === 'function') {
|
|
||||||
for (let i = 0; i < primitives.length; i++) {
|
|
||||||
const p = primitives.get(i);
|
|
||||||
if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) {
|
|
||||||
layerState._activeTileset = p;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!layerState._activeTileset) return true;
|
|
||||||
if (layerState._activeTileset.show === false) return false;
|
|
||||||
return layerState._activeTileset.tilesLoaded === true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -399,8 +379,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
* frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry —
|
* frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry —
|
||||||
* computed only on slider/save/activation, never per frame (§2b).
|
* computed only on slider/save/activation, never per frame (§2b).
|
||||||
* @param {Object} camera - Camera pose.
|
* @param {Object} camera - Camera pose.
|
||||||
* @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF.
|
* @param {{x: number, y: number, z: number}} capCenterPos - Plane center in ECEF.
|
||||||
* @returns {Cesium.Quaternion}
|
* @returns {{x: number, y: number, z: number, w: number}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function planeOrientationFor(camera, capCenterPos) {
|
function planeOrientationFor(camera, capCenterPos) {
|
||||||
@@ -422,9 +402,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
m[6] = normal.x;
|
m[6] = normal.x;
|
||||||
m[7] = normal.y;
|
m[7] = normal.y;
|
||||||
m[8] = normal.z;
|
m[8] = normal.z;
|
||||||
// The quaternion becomes an entity's ConstantProperty value, so it stays a
|
return geo.quaternion.fromRotationMatrix(m, geo.quaternion.create());
|
||||||
// Cesium Quaternion there (that property clones and compares its value).
|
|
||||||
return geo.quaternion.fromRotationMatrix(m, new Cesium.Quaternion());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -442,7 +420,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function projectionLoopIsNeeded() {
|
function projectionLoopIsNeeded() {
|
||||||
if (!layerState._enabled || !layerState._viewer) return false;
|
if (!layerState._enabled || !parts.renderer?.ready) return false;
|
||||||
if (layerState._showProjection && parts.selection.getActiveRecord())
|
if (layerState._showProjection && parts.selection.getActiveRecord())
|
||||||
return true;
|
return true;
|
||||||
return focusPassIsNeeded(
|
return focusPassIsNeeded(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { CCTV_FOCUS_RESULT } from './policy.js';
|
import { CCTV_FOCUS_RESULT } from './policy.js';
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ export function createNavigation({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function nearestCameraIdToViewer() {
|
function nearestCameraIdToViewer() {
|
||||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
const carto = parts.renderer?.cameraCartographic();
|
||||||
if (!carto || !layerState._records.length) return null;
|
if (!carto || !layerState._records.length) return null;
|
||||||
const lat = geo.math.toDegrees(carto.latitude);
|
const lat = geo.math.toDegrees(carto.latitude);
|
||||||
const lon = geo.math.toDegrees(carto.longitude);
|
const lon = geo.math.toDegrees(carto.longitude);
|
||||||
@@ -43,8 +42,8 @@ export function createNavigation({
|
|||||||
* @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result.
|
* @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function focusCctvRecord(viewer, record, duration = 2.2) {
|
function focusCctvRecord(renderer, record, duration = 2.2) {
|
||||||
if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
if (!renderer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
||||||
if (
|
if (
|
||||||
typeof document !== 'undefined' &&
|
typeof document !== 'undefined' &&
|
||||||
document.body?.classList.contains('cockpit-mode')
|
document.body?.classList.contains('cockpit-mode')
|
||||||
@@ -52,35 +51,19 @@ export function createNavigation({
|
|||||||
console.debug('[Data:CCTV] focus ignored while cockpit owns the camera');
|
console.debug('[Data:CCTV] focus ignored while cockpit owns the camera');
|
||||||
return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE;
|
return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE;
|
||||||
}
|
}
|
||||||
if (viewer.trackedEntity) {
|
if (renderer.following()) {
|
||||||
console.debug(
|
console.debug(
|
||||||
'[Data:CCTV] focus ignored while a tracked entity owns the camera',
|
'[Data:CCTV] focus ignored while a tracked entity owns the camera',
|
||||||
);
|
);
|
||||||
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
|
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
|
||||||
}
|
}
|
||||||
const { camera } = record;
|
renderer.focus(record, duration);
|
||||||
const range = Math.max(280, camera.rangeM * 1.18);
|
|
||||||
viewer.camera.flyToBoundingSphere(
|
|
||||||
new Cesium.BoundingSphere(
|
|
||||||
record.position,
|
|
||||||
Math.max(40, camera.rangeM * 0.36),
|
|
||||||
),
|
|
||||||
{
|
|
||||||
offset: new Cesium.HeadingPitchRange(
|
|
||||||
parts.model.toRad(camera.headingDeg),
|
|
||||||
parts.model.toRad(-22),
|
|
||||||
range,
|
|
||||||
),
|
|
||||||
duration: Math.max(0.2, duration || 0),
|
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return CCTV_FOCUS_RESULT.FOCUSED;
|
return CCTV_FOCUS_RESULT.FOCUSED;
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusCamera(cameraId, duration = 2.2) {
|
function focusCamera(cameraId, duration = 2.2) {
|
||||||
return focusCctvRecord(
|
return focusCctvRecord(
|
||||||
layerState._viewer,
|
parts.renderer?.ready ? parts.renderer : null,
|
||||||
layerState._recordById.get(cameraId),
|
layerState._recordById.get(cameraId),
|
||||||
duration,
|
duration,
|
||||||
);
|
);
|
||||||
|
|||||||
+21
-11
@@ -5,7 +5,7 @@ export {
|
|||||||
MEDIA_ENDPOINT,
|
MEDIA_ENDPOINT,
|
||||||
ACTIVE_FRAME_REFRESH_MS,
|
ACTIVE_FRAME_REFRESH_MS,
|
||||||
} from './sourcePolicy.js';
|
} from './sourcePolicy.js';
|
||||||
import * as Cesium from 'cesium';
|
import { rgbaFromCss } from '../shared/color.js';
|
||||||
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
||||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||||
|
|
||||||
@@ -404,38 +404,48 @@ export const CAMERA_SEEDS = [
|
|||||||
// Visual style constants
|
// Visual style constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const IDLE_CAMERA_COLOR =
|
export const IDLE_CAMERA_COLOR =
|
||||||
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88);
|
rgbaFromCss('#6be8ff').withAlpha(0.88);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const ACTIVE_CAMERA_COLOR =
|
export const ACTIVE_CAMERA_COLOR =
|
||||||
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
|
rgbaFromCss('#ffd97a').withAlpha(0.95);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const IDLE_COVERAGE_COLOR =
|
export const IDLE_COVERAGE_COLOR =
|
||||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24);
|
rgbaFromCss('#2fe0ff').withAlpha(0.24);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const IDLE_COVERAGE_CENTER_MUTED =
|
export const IDLE_COVERAGE_CENTER_MUTED =
|
||||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2);
|
rgbaFromCss('#2fe0ff').withAlpha(0.2);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const IDLE_COVERAGE_EDGE_MUTED =
|
export const IDLE_COVERAGE_EDGE_MUTED =
|
||||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.18);
|
rgbaFromCss('#2fe0ff').withAlpha(0.18);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const ACTIVE_COVERAGE_EDGE =
|
export const ACTIVE_COVERAGE_EDGE =
|
||||||
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58);
|
rgbaFromCss('#8dff87').withAlpha(0.58);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const ACTIVE_COVERAGE_CENTER =
|
export const ACTIVE_COVERAGE_CENTER =
|
||||||
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.82);
|
rgbaFromCss('#d7ff8d').withAlpha(0.82);
|
||||||
|
|
||||||
// H6: dimmer depth-fail materials let the active frustum wireframe read
|
// H6: dimmer depth-fail materials let the active frustum wireframe read
|
||||||
// through buildings while in monitor fallback mode.
|
// through buildings while in monitor fallback mode.
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const ACTIVE_COVERAGE_EDGE_DEPTHFAIL =
|
export const ACTIVE_COVERAGE_EDGE_DEPTHFAIL =
|
||||||
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.18);
|
rgbaFromCss('#8dff87').withAlpha(0.18);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const ACTIVE_COVERAGE_CENTER_DEPTHFAIL =
|
export const ACTIVE_COVERAGE_CENTER_DEPTHFAIL =
|
||||||
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26);
|
rgbaFromCss('#d7ff8d').withAlpha(0.26);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
export const PLANE_OUTLINE_COLOR =
|
export const PLANE_OUTLINE_COLOR =
|
||||||
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.55);
|
rgbaFromCss('#6be8ff').withAlpha(0.55);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card budget while the staggered geometry drain is running — the raised
|
* Card budget while the staggered geometry drain is running — the raised
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { attachCctvVideo } from './videoPlayback.js';
|
import { attachCctvVideo } from './videoPlayback.js';
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import {
|
import {
|
||||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||||
PLANE_OUTLINE_COLOR,
|
|
||||||
PROJECTION_CANVAS_WIDTH,
|
PROJECTION_CANVAS_WIDTH,
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
PROJECTION_TEXTURE_SWAP_MS,
|
PROJECTION_TEXTURE_SWAP_MS,
|
||||||
@@ -72,17 +70,12 @@ export function createProjection({
|
|||||||
);
|
);
|
||||||
const positions =
|
const positions =
|
||||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||||
runtime.planeEntity.position = positions.capCenter;
|
parts.renderer.updatePlane(
|
||||||
runtime.planeEntity.orientation = parts.model.planeOrientationFor(
|
runtime,
|
||||||
record.camera,
|
geometry,
|
||||||
positions.capCenter,
|
positions,
|
||||||
|
parts.model.planeOrientationFor(record.camera, positions.capCenter),
|
||||||
);
|
);
|
||||||
if (runtime.planeEntity.plane) {
|
|
||||||
runtime.planeEntity.plane.dimensions = new Cesium.Cartesian2(
|
|
||||||
geometry.halfW * 2,
|
|
||||||
geometry.halfH * 2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (runtime.labelPosition) {
|
if (runtime.labelPosition) {
|
||||||
geo.cartesian3.clone(positions.label, runtime.labelPosition);
|
geo.cartesian3.clone(positions.label, runtime.labelPosition);
|
||||||
}
|
}
|
||||||
@@ -109,7 +102,8 @@ export function createProjection({
|
|||||||
|
|
||||||
function setPlaneVisible(runtime, visible) {
|
function setPlaneVisible(runtime, visible) {
|
||||||
if (!runtime) return;
|
if (!runtime) return;
|
||||||
if (runtime.planeEntity) runtime.planeEntity.show = !!visible;
|
runtime.planeVisible = !!visible;
|
||||||
|
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(
|
||||||
@@ -139,26 +133,14 @@ export function createProjection({
|
|||||||
name: record.camera.name,
|
name: record.camera.name,
|
||||||
position: () => runtime.labelPosition,
|
position: () => runtime.labelPosition,
|
||||||
});
|
});
|
||||||
runtime.planeEntity = layerState._viewer.entities.add({
|
runtime.planeVisible = false;
|
||||||
id: `cctv-${record.camera.id}-plane`,
|
runtime.planeEntity = parts.renderer.createPlane(
|
||||||
properties: { cctvCameraId: record.camera.id },
|
record,
|
||||||
show: false,
|
runtime,
|
||||||
position: positions.capCenter,
|
geometry,
|
||||||
orientation: parts.model.planeOrientationFor(
|
positions,
|
||||||
record.camera,
|
parts.model.planeOrientationFor(record.camera, positions.capCenter),
|
||||||
positions.capCenter,
|
);
|
||||||
),
|
|
||||||
plane: {
|
|
||||||
plane: new Cesium.Plane(geo.cartesian3.UNIT_Z, 0.0),
|
|
||||||
dimensions: new Cesium.Cartesian2(
|
|
||||||
geometry.halfW * 2,
|
|
||||||
geometry.halfH * 2,
|
|
||||||
),
|
|
||||||
material: runtime.planeMaterial,
|
|
||||||
outline: true,
|
|
||||||
outlineColor: PLANE_OUTLINE_COLOR,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return runtime.planeEntity;
|
return runtime.planeEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +159,7 @@ export function createProjection({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function createProjectionRuntime(record) {
|
function createProjectionRuntime(record) {
|
||||||
if (!layerState._viewer) return null;
|
if (!parts.renderer?.ready) return null;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = PROJECTION_CANVAS_WIDTH;
|
canvas.width = PROJECTION_CANVAS_WIDTH;
|
||||||
canvas.height = PROJECTION_CANVAS_HEIGHT;
|
canvas.height = PROJECTION_CANVAS_HEIGHT;
|
||||||
@@ -219,6 +201,7 @@ export function createProjection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
|
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
|
||||||
|
runtime.planeMaterial = parts.renderer.createPlaneMaterial(canvas);
|
||||||
|
|
||||||
if (mode === 'video') {
|
if (mode === 'video') {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
@@ -230,15 +213,25 @@ export function createProjection({
|
|||||||
// attributes at first upload. Set them from the real stream dimensions
|
// attributes at first upload. Set them from the real stream dimensions
|
||||||
// and rebind on any resolution change (camera switch, adaptive source).
|
// and rebind on any resolution change (camera switch, adaptive source).
|
||||||
const bindVideoTexture = () => {
|
const bindVideoTexture = () => {
|
||||||
if (runtime.video !== video || !runtime.planeMaterial) return;
|
if (
|
||||||
|
runtime.disposed ||
|
||||||
|
runtime.video !== video ||
|
||||||
|
!runtime.planeMaterial
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
|
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
|
||||||
video.width = video.videoWidth;
|
video.width = video.videoWidth;
|
||||||
video.height = video.videoHeight;
|
video.height = video.videoHeight;
|
||||||
runtime.planeMaterial.image = runtime.canvas;
|
parts.renderer.setPlaneImage(runtime, runtime.canvas);
|
||||||
runtime.planeMaterial.image = video;
|
parts.renderer.setPlaneImage(runtime, video);
|
||||||
};
|
};
|
||||||
video.addEventListener('loadedmetadata', bindVideoTexture);
|
video.addEventListener('loadedmetadata', bindVideoTexture);
|
||||||
video.addEventListener('resize', bindVideoTexture);
|
video.addEventListener('resize', bindVideoTexture);
|
||||||
|
runtime.removeVideoBindings = () => {
|
||||||
|
video.removeEventListener('loadedmetadata', bindVideoTexture);
|
||||||
|
video.removeEventListener('resize', bindVideoTexture);
|
||||||
|
runtime.removeVideoBindings = null;
|
||||||
|
};
|
||||||
runtime.video = video;
|
runtime.video = video;
|
||||||
runtime.playback = attachCctvVideo(
|
runtime.playback = attachCctvVideo(
|
||||||
video,
|
video,
|
||||||
@@ -247,20 +240,23 @@ export function createProjection({
|
|||||||
{
|
{
|
||||||
onFailure: () => {
|
onFailure: () => {
|
||||||
if (runtime.disposed) return;
|
if (runtime.disposed) return;
|
||||||
|
runtime.removeVideoBindings?.();
|
||||||
runtime.video = null;
|
runtime.video = null;
|
||||||
runtime.mode = 'image';
|
runtime.mode = 'image';
|
||||||
runtime.image = new Image();
|
runtime.image = new Image();
|
||||||
runtime.image.decoding = 'async';
|
runtime.image.decoding = 'async';
|
||||||
runtime.image.onload = () => {
|
runtime.image.onload = () => {
|
||||||
|
if (runtime.disposed) return;
|
||||||
runtime.imageLoading = false;
|
runtime.imageLoading = false;
|
||||||
runtime.imageReady = true;
|
runtime.imageReady = true;
|
||||||
runtime.imageStamp = Date.now();
|
runtime.imageStamp = Date.now();
|
||||||
};
|
};
|
||||||
runtime.image.onerror = () => {
|
runtime.image.onerror = () => {
|
||||||
|
if (runtime.disposed) return;
|
||||||
runtime.imageLoading = false;
|
runtime.imageLoading = false;
|
||||||
runtime.imageReady = false;
|
runtime.imageReady = false;
|
||||||
};
|
};
|
||||||
runtime.planeMaterial.image = runtime.canvas;
|
parts.renderer.setPlaneImage(runtime, runtime.canvas);
|
||||||
parts.frames.refreshProjectionImage(record, true);
|
parts.frames.refreshProjectionImage(record, true);
|
||||||
parts.presentation.notifyListeners();
|
parts.presentation.notifyListeners();
|
||||||
},
|
},
|
||||||
@@ -271,11 +267,13 @@ export function createProjection({
|
|||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
|
if (runtime.disposed) return;
|
||||||
runtime.imageLoading = false;
|
runtime.imageLoading = false;
|
||||||
runtime.imageReady = true;
|
runtime.imageReady = true;
|
||||||
runtime.imageStamp = Date.now();
|
runtime.imageStamp = Date.now();
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
|
if (runtime.disposed) return;
|
||||||
runtime.imageLoading = false;
|
runtime.imageLoading = false;
|
||||||
runtime.imageReady = false;
|
runtime.imageReady = false;
|
||||||
};
|
};
|
||||||
@@ -295,12 +293,12 @@ export function createProjection({
|
|||||||
);
|
);
|
||||||
const positions =
|
const positions =
|
||||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||||
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
|
try {
|
||||||
image: canvas,
|
createProjectionPlane(record, runtime, geometry, positions);
|
||||||
transparent: true,
|
} catch (error) {
|
||||||
color: Cesium.Color.WHITE.withAlpha(0.95),
|
destroyProjectionRuntime(runtime);
|
||||||
});
|
throw error;
|
||||||
createProjectionPlane(record, runtime, geometry, positions);
|
}
|
||||||
|
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
@@ -332,6 +330,7 @@ export function createProjection({
|
|||||||
function destroyProjectionRuntime(runtime) {
|
function destroyProjectionRuntime(runtime) {
|
||||||
if (!runtime || runtime.disposed) return;
|
if (!runtime || runtime.disposed) return;
|
||||||
runtime.disposed = true;
|
runtime.disposed = true;
|
||||||
|
runtime.removeVideoBindings?.();
|
||||||
runtime.playback?.dispose();
|
runtime.playback?.dispose();
|
||||||
runtime.playback = null;
|
runtime.playback = null;
|
||||||
if (runtime.image) {
|
if (runtime.image) {
|
||||||
@@ -344,8 +343,8 @@ export function createProjection({
|
|||||||
runtime.video.removeAttribute('src');
|
runtime.video.removeAttribute('src');
|
||||||
runtime.video.load();
|
runtime.video.load();
|
||||||
}
|
}
|
||||||
if (runtime.planeEntity && layerState._viewer) {
|
if (runtime.planeEntity) {
|
||||||
layerState._viewer.entities.remove(runtime.planeEntity);
|
parts.renderer?.removePlane(runtime);
|
||||||
runtime.planeEntity = null;
|
runtime.planeEntity = null;
|
||||||
}
|
}
|
||||||
if (layerState._projectionOverlayOwnerId === runtime.cameraId)
|
if (layerState._projectionOverlayOwnerId === runtime.cameraId)
|
||||||
@@ -366,7 +365,7 @@ export function createProjection({
|
|||||||
if (revision !== loopRevision) return;
|
if (revision !== loopRevision) return;
|
||||||
layerState._projectionRaf = 0;
|
layerState._projectionRaf = 0;
|
||||||
layerState._projectionTimer = 0;
|
layerState._projectionTimer = 0;
|
||||||
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
|
if (!parts.renderer?.ready || !parts.model.projectionLoopIsNeeded()) {
|
||||||
releaseContinuousRender('cctv-projection');
|
releaseContinuousRender('cctv-projection');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { createProjection } from './projection.js';
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
t,
|
||||||
|
{ sourceFails = false, planeFails = false, feedType = 'mp4' } = {},
|
||||||
|
) {
|
||||||
|
const previous = { document: globalThis.document, Image: globalThis.Image };
|
||||||
|
const videos = [];
|
||||||
|
const writes = [];
|
||||||
|
let removed = 0;
|
||||||
|
let refreshed = 0;
|
||||||
|
class Video extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.listeners = new Map();
|
||||||
|
this.videoWidth = 320;
|
||||||
|
this.videoHeight = 180;
|
||||||
|
this.paused = true;
|
||||||
|
this.url = '';
|
||||||
|
videos.push(this);
|
||||||
|
}
|
||||||
|
addEventListener(type, callback) {
|
||||||
|
super.addEventListener(type, callback);
|
||||||
|
this.listeners.set(type, callback);
|
||||||
|
}
|
||||||
|
removeEventListener(type, callback) {
|
||||||
|
super.removeEventListener(type, callback);
|
||||||
|
if (this.listeners.get(type) === callback) this.listeners.delete(type);
|
||||||
|
}
|
||||||
|
set src(value) {
|
||||||
|
this.url = value;
|
||||||
|
if (sourceFails) this.dispatchEvent(new Event('error'));
|
||||||
|
}
|
||||||
|
removeAttribute(name) {
|
||||||
|
if (name === 'src') this.url = '';
|
||||||
|
}
|
||||||
|
play() {
|
||||||
|
this.paused = false;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
pause() {
|
||||||
|
this.paused = true;
|
||||||
|
}
|
||||||
|
load() {}
|
||||||
|
}
|
||||||
|
globalThis.document = {
|
||||||
|
createElement: (tag) =>
|
||||||
|
tag === 'video' ? new Video() : { getContext: () => ({}) },
|
||||||
|
};
|
||||||
|
globalThis.Image = class {};
|
||||||
|
const state = {
|
||||||
|
_records: [],
|
||||||
|
_projectionEntities: [],
|
||||||
|
_projectionOverlayOwnerId: null,
|
||||||
|
_cctvOverlayHost: { clearSource() {}, setVisible() {} },
|
||||||
|
};
|
||||||
|
const renderer = {
|
||||||
|
ready: true,
|
||||||
|
createPlaneMaterial: (image) => ({ image }),
|
||||||
|
setPlaneImage(runtime, image) {
|
||||||
|
assert.ok(
|
||||||
|
runtime.planeMaterial,
|
||||||
|
'material must exist before playback failure',
|
||||||
|
);
|
||||||
|
runtime.planeMaterial.image = image;
|
||||||
|
writes.push(image);
|
||||||
|
},
|
||||||
|
createPlane() {
|
||||||
|
if (planeFails) throw new Error('plane construction failed');
|
||||||
|
return { show: false };
|
||||||
|
},
|
||||||
|
removePlane() {
|
||||||
|
removed++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const record = {
|
||||||
|
camera: { id: 'camera', feedType },
|
||||||
|
frustumGeometry: { halfW: 16, halfH: 9 },
|
||||||
|
frustumPositions: {
|
||||||
|
capCenter: { x: 1, y: 2, z: 3 },
|
||||||
|
label: { x: 4, y: 5, z: 6 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const projection = createProjection({
|
||||||
|
state,
|
||||||
|
services: { render: {} },
|
||||||
|
parts: {
|
||||||
|
renderer,
|
||||||
|
model: {
|
||||||
|
normalizeFeedType: (value) => value,
|
||||||
|
isVideoFeedType: (value) => value === 'mp4',
|
||||||
|
planeOrientationFor: () => ({}),
|
||||||
|
},
|
||||||
|
frames: {
|
||||||
|
paintProjectionPlaceholder() {},
|
||||||
|
mediaUrlFor: () => '/fixture.mp4',
|
||||||
|
refreshProjectionImage() {
|
||||||
|
refreshed++;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
presentation: { notifyListeners() {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
projection.destroyProjectionRuntime(record.projection);
|
||||||
|
for (const [key, value] of Object.entries(previous)) {
|
||||||
|
if (value === undefined) delete globalThis[key];
|
||||||
|
else globalThis[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
projection,
|
||||||
|
record,
|
||||||
|
videos,
|
||||||
|
writes,
|
||||||
|
removed: () => removed,
|
||||||
|
refreshed: () => refreshed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('video disposal removes texture and playback listeners and ignores retired metadata', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const runtime = h.projection.ensureProjectionRuntime(h.record);
|
||||||
|
const video = h.videos[0];
|
||||||
|
const retired = video.listeners.get('loadedmetadata');
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
assert.equal(video.width, 320);
|
||||||
|
assert.equal(video.height, 180);
|
||||||
|
assert.deepEqual(h.writes, [runtime.canvas, video]);
|
||||||
|
h.projection.destroyProjectionRuntime(runtime);
|
||||||
|
assert.equal(video.listeners.size, 0);
|
||||||
|
assert.equal(video.url, '');
|
||||||
|
assert.equal(video.paused, true);
|
||||||
|
retired();
|
||||||
|
assert.equal(h.writes.length, 2);
|
||||||
|
assert.equal(h.removed(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video failure releases its texture bindings while the image fallback remains usable', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const runtime = h.projection.ensureProjectionRuntime(h.record);
|
||||||
|
const video = h.videos[0];
|
||||||
|
const retired = video.listeners.get('resize');
|
||||||
|
video.dispatchEvent(new Event('error'));
|
||||||
|
assert.equal(runtime.mode, 'image');
|
||||||
|
assert.equal(runtime.video, null);
|
||||||
|
assert.equal(video.listeners.size, 0);
|
||||||
|
assert.equal(h.refreshed(), 1);
|
||||||
|
runtime.image.onload();
|
||||||
|
assert.equal(runtime.imageReady, true);
|
||||||
|
retired();
|
||||||
|
assert.equal(h.writes.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a synchronous video source failure can bind the placeholder and recover as an image', (t) => {
|
||||||
|
const h = setup(t, { sourceFails: true });
|
||||||
|
const runtime = h.projection.ensureProjectionRuntime(h.record);
|
||||||
|
assert.equal(runtime.mode, 'image');
|
||||||
|
assert.ok(runtime.image);
|
||||||
|
assert.equal(runtime.planeMaterial.image, runtime.canvas);
|
||||||
|
assert.equal(h.videos[0].listeners.size, 0);
|
||||||
|
assert.equal(h.refreshed(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plane construction failure releases its already-attached media', (t) => {
|
||||||
|
const h = setup(t, { planeFails: true });
|
||||||
|
assert.throws(
|
||||||
|
() => h.projection.ensureProjectionRuntime(h.record),
|
||||||
|
/plane construction failed/,
|
||||||
|
);
|
||||||
|
assert.equal(h.videos[0].listeners.size, 0);
|
||||||
|
assert.equal(h.videos[0].url, '');
|
||||||
|
assert.equal(h.videos[0].paused, true);
|
||||||
|
assert.equal(h.record.projection, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retired image callbacks cannot change a disposed projection', (t) => {
|
||||||
|
const h = setup(t, { feedType: 'image' });
|
||||||
|
const runtime = h.projection.ensureProjectionRuntime(h.record);
|
||||||
|
const onload = runtime.image.onload;
|
||||||
|
h.projection.destroyProjectionRuntime(runtime);
|
||||||
|
onload();
|
||||||
|
assert.equal(runtime.imageReady, false);
|
||||||
|
assert.equal(runtime.imageStamp, 0);
|
||||||
|
});
|
||||||
@@ -54,6 +54,7 @@ function setup(t) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
parts: {
|
parts: {
|
||||||
|
renderer: { ready: true },
|
||||||
model: { projectionLoopIsNeeded: () => state._enabled },
|
model: { projectionLoopIsNeeded: () => state._enabled },
|
||||||
selection: { getActiveRecord: () => record },
|
selection: { getActiveRecord: () => record },
|
||||||
rendering: { refreshCctvFocusStyles() {} },
|
rendering: { refreshCctvFocusStyles() {} },
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import { horizonOccluder } from '../../../data/iconOrientation.js';
|
||||||
|
import { createFrustumVolumePrimitive } from './cesiumViewshed.js';
|
||||||
|
import {
|
||||||
|
bindTrackingClickGesture,
|
||||||
|
isTrackingClickGesture,
|
||||||
|
} from '../../../data/trackingClickGesture.js';
|
||||||
|
import { createCesiumCctvGizmoRenderer } from './cesiumGizmo.js';
|
||||||
|
import {
|
||||||
|
CAMERA_ICON,
|
||||||
|
IDLE_CAMERA_COLOR,
|
||||||
|
IDLE_COVERAGE_COLOR,
|
||||||
|
PLANE_OUTLINE_COLOR,
|
||||||
|
} from '../policy.js';
|
||||||
|
|
||||||
|
/** Native CCTV resources and scene operations owned by one viewer. */
|
||||||
|
export function createCctvCesiumRenderer({ services, state }) {
|
||||||
|
let viewer = null;
|
||||||
|
let billboards = null;
|
||||||
|
let activeTileset = null;
|
||||||
|
const entities = new Set();
|
||||||
|
const volumes = new Set();
|
||||||
|
const listeners = new Set();
|
||||||
|
const gizmos = new Set();
|
||||||
|
const color = (value) => (value ? Cesium.Color.clone(value) : undefined);
|
||||||
|
function ownListener(remove) {
|
||||||
|
const release = () => {
|
||||||
|
if (!listeners.delete(release)) return;
|
||||||
|
remove();
|
||||||
|
};
|
||||||
|
listeners.add(release);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
function addEntity(options) {
|
||||||
|
const entity = viewer.entities.add(options);
|
||||||
|
entities.add(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
function removeEntity(entity) {
|
||||||
|
if (!entities.delete(entity)) return;
|
||||||
|
if (viewer.entities.getById(entity.id) === entity)
|
||||||
|
viewer.entities.remove(entity);
|
||||||
|
}
|
||||||
|
function tilesReady() {
|
||||||
|
if (!activeTileset || activeTileset.isDestroyed?.()) {
|
||||||
|
activeTileset = null;
|
||||||
|
const primitives = viewer?.scene?.primitives;
|
||||||
|
if (primitives && typeof primitives.get === 'function') {
|
||||||
|
for (let i = 0; i < primitives.length; i++) {
|
||||||
|
const p = primitives.get(i);
|
||||||
|
if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) {
|
||||||
|
activeTileset = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!activeTileset) return true;
|
||||||
|
return activeTileset.show !== false && activeTileset.tilesLoaded === true;
|
||||||
|
}
|
||||||
|
function extractPickedCameraId(picked) {
|
||||||
|
if (!picked) return null;
|
||||||
|
|
||||||
|
const entity = picked.id?.properties
|
||||||
|
? picked.id
|
||||||
|
: picked.primitive?.id?.properties
|
||||||
|
? picked.primitive.id
|
||||||
|
: null;
|
||||||
|
const maybeProp = entity?.properties?.cctvCameraId;
|
||||||
|
if (maybeProp) {
|
||||||
|
const value =
|
||||||
|
typeof maybeProp.getValue === 'function'
|
||||||
|
? maybeProp.getValue(Cesium.JulianDate.now())
|
||||||
|
: maybeProp;
|
||||||
|
const record =
|
||||||
|
typeof value === 'string' ? state._recordById.get(value) : null;
|
||||||
|
const ownsCoverageEntity = Boolean(
|
||||||
|
record?.coverageEntities?.includes(entity),
|
||||||
|
);
|
||||||
|
const ownsProjectionEntity =
|
||||||
|
record?.projection?.planeEntity === entity ||
|
||||||
|
state._projectionEntities.some(
|
||||||
|
(runtime) =>
|
||||||
|
runtime?.cameraId === value && runtime.planeEntity === entity,
|
||||||
|
);
|
||||||
|
if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera billboard IDs are intentionally the upstream camera ID, so a
|
||||||
|
// sibling may legitimately use the same string. A bare ID match is not
|
||||||
|
// ownership proof: require this layer's billboard collection or the exact
|
||||||
|
// billboard stored on the record.
|
||||||
|
const directId =
|
||||||
|
typeof picked.id === 'string'
|
||||||
|
? picked.id
|
||||||
|
: typeof picked.primitive?.id === 'string'
|
||||||
|
? picked.primitive.id
|
||||||
|
: null;
|
||||||
|
const record = directId === null ? null : state._recordById.get(directId);
|
||||||
|
if (!record) return null;
|
||||||
|
return picked.primitive === billboards ||
|
||||||
|
picked.primitive === record.billboard
|
||||||
|
? directId
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
extractPickedCameraId,
|
||||||
|
attach(owner) {
|
||||||
|
if (viewer && viewer !== owner)
|
||||||
|
throw new Error('CCTV renderer already has an owner');
|
||||||
|
viewer = owner;
|
||||||
|
activeTileset = null;
|
||||||
|
},
|
||||||
|
get ready() {
|
||||||
|
return Boolean(viewer && !viewer.isDestroyed?.());
|
||||||
|
},
|
||||||
|
cameraCartographic: () => viewer?.camera?.positionCartographic,
|
||||||
|
following: () => Boolean(viewer?.trackedEntity),
|
||||||
|
globeVisible: () => viewer?.scene?.globe?.show,
|
||||||
|
tilesReady,
|
||||||
|
project: (position, result) =>
|
||||||
|
viewer.scene.cartesianToCanvasCoordinates(position, result),
|
||||||
|
distance: (position) =>
|
||||||
|
geo.cartesian3.distance(viewer.camera.positionWC, position),
|
||||||
|
horizonTest() {
|
||||||
|
const occluder = horizonOccluder(viewer.camera);
|
||||||
|
return (position) => occluder.isPointVisible(position);
|
||||||
|
},
|
||||||
|
viewport() {
|
||||||
|
const canvas = viewer.scene.canvas;
|
||||||
|
return {
|
||||||
|
width: canvas.clientWidth || canvas.width || 0,
|
||||||
|
height: canvas.clientHeight || canvas.height || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
request: () => viewer?.scene?.requestRender?.(),
|
||||||
|
pick: (screen) => viewer.scene.pick(screen),
|
||||||
|
onMoveEnd: (fn) => ownListener(viewer.camera.moveEnd.addEventListener(fn)),
|
||||||
|
onMoveStart: (fn) =>
|
||||||
|
ownListener(viewer.camera.moveStart.addEventListener(fn)),
|
||||||
|
bindClick(onClick, options = {}) {
|
||||||
|
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||||
|
bindTrackingClickGesture(
|
||||||
|
handler,
|
||||||
|
(event, gesture) => {
|
||||||
|
if (isTrackingClickGesture(gesture)) onClick(event);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return ownListener(() => handler.destroy());
|
||||||
|
},
|
||||||
|
createGizmoRenderer() {
|
||||||
|
const gizmo = createCesiumCctvGizmoRenderer(viewer);
|
||||||
|
gizmos.add(gizmo);
|
||||||
|
return gizmo;
|
||||||
|
},
|
||||||
|
createMarkers() {
|
||||||
|
if (billboards) throw new Error('CCTV markers already exist');
|
||||||
|
billboards = new Cesium.BillboardCollection();
|
||||||
|
viewer.scene.primitives.add(billboards);
|
||||||
|
services.sprites.registerSpriteCollection('cctv', billboards);
|
||||||
|
return billboards;
|
||||||
|
},
|
||||||
|
createMarker(camera, position) {
|
||||||
|
return billboards.add({
|
||||||
|
id: camera.id,
|
||||||
|
image: CAMERA_ICON,
|
||||||
|
position,
|
||||||
|
color: color(IDLE_CAMERA_COLOR),
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setMarkersVisible(value) {
|
||||||
|
if (billboards) billboards.show = value;
|
||||||
|
},
|
||||||
|
restoreOrder: () => services.sprites.restoreSpriteOrder(viewer),
|
||||||
|
showCredit: () => services.credits?.showOsmCredit?.(viewer, 'cctv'),
|
||||||
|
hideCredit: () => services.credits?.hideOsmCredit?.(viewer, 'cctv'),
|
||||||
|
sampleMeshFloorCells: (points, options) =>
|
||||||
|
services.mesh.sampleMeshFloorCells(viewer?.scene, points, options),
|
||||||
|
obstructionRay(origin, direction, exclude, far) {
|
||||||
|
if (!viewer?.scene?.pickFromRay) return undefined;
|
||||||
|
const hit = viewer.scene.pickFromRay(
|
||||||
|
new Cesium.Ray(origin, direction),
|
||||||
|
[billboards, ...exclude].filter(Boolean),
|
||||||
|
);
|
||||||
|
return hit?.position &&
|
||||||
|
geo.cartesian3.distance(origin, hit.position) <= far
|
||||||
|
? hit
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
createPlaneMaterial(image) {
|
||||||
|
return new Cesium.ImageMaterialProperty({
|
||||||
|
image,
|
||||||
|
transparent: true,
|
||||||
|
color: Cesium.Color.WHITE.withAlpha(0.95),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createTestPlaneMaterial: () =>
|
||||||
|
new Cesium.ColorMaterialProperty(Cesium.Color.WHITE),
|
||||||
|
setPlaneImage(runtime, image) {
|
||||||
|
runtime.planeMaterial.image = image;
|
||||||
|
},
|
||||||
|
createPlane(record, runtime, geometry, positions, orientation) {
|
||||||
|
return addEntity({
|
||||||
|
id: `cctv-${record.camera.id}-plane`,
|
||||||
|
properties: { cctvCameraId: record.camera.id },
|
||||||
|
show: false,
|
||||||
|
position: positions.capCenter,
|
||||||
|
orientation: Cesium.Quaternion.clone(orientation),
|
||||||
|
plane: {
|
||||||
|
plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0),
|
||||||
|
dimensions: new Cesium.Cartesian2(
|
||||||
|
geometry.halfW * 2,
|
||||||
|
geometry.halfH * 2,
|
||||||
|
),
|
||||||
|
material: runtime.planeMaterial,
|
||||||
|
outline: true,
|
||||||
|
outlineColor: color(PLANE_OUTLINE_COLOR),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updatePlane(runtime, geometry, positions, orientation) {
|
||||||
|
const entity = runtime.planeEntity;
|
||||||
|
entity.position = positions.capCenter;
|
||||||
|
entity.orientation = Cesium.Quaternion.clone(orientation);
|
||||||
|
if (entity.plane)
|
||||||
|
entity.plane.dimensions = new Cesium.Cartesian2(
|
||||||
|
geometry.halfW * 2,
|
||||||
|
geometry.halfH * 2,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setPlaneVisible(runtime, visible) {
|
||||||
|
if (runtime.planeEntity) runtime.planeEntity.show = visible;
|
||||||
|
},
|
||||||
|
removePlane: (runtime) => removeEntity(runtime.planeEntity),
|
||||||
|
createCoverage(cameraId, positions) {
|
||||||
|
const created = [];
|
||||||
|
try {
|
||||||
|
for (const [role, points] of [
|
||||||
|
['ray-tl', [positions.mount, positions.tl]],
|
||||||
|
['ray-tr', [positions.mount, positions.tr]],
|
||||||
|
['ray-br', [positions.mount, positions.br]],
|
||||||
|
['ray-bl', [positions.mount, positions.bl]],
|
||||||
|
[
|
||||||
|
'cap',
|
||||||
|
[
|
||||||
|
positions.tl,
|
||||||
|
positions.tr,
|
||||||
|
positions.br,
|
||||||
|
positions.bl,
|
||||||
|
positions.tl,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const entity = addEntity({
|
||||||
|
id: `cctv-${cameraId}-${role}`,
|
||||||
|
properties: { cctvCameraId: cameraId },
|
||||||
|
polyline: {
|
||||||
|
positions: points,
|
||||||
|
width: 1.2,
|
||||||
|
material: color(IDLE_COVERAGE_COLOR),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
entity._coverageRole = role === 'cap' ? 'cap' : 'edge';
|
||||||
|
created.push(entity);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
for (const entity of created) removeEntity(entity);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateCoverage(record, positions) {
|
||||||
|
const lines = record.coverageEntities;
|
||||||
|
if (!lines || lines.length < 5) return;
|
||||||
|
for (const [i, role] of ['tl', 'tr', 'br', 'bl'].entries())
|
||||||
|
lines[i].polyline.positions = [positions.mount, positions[role]];
|
||||||
|
lines[4].polyline.positions = [
|
||||||
|
positions.tl,
|
||||||
|
positions.tr,
|
||||||
|
positions.br,
|
||||||
|
positions.bl,
|
||||||
|
positions.tl,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
removeCoverageEntity: removeEntity,
|
||||||
|
styleLine(entity, { visible, width, tint, dashed, depthFail }) {
|
||||||
|
entity.show = visible;
|
||||||
|
if (!entity.polyline) return;
|
||||||
|
entity.polyline.width = width;
|
||||||
|
entity.polyline.material = dashed
|
||||||
|
? new Cesium.PolylineDashMaterialProperty({ color: color(tint) })
|
||||||
|
: color(tint);
|
||||||
|
entity.polyline.depthFailMaterial = color(depthFail);
|
||||||
|
},
|
||||||
|
hideCoverage(record) {
|
||||||
|
for (const entity of record.coverageEntities || []) entity.show = false;
|
||||||
|
},
|
||||||
|
removeCoverage(record) {
|
||||||
|
for (const entity of record.coverageEntities || []) removeEntity(entity);
|
||||||
|
},
|
||||||
|
createVolume(record, tint) {
|
||||||
|
const primitive = createFrustumVolumePrimitive(
|
||||||
|
record.frustumPositions,
|
||||||
|
color(tint),
|
||||||
|
);
|
||||||
|
primitive._gevViewshed = record.camera.id;
|
||||||
|
viewer.scene.primitives.add(primitive);
|
||||||
|
volumes.add(primitive);
|
||||||
|
return primitive;
|
||||||
|
},
|
||||||
|
removeVolume(primitive) {
|
||||||
|
if (volumes.delete(primitive)) viewer.scene.primitives.remove(primitive);
|
||||||
|
},
|
||||||
|
focus(record, duration) {
|
||||||
|
viewer.camera.flyToBoundingSphere(
|
||||||
|
new Cesium.BoundingSphere(
|
||||||
|
record.position,
|
||||||
|
Math.max(40, record.camera.rangeM * 0.36),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
offset: new Cesium.HeadingPitchRange(
|
||||||
|
geo.math.toRadians(record.camera.headingDeg),
|
||||||
|
geo.math.toRadians(-22),
|
||||||
|
Math.max(280, record.camera.rangeM * 1.18),
|
||||||
|
),
|
||||||
|
duration: Math.max(0.2, duration || 0),
|
||||||
|
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (!viewer) return;
|
||||||
|
services.credits?.hideOsmCredit?.(viewer, 'cctv');
|
||||||
|
for (const remove of listeners) remove();
|
||||||
|
for (const gizmo of gizmos) gizmo.destroy();
|
||||||
|
gizmos.clear();
|
||||||
|
for (const entity of entities) removeEntity(entity);
|
||||||
|
for (const volume of volumes) viewer.scene.primitives.remove(volume);
|
||||||
|
volumes.clear();
|
||||||
|
if (billboards) {
|
||||||
|
services.sprites.unregisterSpriteCollection?.('cctv', billboards);
|
||||||
|
viewer.scene.primitives.remove(billboards);
|
||||||
|
}
|
||||||
|
viewer = billboards = activeTileset = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindCctvWorldClickGesture(handler, onClick, options = {}) {
|
||||||
|
bindTrackingClickGesture(
|
||||||
|
handler,
|
||||||
|
(click, gesture) => {
|
||||||
|
if (!isTrackingClickGesture(gesture)) return;
|
||||||
|
onClick(click);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createCctvCesiumRenderer } from './cesium.js';
|
||||||
|
import { viewshedColors } from '../../../data/cctvViewshed.js';
|
||||||
|
|
||||||
|
function viewer() {
|
||||||
|
return {
|
||||||
|
entities: new Cesium.EntityCollection(),
|
||||||
|
scene: { primitives: new Cesium.PrimitiveCollection() },
|
||||||
|
camera: { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() },
|
||||||
|
isDestroyed: () => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setup(t) {
|
||||||
|
const owner = viewer();
|
||||||
|
const sprites = [];
|
||||||
|
const state = { _recordById: new Map(), _projectionEntities: [] };
|
||||||
|
const renderer = createCctvCesiumRenderer({
|
||||||
|
state,
|
||||||
|
services: {
|
||||||
|
sprites: {
|
||||||
|
registerSpriteCollection: (...args) => sprites.push(['add', ...args]),
|
||||||
|
unregisterSpriteCollection: (...args) =>
|
||||||
|
sprites.push(['remove', ...args]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderer.attach(owner);
|
||||||
|
t.after(() => renderer.destroy());
|
||||||
|
const p = (x, y, z) => new Cesium.Cartesian3(x, y, z);
|
||||||
|
const positions = {
|
||||||
|
mount: p(6378137, 0, 10),
|
||||||
|
tl: p(6378137, 100, 40),
|
||||||
|
tr: p(6378137, 140, 40),
|
||||||
|
br: p(6378137, 140, 0),
|
||||||
|
bl: p(6378137, 100, 0),
|
||||||
|
capCenter: p(6378137, 120, 20),
|
||||||
|
};
|
||||||
|
return { owner, renderer, sprites, positions, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('native CCTV releases only its original viewer resources and listeners', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const foreignViewer = viewer();
|
||||||
|
const foreign = foreignViewer.entities.add({ id: 'cctv-a-cap' });
|
||||||
|
const neighbor = h.owner.entities.add({ id: 'neighbor' });
|
||||||
|
const markers = h.renderer.createMarkers();
|
||||||
|
h.renderer.createMarker({ id: 'a' }, h.positions.mount);
|
||||||
|
h.renderer.createCoverage('a', h.positions);
|
||||||
|
const record = { camera: { id: 'a' }, frustumPositions: h.positions };
|
||||||
|
const volume = h.renderer.createVolume(record, viewshedColors(30).fill);
|
||||||
|
assert.equal(volume.allowPicking, false);
|
||||||
|
assert.equal(volume.asynchronous, false);
|
||||||
|
let moves = 0;
|
||||||
|
h.renderer.onMoveEnd(() => moves++);
|
||||||
|
h.owner.camera.moveEnd.raiseEvent();
|
||||||
|
assert.equal(moves, 1);
|
||||||
|
assert.throws(() => h.renderer.attach(foreignViewer), /already has an owner/);
|
||||||
|
h.renderer.destroy(foreignViewer);
|
||||||
|
h.renderer.destroy();
|
||||||
|
h.owner.camera.moveEnd.raiseEvent();
|
||||||
|
assert.equal(moves, 1);
|
||||||
|
assert.deepEqual(h.owner.entities.values, [neighbor]);
|
||||||
|
assert.equal(h.owner.scene.primitives.length, 0);
|
||||||
|
assert.equal(foreignViewer.entities.getById(foreign.id), foreign);
|
||||||
|
assert.deepEqual(h.sprites, [
|
||||||
|
['add', 'cctv', markers],
|
||||||
|
['remove', 'cctv', markers],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverage construction rolls back partial entities and can retry', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const collision = h.owner.entities.add({ id: 'cctv-a-ray-br' });
|
||||||
|
assert.throws(() => h.renderer.createCoverage('a', h.positions));
|
||||||
|
assert.deepEqual(h.owner.entities.values, [collision]);
|
||||||
|
h.owner.entities.remove(collision);
|
||||||
|
const lines = h.renderer.createCoverage('a', h.positions);
|
||||||
|
assert.equal(lines.length, 5);
|
||||||
|
assert.deepEqual(
|
||||||
|
lines.map((line) => line._coverageRole),
|
||||||
|
['edge', 'edge', 'edge', 'edge', 'cap'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaced native entity identities survive CCTV teardown', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const [line] = h.renderer.createCoverage('a', h.positions);
|
||||||
|
h.owner.entities.remove(line);
|
||||||
|
const replacement = h.owner.entities.add({ id: line.id });
|
||||||
|
h.renderer.destroy();
|
||||||
|
assert.deepEqual(h.owner.entities.values, [replacement]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native materials convert portable colours and keep depth-fail and dash policy', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const [line] = h.renderer.createCoverage('a', h.positions);
|
||||||
|
const tint = viewshedColors(210).line;
|
||||||
|
h.renderer.styleLine(line, {
|
||||||
|
visible: true,
|
||||||
|
width: 1.8,
|
||||||
|
tint,
|
||||||
|
dashed: true,
|
||||||
|
depthFail: tint.withAlpha(0.18),
|
||||||
|
});
|
||||||
|
const now = Cesium.JulianDate.now();
|
||||||
|
assert.equal(line.polyline.material.getType(now), 'PolylineDash');
|
||||||
|
assert.ok(
|
||||||
|
Cesium.Color.equals(line.polyline.material.getValue(now).color, tint),
|
||||||
|
);
|
||||||
|
assert.equal(line.polyline.depthFailMaterial.getValue(now).color.alpha, 0.18);
|
||||||
|
h.renderer.styleLine(line, {
|
||||||
|
visible: false,
|
||||||
|
width: 0.9,
|
||||||
|
tint,
|
||||||
|
dashed: false,
|
||||||
|
});
|
||||||
|
assert.equal(line.show, false);
|
||||||
|
assert.equal(line.polyline.material.getType(now), 'Color');
|
||||||
|
assert.equal(line.polyline.depthFailMaterial, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('obstruction queries include the owned marker set and enforce finite range', (t) => {
|
||||||
|
const h = setup(t);
|
||||||
|
const markers = h.renderer.createMarkers();
|
||||||
|
const exclude = [{}];
|
||||||
|
let query;
|
||||||
|
h.owner.scene.pickFromRay = (ray, exclusions) => {
|
||||||
|
query = { ray, exclusions };
|
||||||
|
return {
|
||||||
|
position: Cesium.Cartesian3.add(
|
||||||
|
ray.origin,
|
||||||
|
new Cesium.Cartesian3(0, 20, 0),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
h.renderer.obstructionRay(
|
||||||
|
h.positions.mount,
|
||||||
|
Cesium.Cartesian3.UNIT_Y,
|
||||||
|
exclude,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
h.renderer.obstructionRay(
|
||||||
|
h.positions.mount,
|
||||||
|
Cesium.Cartesian3.UNIT_Y,
|
||||||
|
exclude,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(query.exclusions, [markers, ...exclude]);
|
||||||
|
assert.ok(query.ray instanceof Cesium.Ray);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the translucent frustum-volume primitive for one camera.
|
||||||
|
* Synchronous (6 triangles — cheaper than the async geometry pipeline's
|
||||||
|
* bookkeeping), unlit flat color, both faces visible (the viewer is routinely
|
||||||
|
* inside or behind a cone), never pickable (clicks fall through to the
|
||||||
|
* billboard/wireframe/plane, whose pick semantics are established).
|
||||||
|
* @param {Object} positions - frustumCartesians shape (see frustumVolumeGeometryData).
|
||||||
|
* @param {Cesium.Color} color - Per-camera fill color (already alpha'd).
|
||||||
|
* @returns {Cesium.Primitive}
|
||||||
|
*/
|
||||||
|
export function createFrustumVolumePrimitive(positions, color) {
|
||||||
|
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
|
||||||
|
const geometry = new Cesium.Geometry({
|
||||||
|
attributes: {
|
||||||
|
position: new Cesium.GeometryAttribute({
|
||||||
|
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
|
||||||
|
componentsPerAttribute: 3,
|
||||||
|
values: flat,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
indices,
|
||||||
|
primitiveType: Cesium.PrimitiveType.TRIANGLES,
|
||||||
|
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
|
||||||
|
});
|
||||||
|
return new Cesium.Primitive({
|
||||||
|
geometryInstances: new Cesium.GeometryInstance({
|
||||||
|
geometry,
|
||||||
|
attributes: {
|
||||||
|
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
appearance: new Cesium.PerInstanceColorAppearance({
|
||||||
|
flat: true,
|
||||||
|
translucent: true,
|
||||||
|
renderState: {
|
||||||
|
cull: { enabled: false },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
asynchronous: false,
|
||||||
|
allowPicking: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
|
||||||
import { isHeadingEstimated } from './headingConfidence.js';
|
import { isHeadingEstimated } from './headingConfidence.js';
|
||||||
import {
|
import {
|
||||||
ACTIVE_CAMERA_COLOR,
|
ACTIVE_CAMERA_COLOR,
|
||||||
@@ -37,27 +35,26 @@ export function createRendering({
|
|||||||
const target = getFocusTarget();
|
const target = getFocusTarget();
|
||||||
if (
|
if (
|
||||||
!layerState._enabled ||
|
!layerState._enabled ||
|
||||||
!layerState._viewer ||
|
!parts.renderer?.ready ||
|
||||||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (nowMs - layerState._lastFocusStyleAt < 80) return;
|
if (nowMs - layerState._lastFocusStyleAt < 80) return;
|
||||||
layerState._lastFocusStyleAt = nowMs;
|
layerState._lastFocusStyleAt = nowMs;
|
||||||
const scene = layerState._viewer.scene;
|
|
||||||
const camera = layerState._viewer.camera;
|
|
||||||
const result = applyCctvFocusDeemphasis({
|
const result = applyCctvFocusDeemphasis({
|
||||||
records: layerState._records,
|
records: layerState._records,
|
||||||
target,
|
target,
|
||||||
previousActiveCount: layerState._activeFocusStyleCount,
|
previousActiveCount: layerState._activeFocusStyleCount,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
// prettier-ignore
|
||||||
screenPositionFor: (position) =>
|
screenPositionFor: (position) =>
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
parts.renderer.project(
|
||||||
scene,
|
|
||||||
position,
|
position,
|
||||||
layerState._scratchFocusScreen,
|
layerState._scratchFocusScreen,
|
||||||
),
|
),
|
||||||
|
// prettier-ignore
|
||||||
cameraDistanceFor: (position) =>
|
cameraDistanceFor: (position) =>
|
||||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
parts.renderer.distance(position),
|
||||||
baseColorFor: (record) =>
|
baseColorFor: (record) =>
|
||||||
record.camera.id === layerState._activeCameraId
|
record.camera.id === layerState._activeCameraId
|
||||||
? ACTIVE_CAMERA_COLOR
|
? ACTIVE_CAMERA_COLOR
|
||||||
@@ -136,17 +133,17 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function refreshHorizonCulling() {
|
function refreshHorizonCulling() {
|
||||||
|
// prettier-ignore
|
||||||
if (
|
if (
|
||||||
!layerState._viewer ||
|
!parts.renderer?.ready ||
|
||||||
layerState._viewer.isDestroyed() ||
|
|
||||||
!layerState._records.length
|
!layerState._records.length
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
const horizonVisible = parts.renderer.horizonTest();
|
||||||
for (const record of layerState._records) {
|
for (const record of layerState._records) {
|
||||||
const bb = record.billboard;
|
const bb = record.billboard;
|
||||||
if (!bb) continue;
|
if (!bb) continue;
|
||||||
const visible = occluder.isPointVisible(bb.position);
|
const visible = horizonVisible(bb.position);
|
||||||
if (bb.show !== visible) bb.show = visible;
|
if (bb.show !== visible) bb.show = visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,8 +170,10 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
for (const entity of record?.coverageEntities || []) entity.show = false;
|
for (const entity of record?.coverageEntities || []) entity.show = false;
|
||||||
if (record?.viewshedPrimitive) destroyVolume?.(record);
|
if (record?.viewshedPrimitive) destroyVolume?.(record);
|
||||||
if (record?.projection?.planeEntity)
|
if (record?.projection?.planeEntity) {
|
||||||
record.projection.planeEntity.show = false;
|
record.projection.planeEntity.show = false;
|
||||||
|
record.projection.planeVisible = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +184,7 @@ export function createRendering({
|
|||||||
layerState._activeCameraId,
|
layerState._activeCameraId,
|
||||||
);
|
);
|
||||||
parts.projection.clearProjectionOverlay();
|
parts.projection.clearProjectionOverlay();
|
||||||
if (layerState._billboards) layerState._billboards.show = false;
|
parts.renderer?.setMarkersVisible(false);
|
||||||
parts.projection.pauseInactiveProjectionFeeds(null);
|
parts.projection.pauseInactiveProjectionFeeds(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,56 +238,40 @@ export function createRendering({
|
|||||||
// rather than rendering identically to a surveyed one (#639). Colors,
|
// rather than rendering identically to a surveyed one (#639). Colors,
|
||||||
// widths, and the active/idle emphasis are unchanged.
|
// widths, and the active/idle emphasis are unchanged.
|
||||||
const bearingEstimated = isHeadingEstimated(record.camera);
|
const bearingEstimated = isHeadingEstimated(record.camera);
|
||||||
const lineMaterial = (color) =>
|
|
||||||
bearingEstimated
|
|
||||||
? new Cesium.PolylineDashMaterialProperty({ color })
|
|
||||||
: color;
|
|
||||||
for (const entity of record.coverageEntities || []) {
|
for (const entity of record.coverageEntities || []) {
|
||||||
// The frustum wireframe is part of the projection representation —
|
const visible = !!(
|
||||||
// force it on for the active camera and let it read through geometry
|
|
||||||
// via depthFailMaterial (polylines have no disableDepthTestDistance).
|
|
||||||
entity.show = !!(
|
|
||||||
layerState._enabled &&
|
layerState._enabled &&
|
||||||
((coverageOn && inVisibleSet) || planeShowing)
|
((coverageOn && inVisibleSet) || planeShowing)
|
||||||
);
|
);
|
||||||
if (!entity.polyline) continue;
|
|
||||||
// Viewshed mode swaps the cyan/green scheme for the camera's own hue so
|
|
||||||
// adjacent cones read as distinct coverage claims (design §3b); the
|
|
||||||
// active camera keeps its width/alpha emphasis in both schemes.
|
|
||||||
const hue = viewshedOn ? record.viewshedColors : null;
|
const hue = viewshedOn ? record.viewshedColors : null;
|
||||||
if (entity._coverageRole === 'cap') {
|
const cap = entity._coverageRole === 'cap';
|
||||||
entity.polyline.material = lineMaterial(
|
const tint = hue
|
||||||
hue
|
? isActive
|
||||||
? isActive
|
? hue.lineActive
|
||||||
? hue.lineActive
|
: cap
|
||||||
: hue.line
|
? hue.line
|
||||||
: isActive
|
: hue.line.withAlpha(0.6)
|
||||||
? ACTIVE_COVERAGE_CENTER
|
: cap
|
||||||
: IDLE_COVERAGE_CENTER_MUTED,
|
? isActive
|
||||||
);
|
? ACTIVE_COVERAGE_CENTER
|
||||||
entity.polyline.width = isActive ? 2.2 : 1.0;
|
: IDLE_COVERAGE_CENTER_MUTED
|
||||||
entity.polyline.depthFailMaterial = planeShowing
|
: isActive
|
||||||
? hue
|
? ACTIVE_COVERAGE_EDGE
|
||||||
? hue.line.withAlpha(0.26)
|
: IDLE_COVERAGE_EDGE_MUTED;
|
||||||
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL
|
const depthFail = !planeShowing
|
||||||
: undefined;
|
? undefined
|
||||||
} else {
|
: hue
|
||||||
entity.polyline.material = lineMaterial(
|
? hue.line.withAlpha(cap ? 0.26 : 0.18)
|
||||||
hue
|
: cap
|
||||||
? isActive
|
? ACTIVE_COVERAGE_CENTER_DEPTHFAIL
|
||||||
? hue.lineActive
|
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL;
|
||||||
: hue.line.withAlpha(0.6)
|
parts.renderer.styleLine(entity, {
|
||||||
: isActive
|
visible,
|
||||||
? ACTIVE_COVERAGE_EDGE
|
width: cap ? (isActive ? 2.2 : 1.0) : isActive ? 1.8 : 0.9,
|
||||||
: IDLE_COVERAGE_EDGE_MUTED,
|
tint,
|
||||||
);
|
dashed: bearingEstimated,
|
||||||
entity.polyline.width = isActive ? 1.8 : 0.9;
|
depthFail,
|
||||||
entity.polyline.depthFailMaterial = planeShowing
|
});
|
||||||
? hue
|
|
||||||
? hue.line.withAlpha(0.18)
|
|
||||||
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the
|
// Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the
|
||||||
@@ -316,8 +299,7 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layerState._billboards)
|
parts.renderer?.setMarkersVisible(!!layerState._enabled);
|
||||||
layerState._billboards.show = !!layerState._enabled;
|
|
||||||
parts.projection.pauseInactiveProjectionFeeds(activeId);
|
parts.projection.pauseInactiveProjectionFeeds(activeId);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import {
|
|
||||||
bindTrackingClickGesture,
|
|
||||||
isTrackingClickGesture,
|
|
||||||
} from '../../data/trackingClickGesture.js';
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
|
|
||||||
export function createSelection({
|
export function createSelection({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
services,
|
services,
|
||||||
@@ -33,27 +27,6 @@ export function createSelection({
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind CCTV activation to clean taps while preserving the layer's hover-move
|
|
||||||
* callback on the shared Cesium handler. Drag-like and long-press gestures do
|
|
||||||
* not reach camera activation or focus dispatch.
|
|
||||||
* @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler.
|
|
||||||
* @param {(click: Object) => void} onClick - Accepted CCTV click callback.
|
|
||||||
* @param {Object} [options] - Gesture test seams and optional onMouseMove hook.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
|
|
||||||
function bindCctvWorldClickGesture(handler, onClick, options = {}) {
|
|
||||||
bindTrackingClickGesture(
|
|
||||||
handler,
|
|
||||||
(click, gesture) => {
|
|
||||||
if (!isTrackingClickGesture(gesture)) return;
|
|
||||||
onClick(click);
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the active camera by ID, initializes its projection runtime, refreshes
|
* Sets the active camera by ID, initializes its projection runtime, refreshes
|
||||||
* its frame, and updates styles.
|
* its frame, and updates styles.
|
||||||
@@ -206,54 +179,10 @@ export function createSelection({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function extractPickedCameraId(picked) {
|
function extractPickedCameraId(picked) {
|
||||||
if (!picked) return null;
|
return parts.renderer?.extractPickedCameraId(picked) ?? null;
|
||||||
|
|
||||||
const entity = picked.id?.properties
|
|
||||||
? picked.id
|
|
||||||
: picked.primitive?.id?.properties
|
|
||||||
? picked.primitive.id
|
|
||||||
: null;
|
|
||||||
const maybeProp = entity?.properties?.cctvCameraId;
|
|
||||||
if (maybeProp) {
|
|
||||||
const value =
|
|
||||||
typeof maybeProp.getValue === 'function'
|
|
||||||
? maybeProp.getValue(Cesium.JulianDate.now())
|
|
||||||
: maybeProp;
|
|
||||||
const record =
|
|
||||||
typeof value === 'string' ? layerState._recordById.get(value) : null;
|
|
||||||
const ownsCoverageEntity = Boolean(
|
|
||||||
record?.coverageEntities?.includes(entity),
|
|
||||||
);
|
|
||||||
const ownsProjectionEntity =
|
|
||||||
record?.projection?.planeEntity === entity ||
|
|
||||||
layerState._projectionEntities.some(
|
|
||||||
(runtime) =>
|
|
||||||
runtime?.cameraId === value && runtime.planeEntity === entity,
|
|
||||||
);
|
|
||||||
if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Camera billboard IDs are intentionally the upstream camera ID, so a
|
|
||||||
// sibling may legitimately use the same string. A bare ID match is not
|
|
||||||
// ownership proof: require this layer's billboard collection or the exact
|
|
||||||
// billboard stored on the record.
|
|
||||||
const directId =
|
|
||||||
typeof picked.id === 'string'
|
|
||||||
? picked.id
|
|
||||||
: typeof picked.primitive?.id === 'string'
|
|
||||||
? picked.primitive.id
|
|
||||||
: null;
|
|
||||||
const record =
|
|
||||||
directId === null ? null : layerState._recordById.get(directId);
|
|
||||||
if (!record) return null;
|
|
||||||
return picked.primitive === layerState._billboards ||
|
|
||||||
picked.primitive === record.billboard
|
|
||||||
? directId
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
getActiveRecord,
|
getActiveRecord,
|
||||||
bindCctvWorldClickGesture,
|
|
||||||
setActiveCamera,
|
setActiveCamera,
|
||||||
deactivateActiveCamera,
|
deactivateActiveCamera,
|
||||||
cctvEmptyClickDeselects,
|
cctvEmptyClickDeselects,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createCctvSource, createCctvLayer } from './index.js';
|
import { createCctvSource, createCctvLayer } from './index.js';
|
||||||
|
import { createCctvCesiumRenderer } from './render/cesium.js';
|
||||||
|
|
||||||
const camera = {
|
const camera = {
|
||||||
id: 'pack/camera ?x',
|
id: 'pack/camera ?x',
|
||||||
@@ -131,8 +132,16 @@ test('camera construction is inert and destruction cancels a pending catalog and
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const a = createCctvLayer({ services, source });
|
const a = createCctvLayer({
|
||||||
const b = createCctvLayer({ services, source });
|
services,
|
||||||
|
source,
|
||||||
|
rendererFactory: createCctvCesiumRenderer,
|
||||||
|
});
|
||||||
|
const b = createCctvLayer({
|
||||||
|
services,
|
||||||
|
source,
|
||||||
|
rendererFactory: createCctvCesiumRenderer,
|
||||||
|
});
|
||||||
assert.equal(listeners.size, 0);
|
assert.equal(listeners.size, 0);
|
||||||
const viewer = {
|
const viewer = {
|
||||||
scene: { primitives: { add: (value) => value, remove: () => true } },
|
scene: { primitives: { add: (value) => value, remove: () => true } },
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ export function createState({ services }) {
|
|||||||
// Module-scoped mutable state
|
// Module-scoped mutable state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
layerState._viewer = null;
|
|
||||||
|
|
||||||
layerState._billboards = null;
|
|
||||||
|
|
||||||
layerState._records = [];
|
layerState._records = [];
|
||||||
|
|
||||||
layerState._recordById = new Map();
|
layerState._recordById = new Map();
|
||||||
@@ -48,7 +44,7 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._lastViewContext = '';
|
layerState._lastViewContext = '';
|
||||||
|
|
||||||
layerState._clickHandler = null;
|
layerState._removeClick = null;
|
||||||
|
|
||||||
layerState._count = 0;
|
layerState._count = 0;
|
||||||
|
|
||||||
@@ -113,8 +109,6 @@ export function createState({ services }) {
|
|||||||
// tilesLoaded flag so a coarse-LOD miss is never baked in. Cleared when the
|
// tilesLoaded flag so a coarse-LOD miss is never baked in. Cleared when the
|
||||||
// tileset is destroyed / the layer tears down.
|
// tileset is destroyed / the layer tears down.
|
||||||
|
|
||||||
layerState._activeTileset = null;
|
|
||||||
|
|
||||||
// Task 5: last surface regime the record geometry was recomputed for. The
|
// Task 5: last surface regime the record geometry was recomputed for. The
|
||||||
// map-stack change listener compares the CURRENT regime (derived live from
|
// map-stack change listener compares the CURRENT regime (derived live from
|
||||||
// scene.globe.show) against this so bing→osm switches (same 'terrain-globe'
|
// scene.globe.show) against this so bing→osm switches (same 'terrain-globe'
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as geo from '../../geo/index.js';
|
||||||
|
|
||||||
|
export function createTesting({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
rendererFactory,
|
||||||
|
}) {
|
||||||
|
let testViewer;
|
||||||
|
function useRenderer(viewer) {
|
||||||
|
if (parts.renderer && testViewer === viewer) return;
|
||||||
|
parts.renderer?.destroy();
|
||||||
|
parts.renderer = rendererFactory({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
parts.renderer.attach(viewer);
|
||||||
|
testViewer = viewer;
|
||||||
|
}
|
||||||
|
|
||||||
export function createTesting({ state: layerState, services, parts, source }) {
|
|
||||||
/** Test seam: republishes host entries through the real push path, so tests
|
/** Test seam: republishes host entries through the real push path, so tests
|
||||||
* can observe the pristine module default without touching the setter. */
|
* can observe the pristine module default without touching the setter. */
|
||||||
|
|
||||||
@@ -25,7 +45,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _createCctvProjectionPlaneForTest(viewer, record) {
|
function _createCctvProjectionPlaneForTest(viewer, record) {
|
||||||
layerState._viewer = viewer;
|
useRenderer(viewer);
|
||||||
const geometry =
|
const geometry =
|
||||||
record.frustumGeometry ||
|
record.frustumGeometry ||
|
||||||
parts.geometry.computeFrustumGeometry(
|
parts.geometry.computeFrustumGeometry(
|
||||||
@@ -40,9 +60,9 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
const runtime = {
|
const runtime = {
|
||||||
cameraId: String(record.camera.id),
|
cameraId: String(record.camera.id),
|
||||||
planeEntity: null,
|
planeEntity: null,
|
||||||
labelPosition: new Cesium.Cartesian3(),
|
labelPosition: geo.cartesian3.create(),
|
||||||
overlayEntry: null,
|
overlayEntry: null,
|
||||||
planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE),
|
planeMaterial: parts.renderer.createTestPlaneMaterial(),
|
||||||
};
|
};
|
||||||
parts.projection.createProjectionPlane(
|
parts.projection.createProjectionPlane(
|
||||||
record,
|
record,
|
||||||
@@ -90,7 +110,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
coverageMode = 'on',
|
coverageMode = 'on',
|
||||||
showProjection = false,
|
showProjection = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
layerState._viewer = viewer;
|
useRenderer(viewer);
|
||||||
layerState._records = Array.isArray(records) ? records : [];
|
layerState._records = Array.isArray(records) ? records : [];
|
||||||
layerState._recordById = new Map(
|
layerState._recordById = new Map(
|
||||||
layerState._records
|
layerState._records
|
||||||
@@ -99,7 +119,6 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
);
|
);
|
||||||
layerState._coverageEntities = [];
|
layerState._coverageEntities = [];
|
||||||
layerState._projectionEntities = [];
|
layerState._projectionEntities = [];
|
||||||
layerState._billboards = null;
|
|
||||||
layerState._activeCameraId = activeCameraId;
|
layerState._activeCameraId = activeCameraId;
|
||||||
layerState._autoHopSuspended = false;
|
layerState._autoHopSuspended = false;
|
||||||
layerState._enabled = !!enabled;
|
layerState._enabled = !!enabled;
|
||||||
|
|||||||
Reference in new issue
Block a user