mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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/gizmo.test.mjs",
|
||||
"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/rendering.js",
|
||||
"src/layers/cctv/testing.js",
|
||||
|
||||
@@ -1449,10 +1449,6 @@
|
||||
"src/layers/shared/color.js",
|
||||
"src/data/cctvLod.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/cards.js",
|
||||
"src/layers/cctv/catalog.js",
|
||||
@@ -1497,11 +1493,9 @@
|
||||
"src/geo/scaleToGeodeticSurface.js",
|
||||
"src/geo/check.js",
|
||||
"src/geo/debug.js",
|
||||
"src/geo/developerError.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
"src/geo/developerError.js"
|
||||
],
|
||||
"external": ["hls.js", "cesium"]
|
||||
"external": ["hls.js"]
|
||||
},
|
||||
"street-level-layer": {
|
||||
"exports": [
|
||||
@@ -1835,6 +1829,8 @@
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.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/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
@@ -2793,6 +2789,8 @@
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.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/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
|
||||
+77
-3
@@ -68,6 +68,8 @@
|
||||
* 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]
|
||||
* 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.
|
||||
*/
|
||||
@@ -99,6 +101,9 @@ const output = getOpt('--out');
|
||||
if (!explicitUrl || !output)
|
||||
throw new Error('Explicit --url and --out are required');
|
||||
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);
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
@@ -404,6 +409,17 @@ async function main() {
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
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 });
|
||||
watchW1Page(page, evidence, {
|
||||
name: 'cctv-legacy',
|
||||
@@ -435,6 +451,27 @@ async function main() {
|
||||
if (layer.enabled) await app.dataManager.setEnabled(id, false);
|
||||
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.
|
||||
await sleep(4000);
|
||||
|
||||
@@ -619,7 +656,7 @@ async function main() {
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded) {
|
||||
if (tilesSeenLoaded && !terrainGlobe) {
|
||||
sampleFloorOk = await page
|
||||
.waitForFunction(
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
@@ -631,8 +668,12 @@ async function main() {
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
tilesSeenLoaded && sampleFloorOk,
|
||||
terrainGlobe
|
||||
? '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
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
@@ -1588,6 +1629,24 @@ async function main() {
|
||||
});
|
||||
|
||||
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 mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
@@ -1638,6 +1697,18 @@ async function main() {
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
});
|
||||
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(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
window.__qaViewshedRefs = [];
|
||||
@@ -1659,6 +1730,9 @@ async function main() {
|
||||
return { sameCount, sameIdentity, count: now.length };
|
||||
});
|
||||
const cAfterViewshed = await readCounters();
|
||||
evidence.viewshedTrace = await page.evaluate(
|
||||
() => window.__qaViewshedTrace,
|
||||
);
|
||||
const lateFloorSamples =
|
||||
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
||||
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 * as credits from '../../data/dataCredits.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';
|
||||
|
||||
/** 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;
|
||||
return createCctvLayer({
|
||||
const layer = createCctvLayer({
|
||||
source,
|
||||
rendererFactory,
|
||||
services: {
|
||||
createGizmoRenderer: createCesiumCctvGizmoRenderer,
|
||||
credits,
|
||||
sprites,
|
||||
activation,
|
||||
@@ -29,4 +36,16 @@ export function createApplicationCctv({ surface, source }) {
|
||||
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);
|
||||
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);
|
||||
@@ -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.match(
|
||||
componentFunctionSource(cctvLayer.init),
|
||||
/bindCctvWorldClickGesture\(_clickHandler/,
|
||||
/_removeClick = bindClick\(/,
|
||||
);
|
||||
assert.match(
|
||||
componentFunctionSource(cctvLayer.init),
|
||||
|
||||
+35
-55
@@ -6,17 +6,17 @@
|
||||
*
|
||||
* Two responsibilities, both pure of layer state:
|
||||
* - Color identity: a stable per-camera hue (golden-angle spaced over the
|
||||
* id-sorted catalog index) and the derived fill/line Cesium colors.
|
||||
* - Volume geometry: a translucent frustum pyramid primitive built FROM the
|
||||
* id-sorted catalog index) and the derived fill/line RGBA colors.
|
||||
* - Volume geometry: frustum pyramid buffers built FROM the
|
||||
* exact frustumCartesians positions the wireframe uses — welded by
|
||||
* construction, never recomputed independently. 5 vertices, 6 triangles,
|
||||
* synchronous build (trivially small), no picking.
|
||||
* construction, never recomputed independently. 5 vertices, 6 triangles.
|
||||
* The renderer owns primitive creation and picking policy.
|
||||
*
|
||||
* Nothing here queries the scene or persists anything: colors are
|
||||
* presentation-only and volumes are rebuilt only at the moments the frustum
|
||||
* 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. */
|
||||
const GOLDEN_ANGLE_DEG = 137.50776405003785;
|
||||
@@ -39,19 +39,42 @@ export function cameraHue(index) {
|
||||
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)
|
||||
* and wireframe line tints (idle/active), all in the same hue family.
|
||||
* @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) {
|
||||
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
|
||||
return {
|
||||
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||
line: Cesium.Color.fromHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
|
||||
lineActive: Cesium.Color.fromHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE),
|
||||
fill: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||
fillActive: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||
line: viewshedHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
|
||||
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
|
||||
* geometry definition (weld-by-construction with the wireframe, which draws
|
||||
* its rays/cap from the same 5 Cartesians).
|
||||
* @param {{mount: Cesium.Cartesian3, tl: Cesium.Cartesian3, tr: Cesium.Cartesian3,
|
||||
* br: Cesium.Cartesian3, bl: Cesium.Cartesian3}} positions - frustumCartesians shape.
|
||||
* @param {{mount: Object, tl: Object, tr: Object,
|
||||
* br: Object, bl: Object}} positions - ECEF frustumCartesians shape.
|
||||
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
||||
*/
|
||||
export function frustumVolumeGeometryData(positions) {
|
||||
@@ -85,46 +108,3 @@ export function frustumVolumeGeometryData(positions) {
|
||||
]);
|
||||
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', () => {
|
||||
const colors = viewshedColors(210);
|
||||
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.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);
|
||||
});
|
||||
|
||||
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. */
|
||||
function positionsFixture() {
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
import { createDirectionsSource } from '../layers/directions/source.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 { 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) => {
|
||||
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 },
|
||||
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();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
@@ -297,7 +297,7 @@ export function createCalibration({
|
||||
*/
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -306,7 +306,7 @@ export function createCalibration({
|
||||
? record
|
||||
: null;
|
||||
layerState._gizmo = createCalibrationGizmo({
|
||||
renderer: services.createGizmoRenderer(layerState._viewer),
|
||||
renderer: parts.renderer.createGizmoRenderer(),
|
||||
getActiveRecord: () =>
|
||||
layerState._enabled && layerState._calibrationMode
|
||||
? parts.selection.getActiveRecord()
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
applyFrameResult,
|
||||
} from '../../data/cctvCards.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js';
|
||||
import {
|
||||
CCTV_OVERLAY_SOURCE_OPTIONS,
|
||||
@@ -35,7 +34,7 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
} = {}) {
|
||||
layerState._activeCameraCardEnabled = activeCameraCardEnabled === true;
|
||||
if (layerState._enabled) pushAmbientCardEntries();
|
||||
layerState._viewer?.scene?.requestRender?.();
|
||||
parts.renderer?.request();
|
||||
|
||||
return { activeCameraCardEnabled: layerState._activeCameraCardEnabled };
|
||||
}
|
||||
@@ -72,8 +71,7 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
function refreshAmbientCards() {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._viewer ||
|
||||
layerState._viewer.isDestroyed() ||
|
||||
!parts.renderer?.ready ||
|
||||
!layerState._records.length
|
||||
) {
|
||||
layerState._cctvOverlayHost.setEntries(
|
||||
@@ -83,17 +81,15 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const scene = layerState._viewer.scene;
|
||||
const carto = layerState._viewer.camera.positionCartographic;
|
||||
const carto = parts.renderer.cameraCartographic();
|
||||
const viewerLat = carto ? geo.math.toDegrees(carto.latitude) : 0;
|
||||
const viewerLon = carto ? geo.math.toDegrees(carto.longitude) : 0;
|
||||
// The active camera is excluded from ambient selection ALWAYS (not just
|
||||
// after its async activation settles). It is published separately in the
|
||||
// protected lane below, and grace never applies to it.
|
||||
const activeId = layerState._activeCameraId;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
|
||||
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
|
||||
const horizonVisible = parts.renderer.horizonTest();
|
||||
const { width, height } = parts.renderer.viewport();
|
||||
const marginX = width * 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 sx = NaN;
|
||||
let sy = NaN;
|
||||
if (occluder.isPointVisible(record.position)) {
|
||||
const screen = scene.cartesianToCanvasCoordinates(record.position);
|
||||
if (horizonVisible(record.position)) {
|
||||
const screen = parts.renderer.project(record.position);
|
||||
if (
|
||||
screen &&
|
||||
Number.isFinite(screen.x) &&
|
||||
@@ -325,7 +321,7 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
slot,
|
||||
applyFrameResult(slot, { ok: !!frame, frame }, Date.now()),
|
||||
);
|
||||
layerState._viewer?.scene?.requestRender?.();
|
||||
parts.renderer?.request();
|
||||
};
|
||||
image.onload = () => settle(true);
|
||||
image.onerror = () => settle(false);
|
||||
|
||||
@@ -127,7 +127,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
)
|
||||
return;
|
||||
|
||||
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
|
||||
const planeShowing = !!(runtime.planeVisible && runtime.planeMaterial);
|
||||
if (!planeShowing) return;
|
||||
|
||||
// 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;
|
||||
runtime.lastTextureSwapAt = now;
|
||||
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
||||
runtime.planeMaterial.image = buffer;
|
||||
parts.renderer.setPlaneImage(runtime, buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+21
-80
@@ -1,6 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
|
||||
import {
|
||||
PROJECTION_VERT_ASPECT,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
@@ -8,7 +6,6 @@ import {
|
||||
PROBE_CLEARANCE_M,
|
||||
COVERAGE_NEIGHBOR_RADIUS_KM,
|
||||
COVERAGE_NEIGHBOR_LIMIT,
|
||||
IDLE_COVERAGE_COLOR,
|
||||
PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||
} from './policy.js';
|
||||
|
||||
@@ -20,7 +17,6 @@ import {
|
||||
|
||||
export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
||||
const { sampleMeshFloorCells } = services.mesh;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
record.billboard.position = positions.mount;
|
||||
}
|
||||
if (record.coverageEntities?.length >= 5) {
|
||||
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,
|
||||
];
|
||||
}
|
||||
parts.renderer?.updateCoverage(record, positions);
|
||||
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
|
||||
// positions (weld invariant). Only records currently showing a volume pay
|
||||
// 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()) {
|
||||
record.groundMeshSampleRequestCount =
|
||||
(record.groundMeshSampleRequestCount || 0) + 1;
|
||||
const viewerCarto = layerState._viewer?.camera?.positionCartographic;
|
||||
const viewerCarto = parts.renderer?.cameraCartographic();
|
||||
const excludeObjects = [...(record.coverageEntities || [])];
|
||||
if (record.billboard) excludeObjects.push(record.billboard);
|
||||
if (record.projection?.planeEntity)
|
||||
excludeObjects.push(record.projection.planeEntity);
|
||||
sampleMeshFloorCells(layerState._viewer?.scene, [point], {
|
||||
parts.renderer.sampleMeshFloorCells([point], {
|
||||
excludeObjects: excludeObjects.filter(Boolean),
|
||||
viewerLat: viewerCarto
|
||||
? geo.math.toDegrees(viewerCarto.latitude)
|
||||
@@ -530,9 +502,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
|
||||
function destroyViewshedVolume(record) {
|
||||
if (!record) return;
|
||||
if (record.viewshedPrimitive && layerState._viewer) {
|
||||
layerState._viewer.scene.primitives.remove(record.viewshedPrimitive);
|
||||
}
|
||||
if (record.viewshedPrimitive)
|
||||
parts.renderer?.removeVolume(record.viewshedPrimitive);
|
||||
record.viewshedPrimitive = null;
|
||||
}
|
||||
|
||||
@@ -550,7 +521,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
function rebuildViewshedVolume(record, isActive) {
|
||||
destroyViewshedVolume(record);
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
!parts.renderer?.ready ||
|
||||
!record?.frustumPositions ||
|
||||
!record.viewshedColors
|
||||
)
|
||||
@@ -558,14 +529,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
const color = isActive
|
||||
? record.viewshedColors.fillActive
|
||||
: record.viewshedColors.fill;
|
||||
const primitive = createFrustumVolumePrimitive(
|
||||
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.viewshedPrimitive = parts.renderer.createVolume(record, color);
|
||||
record.viewshedActiveTint = !!isActive;
|
||||
}
|
||||
|
||||
@@ -611,8 +575,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
|
||||
function runActivationObstructionProbe(record) {
|
||||
record.probeClampRangeM = null;
|
||||
const scene = layerState._viewer?.scene;
|
||||
if (!scene || typeof scene.pickFromRay !== 'function') return;
|
||||
if (!parts.renderer?.ready) return;
|
||||
const camera = record.camera;
|
||||
const rangeScale = parts.calibration.normalizeCalibration(
|
||||
camera.calibration,
|
||||
@@ -628,11 +591,19 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
const { dir } = frustumFrameEcef(camera, mountPos);
|
||||
// Exclude everything the layer itself draws so the probe can only hit the
|
||||
// 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) {
|
||||
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;
|
||||
const dist = geo.cartesian3.distance(mountPos, hit.position);
|
||||
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
|
||||
@@ -684,37 +655,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
record.position = positions.mount;
|
||||
}
|
||||
|
||||
const addPolyline = (role, linePositions) =>
|
||||
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;
|
||||
return parts.renderer.createCoverage(camera.id, positions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -797,9 +738,9 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function destroyCoverageEntities() {
|
||||
if (!layerState._viewer) return;
|
||||
if (!parts.renderer?.ready) return;
|
||||
for (const entity of layerState._coverageEntities) {
|
||||
layerState._viewer.entities.remove(entity);
|
||||
parts.renderer.removeCoverageEntity(entity);
|
||||
}
|
||||
layerState._coverageEntities = [];
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ export function createGeometryQueue({
|
||||
|
||||
function processGeometryBatch() {
|
||||
layerState._geoQueueTimer = 0;
|
||||
if (!layerState._viewer) {
|
||||
if (!parts.renderer?.ready) {
|
||||
stopGeometryLoadQueue();
|
||||
return;
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export function createGeometryQueue({
|
||||
const batchResult = processCctvGeometryDrainBatch({
|
||||
queue: layerState._geoQueue,
|
||||
readOwnership: () => ({
|
||||
trackedEntity: layerState._viewer.trackedEntity,
|
||||
trackedEntity: parts.renderer.following(),
|
||||
cockpitActive:
|
||||
typeof document !== 'undefined' &&
|
||||
document.body?.classList.contains('cockpit-mode'),
|
||||
@@ -296,7 +296,7 @@ export function createGeometryQueue({
|
||||
layerState._tilesReadyReenqueued = false;
|
||||
if (!layerState._records.length) return;
|
||||
const active = parts.selection.getActiveRecord();
|
||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
||||
const carto = parts.renderer?.cameraCartographic();
|
||||
const refLat = carto
|
||||
? geo.math.toDegrees(carto.latitude)
|
||||
: (active?.camera.lat ?? 0);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
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() {
|
||||
if (!layerState._viewer || !layerState._records.length) return;
|
||||
if (!parts.renderer?.ready || !layerState._records.length) return;
|
||||
const regime = currentSurfaceRegime();
|
||||
if (regime === layerState._lastAppliedRegime) return;
|
||||
layerState._lastAppliedRegime = regime;
|
||||
|
||||
@@ -25,13 +25,13 @@ export function createHover({ state: layerState, services, parts, source }) {
|
||||
!position
|
||||
)
|
||||
return;
|
||||
if (!layerState._viewer || layerState._viewer.isDestroyed()) return;
|
||||
if (!parts.renderer?.ready) return;
|
||||
const now = Date.now();
|
||||
if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return;
|
||||
layerState._hoverLastPickAt = now;
|
||||
let picked = null;
|
||||
try {
|
||||
picked = layerState._viewer.scene.pick(position);
|
||||
picked = parts.renderer.pick(position);
|
||||
} catch {
|
||||
picked = null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createIngestion } from './ingestion.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createCctvLayer({ services, source }) {
|
||||
export function createCctvLayer({ services, source, rendererFactory }) {
|
||||
if (
|
||||
!['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every(
|
||||
(key) => typeof source?.[key] === 'function',
|
||||
@@ -29,7 +29,7 @@ export function createCctvLayer({ services, source }) {
|
||||
throw new TypeError('A camera source is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
const context = { state, services, parts, source, rendererFactory };
|
||||
parts.calibration = createCalibration(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.projection = createProjection(context);
|
||||
@@ -94,7 +94,6 @@ export function createCctvLayer({ services, source }) {
|
||||
clearProbeClampOnDeactivation:
|
||||
parts.geometry.clearProbeClampOnDeactivation,
|
||||
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
||||
bindCctvWorldClickGesture: parts.selection.bindCctvWorldClickGesture,
|
||||
setActiveCamera: parts.selection.setActiveCamera,
|
||||
deactivateActiveCamera: parts.selection.deactivateActiveCamera,
|
||||
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 { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js';
|
||||
import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js';
|
||||
import {
|
||||
GROUND_PRIOR_INIT_WAIT_MS,
|
||||
CAMERA_ICON,
|
||||
IDLE_CAMERA_COLOR,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
@@ -14,8 +12,8 @@ export function createLifecycle({
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
rendererFactory,
|
||||
}) {
|
||||
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
|
||||
const { activateCctvCameraFromWorldClick } = services.activation;
|
||||
const { resolvePickId, registerPickOwner, unregisterPickOwner } =
|
||||
services.picking;
|
||||
@@ -38,9 +36,6 @@ export function createLifecycle({
|
||||
layerState._lastError = null;
|
||||
layerState._lastFocusStyleAt = 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
|
||||
// recomputes it against the then-current scene.
|
||||
layerState._lastAppliedRegime = null;
|
||||
@@ -52,283 +47,277 @@ export function createLifecycle({
|
||||
* handling, and performs initial health sync. Coverage entities stay lazy.
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
|
||||
*/
|
||||
async init(viewer) {
|
||||
layerState._sourceAbort?.abort();
|
||||
const sourceAbort = new AbortController();
|
||||
layerState._sourceAbort = sourceAbort;
|
||||
if (typeof document !== 'undefined')
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
parts.cards.handleVisibilityChange,
|
||||
async init(viewer, options = {}) {
|
||||
if (parts.renderer) throw new Error('CCTV layer is already initialized');
|
||||
if (typeof rendererFactory !== 'function')
|
||||
throw new TypeError('A CCTV renderer factory is required');
|
||||
const renderer = rendererFactory({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
engine: options.engine,
|
||||
});
|
||||
parts.renderer = renderer;
|
||||
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();
|
||||
layerState._viewer.scene.primitives.add(layerState._billboards);
|
||||
registerSpriteCollection('cctv', layerState._billboards);
|
||||
|
||||
const sources = await parts.catalog.loadCameraSources();
|
||||
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]),
|
||||
);
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
parts.model.ensureCameraPose(camera);
|
||||
}
|
||||
|
||||
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
|
||||
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
|
||||
// the catalog's orthometric groundElevationM feeds the geoid fallback
|
||||
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
|
||||
// records are normally built WITH their prior (correct first paint in
|
||||
// every regime); a cold/slow upstream loses the race and the batch
|
||||
// applies post-hoc via applyLateGroundPriors instead of hanging init.
|
||||
const priorsPromise = parts.ground.resolveGroundPriors(catalog);
|
||||
const priors = await Promise.race([
|
||||
priorsPromise,
|
||||
new Promise((resolve) =>
|
||||
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),
|
||||
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
|
||||
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
|
||||
// the catalog's orthometric groundElevationM feeds the geoid fallback
|
||||
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
|
||||
// records are normally built WITH their prior (correct first paint in
|
||||
// every regime); a cold/slow upstream loses the race and the batch
|
||||
// applies post-hoc via applyLateGroundPriors instead of hanging init.
|
||||
const priorsPromise = parts.ground.resolveGroundPriors(catalog);
|
||||
const priors = await Promise.race([
|
||||
priorsPromise,
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS),
|
||||
),
|
||||
viewshedPrimitive: null,
|
||||
viewshedActiveTint: false,
|
||||
};
|
||||
layerState._records.push(record);
|
||||
layerState._recordById.set(camera.id, record);
|
||||
}
|
||||
]);
|
||||
sourceAbort.signal.throwIfAborted();
|
||||
|
||||
layerState._count = layerState._records.length;
|
||||
if (layerState._records.length > 0) {
|
||||
// Projection runtime + first frame fetch are deferred to enable() so
|
||||
// initializing the catalog stays render-cheap.
|
||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||
}
|
||||
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 = 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
|
||||
// when it lands (pure recomputes — applyLateGroundPriors guards against
|
||||
// a torn-down/re-inited catalog).
|
||||
if (!priors) {
|
||||
const initRecords = layerState._records.slice();
|
||||
priorsPromise
|
||||
.then((late) => {
|
||||
if (late) parts.ground.applyLateGroundPriors(initRecords, late);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
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,
|
||||
};
|
||||
layerState._records.push(record);
|
||||
layerState._recordById.set(camera.id, record);
|
||||
}
|
||||
|
||||
// Task 5: track the surface regime the initial geometry was computed for
|
||||
// and listen for map-stack changes (main.js re-dispatches
|
||||
// MapStackController.onChange as this CustomEvent). The handler compares
|
||||
// regimes itself, so 'switching'/'error' emissions and same-regime stack
|
||||
// swaps (bing→osm) no-op.
|
||||
layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime();
|
||||
if (!layerState._mapStackListener && typeof window !== 'undefined') {
|
||||
layerState._mapStackListener = () =>
|
||||
parts.ground.handleMapStackChanged();
|
||||
window.addEventListener(
|
||||
'gev:map-stack-changed',
|
||||
layerState._mapStackListener,
|
||||
);
|
||||
}
|
||||
layerState._count = layerState._records.length;
|
||||
if (layerState._records.length > 0) {
|
||||
// Projection runtime + first frame fetch are deferred to enable() so
|
||||
// initializing the catalog stays render-cheap.
|
||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||
}
|
||||
|
||||
// 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 = () => {
|
||||
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,
|
||||
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
|
||||
// when it lands (pure recomputes — applyLateGroundPriors guards against
|
||||
// a torn-down/re-inited catalog).
|
||||
if (!priors) {
|
||||
const initRecords = layerState._records.slice();
|
||||
priorsPromise
|
||||
.then((late) => {
|
||||
if (late) parts.ground.applyLateGroundPriors(initRecords, late);
|
||||
})
|
||||
) {
|
||||
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),
|
||||
},
|
||||
);
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
await parts.health.syncHealthState(true);
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
restoreSpriteOrder(layerState._viewer);
|
||||
console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras');
|
||||
// Task 5: track the surface regime the initial geometry was computed for
|
||||
// and listen for map-stack changes (main.js re-dispatches
|
||||
// MapStackController.onChange as this CustomEvent). The handler compares
|
||||
// regimes itself, so 'switching'/'error' emissions and same-regime stack
|
||||
// swaps (bing→osm) no-op.
|
||||
layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime();
|
||||
if (!layerState._mapStackListener && typeof window !== 'undefined') {
|
||||
layerState._mapStackListener = () =>
|
||||
parts.ground.handleMapStackChanged();
|
||||
window.addEventListener(
|
||||
'gev:map-stack-changed',
|
||||
layerState._mapStackListener,
|
||||
);
|
||||
}
|
||||
|
||||
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
|
||||
// the billboards' always-on-top depth setting) + one initial pass so the
|
||||
// first paint is already culled.
|
||||
if (!layerState._horizonCullListener) {
|
||||
// Ambient cards piggyback the same settle event: moveEnd-driven
|
||||
// reselection only, never per frame (refreshAmbientCards no-ops while
|
||||
// the layer is disabled).
|
||||
layerState._horizonCullListener = parts.renderer.onMoveEnd(() => {
|
||||
layerState._cameraMoving = false;
|
||||
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',
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
|
||||
parts.renderer?.showCredit();
|
||||
layerState._lastUpdate = Date.now();
|
||||
// Pick-ownership (H2): camera billboards use the camera id directly;
|
||||
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
|
||||
@@ -386,12 +375,12 @@ export function createLifecycle({
|
||||
parts.cards.startCardFrameLoop();
|
||||
parts.cards.refreshAmbientCards();
|
||||
parts.presentation.notifyListeners();
|
||||
restoreSpriteOrder(layerState._viewer);
|
||||
parts.renderer.restoreOrder();
|
||||
},
|
||||
|
||||
/** Disables the layer: hides entities, stops the projection loop and load queue. */
|
||||
disable() {
|
||||
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
|
||||
parts.renderer?.hideCredit();
|
||||
layerState._enabled = false;
|
||||
unregisterPickOwner('cctv');
|
||||
// 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
|
||||
* 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) {
|
||||
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
|
||||
destroy() {
|
||||
parts.renderer?.hideCredit();
|
||||
layerState._sourceAbort?.abort();
|
||||
if (typeof document !== 'undefined')
|
||||
document.removeEventListener(
|
||||
@@ -430,39 +419,27 @@ export function createLifecycle({
|
||||
);
|
||||
layerState._mapStackListener = null;
|
||||
}
|
||||
const teardownViewer = viewer || layerState._viewer;
|
||||
if (layerState._horizonCullListener && teardownViewer?.camera?.moveEnd) {
|
||||
teardownViewer.camera.moveEnd.removeEventListener(
|
||||
layerState._horizonCullListener,
|
||||
);
|
||||
layerState._horizonCullListener = null;
|
||||
}
|
||||
if (layerState._moveStartListener && teardownViewer?.camera?.moveStart) {
|
||||
teardownViewer.camera.moveStart.removeEventListener(
|
||||
layerState._moveStartListener,
|
||||
);
|
||||
layerState._moveStartListener = null;
|
||||
}
|
||||
layerState._horizonCullListener?.();
|
||||
layerState._horizonCullListener = null;
|
||||
layerState._moveStartListener?.();
|
||||
layerState._moveStartListener = null;
|
||||
layerState._removeFocusAppearListener?.();
|
||||
layerState._removeFocusAppearListener = null;
|
||||
if (layerState._gizmo) {
|
||||
layerState._gizmo.destroy();
|
||||
layerState._gizmo = null;
|
||||
}
|
||||
layerState._calibrationMode = false;
|
||||
releaseContinuousRender('cctv-adjust');
|
||||
if (layerState._clickHandler) {
|
||||
layerState._clickHandler.destroy();
|
||||
layerState._clickHandler = null;
|
||||
}
|
||||
layerState._removeClick?.();
|
||||
layerState._removeClick = null;
|
||||
parts.projection.stopProjectionLoop();
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
parts.cards.teardownAmbientCards();
|
||||
parts.geometry.destroyCoverageEntities();
|
||||
if (layerState._billboards && teardownViewer) {
|
||||
teardownViewer.scene.primitives.remove(layerState._billboards);
|
||||
layerState._billboards = null;
|
||||
}
|
||||
parts.renderer?.destroy();
|
||||
parts.renderer = null;
|
||||
clearRuntimeState();
|
||||
layerState._viewer = null;
|
||||
layerState._enabled = false;
|
||||
layerState._activeCameraId = null;
|
||||
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 { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
||||
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
||||
@@ -327,7 +326,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function currentViewContext() {
|
||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
||||
const carto = parts.renderer?.cameraCartographic();
|
||||
if (!carto) return 'none';
|
||||
const lat = geo.math.toDegrees(carto.latitude);
|
||||
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
|
||||
* 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.
|
||||
* Discovers + caches the tileset lazily from scene
|
||||
* primitives (the CCTV module holds only a `_viewer` reference). When no
|
||||
* The renderer discovers and caches its active tileset. When no
|
||||
* tileset is present (OSM fallback) this returns true so ground sampling is
|
||||
* not permanently blocked.
|
||||
*
|
||||
@@ -370,25 +368,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function projectionTilesReady() {
|
||||
if (
|
||||
!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;
|
||||
return parts.renderer?.tilesReady() ?? 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 —
|
||||
* computed only on slider/save/activation, never per frame (§2b).
|
||||
* @param {Object} camera - Camera pose.
|
||||
* @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF.
|
||||
* @returns {Cesium.Quaternion}
|
||||
* @param {{x: number, y: number, z: number}} capCenterPos - Plane center in ECEF.
|
||||
* @returns {{x: number, y: number, z: number, w: number}}
|
||||
*/
|
||||
|
||||
function planeOrientationFor(camera, capCenterPos) {
|
||||
@@ -422,9 +402,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
m[6] = normal.x;
|
||||
m[7] = normal.y;
|
||||
m[8] = normal.z;
|
||||
// The quaternion becomes an entity's ConstantProperty value, so it stays a
|
||||
// Cesium Quaternion there (that property clones and compares its value).
|
||||
return geo.quaternion.fromRotationMatrix(m, new Cesium.Quaternion());
|
||||
return geo.quaternion.fromRotationMatrix(m, geo.quaternion.create());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,7 +420,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function projectionLoopIsNeeded() {
|
||||
if (!layerState._enabled || !layerState._viewer) return false;
|
||||
if (!layerState._enabled || !parts.renderer?.ready) return false;
|
||||
if (layerState._showProjection && parts.selection.getActiveRecord())
|
||||
return true;
|
||||
return focusPassIsNeeded(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { CCTV_FOCUS_RESULT } from './policy.js';
|
||||
|
||||
@@ -14,7 +13,7 @@ export function createNavigation({
|
||||
*/
|
||||
|
||||
function nearestCameraIdToViewer() {
|
||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
||||
const carto = parts.renderer?.cameraCartographic();
|
||||
if (!carto || !layerState._records.length) return null;
|
||||
const lat = geo.math.toDegrees(carto.latitude);
|
||||
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.
|
||||
*/
|
||||
|
||||
function focusCctvRecord(viewer, record, duration = 2.2) {
|
||||
if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
||||
function focusCctvRecord(renderer, record, duration = 2.2) {
|
||||
if (!renderer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
document.body?.classList.contains('cockpit-mode')
|
||||
@@ -52,35 +51,19 @@ export function createNavigation({
|
||||
console.debug('[Data:CCTV] focus ignored while cockpit owns the camera');
|
||||
return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE;
|
||||
}
|
||||
if (viewer.trackedEntity) {
|
||||
if (renderer.following()) {
|
||||
console.debug(
|
||||
'[Data:CCTV] focus ignored while a tracked entity owns the camera',
|
||||
);
|
||||
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
|
||||
}
|
||||
const { camera } = record;
|
||||
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,
|
||||
},
|
||||
);
|
||||
renderer.focus(record, duration);
|
||||
return CCTV_FOCUS_RESULT.FOCUSED;
|
||||
}
|
||||
|
||||
function focusCamera(cameraId, duration = 2.2) {
|
||||
return focusCctvRecord(
|
||||
layerState._viewer,
|
||||
parts.renderer?.ready ? parts.renderer : null,
|
||||
layerState._recordById.get(cameraId),
|
||||
duration,
|
||||
);
|
||||
|
||||
+21
-11
@@ -5,7 +5,7 @@ export {
|
||||
MEDIA_ENDPOINT,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
} 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_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
|
||||
@@ -404,38 +404,48 @@ export const CAMERA_SEEDS = [
|
||||
// Visual style constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// prettier-ignore
|
||||
export const IDLE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88);
|
||||
rgbaFromCss('#6be8ff').withAlpha(0.88);
|
||||
|
||||
// prettier-ignore
|
||||
export const ACTIVE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
|
||||
rgbaFromCss('#ffd97a').withAlpha(0.95);
|
||||
|
||||
// prettier-ignore
|
||||
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 =
|
||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2);
|
||||
rgbaFromCss('#2fe0ff').withAlpha(0.2);
|
||||
|
||||
// prettier-ignore
|
||||
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 =
|
||||
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58);
|
||||
rgbaFromCss('#8dff87').withAlpha(0.58);
|
||||
|
||||
// prettier-ignore
|
||||
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
|
||||
// through buildings while in monitor fallback mode.
|
||||
|
||||
// prettier-ignore
|
||||
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 =
|
||||
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26);
|
||||
rgbaFromCss('#d7ff8d').withAlpha(0.26);
|
||||
|
||||
// prettier-ignore
|
||||
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
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { attachCctvVideo } from './videoPlayback.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
PLANE_OUTLINE_COLOR,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
@@ -72,17 +70,12 @@ export function createProjection({
|
||||
);
|
||||
const positions =
|
||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||
runtime.planeEntity.position = positions.capCenter;
|
||||
runtime.planeEntity.orientation = parts.model.planeOrientationFor(
|
||||
record.camera,
|
||||
positions.capCenter,
|
||||
parts.renderer.updatePlane(
|
||||
runtime,
|
||||
geometry,
|
||||
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) {
|
||||
geo.cartesian3.clone(positions.label, runtime.labelPosition);
|
||||
}
|
||||
@@ -109,7 +102,8 @@ export function createProjection({
|
||||
|
||||
function setPlaneVisible(runtime, visible) {
|
||||
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 (layerState._projectionOverlayOwnerId !== runtime.cameraId) {
|
||||
layerState._cctvOverlayHost.setEntries(
|
||||
@@ -139,26 +133,14 @@ export function createProjection({
|
||||
name: record.camera.name,
|
||||
position: () => runtime.labelPosition,
|
||||
});
|
||||
runtime.planeEntity = layerState._viewer.entities.add({
|
||||
id: `cctv-${record.camera.id}-plane`,
|
||||
properties: { cctvCameraId: record.camera.id },
|
||||
show: false,
|
||||
position: positions.capCenter,
|
||||
orientation: parts.model.planeOrientationFor(
|
||||
record.camera,
|
||||
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,
|
||||
},
|
||||
});
|
||||
runtime.planeVisible = false;
|
||||
runtime.planeEntity = parts.renderer.createPlane(
|
||||
record,
|
||||
runtime,
|
||||
geometry,
|
||||
positions,
|
||||
parts.model.planeOrientationFor(record.camera, positions.capCenter),
|
||||
);
|
||||
return runtime.planeEntity;
|
||||
}
|
||||
|
||||
@@ -177,7 +159,7 @@ export function createProjection({
|
||||
*/
|
||||
|
||||
function createProjectionRuntime(record) {
|
||||
if (!layerState._viewer) return null;
|
||||
if (!parts.renderer?.ready) return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = PROJECTION_CANVAS_WIDTH;
|
||||
canvas.height = PROJECTION_CANVAS_HEIGHT;
|
||||
@@ -219,6 +201,7 @@ export function createProjection({
|
||||
};
|
||||
|
||||
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
|
||||
runtime.planeMaterial = parts.renderer.createPlaneMaterial(canvas);
|
||||
|
||||
if (mode === 'video') {
|
||||
const video = document.createElement('video');
|
||||
@@ -230,15 +213,25 @@ export function createProjection({
|
||||
// attributes at first upload. Set them from the real stream dimensions
|
||||
// and rebind on any resolution change (camera switch, adaptive source).
|
||||
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;
|
||||
video.width = video.videoWidth;
|
||||
video.height = video.videoHeight;
|
||||
runtime.planeMaterial.image = runtime.canvas;
|
||||
runtime.planeMaterial.image = video;
|
||||
parts.renderer.setPlaneImage(runtime, runtime.canvas);
|
||||
parts.renderer.setPlaneImage(runtime, video);
|
||||
};
|
||||
video.addEventListener('loadedmetadata', bindVideoTexture);
|
||||
video.addEventListener('resize', bindVideoTexture);
|
||||
runtime.removeVideoBindings = () => {
|
||||
video.removeEventListener('loadedmetadata', bindVideoTexture);
|
||||
video.removeEventListener('resize', bindVideoTexture);
|
||||
runtime.removeVideoBindings = null;
|
||||
};
|
||||
runtime.video = video;
|
||||
runtime.playback = attachCctvVideo(
|
||||
video,
|
||||
@@ -247,20 +240,23 @@ export function createProjection({
|
||||
{
|
||||
onFailure: () => {
|
||||
if (runtime.disposed) return;
|
||||
runtime.removeVideoBindings?.();
|
||||
runtime.video = null;
|
||||
runtime.mode = 'image';
|
||||
runtime.image = new Image();
|
||||
runtime.image.decoding = 'async';
|
||||
runtime.image.onload = () => {
|
||||
if (runtime.disposed) return;
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = true;
|
||||
runtime.imageStamp = Date.now();
|
||||
};
|
||||
runtime.image.onerror = () => {
|
||||
if (runtime.disposed) return;
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = false;
|
||||
};
|
||||
runtime.planeMaterial.image = runtime.canvas;
|
||||
parts.renderer.setPlaneImage(runtime, runtime.canvas);
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
@@ -271,11 +267,13 @@ export function createProjection({
|
||||
img.decoding = 'async';
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (runtime.disposed) return;
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = true;
|
||||
runtime.imageStamp = Date.now();
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (runtime.disposed) return;
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = false;
|
||||
};
|
||||
@@ -295,12 +293,12 @@ export function createProjection({
|
||||
);
|
||||
const positions =
|
||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
|
||||
image: canvas,
|
||||
transparent: true,
|
||||
color: Cesium.Color.WHITE.withAlpha(0.95),
|
||||
});
|
||||
createProjectionPlane(record, runtime, geometry, positions);
|
||||
try {
|
||||
createProjectionPlane(record, runtime, geometry, positions);
|
||||
} catch (error) {
|
||||
destroyProjectionRuntime(runtime);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return runtime;
|
||||
}
|
||||
@@ -332,6 +330,7 @@ export function createProjection({
|
||||
function destroyProjectionRuntime(runtime) {
|
||||
if (!runtime || runtime.disposed) return;
|
||||
runtime.disposed = true;
|
||||
runtime.removeVideoBindings?.();
|
||||
runtime.playback?.dispose();
|
||||
runtime.playback = null;
|
||||
if (runtime.image) {
|
||||
@@ -344,8 +343,8 @@ export function createProjection({
|
||||
runtime.video.removeAttribute('src');
|
||||
runtime.video.load();
|
||||
}
|
||||
if (runtime.planeEntity && layerState._viewer) {
|
||||
layerState._viewer.entities.remove(runtime.planeEntity);
|
||||
if (runtime.planeEntity) {
|
||||
parts.renderer?.removePlane(runtime);
|
||||
runtime.planeEntity = null;
|
||||
}
|
||||
if (layerState._projectionOverlayOwnerId === runtime.cameraId)
|
||||
@@ -366,7 +365,7 @@ export function createProjection({
|
||||
if (revision !== loopRevision) return;
|
||||
layerState._projectionRaf = 0;
|
||||
layerState._projectionTimer = 0;
|
||||
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
|
||||
if (!parts.renderer?.ready || !parts.model.projectionLoopIsNeeded()) {
|
||||
releaseContinuousRender('cctv-projection');
|
||||
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: {
|
||||
renderer: { ready: true },
|
||||
model: { projectionLoopIsNeeded: () => state._enabled },
|
||||
selection: { getActiveRecord: () => record },
|
||||
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 {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
@@ -37,27 +35,26 @@ export function createRendering({
|
||||
const target = getFocusTarget();
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._viewer ||
|
||||
!parts.renderer?.ready ||
|
||||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
||||
)
|
||||
return;
|
||||
if (nowMs - layerState._lastFocusStyleAt < 80) return;
|
||||
layerState._lastFocusStyleAt = nowMs;
|
||||
const scene = layerState._viewer.scene;
|
||||
const camera = layerState._viewer.camera;
|
||||
const result = applyCctvFocusDeemphasis({
|
||||
records: layerState._records,
|
||||
target,
|
||||
previousActiveCount: layerState._activeFocusStyleCount,
|
||||
nowMs,
|
||||
// prettier-ignore
|
||||
screenPositionFor: (position) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
parts.renderer.project(
|
||||
position,
|
||||
layerState._scratchFocusScreen,
|
||||
),
|
||||
// prettier-ignore
|
||||
cameraDistanceFor: (position) =>
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
parts.renderer.distance(position),
|
||||
baseColorFor: (record) =>
|
||||
record.camera.id === layerState._activeCameraId
|
||||
? ACTIVE_CAMERA_COLOR
|
||||
@@ -136,17 +133,17 @@ export function createRendering({
|
||||
*/
|
||||
|
||||
function refreshHorizonCulling() {
|
||||
// prettier-ignore
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
layerState._viewer.isDestroyed() ||
|
||||
!parts.renderer?.ready ||
|
||||
!layerState._records.length
|
||||
)
|
||||
return;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
const horizonVisible = parts.renderer.horizonTest();
|
||||
for (const record of layerState._records) {
|
||||
const bb = record.billboard;
|
||||
if (!bb) continue;
|
||||
const visible = occluder.isPointVisible(bb.position);
|
||||
const visible = horizonVisible(bb.position);
|
||||
if (bb.show !== visible) bb.show = visible;
|
||||
}
|
||||
}
|
||||
@@ -173,8 +170,10 @@ export function createRendering({
|
||||
}
|
||||
for (const entity of record?.coverageEntities || []) entity.show = false;
|
||||
if (record?.viewshedPrimitive) destroyVolume?.(record);
|
||||
if (record?.projection?.planeEntity)
|
||||
if (record?.projection?.planeEntity) {
|
||||
record.projection.planeEntity.show = false;
|
||||
record.projection.planeVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +184,7 @@ export function createRendering({
|
||||
layerState._activeCameraId,
|
||||
);
|
||||
parts.projection.clearProjectionOverlay();
|
||||
if (layerState._billboards) layerState._billboards.show = false;
|
||||
parts.renderer?.setMarkersVisible(false);
|
||||
parts.projection.pauseInactiveProjectionFeeds(null);
|
||||
}
|
||||
|
||||
@@ -239,56 +238,40 @@ export function createRendering({
|
||||
// rather than rendering identically to a surveyed one (#639). Colors,
|
||||
// widths, and the active/idle emphasis are unchanged.
|
||||
const bearingEstimated = isHeadingEstimated(record.camera);
|
||||
const lineMaterial = (color) =>
|
||||
bearingEstimated
|
||||
? new Cesium.PolylineDashMaterialProperty({ color })
|
||||
: color;
|
||||
for (const entity of record.coverageEntities || []) {
|
||||
// The frustum wireframe is part of the projection representation —
|
||||
// force it on for the active camera and let it read through geometry
|
||||
// via depthFailMaterial (polylines have no disableDepthTestDistance).
|
||||
entity.show = !!(
|
||||
const visible = !!(
|
||||
layerState._enabled &&
|
||||
((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;
|
||||
if (entity._coverageRole === 'cap') {
|
||||
entity.polyline.material = lineMaterial(
|
||||
hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_CENTER
|
||||
: IDLE_COVERAGE_CENTER_MUTED,
|
||||
);
|
||||
entity.polyline.width = isActive ? 2.2 : 1.0;
|
||||
entity.polyline.depthFailMaterial = planeShowing
|
||||
? hue
|
||||
? hue.line.withAlpha(0.26)
|
||||
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL
|
||||
: undefined;
|
||||
} else {
|
||||
entity.polyline.material = lineMaterial(
|
||||
hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line.withAlpha(0.6)
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_EDGE
|
||||
: IDLE_COVERAGE_EDGE_MUTED,
|
||||
);
|
||||
entity.polyline.width = isActive ? 1.8 : 0.9;
|
||||
entity.polyline.depthFailMaterial = planeShowing
|
||||
? hue
|
||||
? hue.line.withAlpha(0.18)
|
||||
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL
|
||||
: undefined;
|
||||
}
|
||||
const cap = entity._coverageRole === 'cap';
|
||||
const tint = hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: cap
|
||||
? hue.line
|
||||
: hue.line.withAlpha(0.6)
|
||||
: cap
|
||||
? isActive
|
||||
? ACTIVE_COVERAGE_CENTER
|
||||
: IDLE_COVERAGE_CENTER_MUTED
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_EDGE
|
||||
: IDLE_COVERAGE_EDGE_MUTED;
|
||||
const depthFail = !planeShowing
|
||||
? undefined
|
||||
: hue
|
||||
? hue.line.withAlpha(cap ? 0.26 : 0.18)
|
||||
: cap
|
||||
? ACTIVE_COVERAGE_CENTER_DEPTHFAIL
|
||||
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL;
|
||||
parts.renderer.styleLine(entity, {
|
||||
visible,
|
||||
width: cap ? (isActive ? 2.2 : 1.0) : isActive ? 1.8 : 0.9,
|
||||
tint,
|
||||
dashed: bearingEstimated,
|
||||
depthFail,
|
||||
});
|
||||
}
|
||||
|
||||
// Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the
|
||||
@@ -316,8 +299,7 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
|
||||
if (layerState._billboards)
|
||||
layerState._billboards.show = !!layerState._enabled;
|
||||
parts.renderer?.setMarkersVisible(!!layerState._enabled);
|
||||
parts.projection.pauseInactiveProjectionFeeds(activeId);
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import {
|
||||
bindTrackingClickGesture,
|
||||
isTrackingClickGesture,
|
||||
} from '../../data/trackingClickGesture.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -33,27 +27,6 @@ export function createSelection({
|
||||
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
|
||||
* its frame, and updates styles.
|
||||
@@ -206,54 +179,10 @@ export function createSelection({
|
||||
*/
|
||||
|
||||
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' ? 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 parts.renderer?.extractPickedCameraId(picked) ?? null;
|
||||
}
|
||||
return {
|
||||
getActiveRecord,
|
||||
bindCctvWorldClickGesture,
|
||||
setActiveCamera,
|
||||
deactivateActiveCamera,
|
||||
cctvEmptyClickDeselects,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCctvSource, createCctvLayer } from './index.js';
|
||||
import { createCctvCesiumRenderer } from './render/cesium.js';
|
||||
|
||||
const camera = {
|
||||
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 b = createCctvLayer({ services, source });
|
||||
const a = createCctvLayer({
|
||||
services,
|
||||
source,
|
||||
rendererFactory: createCctvCesiumRenderer,
|
||||
});
|
||||
const b = createCctvLayer({
|
||||
services,
|
||||
source,
|
||||
rendererFactory: createCctvCesiumRenderer,
|
||||
});
|
||||
assert.equal(listeners.size, 0);
|
||||
const viewer = {
|
||||
scene: { primitives: { add: (value) => value, remove: () => true } },
|
||||
|
||||
@@ -13,10 +13,6 @@ export function createState({ services }) {
|
||||
// Module-scoped mutable state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
layerState._billboards = null;
|
||||
|
||||
layerState._records = [];
|
||||
|
||||
layerState._recordById = new Map();
|
||||
@@ -48,7 +44,7 @@ export function createState({ services }) {
|
||||
|
||||
layerState._lastViewContext = '';
|
||||
|
||||
layerState._clickHandler = null;
|
||||
layerState._removeClick = null;
|
||||
|
||||
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
|
||||
// tileset is destroyed / the layer tears down.
|
||||
|
||||
layerState._activeTileset = null;
|
||||
|
||||
// Task 5: last surface regime the record geometry was recomputed for. The
|
||||
// map-stack change listener compares the CURRENT regime (derived live from
|
||||
// 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
|
||||
* 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) {
|
||||
layerState._viewer = viewer;
|
||||
useRenderer(viewer);
|
||||
const geometry =
|
||||
record.frustumGeometry ||
|
||||
parts.geometry.computeFrustumGeometry(
|
||||
@@ -40,9 +60,9 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
const runtime = {
|
||||
cameraId: String(record.camera.id),
|
||||
planeEntity: null,
|
||||
labelPosition: new Cesium.Cartesian3(),
|
||||
labelPosition: geo.cartesian3.create(),
|
||||
overlayEntry: null,
|
||||
planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE),
|
||||
planeMaterial: parts.renderer.createTestPlaneMaterial(),
|
||||
};
|
||||
parts.projection.createProjectionPlane(
|
||||
record,
|
||||
@@ -90,7 +110,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
coverageMode = 'on',
|
||||
showProjection = false,
|
||||
} = {}) {
|
||||
layerState._viewer = viewer;
|
||||
useRenderer(viewer);
|
||||
layerState._records = Array.isArray(records) ? records : [];
|
||||
layerState._recordById = new Map(
|
||||
layerState._records
|
||||
@@ -99,7 +119,6 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
layerState._coverageEntities = [];
|
||||
layerState._projectionEntities = [];
|
||||
layerState._billboards = null;
|
||||
layerState._activeCameraId = activeCameraId;
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._enabled = !!enabled;
|
||||
|
||||
Reference in new issue
Block a user