From 7204d66f0b38bb1c19f3bba738a2afde4be7530d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:57:31 -0800 Subject: [PATCH] Keep installation and local GeoJSON pick state off Cesium entities Installation selection fills and outlines record the installation they belong to in the layer's installationMarkers store instead of an installationId expando, and the click handler resolves them from there. Named-marker points keep their { installationId } pick payload, which is Core's own object, so sibling layers treat a point click as before; the marker remembers its record id for visit() instead of reading the point. The local GeoJSON package tracks the entities each layer loaded in a per-instance set instead of a __localLayerId tag, and passes the stem base to registerEntityContext as targetPosition instead of hanging it on the entity; FIRMS does the same, and voice targeting reads the context record. QA scripts find a layer's features by their Core context id. --- scripts/qa-draw-tool.mjs | 13 ++++++++++--- scripts/qa-infra-lod.mjs | 4 +++- scripts/qa-installation-polish.mjs | 7 +++++-- scripts/qa-overpass-offload.mjs | 5 +++-- src/data/localGeojson.test.mjs | 12 ++++++++++-- src/data/localGeojsonCore.js | 15 ++++++++------- src/layers/firms/selection.js | 3 +-- src/layers/installations/markerState.js | 2 ++ src/layers/installations/namedMarkers.js | 9 ++++++--- src/layers/installations/rendering.js | 4 ++-- src/layers/installations/selection.js | 7 +++++-- src/layers/installations/selectionFill.test.mjs | 3 ++- src/voice/gevActions.js | 2 +- 13 files changed, 58 insertions(+), 28 deletions(-) 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,