From 93c71ffc86ff061afe93d69609e1c3770bd3852c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 00:06:22 +0400 Subject: [PATCH] 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. --- scripts/format-scope.json | 5 + scripts/package-boundaries.json | 14 +- scripts/qa-cctv-v2.mjs | 80 ++- src/app/layers/cctv.js | 27 +- src/data/cctv.test.mjs | 11 +- src/data/cctvViewshed.js | 90 ++- src/data/cctvViewshed.test.mjs | 16 +- src/data/sharedOsmDisplays.test.mjs | 8 +- src/layers/cctv/calibration.js | 4 +- src/layers/cctv/cards.js | 20 +- src/layers/cctv/frames.js | 4 +- src/layers/cctv/geometry.js | 101 +-- src/layers/cctv/geometryQueue.js | 6 +- src/layers/cctv/ground.js | 4 +- src/layers/cctv/hover.js | 4 +- src/layers/cctv/index.js | 5 +- src/layers/cctv/lifecycle.js | 579 +++++++++--------- src/layers/cctv/lifecycle.test.mjs | 122 ++++ src/layers/cctv/model.js | 36 +- src/layers/cctv/navigation.js | 29 +- src/layers/cctv/policy.js | 32 +- src/layers/cctv/projection.js | 93 ++- src/layers/cctv/projectionMedia.test.mjs | 187 ++++++ src/layers/cctv/projectionScheduling.test.mjs | 1 + src/layers/cctv/render/cesium.js | 364 +++++++++++ src/layers/cctv/render/cesium.test.mjs | 158 +++++ src/layers/cctv/render/cesiumViewshed.js | 45 ++ src/layers/cctv/rendering.js | 104 ++-- src/layers/cctv/selection.js | 73 +-- src/layers/cctv/source.test.mjs | 13 +- src/layers/cctv/state.js | 8 +- src/layers/cctv/testing.js | 33 +- 32 files changed, 1536 insertions(+), 740 deletions(-) create mode 100644 src/layers/cctv/lifecycle.test.mjs create mode 100644 src/layers/cctv/projectionMedia.test.mjs create mode 100644 src/layers/cctv/render/cesium.js create mode 100644 src/layers/cctv/render/cesium.test.mjs create mode 100644 src/layers/cctv/render/cesiumViewshed.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 60daa228d..cad7d1a80 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index eb8a7416d..b2510ed6f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index d7395014c..e1d187256 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -68,6 +68,8 @@ * is no longer needed to avoid a plane/wireframe disagreement. * * Run: node scripts/qa-cctv-v2.mjs --url --out [--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( diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 7949fa1ec..711ff20c0 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -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); + }, + }; } diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index ca6a3c11a..a4d5605bf 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -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), diff --git a/src/data/cctvViewshed.js b/src/data/cctvViewshed.js index 6654b599c..1b9fce0c5 100644 --- a/src/data/cctvViewshed.js +++ b/src/data/cctvViewshed.js @@ -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, - }); -} diff --git a/src/data/cctvViewshed.test.mjs b/src/data/cctvViewshed.test.mjs index c2359ceea..c174623fb 100644 --- a/src/data/cctvViewshed.test.mjs +++ b/src/data/cctvViewshed.test.mjs @@ -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 { diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 273a9dc56..8a74eb25c 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -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); }); diff --git a/src/layers/cctv/calibration.js b/src/layers/cctv/calibration.js index be71125d8..7b9f83de4 100644 --- a/src/layers/cctv/calibration.js +++ b/src/layers/cctv/calibration.js @@ -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() diff --git a/src/layers/cctv/cards.js b/src/layers/cctv/cards.js index e432938e5..03df3434f 100644 --- a/src/layers/cctv/cards.js +++ b/src/layers/cctv/cards.js @@ -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); diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index 1062d177d..a561c849e 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -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; } diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index b01cb1307..1cc54dd01 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -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 = []; diff --git a/src/layers/cctv/geometryQueue.js b/src/layers/cctv/geometryQueue.js index dc68d139a..0bf7d4aa6 100644 --- a/src/layers/cctv/geometryQueue.js +++ b/src/layers/cctv/geometryQueue.js @@ -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); diff --git a/src/layers/cctv/ground.js b/src/layers/cctv/ground.js index 5ff41c377..cd1b8baeb 100644 --- a/src/layers/cctv/ground.js +++ b/src/layers/cctv/ground.js @@ -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; diff --git a/src/layers/cctv/hover.js b/src/layers/cctv/hover.js index cdd3375bf..77a79a073 100644 --- a/src/layers/cctv/hover.js +++ b/src/layers/cctv/hover.js @@ -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; } diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 0deba580e..2d3245890 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -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, diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 4ee652f3a..49ba4b87b 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -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--` 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; diff --git a/src/layers/cctv/lifecycle.test.mjs b/src/layers/cctv/lifecycle.test.mjs new file mode 100644 index 000000000..64eac7c8e --- /dev/null +++ b/src/layers/cctv/lifecycle.test.mjs @@ -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); +}); diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js index c1c9682fb..05b3adfae 100644 --- a/src/layers/cctv/model.js +++ b/src/layers/cctv/model.js @@ -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( diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js index a1f41682c..12eb1e726 100644 --- a/src/layers/cctv/navigation.js +++ b/src/layers/cctv/navigation.js @@ -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, ); diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index 85bd0a84b..1bda8fed5 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -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 diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js index 3bba97da7..43af31f9a 100644 --- a/src/layers/cctv/projection.js +++ b/src/layers/cctv/projection.js @@ -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; } diff --git a/src/layers/cctv/projectionMedia.test.mjs b/src/layers/cctv/projectionMedia.test.mjs new file mode 100644 index 000000000..39bd8499d --- /dev/null +++ b/src/layers/cctv/projectionMedia.test.mjs @@ -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); +}); diff --git a/src/layers/cctv/projectionScheduling.test.mjs b/src/layers/cctv/projectionScheduling.test.mjs index 1b4c8043e..7f929492e 100644 --- a/src/layers/cctv/projectionScheduling.test.mjs +++ b/src/layers/cctv/projectionScheduling.test.mjs @@ -54,6 +54,7 @@ function setup(t) { }, }, parts: { + renderer: { ready: true }, model: { projectionLoopIsNeeded: () => state._enabled }, selection: { getActiveRecord: () => record }, rendering: { refreshCctvFocusStyles() {} }, diff --git a/src/layers/cctv/render/cesium.js b/src/layers/cctv/render/cesium.js new file mode 100644 index 000000000..7b2b3d797 --- /dev/null +++ b/src/layers/cctv/render/cesium.js @@ -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, + ); +} diff --git a/src/layers/cctv/render/cesium.test.mjs b/src/layers/cctv/render/cesium.test.mjs new file mode 100644 index 000000000..d7ac35245 --- /dev/null +++ b/src/layers/cctv/render/cesium.test.mjs @@ -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); +}); diff --git a/src/layers/cctv/render/cesiumViewshed.js b/src/layers/cctv/render/cesiumViewshed.js new file mode 100644 index 000000000..860c5338d --- /dev/null +++ b/src/layers/cctv/render/cesiumViewshed.js @@ -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, + }); +} diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index 6601d938b..72ab83753 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -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 { diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js index 317d80c7e..b8eb5d4b6 100644 --- a/src/layers/cctv/selection.js +++ b/src/layers/cctv/selection.js @@ -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, diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs index 0103c03de..478de5e0b 100644 --- a/src/layers/cctv/source.test.mjs +++ b/src/layers/cctv/source.test.mjs @@ -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 } }, diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index 59e2ebfcb..47582dd65 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -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' diff --git a/src/layers/cctv/testing.js b/src/layers/cctv/testing.js index 5c1e67956..4994e55b3 100644 --- a/src/layers/cctv/testing.js +++ b/src/layers/cctv/testing.js @@ -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;