diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index 5985730ec..d579234e9 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -941,6 +941,11 @@ async function findClickableTarget() { const site = await page .evaluate(async () => { const viewer = window.__godsEyeView.viewer; + // Core registers each local feature's context as `:`. + const isDataCentre = (entity) => + window.__godsEyeView.entityRecords + .read(entity) + ?.contextId?.startsWith('local-datacenters:'); await window.__godsEyeView.dataManager.setEnabled( 'local-datacenters', true, @@ -960,7 +965,7 @@ async function findClickableTarget() { for (let attempt = 0; attempt < 40; attempt += 1) { for (let i = 0; i < viewer.dataSources.length; i += 1) { for (const entity of viewer.dataSources.get(i).entities.values) { - if (entity.__localLayerId !== 'local-datacenters') continue; + if (!isDataCentre(entity)) continue; const point = centreOf(entity); if (!point) continue; const carto = window.Cesium @@ -993,8 +998,10 @@ async function findClickableTarget() { viewer.scene.requestRender(); }, site); await wait(4000); - const spot = await onScreen( - (entity) => entity.__localLayerId === 'local-datacenters', + const spot = await onScreen((entity) => + window.__godsEyeView.entityRecords + .read(entity) + ?.contextId?.startsWith('local-datacenters:'), ); return spot ? { ...spot, layer: 'data centre' } : null; } diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs index 5be19fe64..914968cc3 100644 --- a/scripts/qa-infra-lod.mjs +++ b/scripts/qa-infra-lod.mjs @@ -218,9 +218,11 @@ try { const gev = window.__godsEyeView; const snap = () => infraIds.map((id) => { const visibleIds = []; + // Core registers each local feature's context as `:`. for (let i = 0; i < gev.viewer.dataSources.length; i++) { for (const entity of gev.viewer.dataSources.get(i).entities.values) { - if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id)); + const contextId = gev.entityRecords.read(entity)?.contextId; + if (contextId?.startsWith(`${id}:`) && entity.show) visibleIds.push(String(entity.id)); } } return visibleIds.sort(); diff --git a/scripts/qa-installation-polish.mjs b/scripts/qa-installation-polish.mjs index bda5f0dfe..7180c2560 100644 --- a/scripts/qa-installation-polish.mjs +++ b/scripts/qa-installation-polish.mjs @@ -17,9 +17,11 @@ export async function checkInstallationPan(page, dir) { 'military-installations', ).module; const centerIds = []; + // Core registers each local feature's context as `:`. for (let i = 0; i < g.viewer.dataSources.length; i++) for (const e of g.viewer.dataSources.get(i).entities.values) - if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id); + if (g.entityRecords.read(e)?.contextId?.startsWith('local-datacenters:')) + centerIds.push(e.id); window.__polishPanFrames = []; window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => { const sites = [], @@ -333,7 +335,8 @@ export async function checkInstallationSelection( for (let y = 250; y < 650; y += 75) for (let x = 400; x < 850; x += 75) { const pick = g.viewer.scene.pick({ x, y }); - if (!pick?.id?.installationId && !window.__godsEyeView.entityRecords.read(pick?.id)?.trackedId) + // Nothing picked: no installation marker, outline or tracked contact. + if (!pick?.id) return { x, y }; } throw new Error('No empty map point'); diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs index 8f6594011..4d5c7c3f0 100644 --- a/scripts/qa-overpass-offload.mjs +++ b/scripts/qa-overpass-offload.mjs @@ -1277,8 +1277,9 @@ try { outlines = 0; for (let i = 0; i < viewer.dataSources.length; i++) for (const entity of viewer.dataSources.get(i).entities.values) { - if (!ids.has(entity.installationId || entity.id) || !entity.show) - continue; + // Outline entities are `:outline:`. + const recordId = String(entity.id).replace(/:outline:\d+$/, ''); + if (!ids.has(recordId) || !entity.show) continue; if (entity.billboard || entity.point) markers++; if (entity.polyline) outlines++; } diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 13cde65a5..0535fc2e6 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -21,6 +21,8 @@ import { getRenderGovernorDiagnostics, _resetRenderGovernorForTest, } from '../renderGovernor.js'; +import { getContextStore } from './contextStore.js'; +import { readEntityRecord } from './entityRecords.js'; /** The catalog's services, with the overlay host each test supplies. */ const createLocalGeoJsonLayer = (options) => @@ -486,7 +488,7 @@ for (const [heightM, minStemM, maxStemM] of [[500, 45, 90], [10000, 1100, 1400]] const env = await createRealLocalLayerHarness(); t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); const entity = env.dataSources[0].entities.values[0]; - const carto = entity.__localBaseCarto; + const carto = Cesium.Cartographic.fromCartesian(targetPositionOf(entity)); env.viewer.camera.positionWC = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, heightM); env.preRender.raise(env.viewer.scene, Cesium.JulianDate.now()); const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now()); @@ -894,10 +896,16 @@ function runArmedRetryChain(env, t, clock, cycles) { } } +/** The stem base an entity registered for voice targeting. */ +function targetPositionOf(entity) { + const contextId = readEntityRecord(entity)?.contextId; + return getContextStore().entities.get(contextId)?.targetPosition; +} + /** Height of the (mutated-in-place) stem base — 0 until a sample lands. */ function baseHeightM(env) { const entity = env.dataSources[0].entities.values[0]; - return Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height; + return Cesium.Cartographic.fromCartesian(targetPositionOf(entity)).height; } /** diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 1d04fa46c..90276634a 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -327,6 +327,8 @@ export function createLocalGeoJsonLayer( }, ) { let _dataSource = null; + // The entities this layer loaded, so its click and teardown recognise them. + const _ownEntities = new WeakSet(); let _enabled = false; let _clickHandler = null; let _count = 0; @@ -464,7 +466,7 @@ export function createLocalGeoJsonLayer( releaseDataSource(viewer); _overlayPublisher.hide(); clearSelectedEntityContextForLayer(id); - if (viewer?.selectedEntity?.__localLayerId === id) { + if (_ownEntities.has(viewer?.selectedEntity)) { viewer.selectedEntity = undefined; } if (_preRenderRemover) { @@ -641,7 +643,7 @@ export function createLocalGeoJsonLayer( for (let i = 0; i < entities.length; i++) { const feature = entities[i]; - feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer + _ownEntities.add(feature); let pos = feature.position?.getValue(Cesium.JulianDate.now()); @@ -686,9 +688,6 @@ export function createLocalGeoJsonLayer( const properties = propertyObject(feature); const recordId = String(feature.id ?? i); - // Store references for bounded stem scaling and native picking. - feature.__localBaseCarto = carto; - feature.__localBaseCartesian = base; registerEntityContext(feature, { id: `${id}:${recordId}`, layerId: id, @@ -703,6 +702,9 @@ export function createLocalGeoJsonLayer( longitude: Number( Cesium.Math.toDegrees(carto.longitude).toFixed(6), ), + // The surface base voice targeting projects. Ground sampling + // re-grounds it in place, so this stays current. + targetPosition: base, }); // Constant properties are refreshed on the existing 450 ms source @@ -792,7 +794,7 @@ export function createLocalGeoJsonLayer( if (!_enabled) return; const picked = viewer.scene.pick(click.position); - if (picked && picked.id && picked.id.__localLayerId === id) { + if (picked && _ownEntities.has(picked.id)) { const entity = picked.id; viewer.selectedEntity = entity; selectEntityContext(entity); @@ -1174,7 +1176,6 @@ function setLocalGroundHeight(record, height) { Cesium.Ellipsoid.WGS84, record.base, ); - record.entity.__localBaseCartesian = record.base; } function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index d5cfc4d26..3ffc33354 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -185,9 +185,8 @@ export function createSelection({ // Refreshed every registration: the anchor re-grounds onto the DEM when // its floor cell warms, and voice targeting must project the same point // the sprite renders at. - fire.contextEntity.__localBaseCartesian = - components.model.firePosition(fire); registerEntityContext(fire.contextEntity, { + targetPosition: components.model.firePosition(fire), id: recordId, layerId: id, layerName: name, diff --git a/src/layers/installations/markerState.js b/src/layers/installations/markerState.js index 7738011ac..295e9a733 100644 --- a/src/layers/installations/markerState.js +++ b/src/layers/installations/markerState.js @@ -3,5 +3,7 @@ import { createEntityStore } from '../../data/entityRecords.js'; /** * What the installations layer last drew for a retained marker entity, so an * unchanged record leaves it alone: `heightM` (surface height) and `selected`. + * Selection fills and outlines record the installation they belong to as + * `recordId`, which a pick on them resolves through. */ export const installationMarkers = createEntityStore(); diff --git a/src/layers/installations/namedMarkers.js b/src/layers/installations/namedMarkers.js index 48a314ac2..2012dc69f 100644 --- a/src/layers/installations/namedMarkers.js +++ b/src/layers/installations/namedMarkers.js @@ -128,6 +128,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) { const marker = markers.get(alias); markers.delete(alias); marker.point.id = { installationId: record.id }; + marker.recordId = record.id; marker.entry.id = record.id; markers.set(record.id, marker); } @@ -148,10 +149,12 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) { ); let marker = markers.get(record.id); if (!marker) { - const id = { installationId: record.id }; marker = { + recordId: record.id, point: points.add({ - id, + // Core's own pick payload. An object, not the bare record id, so the + // shared pick registry keeps treating a point click as it always has. + id: { installationId: record.id }, position, pixelSize: 7, color: parts.model.colorFor(record), @@ -197,7 +200,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) { visit(visitor) { for (let i = 0; i < ordered.length; i++) { const marker = ordered[i]; - visitor(marker.point.id.installationId, marker.point); + visitor(marker.recordId, marker.point); } }, refresh, diff --git a/src/layers/installations/rendering.js b/src/layers/installations/rendering.js index e1a46f700..8b76d83d3 100644 --- a/src/layers/installations/rendering.js +++ b/src/layers/installations/rendering.js @@ -84,7 +84,7 @@ export function createRendering({ classificationType, }, }); - fill.installationId = record.id; + installationMarkers.write(fill).recordId = record.id; selectedFillIds.push(fill.id); } governorRequestRender('installation-selection-fill'); @@ -267,7 +267,7 @@ export function createRendering({ classificationType: Cesium.ClassificationType.BOTH, }, }); - outline.installationId = record.id; + installationMarkers.write(outline).recordId = record.id; entityIds.push(outline.id); } Object.assign(installationMarkers.write(entity), { diff --git a/src/layers/installations/selection.js b/src/layers/installations/selection.js index 26a4980ef..bcf623b78 100644 --- a/src/layers/installations/selection.js +++ b/src/layers/installations/selection.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; import { LAYER_ID } from './policy.js'; +import { installationMarkers } from './markerState.js'; export function createSelection({ state: layerState, @@ -58,9 +59,11 @@ export function createSelection({ if (!isPointerFree()) return; if (!layerState.enabled) return; const picked = viewer.scene.pick(click.position); + const target = picked?.id; const id = - picked?.id?.installationId || - (typeof picked?.id?.id === 'string' ? picked.id.id : null); + target?.installationId || + installationMarkers.read(target)?.recordId || + (typeof target?.id === 'string' ? target.id : null); if (id && layerState.recordById.has(id) && id !== layerState.selectedId) { selectRecord(id); } else if (layerState.selectedId) { diff --git a/src/layers/installations/selectionFill.test.mjs b/src/layers/installations/selectionFill.test.mjs index e3a29c376..5086a4a5a 100644 --- a/src/layers/installations/selectionFill.test.mjs +++ b/src/layers/installations/selectionFill.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import * as C from 'cesium'; import { createRendering } from './rendering.js'; import { createLifecycle } from './lifecycle.js'; +import { installationMarkers } from './markerState.js'; function harness() { const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]]; @@ -23,7 +24,7 @@ test('selected multipart footprints classify Google tiles with one constant tran const fills = h.state.dataSource.entities.values.slice(); assert.equal(fills.length, 2); for (const fill of fills) { - assert.equal(fill.installationId, 'base'); + assert.equal(installationMarkers.read(fill)?.recordId, 'base'); assert.equal(fill.polygon.classificationType.getValue(), C.ClassificationType.CESIUM_3D_TILE); assert.equal(fill.polygon.height, undefined); assert.equal(fill.polygon.extrudedHeight, undefined); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 6ab688456..24d5f109b 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3983,7 +3983,7 @@ function visibleEntityContexts( const candidates = []; for (const { record } of nearbyRecords) { - const position = record.entity?.__localBaseCartesian; + const position = record.targetPosition; if (!position) continue; const screen = Cesium.SceneTransforms.worldToWindowCoordinates( viewer.scene,