diff --git a/scripts/qa-alpr-journey.mjs b/scripts/qa-alpr-journey.mjs index 5393f135e..fe8d67844 100644 --- a/scripts/qa-alpr-journey.mjs +++ b/scripts/qa-alpr-journey.mjs @@ -253,23 +253,17 @@ try { const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras'); const stats = entry?.module?.getStats?.() || {}; // Layers that publish a render revision are measured by it; otherwise - // hash the rendered entity ids and their visibility. + // hash the layer's mover sets on the globe contract (ids and counts). let hash = 0; let shown = 0; if (Number.isFinite(stats.renderRevision)) { hash = stats.renderRevision; shown = stats.shown ?? stats.count ?? 0; - } else { - const entities = - entry?.module && entry.enabled - ? window.__godsEyeView.viewer.dataSources.getByName( - 'alpr-cameras', - )[0]?.entities.values || [] - : []; - for (const entity of entities) { - if (entity.show === false) continue; - shown += 1; - const id = String(entity.id); + } else if (entry?.module && entry.enabled) { + for (const set of window.__godsEyeView.engine.movers.list()) { + if (!String(set.id).startsWith('alpr-')) continue; + shown += set.count; + const id = `${set.id}:${set.count}`; for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0; } diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs index a27dd57c3..530640514 100644 --- a/scripts/qa-cables-overlay.mjs +++ b/scripts/qa-cables-overlay.mjs @@ -8,10 +8,11 @@ * a. per-frame `scene.render` cost over a ~10 s driven-orbit window * (mean / p50 / p95 / max — relative numbers, SwiftShader surface), * b. toggle-ON → first-labels-visible latency, - * c. entity / native-label / host-entry counts, + * c. contract set (ground paths, mover tips, stems) / host-entry counts, * d. parked-idle postRender fires over 5 s (render-governor honesty). * 2. GATE (unified build only): assert the migration contract — - * zero native `LabelGraphics` on cable reference entities, host entries + * no layer-owned Cesium data source (paths, pins and tips are globe + * contract sets), host entries * present for the source, idle stays near-zero, and a clean OFF→ON cycle * (no orphan host entries after disable). * @@ -105,28 +106,40 @@ try { await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle } - // ── c. entity / label / host counts ─────────────────────────────────── + // ── c. drawn-set / label / host counts ──────────────────────────────── + // The layer draws through the globe contract (render/display.js): cable + // paths in one engine.ground corridor set, landing points and reference + // tips in mover sets, stems in engine.lines, labels on the world-overlay + // host. It owns no Cesium DataSource or Entity any more. const counts = await page.evaluate((layerId) => { const gev = window.__godsEyeView; const v = gev.viewer; + const engine = gev.engine; const out = { - dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0, - referenceEntities: 0, hostEntries: 0, hostPainted: 0, + layerDataSources: 0, layerEntities: 0, + pathSets: 0, pathItems: 0, markers: 0, tipSets: 0, tips: 0, stems: 0, + hostEntries: 0, hostPainted: 0, }; - for (let i = 0; i < v.dataSources.length; i++) { + for (let i = 0; i < (v?.dataSources?.length ?? 0); i++) { const ds = v.dataSources.get(i); if (!/TeleGeography/.test(ds.name || '')) continue; - const list = ds.entities.values; - out.dataSources[ds.name] = list.length; - for (const e of list) { - if (e.label !== undefined) out.nativeLabels++; - if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++; - if (e.polyline?.positions && e.polyline.positions.isConstant === false) { - out.dynamicPositionEntities++; - } - } - if (/References/.test(ds.name)) out.referenceEntities = list.length; + out.layerDataSources++; + out.layerEntities += ds.entities.values.length; } + for (const set of engine.ground.list()) { + if (set.id !== `${layerId}-paths`) continue; + out.pathSets++; + out.pathItems += set.count; + } + for (const set of engine.movers.list()) { + if (set.id === `${layerId}-markers`) out.markers += set.count; + else if (set.id.startsWith(`${layerId}-`) && set.id.endsWith('-tips')) { + out.tipSets++; + out.tips += set.count; + } + } + out.stems = engine.lines.list().filter((line) => + line.id.startsWith(`${layerId}-stem-`) && line.shown).length; const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; out.hostEntries = diag.entriesBySource?.[layerId] || 0; out.hostPainted = diag.paintedBySource?.[layerId] || 0; @@ -189,8 +202,10 @@ try { if (!legacy && !control) { // ── unified-build contract gates ──────────────────────────────────── - check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels); - check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities); + // Paths, pins and tips belong to contract sets; labels belong to the host. + check('no layer-owned Cesium data source or entities', counts.layerDataSources === 0 && counts.layerEntities === 0, counts); + check('cable paths drawn as an engine.ground corridor set', counts.pathSets === 1 && counts.pathItems > 0, counts); + check('reference tips drawn as mover sets', counts.tipSets > 0 && counts.tips > 0, counts); check('host has cable entries', counts.hostEntries > 0, counts.hostEntries); check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted); // The cables source never republishes an unchanged cohort (unit pin), and diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 6a889cec6..aa491d4f2 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -286,24 +286,106 @@ function installRenderFramesHelper(page) { }); } -// In-page helper (stringified and re-defined inside each evaluate that needs -// it, since functions can't cross the puppeteer boundary): serializes the -// 5 frustum polylines + plane position/orientation/dimensions for a camera -// into plain arrays of numbers, using only the camera's own clock time (no -// Cesium namespace needed — Property.getValue just needs *a* JulianDate, -// and the viewer's own clock.currentTime is a valid live instance of one). +// In-page helpers (stringified and re-defined inside each evaluate that needs +// them, since functions can't cross the puppeteer boundary). +// +// The frustum wireframe (four corner rays and the far cap) and the gizmo draw +// through the globe contract (src/layers/cctv/render/display.js): engine +// lines named `cctv--` and `cctv-gizmo-`, and the gizmo's +// handles in the `cctv-gizmo-points` point set. engine.lines.list() names the +// live lines (id, points, shown); their positions are what the layer's own +// line handles hold (record.coverageEntities). The monitor plane is still a +// Cesium plane Entity (render/cesiumMeshes.js), read as before. +const CONTRACT_SRC = ` + function cctvRecord(camId) { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + return ( + mod._cctvRecordsForTest().find((r) => r.camera && r.camera.id === camId) || + null + ); + } + function liveLines() { + return new Map( + window.__godsEyeView.engine.lines.list().map((line) => [line.id, line]), + ); + } + // The positions of camera camId's coverage line 'role', when the engine + // draws that line with the handle's points; null otherwise. + function coverageLinePositions(camId, role, lines = liveLines()) { + const id = 'cctv-' + camId + '-' + role; + const line = lines.get(id); + const record = cctvRecord(camId); + const handle = ((record && record.coverageEntities) || []).find( + (h) => h && h.id === id, + ); + const positions = handle && handle.polyline && handle.polyline.positions; + if (!line || !positions || line.points !== positions.length) return null; + return Array.from(positions, (p) => [p.x, p.y, p.z]); + } + // A Cesium mover set's instance by pickId (the contract's own primitives: + // a collection tagged gevMoverSet in scene.primitives). + function moverInstance(setId, pickId) { + const walk = (collection) => { + if (!collection || typeof collection.get !== 'function') return null; + if (collection.gevMoverSet === setId) { + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if (item && item.id === pickId) return item; + } + return null; + } + for (let i = 0; i < collection.length; i++) { + const found = walk(collection.get(i)); + if (found) return found; + } + return null; + }; + return walk(window.__godsEyeView.viewer.scene.primitives); + } + // 'shown' | 'hidden' | 'missing' for one gizmo part: its line (rings and + // arrow shafts) and its point (arrow tips and handles), as many as it has. + function gizmoPartState(part) { + const id = 'cctv-gizmo-' + part; + const line = liveLines().get(id); + const point = moverInstance('cctv-gizmo-points', id); + if (!line && !point) return 'missing'; + const shown = [line && line.shown, point && point.show].filter( + (value) => value !== undefined && value !== null, + ); + return shown.every(Boolean) ? 'shown' : 'hidden'; + } + // The viewshed volumes the layer holds (record.viewshedPrimitive), with + // their Cesium Primitive, only where that primitive is in the scene. + function viewshedVolumes() { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const primitives = window.__godsEyeView.viewer.scene.primitives; + const out = []; + for (const record of mod._cctvRecordsForTest()) { + const volume = record.viewshedPrimitive; + const primitive = volume && !volume.destroyed && volume.native + ? volume.native.primitive + : null; + if (primitive && primitives.contains(primitive)) + out.push({ camera: record.camera.id, volume, primitive }); + } + return out; + } +`; + +// serializeGeom: the 5 frustum polylines + plane position/orientation/ +// dimensions for a camera as plain arrays of numbers. The plane's properties +// are read at the viewer's own clock.currentTime (Property.getValue just needs +// *a* JulianDate, and that is a valid live instance of one). const SERIALIZE_GEOM_SRC = ` + ${CONTRACT_SRC} function serializeGeom(camId) { const viewer = window.__godsEyeView.viewer; const time = viewer.clock.currentTime; const roles = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap']; + const lines = liveLines(); const poly = {}; - for (const role of roles) { - const ent = viewer.entities.getById('cctv-' + camId + '-' + role); - poly[role] = ent && ent.polyline - ? ent.polyline.positions.getValue(time).map((p) => [p.x, p.y, p.z]) - : null; - } + for (const role of roles) + poly[role] = coverageLinePositions(camId, role, lines); const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); let plane = null; if (planeEnt) { @@ -529,6 +611,8 @@ async function main() { ), { timeoutMs: 60000 }, ); + if (!appReady) throw new Error('app did not initialise within 60s'); + await installRenderFramesHelper(page); await page.evaluate(async () => { const app = window.__godsEyeView; await app.styleManager.initialRestorePromise; @@ -790,14 +874,18 @@ async function main() { (await waitForPredicate( page, (expected, base) => { - const { viewer, dataManager } = window.__godsEyeView; - const camera = dataManager.layers - .get('cctv') - .module.getUIState().activeCamera; - const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`); - const mount = ray?.polyline?.positions.getValue( - viewer.clock.currentTime, - )?.[0]; + const { viewer, dataManager, engine } = window.__godsEyeView; + const module = dataManager.layers.get('cctv').module; + const camera = module.getUIState().activeCamera; + // The corner ray is an engine line; its first point (the mount) + // is what the layer's line handle gave it. + const id = `cctv-${camera.id}-ray-tl`; + const drawn = engine.lines.list().some((line) => line.id === id); + const ray = module + ._cctvRecordsForTest() + .find((record) => record.camera?.id === camera.id) + ?.coverageEntities?.find((handle) => handle?.id === id); + const mount = drawn ? ray?.polyline?.positions?.[0] : undefined; const height = mount && viewer.scene.globe.ellipsoid.cartesianToCartographic(mount) @@ -961,7 +1049,7 @@ async function main() { 'cap', ].every((r) => Array.isArray(geomInfo.poly[r])); record( - 'all 5 frustum polyline entities exist for the active camera', + 'all 5 frustum lines are drawn for the active camera (engine lines)', allPolylinesPresent, Object.keys(geomInfo.poly) .map((k) => `${k}=${geomInfo.poly[k] ? 'ok' : 'MISSING'}`) @@ -1775,35 +1863,51 @@ async function main() { console.log( 'Checking viewshed mode (coverage tri-state + color-coded volumes)...', ); + // The volumes are the layer's (record.viewshedPrimitive), each a Cesium + // Primitive in scene.primitives (render/cesiumMeshes.js); they carry no + // tag of their own any more. const countVolumes = () => - page.evaluate(() => { - const prims = window.__godsEyeView.viewer.scene.primitives; - let n = 0; - for (let i = 0; i < prims.length; i++) { - if (prims.get(i) && prims.get(i)._gevViewshed) n += 1; - } - return n; - }); + page.evaluate(`(function(){ + ${CONTRACT_SRC} + return viewshedVolumes().length; + })()`); const cBeforeViewshed = await readCounters(); - await page.evaluate(() => { + await page.evaluate(`(function(){ + ${CONTRACT_SRC} const primitives = window.__godsEyeView.viewer.scene.primitives; const events = []; window.__qaViewshedTrace = events; + // A volume is a synchronous Primitive the pick pass skips; which + // camera it belongs to is looked up when the trace is read. + const isVolume = (primitive) => + primitive && + primitive.allowPicking === false && + primitive.asynchronous === false && + typeof primitive.get !== 'function'; for (const method of ['add', 'remove']) { const original = primitives[method].bind(primitives); primitives[method] = (primitive, ...args) => { - if (primitive?._gevViewshed) + if (isVolume(primitive)) events.push({ method, - camera: primitive._gevViewshed, + primitive, at: performance.now(), stack: new Error().stack, }); return original(primitive, ...args); }; } - }); + window.__qaViewshedTraceRead = () => { + const owners = new Map( + viewshedVolumes().map((entry) => [entry.primitive, entry.camera]), + ); + return events.map(({ primitive, ...event }) => ({ + ...event, + camera: owners.get(primitive) ?? null, + })); + }; + })()`); const modeAfterSet = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ coverageMode: 'viewshed' }); @@ -1866,29 +1970,22 @@ async function main() { 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 = []; - for (let i = 0; i < prims.length; i++) { - if (prims.get(i) && prims.get(i)._gevViewshed) - window.__qaViewshedRefs.push(prims.get(i)); - } - }); + await page.evaluate(`(function(){ + ${CONTRACT_SRC} + window.__qaViewshedRefs = viewshedVolumes().map((entry) => entry.primitive); + })()`); await sleep(8000); - const viewshedIdle = await page.evaluate(() => { - const prims = window.__godsEyeView.viewer.scene.primitives; - const now = []; - for (let i = 0; i < prims.length; i++) { - if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i)); - } + const viewshedIdle = await page.evaluate(`(function(){ + ${CONTRACT_SRC} + const now = viewshedVolumes().map((entry) => entry.primitive); const before = window.__qaViewshedRefs || []; const sameCount = now.length === before.length; const sameIdentity = sameCount && before.every((p) => now.includes(p)); return { sameCount, sameIdentity, count: now.length }; - }); + })()`); const cAfterViewshed = await readCounters(); - evidence.viewshedTrace = await page.evaluate( - () => window.__qaViewshedTrace, + evidence.viewshedTrace = await page.evaluate(() => + window.__qaViewshedTraceRead?.(), ); const lateFloorSamples = cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight; @@ -1931,15 +2028,12 @@ async function main() { 'handle-fov-l', 'handle-fov-r', ]; - const gizmoStates = await page.evaluate((parts) => { + const gizmoStates = await page.evaluate(`(function(parts){ + ${CONTRACT_SRC} const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibrationMode: true }); - const viewer = window.__godsEyeView.viewer; - return parts.map((p) => { - const e = viewer.entities.getById('cctv-gizmo-' + p); - return e ? (e.show ? 'shown' : 'hidden') : 'missing'; - }); - }, GIZMO_PARTS); + return parts.map(gizmoPartState); + })(${JSON.stringify(GIZMO_PARTS)})`); record( 'all 8 gizmo parts exist and show in ADJUST mode', gizmoStates.every((s) => s === 'shown'), @@ -1949,11 +2043,20 @@ async function main() { // Synthetic CDP drag on the EAST arrow — the owner's field-test // regression. Sample effective elevation after every mouse move: it must // stay frozen until release, with no transient sampleHeight calls. - const eastDrag = await page.evaluate(() => { + // The E arrow's shaft runs from the active camera's mount to its tip, + // the `cctv-gizmo-move-east` point in the gizmo's point set. + const eastDrag = await page.evaluate(`(function(){ + ${CONTRACT_SRC} const viewer = window.__godsEyeView.viewer; - const e = viewer.entities.getById('cctv-gizmo-move-east'); - if (!e) return null; - const pts = e.polyline.positions.getValue(viewer.clock.currentTime); + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const active = mod.getUIState().activeCameraId; + const mount = cctvRecord(active)?.frustumPositions?.mount; + const tip = moverInstance('cctv-gizmo-points', 'cctv-gizmo-move-east'); + if (!mount || !tip || gizmoPartState('move-east') !== 'shown') return null; + const pts = [ + new tip.position.constructor(mount.x, mount.y, mount.z), + tip.position, + ]; const scene = viewer.scene; const w = scene.canvas.clientWidth; const h = scene.canvas.clientHeight; @@ -1982,7 +2085,7 @@ async function main() { } if (!candidates.length) return null; return { ...candidates[0], candidates, ux: dx / mag, uy: dy / mag }; - }); + })()`); const calBeforeDrag = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -2227,16 +2330,13 @@ async function main() { ); // ADJUST off: parts hidden; leave a clean calibration for the next group. - const gizmoOff = await page.evaluate((parts) => { + const gizmoOff = await page.evaluate(`(function(parts){ + ${CONTRACT_SRC} const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibrationMode: false }); mod.setParams({ calibration: { reset: true } }); - const viewer = window.__godsEyeView.viewer; - return parts.map((p) => { - const e = viewer.entities.getById('cctv-gizmo-' + p); - return e ? (e.show ? 'shown' : 'hidden') : 'missing'; - }); - }, GIZMO_PARTS); + return parts.map(gizmoPartState); + })(${JSON.stringify(GIZMO_PARTS)})`); record( 'gizmo parts hide when ADJUST mode turns off', gizmoOff.every((s) => s === 'hidden'), diff --git a/scripts/qa-context-arrivals.mjs b/scripts/qa-context-arrivals.mjs index 72a6ffab8..51361ac7e 100644 --- a/scripts/qa-context-arrivals.mjs +++ b/scripts/qa-context-arrivals.mjs @@ -338,10 +338,16 @@ async function contacts() { candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations', ); - const ds = window.__godsEyeView.viewer.dataSources.getByName( - 'military-installations', - )[0]; - for (const entity of ds?.entities.values || []) + // The drawn sites are context handles in the shared context store + // (src/layers/installations/render/display.js keeps a site's handle + // while its geometry is unchanged), not Entities: a new handle is a + // rebuilt site. + const sites = [ + ...(window.__gevContextStore?.entities.values() || []), + ].filter((record) => + String(record.entity?.gevTrackedId ?? '').startsWith('installations:'), + ); + for (const { entity } of sites) if (!seen.has(entity)) { seen.add(entity); created += 1; @@ -357,7 +363,7 @@ async function contacts() { name: button.childNodes[0]?.textContent?.trim(), distance: button.querySelector('span')?.textContent?.trim(), })), - entities: ds?.entities.values.length ?? 0, + entities: sites.length, created, tracked: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null, @@ -429,7 +435,7 @@ async function contacts() { `${reverted.length}/${flight.length}`, ); check( - 'site entities are not rebuilt while tracking', + 'drawn sites are not rebuilt while tracking', createdDuring === 0, `${createdDuring} created`, ); diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 4bac0ae20..de8f092d9 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -111,11 +111,7 @@ const layerState = () => '#utilities-toggles [data-layer-id="directions"]', ); const controls = module.getRowControls(); - const viewer = gev.viewer; - const entities = [...viewer.entities.values]; - const routeEntities = entities.filter( - (entity) => String(entity.id) === 'directions:route', - ); + const drawn = window.__gevQaDirections.drawn(); const listNodes = row ? [...row.querySelectorAll('.data-row-list-item')] : []; @@ -137,14 +133,9 @@ const layerState = () => domCurrent: listNodes.findIndex((node) => node.classList.contains('current'), ), - routeEntities: routeEntities.length, - routePositions: - routeEntities[0]?.polyline?.positions?.getValue?.( - viewer.clock.currentTime, - )?.length || 0, - markerCount: entities.filter((entity) => - String(entity.id).startsWith('directions:marker:'), - ).length, + routeEntities: drawn.routeSets, + routePositions: drawn.routeVertices, + markerCount: drawn.markersShown, pointerOwner: window.__gevQa.pointerOwner(), cameraMotion: window.__gevQa.getActiveCameraMotion(), }; @@ -230,6 +221,96 @@ try { releasePointer: own.releasePointer, getActiveCameraMotion: verbs.getActiveCameraMotion, }; + // What Directions draws, read through the globe contract + // (src/layers/directions/render/display.js): the route is the + // `directions-route` ground corridor set, cut into dash pieces; the A/B + // discs and the maneuver dots are the `directions-markers` and + // `directions-steps` point mover sets. The route's pieces are seen as the + // display hands them to engine.ground; which instances are shown is read + // from the Cesium engine's own point collection for the set (this gate + // drives the Cesium camera). + const engine = window.__godsEyeView.engine; + let routePieces = null; + const createGroundSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createGroundSet(options); + if (options?.id === 'directions-route') { + const replace = set.replace.bind(set); + set.replace = (items) => { + routePieces = items.map((item) => item.positions.length); + return replace(items); + }; + } + return set; + }; + const moverPrimitive = (id) => { + const walk = (collection) => { + for (let i = 0; i < collection.length; i += 1) { + const primitive = collection.get(i); + if (!primitive) continue; + if (primitive.gevMoverSet === id) return primitive; + if ( + primitive.gevMoverSet === undefined && + typeof primitive.get === 'function' && + typeof primitive.length === 'number' + ) { + const found = walk(primitive); + if (found) return found; + } + } + return null; + }; + return walk(window.__godsEyeView.viewer.scene.primitives); + }; + const moverCount = (id) => + engine.movers.list().find((set) => set.id === id)?.count ?? 0; + const shownInstances = (id) => { + const primitive = moverPrimitive(id); + if (!primitive || primitive.show === false) return []; + const shown = []; + const count = Math.min(moverCount(id), primitive.length); + for (let i = 0; i < count; i += 1) { + const instance = primitive.get(i); + if (instance.show) shown.push(instance); + } + return shown; + }; + window.__gevQaDirections = { + drawn() { + const route = engine.ground + .list() + .filter((set) => set.id === 'directions-route'); + // Every original vertex is kept, and each piece adds its two ends, + // which are the next piece's start: vertices = total - 2·pieces + 2. + const routeVertices = + route.length && routePieces?.length + ? routePieces.reduce((sum, n) => sum + n, 0) - + 2 * routePieces.length + + 2 + : 0; + return { + routeSets: route.filter((set) => set.shown && set.count > 0).length, + routeVertices, + markersShown: shownInstances('directions-markers').length, + stepCount: moverCount('directions-steps'), + stepsShown: shownInstances('directions-steps').map((point) => + point.position.clone(), + ), + }; + }, + // Every other layer's contract sets, to prove a disable touches only + // the Directions ones. + others() { + const mine = (id) => String(id).startsWith('directions'); + return [ + ...engine.movers.list().map((set) => `movers:${set.id}`), + ...engine.lines.list().map((line) => `lines:${line.id}`), + ...engine.ground.list().map((set) => `ground:${set.id}`), + ] + .filter((key) => !mine(key.split(':').slice(1).join(':'))) + .sort(); + }, + }; }); await sleep(3000); @@ -701,28 +782,11 @@ try { const denver = await page.evaluate(() => { const viewer = window.__godsEyeView.viewer; const Cartographic = viewer.camera.positionCartographic.constructor; - const scene = viewer.scene; - let collection = null; - for (let i = 0; i < scene.primitives.length; i += 1) { - const primitive = scene.primitives.get(i); - if ( - primitive && - typeof primitive.get === 'function' && - primitive.length > 0 && - String(primitive.get(0)?.id || '').startsWith('directions:step:') - ) { - collection = primitive; - break; - } - } - if (!collection) return { dots: 0, shown: 0, heights: [] }; - const heights = []; - for (let i = 0; i < collection.length; i += 1) { - const point = collection.get(i); - if (!point.show) continue; - heights.push(Cartographic.fromCartesian(point.position).height); - } - return { dots: collection.length, shown: heights.length, heights }; + const { stepCount, stepsShown } = window.__gevQaDirections.drawn(); + const heights = stepsShown.map( + (position) => Cartographic.fromCartesian(position).height, + ); + return { dots: stepCount, shown: heights.length, heights }; }); check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`); check( @@ -745,15 +809,10 @@ try { } // ── 10. Disable: nothing is left behind. ──────────────────────────────── - const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => { - const entities = [...window.__godsEyeView.viewer.entities.values]; - return { - beforeDisable: entities.length, - directionsBeforeDisable: entities.filter((entity) => - String(entity.id).startsWith('directions:'), - ).length, - }; - }); + const { othersBefore, drawnBefore } = await page.evaluate(() => ({ + othersBefore: window.__gevQaDirections.others(), + drawnBefore: window.__gevQaDirections.drawn(), + })); await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('directions', false, { origin: 'user', @@ -761,39 +820,23 @@ try { ); await sleep(1500); const off = await page.evaluate(() => { - const gev = window.__godsEyeView; - const viewer = gev.viewer; - const left = [...viewer.entities.values].filter((entity) => - String(entity.id).startsWith('directions:'), - ); - let shownDots = 0; - for (let i = 0; i < viewer.scene.primitives.length; i += 1) { - const primitive = viewer.scene.primitives.get(i); - if ( - primitive && - typeof primitive.get === 'function' && - primitive.length > 0 && - String(primitive.get(0)?.id || '').startsWith('directions:step:') - ) { - shownDots = primitive.show ? primitive.length : 0; - } - } + const drawn = window.__gevQaDirections.drawn(); const row = document.querySelector( '#utilities-toggles [data-layer-id="directions"]', ); return { - entities: left.length, - shownDots, + entities: drawn.routeSets + drawn.markersShown, + shownDots: drawn.stepsShown.length, controlsHidden: row?.querySelector('.data-toggle-controls')?.hidden === true, listHidden: row?.querySelector('.data-row-list')?.hidden === true, pointerOwner: window.__gevQa.pointerOwner(), motion: window.__gevQa.getActiveCameraMotion(), - totalEntities: viewer.entities.values.length, + others: window.__gevQaDirections.others(), }; }); check( - 'disabling removes every Directions entity', + 'disabling removes the route and hides both markers', off.entities === 0, `${off.entities} left`, ); @@ -810,9 +853,9 @@ try { ); check('disabling leaves no camera motion running', off.motion === null); check( - 'exactly the Directions entities went away, and nothing else did', - off.totalEntities === beforeDisable - directionsBeforeDisable, - `${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`, + "only the Directions drawing went away: every other layer's sets stay", + JSON.stringify(off.others) === JSON.stringify(othersBefore), + `ours before: route ${drawnBefore.routeSets}, markers ${drawnBefore.markersShown}, dots ${drawnBefore.stepsShown.length}; others ${othersBefore.length} -> ${off.others.length}`, ); check( diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index 5985730ec..924f86f1f 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -37,6 +37,21 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +// Daylight over San Francisco: the amber-pixel checks count the drawn mark, +// and the globe's night side shows warm city lights the count would include. +await page.evaluateOnNewDocument(() => { + const offset = Date.UTC(2026, 5, 15, 20, 0) - Date.now(); + const RealDate = Date; + globalThis.Date = class ShiftedDate extends RealDate { + constructor(...args) { + if (args.length === 0) super(RealDate.now() + offset); + else super(...args); + } + static now() { + return RealDate.now() + offset; + } + }; +}); const errors = []; page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); let failures = 0; @@ -189,7 +204,12 @@ async function waitForMarks(expected, timeout = 15_000) { try { await page.setViewport({ width: 1280, height: 860 }); - await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + // The view in the address too: the boot flight lands there instead of + // flying away from the view set below once deferred init runs. + await page.goto( + `${BASE_URL}/?welcome=0#v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.height}&heading=${VIEW.heading}&pitch=${VIEW.pitch}`, + { waitUntil: 'domcontentloaded' }, + ); await page.waitForFunction( () => window.__gevDrawTool && @@ -601,24 +621,46 @@ try { : 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved', ); if (target) { + // Layers select through their own state now, not the viewer's + // selectedEntity: a CCTV marker makes its camera the active one, a data + // centre selects its record in the shared context store. + const isSelected = (spot) => + page.evaluate((spot) => { + if (spot.kind === 'cctv') + return ( + window.__godsEyeView.dataManager.layers + .get('cctv') + ?.module?.getUIState?.()?.activeCameraId === spot.id + ); + return window.__gevContextStore?.selectedEntityId === spot.id; + }, spot); + // Clear the selection (another camera active; no context selection), as + // the old check cleared the viewer's selectedEntity. + const deselect = (spot) => + page.evaluate((spot) => { + if (spot.kind === 'cctv') { + const module = + window.__godsEyeView.dataManager.layers.get('cctv')?.module; + const other = (module?.getUIState?.()?.cameras || []).find( + (camera) => camera.id !== spot.id, + ); + if (other) module.selectCamera(other.id); + } else if (window.__gevContextStore) + window.__gevContextStore.selectedEntityId = null; + }, spot); + await deselect(target); await clickWorld(target.x, target.y, { settle: 900 }); - const selectedNormally = await page.evaluate(() => - Boolean(window.__godsEyeView.viewer.selectedEntity), - ); + const selectedNormally = await isSelected(target); check( `clicking a ${target.layer} selects it when nothing owns the pointer`, selectedNormally, ); - await page.evaluate(() => { - window.__godsEyeView.viewer.selectedEntity = undefined; - }); + await deselect(target); await page.click('#draw-toggle'); await page.click('.pp-mode-btn[data-shape="area"]'); await clickWorld(target.x, target.y, { settle: 900 }); const whileDrawing = await drawState(); - const selectedWhileDrawing = await page.evaluate(() => - Boolean(window.__godsEyeView.viewer.selectedEntity), - ); + const selectedWhileDrawing = await isSelected(target); check( `clicking the same ${target.layer} while drawing adds a vertex and selects nothing`, whileDrawing.vertices === 1 && selectedWhileDrawing === false, @@ -677,19 +719,37 @@ try { null, }; }); - await page.evaluate((view) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (view.lon * Math.PI) / 180, - latitude: (view.lat * Math.PI) / 180, - height: view.height, - }), - orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, - }); - }, VIEW); - await wait(2500); + // The reload restores the view in the address (the clicked CCTV camera's + // flight left a low, near-horizontal view there) with a boot flight that can + // land after a setView: set the view until it holds. + for (let attempt = 0; attempt < 6; attempt += 1) { + await page.evaluate((view) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (view.lon * Math.PI) / 180, + latitude: (view.lat * Math.PI) / 180, + height: view.height, + }), + orientation: { + heading: 0, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + }, VIEW); + await wait(2500); + const held = await page.evaluate((view) => { + const carto = window.__godsEyeView.viewer.camera.positionCartographic; + return ( + Math.abs((carto.latitude * 180) / Math.PI - view.lat) < 0.01 && + Math.abs((carto.longitude * 180) / Math.PI - view.lon) < 0.01 && + Math.abs(carto.height - view.height) < view.height * 0.2 + ); + }, VIEW); + if (held) break; + } await page.click('#draw-toggle'); await page.click('.pp-mode-btn[data-shape="area"]'); await clickWorld(420, 280); @@ -930,17 +990,15 @@ async function findClickableTarget() { viewer.scene.requestRender(); }, camera); await wait(4000); - const spot = await onScreen( - (entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'), - ); - if (spot) return { ...spot, layer: 'CCTV camera' }; + const spot = await onScreen(['cctv']); + if (spot) return { ...spot, kind: 'cctv', layer: 'CCTV camera' }; } // Bundled infrastructure as the keyless fallback: a different guarded handler, - // the same shared claim, and always present without a key. + // the same shared claim, and always present without a key. The layer + // registers each site in the shared context store with its coordinates. const site = await page .evaluate(async () => { - const viewer = window.__godsEyeView.viewer; await window.__godsEyeView.dataManager.setEnabled( 'local-datacenters', true, @@ -948,30 +1006,15 @@ async function findClickableTarget() { origin: 'user', }, ); - const centreOf = (entity) => { - const time = viewer.clock.currentTime; - const point = entity.position?.getValue?.(time); - if (point) return point; - const hierarchy = entity.polygon?.hierarchy?.getValue?.(time); - if (hierarchy?.positions?.length) return hierarchy.positions[0]; - const line = entity.polyline?.positions?.getValue?.(time); - return line?.length ? line[0] : null; - }; 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; - const point = centreOf(entity); - if (!point) continue; - const carto = window.Cesium - ? window.Cesium.Cartographic.fromCartesian(point) - : viewer.scene.globe.ellipsoid.cartesianToCartographic(point); - if (!carto) continue; - return { - lon: (carto.longitude * 180) / Math.PI, - lat: (carto.latitude * 180) / Math.PI, - }; - } + for (const record of window.__gevContextStore?.entities.values() ?? + []) { + if (record.layerId !== 'local-datacenters') continue; + if ( + Number.isFinite(record.latitude) && + Number.isFinite(record.longitude) + ) + return { lon: record.longitude, lat: record.latitude }; } await new Promise((resolve) => setTimeout(resolve, 500)); } @@ -993,54 +1036,40 @@ async function findClickableTarget() { viewer.scene.requestRender(); }, site); await wait(4000); - const spot = await onScreen( - (entity) => entity.__localLayerId === 'local-datacenters', - ); - return spot ? { ...spot, layer: 'data centre' } : null; + const spot = await onScreen([ + 'local-datacenters-tips', + 'local-datacenters-pins', + ]); + return spot ? { ...spot, kind: 'context', layer: 'data centre' } : null; } /** - * Canvas coordinates of the first entity matching `belongs` that is on screen - * and clear of the side panels. Annotation marks and the draw tool's own - * preview vertices are on screen too, so the predicate has to be specific. + * Canvas coordinates of a spot clear of the side panels where the engine's + * pick returns an instance of one of the mover sets `owners` (layers draw + * through engine.movers; a pick reports the set as `owner` and the instance's + * pickId as `id`). Picking, not projecting, proves the spot is clickable: an + * instance beyond the horizon or behind a building does not count. */ -async function onScreen(belongs, timeout = 20_000) { +async function onScreen(owners, timeout = 20_000) { return page .waitForFunction( - (belongsSource) => { - // eslint-disable-next-line no-new-func - const belongsTo = new Function(`return (${belongsSource})`)(); - const viewer = window.__godsEyeView.viewer; - const scene = viewer.scene; - const collections = [viewer.entities]; - for (let i = 0; i < viewer.dataSources.length; i += 1) { - const source = viewer.dataSources.get(i); - if (source?.name === 'gev-draw-preview') continue; - collections.push(source.entities); - } - for (const source of collections) { - for (const entity of source.values) { - if (!belongsTo(entity)) continue; - const position = entity.position?.getValue?.( - viewer.clock.currentTime, - ); - if (!position) continue; - const window2d = scene.cartesianToCanvasCoordinates(position); - if (!window2d) continue; - const { x, y } = window2d; - if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue; - // Projecting into the window is not the same as being clickable: - // the entity may be beyond the horizon or behind a building. Only a - // spot where the scene actually picks it proves the control case. - const hit = scene.pick({ x: Math.round(x), y: Math.round(y) }); - if (!hit?.id || !belongsTo(hit.id)) continue; - return { x: Math.round(x), y: Math.round(y) }; + (wanted) => { + const { engine } = window.__godsEyeView; + const sets = engine.movers + .list() + .filter((set) => wanted.includes(set.id) && set.count > 0); + if (!sets.length) return false; + for (let y = 190; y < 600; y += 20) { + for (let x = 310; x < 900; x += 20) { + const hit = engine.picker.pick({ x, y }); + if (hit && wanted.includes(hit.owner) && hit.id != null) + return { x, y, id: String(hit.id) }; } } return false; }, - { timeout, polling: 500 }, - belongs.toString(), + { timeout, polling: 1000 }, + owners, ) .then( (handle) => handle.jsonValue(), diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index 6d3f74c1d..52caac3c5 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -67,8 +67,9 @@ const getOpt = (name, dflt) => { }; const BASE_URL = getOpt('--url', 'http://localhost:4173'); -// --engine=cesium|navara: Navara reads the layer through the QA -// probe (window.__godsEyeView.layers.drawn) and moves the engine camera. +// --engine=cesium|navara: both engines read the layer through the QA probe +// (window.__godsEyeView.layers.drawn, the dev server's) and move the engine +// camera. const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9); const APP_URL = ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`; @@ -299,38 +300,24 @@ async function main() { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers, - { timeout: 60000, polling: 200 } + // A cold dev server compiles the Navara chunks on first load. + { timeout: 180000, polling: 200 } ); await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise); await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 }); await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop()); console.log(' App globals ready.'); - // ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ---------- + // ---- In-page billboard probe (same as qa-sprites-b5.mjs) --------------- + // What the flights layer draws, through the engine-neutral probe + // (layers.drawn: the layer's own sprite handles) on either engine. The + // layer owns no Cesium billboards any more: the contract's batched + // billboards carry atlas keys, not the glyph data-URIs. await page.evaluate(() => { window.__collectBillboards = function () { - const v = window.__godsEyeView.viewer; - // Without a viewer (Navara): what the flights layer draws, through - // the engine-neutral probe. - if (!v) - return (window.__godsEyeView.layers.drawn('flights') ?? []) - .filter((row) => !row.trackedModel) - .map(({ id, image, scale, show }) => ({ id, image, scale, show })); - const out = []; - const walk = (coll) => { - const n = coll.length; - for (let i = 0; i < n; i++) { - let p; - try { p = coll.get(i); } catch { continue; } - if (!p) continue; - if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.image !== undefined && p.alignedAxis !== undefined) { - out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show }); - } - } - }; - walk(v.scene.primitives); - return out; + return (window.__godsEyeView.layers.drawn('flights') ?? []) + .filter((row) => !row.trackedModel) + .map(({ id, image, scale, show }) => ({ id, image, scale, show })); }; }); diff --git a/scripts/qa-floor-hold.mjs b/scripts/qa-floor-hold.mjs index b601667d5..147301480 100644 --- a/scripts/qa-floor-hold.mjs +++ b/scripts/qa-floor-hold.mjs @@ -172,19 +172,24 @@ const measure = () => page.evaluate(async (icao) => { } const v = app.viewer; const C = v.camera.positionCartographic.constructor; - const sprites = []; const models = []; let bb = null; + // The contact as the layer draws it (the probe's drawn rows: the layer owns + // no Cesium billboard of its own any more; the engine's batched billboards + // keep a hidden spare's old id, so an id match there can be stale). + const bb = app.layers.drawn('flights').find((row) => row.id === icao && !row.trackedModel); + if (!bb?.position) return { error: 'contact billboard not found' }; + // Every billboard and model in the scene (the engine's mover batches + // included) stays out of the mesh probes. + const sprites = []; const models = []; const walk = (coll) => { for (let i = 0; i < coll.length; i++) { let p; try { p = coll.get(i); } catch { continue; } if (!p) continue; if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.image !== undefined && p.alignedAxis !== undefined) { - sprites.push(p); if (p.id === icao) bb = p; - } else if (p.boundingSphere && p.modelMatrix) models.push(p); + if (p.image !== undefined && p.alignedAxis !== undefined) sprites.push(p); + else if (p.boundingSphere && p.modelMatrix) models.push(p); } }; try { walk(v.scene.primitives); } catch { /* mid-teardown */ } - if (!bb?.position) return { error: 'contact billboard not found' }; const carto = C.fromCartesian(bb.position); const lat = carto.latitude * 180 / Math.PI; const lon = carto.longitude * 180 / Math.PI; diff --git a/scripts/qa-floor-verify.mjs b/scripts/qa-floor-verify.mjs index 0dc8b6379..a6baf1d43 100644 --- a/scripts/qa-floor-verify.mjs +++ b/scripts/qa-floor-verify.mjs @@ -195,23 +195,17 @@ const report = await page.evaluate(async () => { }); } // Visibility census (round 6): getNearby only returns contacts a sprite or a - // 3D model is actually drawing, so also walk the raw collections — a culled - // "invisible plane" shows up here. + // 3D model is actually drawing, so also count every glyph the layer holds + // (the probe's drawn rows) — a culled "invisible plane" shows up here. The + // engine's batched billboards keep hidden spares, so they are not counted. let shown = 0, hidden = 0; - const censusWalk = (coll) => { - const n = coll.length; - for (let i = 0; i < n; i++) { - let pr; try { pr = coll.get(i); } catch { continue; } - if (!pr) continue; - if (typeof pr.length === 'number' && typeof pr.get === 'function') { censusWalk(pr); continue; } - if (pr.image === undefined || pr.alignedAxis === undefined) continue; - const cc = ell.cartesianToCartographic(pr.position); - const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI; - if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue; - if (pr.show) shown++; else hidden++; - } - }; - censusWalk(v.scene.primitives); + for (const row of gev.layers?.drawn('flights') ?? []) { + if (!row.position || row.trackedModel) continue; + const cc = ell.cartesianToCartographic(row.position); + const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI; + if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue; + if (row.show) shown++; else hidden++; + } return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden }; }); console.log(JSON.stringify(report, null, 1)); diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs index 3c3f0b880..4f70b73de 100644 --- a/scripts/qa-ghostmaps.mjs +++ b/scripts/qa-ghostmaps.mjs @@ -8,7 +8,8 @@ * * (i) LIVE — enabling the layer loads the default products (Common * Intelligence Picture and the 30-day kinetic pull) from the live - * repository through /api/ghostmaps, one data source per product, + * repository through /api/ghostmaps, one drawn edition per product + * (its globe-contract sets), * matching the proxy's catalog commit; the row shows one chip per * catalog product with the defaults active, and the page holds an * open event stream to /api/ghostmaps/events. @@ -17,12 +18,12 @@ * and clicking empty ground clears it. * (iii) PUSH (synthetic) — the event stream is replaced in the page and a * catalog naming a new edition of one product is pushed: only that - * product reloads (a new data source replaces the old one, no - * duplicates), the other keeps its data source. A catalog without a + * product reloads (a new edition replaces the old one, no + * duplicates), the other keeps its edition. A catalog without a * product removes it. * (iv) CHIPS — clicking an active product chip removes that product, * clicking it again restores it; the choice survives a reload. - * (v) DISABLE — every GhostMaps data source leaves the viewer. + * (v) DISABLE — every GhostMaps set leaves the globe. * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips * fit the panel width with no horizontal overflow. * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: @@ -78,15 +79,63 @@ async function boot(page) { await page.keyboard.press('Escape'); } -/** GhostMaps data sources on the viewer: name → entity count. */ +/** + * GhostMaps product editions on the globe contract: one row per drawn + * edition, as one data source per product used to be. The display + * (render/display.js) gives each edition three sets named + * `${prefix}${productId}:${edition}:{polygons,lines,pins}`; `count` sums their + * items. + */ function ghostSources(prefix) { - const { dataSources } = window.__godsEyeView.viewer; - const out = []; - for (let i = 0; i < dataSources.length; i++) { - const ds = dataSources.get(i); - if (ds.name?.startsWith(prefix)) - out.push({ name: ds.name, count: ds.entities.values.length }); + const { engine } = window.__godsEyeView; + const editions = new Map(); + for (const set of [...engine.ground.list(), ...engine.movers.list()]) { + if (!set.id.startsWith(prefix)) continue; + const [productId, edition] = set.id.slice(prefix.length).split(':'); + const key = `${productId}:${edition}`; + const row = editions.get(key) || { + name: `${prefix}${productId}`, + edition: Number(edition), + count: 0, + }; + row.count += set.count; + editions.set(key, row); } + return [...editions.values()]; +} + +/** + * The drawn placemarks of the GhostMaps sprite sets (`...:pins`), from the + * Cesium engine's own billboards for those sets (the contract has no + * per-instance read-back): { id, show, position } each. + */ +function ghostPins(prefix) { + const gev = window.__godsEyeView; + const counts = new Map( + gev.engine.movers.list().map((set) => [set.id, set.count]), + ); + const out = []; + const walk = (collection, depth) => { + for (let i = 0; i < (collection?.length || 0); i++) { + const primitive = collection.get(i); + const setId = primitive?.gevMoverSet; + if (typeof setId === 'string') { + if (!setId.startsWith(prefix) || !setId.endsWith(':pins')) continue; + const count = Math.min(counts.get(setId) ?? 0, primitive.length); + for (let j = 0; j < count; j++) { + const billboard = primitive.get(j); + out.push({ + set: setId, + id: String(billboard.id), + show: billboard.show, + position: billboard.position, + }); + } + } else if (depth < 3 && typeof primitive?.get === 'function') + walk(primitive, depth + 1); + } + }; + walk(gev.viewer.scene.primitives, 0); return out; } @@ -127,6 +176,9 @@ const stats = (page) => ); const sources = (page) => page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`); +// Installs ghostPins() in the page (the evaluate callbacks below call it). +const installPins = (page) => + page.evaluate(`window.__qaGhostPins = ${ghostPins}`); async function showRow(page) { await page.evaluate((id) => { @@ -298,14 +350,12 @@ async function main() { ); // ---- (ii) PICK ---------------------------------------------------------- + await installPins(page); const target = await page.evaluate((id) => { const gev = window.__godsEyeView; - const ds = [...Array(gev.viewer.dataSources.length).keys()] - .map((i) => gev.viewer.dataSources.get(i)) - .find((item) => item.name === `${id}:cip`); - const entity = ds.entities.values.find((e) => e.billboard); + const pin = window.__qaGhostPins(`${id}:cip:`)[0]; const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic( - entity.position.getValue(gev.viewer.clock.currentTime), + pin.position, ); try { gev.viewer.camera.cancelFlight(); @@ -321,7 +371,7 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); gev.viewer.scene.requestRender?.(); - return entity.id; + return pin.id; }, LAYER_ID); await page .waitForFunction( @@ -333,24 +383,22 @@ async function main() { await page .waitForFunction( () => { - const v = window.__godsEyeView.viewer; + const { viewer: v, engine } = window.__godsEyeView; v.scene.requestRender?.(); const c = v.scene.canvas; - return v.scene + return engine.picker .drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8) - .some((p) => - String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'), - ); + .some((p) => String(p?.id ?? '').startsWith('ghostmaps:cip:')); }, { timeout: 15000, polling: 500 }, ) .catch(() => {}); const probe = await page.evaluate(() => { - const v = window.__godsEyeView.viewer; + const { viewer: v, engine } = window.__godsEyeView; const c = v.scene.canvas; const box = c.getBoundingClientRect(); const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; - const picked = v.scene.drillPick(centre, 8); + const picked = engine.picker.drillPick(centre, 8); // Empty ground: the first point below the marker (the card is drawn // above it) that picks nothing at all; markers can be dense in a city. let empty = null; @@ -360,14 +408,14 @@ async function main() { x: centre.x + r * Math.cos((a * Math.PI) / 4), y: centre.y + r * Math.sin((a * Math.PI) / 4), }; - if (!v.scene.pick(p)) { + if (engine.picker.pick(p)?.id == null) { empty = p; break; } } empty ||= { x: centre.x + 400, y: centre.y + 300 }; return { - ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')), + ids: picked.map((p) => String(p?.id ?? '')), centre: { x: centre.x + box.left, y: centre.y + box.top }, empty: { x: empty.x + box.left, y: empty.y + box.top }, }; @@ -462,23 +510,18 @@ async function main() { const norm = (v) => Math.hypot(v.x, v.y, v.z); let far = 0; let nearShown = 0; - for (let i = 0; i < gev.viewer.dataSources.length; i++) { - const ds = gev.viewer.dataSources.get(i); - if (!ds.name?.startsWith(`${id}:`)) continue; - for (const e of ds.entities.values) { - if (!e.billboard) continue; - const p = e.position.getValue(gev.viewer.clock.currentTime); - // Angle from the camera's nadir to the pin, against the - // geometric horizon angle; the cull's 1 km margin shows pins up - // to about 1° past it, so "clearly behind" is beyond 2°. - const angle = Math.acos( - (p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)), - ); - const horizon = Math.acos(norm(p) / norm(c)); - const margin = (2 * Math.PI) / 180; - if (e.show && angle > horizon + margin) far++; - if (e.show && angle < horizon - margin) nearShown++; - } + for (const e of window.__qaGhostPins(`${id}:`)) { + const p = e.position; + // Angle from the camera's nadir to the pin, against the + // geometric horizon angle; the cull's 1 km margin shows pins up + // to about 1° past it, so "clearly behind" is beyond 2°. + const angle = Math.acos( + (p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)), + ); + const horizon = Math.acos(norm(p) / norm(c)); + const margin = (2 * Math.PI) / 180; + if (e.show && angle > horizon + margin) far++; + if (e.show && angle < horizon - margin) nearShown++; } return { far, nearShown }; }, @@ -605,21 +648,13 @@ async function main() { }); await boot(push); await enableAndSettle(push); - const identity = () => - push.evaluate((id) => { - const { dataSources } = window.__godsEyeView.viewer; - window.__qaIds ||= new WeakMap(); - window.__qaNext ||= 1; - const out = {}; - for (let i = 0; i < dataSources.length; i++) { - const ds = dataSources.get(i); - if (!ds.name?.startsWith(`${id}:`)) continue; - if (!window.__qaIds.has(ds)) - window.__qaIds.set(ds, window.__qaNext++); - out[ds.name] = window.__qaIds.get(ds); - } - return out; - }, LAYER_ID); + // Each product's drawn edition (its sets' sequence number): a reload + // draws a new edition in place of the old one. + const identity = async () => { + const out = {}; + for (const row of await sources(push)) out[row.name] = row.edition; + return out; + }; const first = await identity(); const pushed = Date.now(); await push.evaluate(async (id) => { diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 41933141e..72dc4f416 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -24,7 +24,7 @@ * CAMERA/GLOBE verify automatic sampling and the screen-centre anchor. * (iii) FAILURE — the route stubbed to 502: stats.error is set and the * cells already on the globe are kept. - * (iv) DISABLE — owned ground primitives are hidden. + * (iv) DISABLE — the layer's engine.ground cell set is hidden. * (v) KEYBOARD — the row's toggle button, focused, turns the layer on * with Enter and off with Space, keeps focus and names its state. * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend @@ -129,18 +129,18 @@ async function enableAndSettle(page, { refresh = false } = {}) { const rowName = document.querySelector(`[data-layer-id="${id}"] .data-name`) ?.textContent ?? ''; - const all = window.__godsEyeView.viewer.scene.groundPrimitives; - const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( - (item) => item.id === id, - ); + // The layer's cells are one engine.ground cell set named after it. + const set = window.__godsEyeView.engine.ground + .list() + .find((item) => item.id === id); return { stats, legend, blurb, rowName, name: layer.name, - entities: ds?.get(0)?.geometryInstances.length ?? 0, - shown: ds?.show ?? null, + entities: set?.count ?? 0, + shown: set?.shown ?? null, }; }, LAYER_ID, @@ -151,6 +151,16 @@ async function enableAndSettle(page, { refresh = false } = {}) { /** Open the Data Layers panel and bring the GNSS row into view. */ async function showLayerRow(page) { await page.evaluate((id) => { + // A phone viewport shows the panel in the shell's Layers sheet + // (src/ui/mobileShell.js), opened from its tab bar. + const layersTab = document.querySelector( + '#mobile-tab-bar [data-mobile-sheet="layers"]', + ); + if ( + layersTab?.offsetParent && + !document.body.matches('[data-mobile-sheet="layers"]') + ) + layersTab.click(); const panel = document.getElementById('data-panel'); if (panel.classList.contains('collapsed')) panel.querySelector('[data-collapse-target="data-panel"]').click(); @@ -184,15 +194,25 @@ async function shoot(page, name) { await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) }); } -/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */ +/** + * Build state of the GNSS ground set's Cesium batches (DONE = 0, PENDING = 1): + * the primitives the Cesium ground engine tags with the set id, under + * scene.groundPrimitives. Empty while the set has nothing committed. + */ function cellStates(id) { - const { viewer } = window.__godsEyeView; - const all = viewer.scene.groundPrimitives; - const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( - (item) => item.id === id, - ); - if (!ds?.length) return []; - return [ds.get(0).ready ? 0 : 1]; + const { viewer, engine } = window.__godsEyeView; + const set = engine.ground.list().find((item) => item.id === id); + if (!set?.count) return []; + const states = []; + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if (item?.gevGroundSet === id) states.push(item.ready ? 0 : 1); + else if (typeof item?.get === 'function' && 'length' in item) walk(item); + } + }; + walk(viewer.scene.groundPrimitives); + return states.length ? states : [1]; } /** Wait until every GNSS cell's ground rectangle has finished building. */ @@ -557,8 +577,9 @@ async function main() { }); await page.waitForFunction( () => { - const v = window.__godsEyeView.viewer, - p = v.scene.pick({ + // The engine's pick names the perimeter ground item's pickId. + const { viewer: v, engine } = window.__godsEyeView, + p = engine.picker.pick({ x: v.scene.canvas.clientWidth / 2, y: v.scene.canvas.clientHeight / 2, }); @@ -569,11 +590,11 @@ async function main() { { timeout: TIMEOUT_MS, polling: 250 }, ); const pickProbe = await page.evaluate(() => { - const v = window.__godsEyeView.viewer, + const { viewer: v, engine } = window.__godsEyeView, c = v.scene.canvas; const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; const empty = { x: centre.x + 300, y: centre.y }; - const picked = v.scene.pick(empty), + const picked = engine.picker.pick(empty), box = c.getBoundingClientRect(); return { centre: { x: centre.x + box.left, y: centre.y + box.top }, @@ -778,12 +799,11 @@ async function main() { console.log('\n(iv) DISABLE...'); const shown = await page.evaluate(async (id) => { await window.__godsEyeView.dataManager.setEnabled(id, false); - const all = window.__godsEyeView.viewer.scene.groundPrimitives; - return Array.from({ length: all.length }, (_, i) => all.get(i)).find( - (item) => item.id === id, - )?.show; + return window.__godsEyeView.engine.ground + .list() + .find((item) => item.id === id)?.shown; }, LAYER_ID); - record('DISABLE: data source hidden', shown === false, `show=${shown}`); + record('DISABLE: ground set hidden', shown === false, `show=${shown}`); await shoot(page, 'disabled'); // ── (v) KEYBOARD ────────────────────────────────────────────────────── diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index c8d458e88..f5b171fd4 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -19,8 +19,9 @@ * add order and the app registered first), i.e. at the exact wall-clock the * rate limiter advanced — postRender sampling skews apparent rates by the * variable render-pass duration (~40% at SwiftShader's ~3 fps). The course - * is derived from the tracked standalone Cesium.Model's modelMatrix (the - * actually-rendered transform), inverting _modelMatrix: with pitch=roll=0 + * is derived from the tracked model's heading-pitch-roll frame (the layer's + * model handle, read through the QA probe's layers.drawn on both engines; + * the drawn Cesium Model adds the glTF axis turn), inverting it: with pitch=roll=0 * the matrix is ENU·Rz(−h), so local x in ENU = (cos h, −sin h, 0) → * h = atan2(−x·north, x·east), course = norm360(deg(h) − MODEL_HEADING_OFFSET). * The 15 s fix cadence × 3°/s turn = 45° course step per interpolation @@ -593,44 +594,25 @@ async function main() { // ---- Model finder + course derivation, installed once ------------------ await page.evaluate(() => { // Every sampler in this harness targets a KNOWN aircraft, and both the - // fleet models and the tracked standalone model carry id=icao — so the - // finder selects by id when given one. (The old nearest-to-tracked - // fallback could mis-pick ANOTHER plane's model for a frame whenever - // the tracked display position read null, which injected a one-frame - // ~90° course spike into the sampled series — a measurement flake, not - // a product regression.) + // fleet models and the tracked model carry id=icao — so the finder + // selects by id. The models draw through the globe contract (engine + // model movers), on Cesium as on Navara; the layer's drawn rows + // (window.__godsEyeView.layers.drawn, src/layers/aircraft/qaDrawn.js) + // give each model's heading-pitch-roll frame, the matrix the course + // derivation below inverts. The Cesium Model a mover set draws carries + // that frame times the layer's glTF axis turn (GLTF_AXIS in + // src/layers/aircraft/render/display.js), so its own modelMatrix reads + // 90° off; the handle's ready/show say whether the engine draws it. window.__findTrackedModel = function (icao) { - const v = (window.__headingView ?? window.__godsEyeView.viewer); - const out = []; - const walk = (coll) => { - const n = coll.length; - for (let i = 0; i < n; i++) { - const p = coll.get(i); - if (!p) continue; - if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.modelMatrix && typeof p.ready !== 'undefined') { - if (icao && p.id !== icao) continue; - out.push(p); - } - } - }; - walk(v.scene.primitives); - const shown = out.filter((m) => m.show && m.ready); - if (icao) return shown[0] || null; // deterministic: the plane's own model or nothing - const pool = shown.length ? shown : out; - if (!pool.length) return null; - const ent = v.trackedEntity; - let tracked = null; - if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition(); - if (!tracked) return pool[0]; - let best = null, bestD = Infinity; - for (const m of pool) { - const mm = m.modelMatrix; - const dx = mm[12] - tracked.x, dy = mm[13] - tracked.y, dz = mm[14] - tracked.z; - const d = dx * dx + dy * dy + dz * dz; - if (d < bestD) { bestD = d; best = m; } - } - return best; + const rows = ['flights', 'military'] + .flatMap((layer) => window.__godsEyeView.layers.drawn(layer) ?? []) + .filter((row) => row.id === icao); + const row = + rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ?? + rows.find((r) => r.model?.ready && r.model.show); + return row + ? { id: icao, ready: row.model.ready, show: row.model.show, modelMatrix: row.model.matrix } + : null; }; // Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0) @@ -952,23 +934,7 @@ async function main() { // ======================================================================== console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)'); await page.evaluate(() => { - if (window.__headingView) { window.__findModelByIcao = window.__findTrackedModel; return; } - window.__findModelByIcao = function (icao) { - const v = (window.__headingView ?? window.__godsEyeView.viewer); - const out = []; - const walk = (coll) => { - const n = coll.length; - for (let i = 0; i < n; i++) { - let p; - try { p = coll.get(i); } catch { continue; } - if (!p) continue; - if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === icao && p.show && p.ready) out.push(p); - } - }; - walk(v.scene.primitives); - return out[0] || null; - }; + window.__findModelByIcao = window.__findTrackedModel; }); // Activate the heli in the feed now (kept out of phases 1–4), ingest it, diff --git a/scripts/qa-height-datum.mjs b/scripts/qa-height-datum.mjs index 05b96b47c..cd861163e 100644 --- a/scripts/qa-height-datum.mjs +++ b/scripts/qa-height-datum.mjs @@ -169,29 +169,36 @@ function waitForTilesLoaded(page, timeoutMs = 15000) { /** * Reads a camera's placed mount-point cartographic height off its - * `cctv--ray-tl` polyline entity (first position = the mount, per - * cctv.js `buildCoverageEntities`/`applyFrustumGeometry`), returns - * `{ mountAltM, groundM }` where `groundM = mountAltM - mountHeightM` — the - * SAME arithmetic cctv.js itself uses in reverse - * (`mountAlt = ground + mountHeightM` in `computeFrustumGeometry`). + * `cctv--ray-tl` corner ray (first position = the mount, per the CCTV + * geometry's `applyFrustumGeometry`), returns `{ mountAltM, groundM }` where + * `groundM = mountAltM - mountHeightM` — the SAME arithmetic the layer itself + * uses in reverse (`mountAlt = ground + mountHeightM` in + * `computeFrustumGeometry`). The ray is an engine line on the globe contract + * (src/layers/cctv/render/display.js): it must be live in + * `engine.lines.list()`, and its positions are the ones the layer's line + * handle (record.coverageEntities) gave it. * @param {import('puppeteer').Page} page * @param {string} camId * @returns {Promise<{mountAltM:number, mountHeightM:number, groundM:number}|null>} */ async function readCameraGround(page, camId) { return page.evaluate((id) => { - const viewer = window.__godsEyeView.viewer; - const time = viewer.clock.currentTime; - const ent = viewer.entities.getById(`cctv-${id}-ray-tl`); - if (!ent || !ent.polyline) return null; - const positions = ent.polyline.positions.getValue(time); - if (!Array.isArray(positions) || positions.length < 1) return null; + const { viewer, engine, dataManager } = window.__godsEyeView; + const mod = dataManager.layers.get('cctv').module; + const lineId = `cctv-${id}-ray-tl`; + const line = engine.lines.list().find((entry) => entry.id === lineId); + const handle = mod + ._cctvRecordsForTest() + .find((record) => record.camera?.id === id) + ?.coverageEntities?.find((entry) => entry?.id === lineId); + const positions = handle?.polyline?.positions; + if (!line || !Array.isArray(positions) || positions.length < 1) return null; + if (line.points !== positions.length) return null; const mount = positions[0]; - // No window.Cesium global (qa-cctv-v2 precedent) — borrow Cartographic's - // static fromCartesian off a live Cartographic instance's constructor. + // No window.Cesium global (qa-cctv-v2 precedent): the ellipsoid's own + // cartesianToCartographic reads the plain { x, y, z }. const carto = viewer.scene.globe.ellipsoid.cartesianToCartographic(mount); const mountAltM = carto.height; - const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().cameras.find((c) => c.id === id); const mountHeightM = cam ? cam.mountHeightM : null; return { @@ -280,7 +287,9 @@ async function main() { console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.layers && window.__godsEyeView.dataManager, + // The QA probe (`layers`) exists on the dev server only; a built + // preview has the engine and the data manager without it. + () => window.__godsEyeView?.engine && window.__godsEyeView.dataManager, { timeout: 60000 } ); await sleep(4000); @@ -288,7 +297,10 @@ async function main() { // ========================================================================= // Group 1: CCTV ground correctness // ========================================================================= - const hasCctv = await page.evaluate(() => window.__godsEyeView.layers.available('cctv')); + const hasCctv = await page.evaluate(() => { + const gev = window.__godsEyeView; + return gev.layers ? gev.layers.available('cctv') : gev.dataManager.layers.has('cctv'); + }); let allCameras = [], londonCams = [], austinCams = []; if (!hasCctv) console.log(' [SKIP] CCTV ground/regime groups are outside the Navara W1 catalog'); if (hasCctv) { @@ -377,13 +389,28 @@ async function main() { for (const { label, cams } of cityBuckets) { for (const cam of cams) { + // Coverage is built only in view; frame the camera before reading it. + await page.evaluate((camera) => { + const { viewer, engine } = window.__godsEyeView; + engine.camera.cancelFlight(); + engine.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: camera.lon * Math.PI / 180, + latitude: camera.lat * Math.PI / 180, + height: 5000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + engine.render.request('qa-camera-datum'); + }, cam); + await page.waitForFunction((id) => window.__godsEyeView.engine.lines.list().some(line => line.id === `cctv-${id}-ray-tl` && line.points > 0), { timeout: 20000 }, cam.id).catch(() => {}); const state = await page.evaluate((id) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; return mod.getUIState().cameras.find((camera) => camera.id === id) || null; }, cam.id); const geom = await readCameraGround(page, cam.id); if (!geom || !Number.isFinite(geom.groundM)) { - record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl entity missing or non-finite height'); + record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl line missing or non-finite height'); continue; } record(`${label} ${cam.id}: rendered ground reads finitely`, true, diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs index 5be19fe64..732144470 100644 --- a/scripts/qa-infra-lod.mjs +++ b/scripts/qa-infra-lod.mjs @@ -83,6 +83,20 @@ try { await page.goto(testUrl.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.__godsEyeView?.styleManager && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90_000 }); + // The startup view restore and any boot flight land after the loading + // screen; park only once they are over, or they move the camera away from + // the full-earth view before the first LOD walk. + await page.evaluate(async () => { + const gev = window.__godsEyeView; + await gev.styleManager.initialRestorePromise; + let quietSince = performance.now(); + const deadline = performance.now() + 30_000; + while (performance.now() < deadline) { + if (gev.engine.camera.flightActive()) quietSince = performance.now(); + else if (performance.now() - quietSince > 3_000) break; + await new Promise((r) => setTimeout(r, 200)); + } + }); check('first-run chooser does not cover the test surface', await page.evaluate(() => ( !document.querySelector('#first-run-launcher:not([hidden])') ))); @@ -158,8 +172,12 @@ try { for (const id of infraIds) { out[id] = gev.dataManager.layers.get(id)?.module?.getLodDiagnostics?.() || null; } - return out; - }, INFRA_LAYER_IDS).then((lod) => { report(label, lod); return lod; }); + // Where the camera is when the LOD is read (reported, not compared). + return { lod: out, cameraHeightM: Math.round(gev.engine.camera.height()) }; + }, INFRA_LAYER_IDS).then(({ lod, cameraHeightM }) => { + report(label, { ...lod, cameraHeightM }); + return lod; + }); if (!CONTROL) { const globalLod = await readLod('getLodDiagnostics @ full-earth'); @@ -216,15 +234,13 @@ try { // ── GATE c: no churn between camera moves ─────────────────────────── const churn = await page.evaluate(async (infraIds) => { const gev = window.__godsEyeView; - const snap = () => infraIds.map((id) => { - const visibleIds = []; - 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)); - } - } - return visibleIds.sort(); - }); + // The layer draws through the globe contract: a reference in view has + // a stem line `${layerId}-stem-${featureId}` (render/display.js drops + // it when the reference leaves the view or the LOD set). + const snap = () => infraIds.map((id) => gev.engine.lines.list() + .filter((line) => line.id.startsWith(`${id}-stem-`) && line.shown) + .map((line) => line.id) + .sort()); const before = snap(); for (let i = 0; i < 5; i++) { gev.viewer.scene.requestRender?.(); @@ -297,7 +313,8 @@ try { // ── parked-idle honesty: the LOD walk must not force continuous render ─ await new Promise((r) => setTimeout(r, 4_000)); const idle = await page.evaluate(async () => { - const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js'); + // The debug handle's own governor read (no /src import: runs on a build). + const { getRenderGovernorDiagnostics } = window.__godsEyeView; return new Promise((resolve) => { const scene = window.__godsEyeView.viewer.scene; const startedAt = Date.now(); diff --git a/scripts/qa-installation-navigation.mjs b/scripts/qa-installation-navigation.mjs index 5ecf03e1a..5a29ff95b 100644 --- a/scripts/qa-installation-navigation.mjs +++ b/scripts/qa-installation-navigation.mjs @@ -40,9 +40,20 @@ try { page.on('console', (m) => { if (m.type() === 'error') result.errors.push(m.text()); }); - await page.evaluateOnNewDocument(() => - sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'), - ); + await page.evaluateOnNewDocument(() => { + sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'); + // The sites drawn as discs, as their context handles: the layer draws + // through the globe contract and owns no Cesium entities. + window.__qaInstallationSites = () => + [...(window.__gevContextStore?.entities.values() ?? [])] + .filter( + (r) => + r.layerId === 'military-installations' && + r.entity && + !r.entity.record?.namedArea, + ) + .map((r) => r.entity); + }); await page.goto( args.find((a) => /^https?:/.test(a)), { waitUntil: 'domcontentloaded' }, @@ -156,8 +167,9 @@ try { .catch(() => {}); const snapshot = await page.evaluate(() => { const g = window.__godsEyeView; - const ds = g.viewer.dataSources.getByName('military-installations')[0]; - const points = ds.entities.values.filter((e) => e.point); + // The drawn sites' context handles (render/display.js keeps one per + // drawn record and prunes the rest); a named area draws no disc. + const points = window.__qaInstallationSites(); const stable = points.every( (e) => !window.installationEntityRefs.has(e.id) || @@ -244,10 +256,7 @@ try { 0.6, ); await page.waitForFunction( - () => - window.__godsEyeView.viewer.dataSources - .getByName('military-installations')[0] - .entities.values.some((e) => e.point), + () => window.__qaInstallationSites().length > 0, { timeout: 5000 }, ); await page.evaluate(() => { @@ -268,8 +277,7 @@ try { await sleep(1000); await page.evaluate(async () => { const g = window.__godsEyeView; - const ds = g.viewer.dataSources.getByName('military-installations')[0]; - const id = ds.entities.values.find((e) => e.point).id; + const id = window.__qaInstallationSites()[0].id; window.installationSubjectId = id; await g.dataManager.layers .get('military-awareness') diff --git a/scripts/qa-installation-polish.mjs b/scripts/qa-installation-polish.mjs index 6971d85ba..03b0e1d6a 100644 --- a/scripts/qa-installation-polish.mjs +++ b/scripts/qa-installation-polish.mjs @@ -16,10 +16,13 @@ export async function checkInstallationPan(page, dir) { const installations = g.dataManager.layers.get( 'military-installations', ).module; + // The datacenters' overlay entry ids: the layer draws through the globe + // contract and registers each reference row (its overlay entry's id) in + // the context store; it owns no Cesium entities. const centerIds = []; - 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); + for (const record of window.__gevContextStore?.entities.values() ?? []) + if (record.layerId === 'local-datacenters' && record.entity?.id != null) + centerIds.push(record.entity.id); window.__polishPanFrames = []; window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => { const sites = [], @@ -106,6 +109,17 @@ export async function checkInstallationSelection( dir, { video = true } = {}, ) { + await page.evaluate(() => { + const ground = window.__godsEyeView.engine.ground; + const create = ground.createSet.bind(ground); + window.__polishGroundSets = new Map(); + ground.createSet = (options) => { + const set = create(options); + window.__polishGroundSets.set(options.id, { set, drape: options.drape ?? 'both' }); + return set; + }; + window.__restorePolishGround = () => { ground.createSet = create; }; + }); const result = []; const recorder = video ? await createScreencast(page, `${dir}/selection-orbit`, { fps: 24 }) @@ -175,50 +189,38 @@ export async function checkInstallationSelection( await import('/src/data/contextStore.js'); const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const knownFills = new Set(); - const fillEntities = () => { - const list = []; - for (let i = 0; i < g.viewer.dataSources.length; i++) - for (const e of g.viewer.dataSources.get(i).entities.values) - if (e.id.startsWith(`${record.id}:selected-fill:`)) list.push(e); - for (const e of list) knownFills.add(e); - return list; - }; - // Geometry-instance ids prove the ground primitive actually built, not just an entity declaration. - const primitives = () => { - let count = 0; - const walk = (collection) => { - if (collection?._collectionsArray) { - for (const child of collection._collectionsArray) walk(child); - return; - } - for (let i = 0; i < (collection?.length || 0); i++) { - const p = collection.get(i); - if ( - p.ready && - typeof p.getGeometryInstanceAttributes === 'function' - ) { - for (const e of knownFills) { - try { - const a = p.getGeometryInstanceAttributes(e); - if (a && (!a.show || a.show[0])) count++; - } catch {} - } - } - if (typeof p.get === 'function' || p._collectionsArray) walk(p); + // The selected site's fill is one engine.ground polygon set + // (render/display.js SELECTION_FILL_SET_ID), an item per footprint + // part; the layer owns no Cesium entities or ground primitives. + const fillSet = () => + g.engine.ground + .list() + .find((set) => set.id === 'military-installations-selection-fill'); + window.__polishFillProbe = () => { + const set = fillSet(); + const items = set?.shown ? set.count : 0; + const captured = window.__polishGroundSets.get(set?.id); + // A retained fill may predate this probe. Its engine-owned batches + // prove readiness and classification on this Cesium-only check. + const batches = []; + const visit = collection => { + for (let i = 0; i < collection.length; i++) { + const primitive = collection.get(i); + if (primitive.gevGroundSet === set?.id) batches.push(primitive); + else if (typeof primitive.get === 'function') visit(primitive); } }; - walk(g.viewer.scene.groundPrimitives); - return count; + if (!captured && set) visit(g.viewer.scene.groundPrimitives); + const ready = captured ? captured.set.ready() : batches.length > 0 && batches.every(p => p.ready); + const drape = captured?.drape ?? ['terrain', 'tiles', 'both'][batches[0]?.classificationType]; + return { + items, + readyItems: ready ? items : 0, + drape, + expectedDrape: g.engine.maps.photorealActive() ? 'tiles' : 'both', + selected: getSelectedEntityContext()?.id || null, + }; }; - window.__polishFillProbe = () => ({ - entities: fillEntities().length, - primitives: primitives(), - classification: fillEntities().map((e) => - e.polygon.classificationType.getValue(), - ), - selected: getSelectedEntityContext()?.id || null, - }); window.__stopPolishSelection = g.viewer.scene.postRender.addEventListener(() => { window.__polishSelectionFrames.push({ @@ -251,7 +253,7 @@ export async function checkInstallationSelection( .waitForFunction( () => { const p = window.__polishFillProbe(); - return p.selected === window.__polishSelectedId && p.primitives > 0; + return p.selected === window.__polishSelectedId && p.readyItems > 0; }, { timeout: 20000 }, ) @@ -259,27 +261,12 @@ export async function checkInstallationSelection( await page.screenshot({ path: `${dir}/selection-failure.png` }); const diagnostic = await page.evaluate(() => { const g = window.__godsEyeView; - const describe = (p) => ({ - type: p.constructor.name, - ready: p.ready, - length: p.length, - keys: Object.keys(p).filter((k) => - /primitive|instance|collection/i.test(k), - ), - ids: p._instanceIds?.map((id) => id.id || String(id)), - children: - typeof p.get === 'function' - ? Array.from({ length: p.length }, (_, i) => - describe(p.get(i)), - ) - : p._collectionsArray?.map(describe), - }); return { probe: window.__polishFillProbe(), stats: g.dataManager.layers .get('military-installations') .module.getStats(), - primitives: describe(g.viewer.scene.groundPrimitives), + ground: g.engine.ground.list(), frames: window.__polishSelectionFrames.slice(-5), }; }); @@ -332,9 +319,9 @@ export async function checkInstallationSelection( const g = window.__godsEyeView; 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 && !pick?.id?.gevTrackedId) - return { x, y }; + // Nothing drawn there: no mover, line or ground item's pickId. + const pick = g.engine.picker.pick({ x, y }); + if (pick?.id == null) return { x, y }; } throw new Error('No empty map point'); }); @@ -342,7 +329,7 @@ export async function checkInstallationSelection( await page.waitForFunction( () => window.__polishFillProbe().selected === null && - window.__polishFillProbe().entities === 0, + window.__polishFillProbe().items === 0, ); await sleep(600); const deselected = await page.evaluate(() => { @@ -353,14 +340,11 @@ export async function checkInstallationSelection( frames: window.__polishSelectionFrames, }; }); - assert.equal(selected.entities, picked.parts); - assert.equal(selected.primitives, picked.parts); - assert.ok( - selected.classification.every((c) => c === 1), - 'Google 3D tile classification', - ); - assert.equal(deselected.entities, 0); - assert.equal(deselected.primitives, 0); + assert.equal(selected.items, picked.parts); + assert.equal(selected.readyItems, picked.parts); + assert.equal(selected.drape, selected.expectedDrape, 'selection fill follows the map surface'); + assert.equal(deselected.items, 0); + assert.equal(deselected.readyItems, 0); const selectedFrames = deselected.frames.filter( (f) => f.selected && f.own, ); @@ -381,6 +365,7 @@ export async function checkInstallationSelection( result.push({ site, picked, selected, deselected }); } } finally { + await page.evaluate(() => window.__restorePolishGround?.()); await fs.writeFile( `${dir}/selection-checks.json`, JSON.stringify(result, null, 2), @@ -396,8 +381,8 @@ export async function checkInstallationSelection( id: picked.id, selected, deselected: { - entities: deselected.entities, - primitives: deselected.primitives, + entities: deselected.items, + primitives: deselected.readyItems, }, frames: deselected.frames.length, })); diff --git a/scripts/qa-overlay-baseline.mjs b/scripts/qa-overlay-baseline.mjs index fc22904b0..6a5fbf47f 100644 --- a/scripts/qa-overlay-baseline.mjs +++ b/scripts/qa-overlay-baseline.mjs @@ -18,9 +18,11 @@ * node scripts/qa-overlay-baseline.mjs --engine=navara * * Engine-neutral: the camera and frames go through the globe engine and the - * layers through the QA probe (window.__godsEyeView.layers). A scene whose - * layers the engine does not have is SKIPPED; without a Cesium viewer the - * entity inventory reports the engine's mover sets instead. + * layers through the QA probe (window.__godsEyeView.layers, dev server only). + * A scene whose layers the engine does not have is SKIPPED. The inventory + * counts the globe contract's sets (movers, lines, ground, imagery) on both + * engines; on Cesium it also counts what is left of Entities, LabelGraphics + * and primitives (none of them a layer's own any more). */ import fs from 'node:fs'; @@ -233,14 +235,21 @@ function numericDelta(after, before, key) { function inventoryDelta(before, after) { return { - entities: numericDelta(after, before, 'entities'), - labelGraphics: numericDelta(after, before, 'labelGraphics'), - nonemptyLabelText: numericDelta(after, before, 'nonemptyLabelText'), - emptyLabelText: numericDelta(after, before, 'emptyLabelText'), - shownLabelGraphics: numericDelta(after, before, 'shownLabelGraphics'), - primitiveLabels: numericDelta(after, before, 'primitiveLabels'), - primitiveBillboards: numericDelta(after, before, 'primitiveBillboards'), - primitivePoints: numericDelta(after, before, 'primitivePoints'), + moverInstances: numericDelta(after, before, 'moverInstances'), + lines: numericDelta(after, before, 'lines'), + groundItems: numericDelta(after, before, 'groundItems'), + imageryLayers: numericDelta(after, before, 'imageryLayers'), + // Cesium only: what is left of Entities and primitives. + ...(after?.totals?.entities === undefined ? {} : { + entities: numericDelta(after, before, 'entities'), + labelGraphics: numericDelta(after, before, 'labelGraphics'), + nonemptyLabelText: numericDelta(after, before, 'nonemptyLabelText'), + emptyLabelText: numericDelta(after, before, 'emptyLabelText'), + shownLabelGraphics: numericDelta(after, before, 'shownLabelGraphics'), + primitiveLabels: numericDelta(after, before, 'primitiveLabels'), + primitiveBillboards: numericDelta(after, before, 'primitiveBillboards'), + primitivePoints: numericDelta(after, before, 'primitivePoints'), + }), }; } @@ -265,14 +274,13 @@ function printScene(result) { if (result.reason) console.log(` reason : ${result.reason}`); for (const activation of result.layerActivations || []) { const delta = activation.delta; - console.log(` toggle ${activation.layerId.padEnd(31)} entities ${String(delta.entities).padStart(6)} · LabelGraphics ${String(delta.labelGraphics).padStart(6)} · primitive labels ${String(delta.primitiveLabels).padStart(6)}`); + console.log(` toggle ${activation.layerId.padEnd(31)} movers ${String(delta.moverInstances).padStart(6)} · lines ${String(delta.lines).padStart(5)} · ground ${String(delta.groundItems).padStart(6)} · imagery ${String(delta.imageryLayers).padStart(2)}${delta.entities === undefined ? '' : ` · entities ${String(delta.entities).padStart(6)} · LabelGraphics ${String(delta.labelGraphics).padStart(6)}`}`); } if (result.entityInventory) { const totals = result.entityInventory.totals; - if (totals.entities === undefined) - console.log(` scene total : mover sets ${totals.moverSets} · instances ${totals.moverInstances}`); - else - console.log(` scene total : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`); + console.log(` scene total : mover sets ${totals.moverSets} · instances ${totals.moverInstances} · lines ${totals.lines} · ground sets ${totals.groundSets} (${totals.groundItems} items) · imagery ${totals.imageryLayers}`); + if (totals.entities !== undefined) + console.log(` Cesium : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`); } for (const phaseName of ['motion', 'rest']) { const phase = result.samples?.[phaseName]; @@ -416,22 +424,38 @@ async function waitForLayer(page, layerId) { async function readEntityInventory(page) { return page.evaluate(() => { - const { viewer, layers } = window.__godsEyeView; - // Without a Cesium viewer (Navara): the engine's mover sets. + const { viewer, engine } = window.__godsEyeView; + // What the layers draw, on either engine: the globe contract's sets + // (layers own no Cesium Entities, DataSources or primitives any more). + const movers = engine.movers.list(); + const lines = engine.lines.list(); + const ground = engine.ground.list(); + const imagery = engine.imagery.list(); + const contract = { + moverSets: movers.length, + moverInstances: movers.reduce((sum, set) => sum + set.count, 0), + lines: lines.filter((line) => line.shown).length, + groundSets: ground.length, + groundItems: ground.reduce((sum, set) => sum + (set.shown ? set.count : 0), 0), + imageryLayers: imagery.filter((layer) => layer.shown).length, + }; + const contractCollections = [ + ...movers.map((set) => ({ scope: 'movers', ...set })), + ...ground.map((set) => ({ scope: 'ground', ...set })), + ...imagery.map((layer) => ({ scope: 'imagery', ...layer })), + ]; + // Without a Cesium viewer (Navara): the contract sets only. if (!viewer) { - const sets = layers.movers(); return { - totals: { - moverSets: sets.length, - moverInstances: sets.reduce((sum, set) => sum + set.count, 0), - }, - collections: sets, + totals: contract, + collections: contractCollections, primitiveTypes: {}, }; } const time = viewer.clock.currentTime; - const collections = []; + const collections = [...contractCollections]; const totals = { + ...contract, entities: 0, labelGraphics: 0, shownLabelGraphics: 0, diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs index 8f6594011..b7181c0f5 100644 --- a/scripts/qa-overpass-offload.mjs +++ b/scripts/qa-overpass-offload.mjs @@ -295,9 +295,19 @@ function footprintSummary() { if (summary.tourTable.length) console.table(summary.tourTable); return summary; } +// The Mapped Installations sites drawn now, as their context handles (the +// layer draws through the globe contract and owns no Cesium entities; its +// display keeps one handle per drawn record in the context store). +function installSiteProbe() { + window.__qaInstallationSites = () => + [...(window.__gevContextStore?.entities.values() ?? [])] + .filter((r) => r.layerId === 'military-installations' && r.entity?.record) + .map((r) => r.entity); +} let page; try { page = await browser.newPage(); + await page.evaluateOnNewDocument(installSiteProbe); await page.evaluateOnNewDocument(() => { sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'); }); @@ -513,12 +523,23 @@ try { const C = await import('/node_modules/cesium/Build/Cesium/index.js'); const { viewer, dataManager } = window.__godsEyeView; const { scene } = viewer; + // The traffic dots are point mover sets on the globe contract; on + // Cesium each set is a PointPrimitiveCollection under the engine's + // mover collection, tagged with its set id (gevMoverSet). const collections = []; - for (let i = 0; i < scene.primitives.length; i++) { - const p = scene.primitives.get(i); - if (Array.isArray(p._pointPrimitives) && p.show && p.length > 0) - collections.push(p); - } + const walk = (collection, depth) => { + for (let i = 0; i < (collection?.length || 0); i++) { + const p = collection.get(i); + if ( + Array.isArray(p?._pointPrimitives) && + String(p.gevMoverSet ?? '').startsWith('traffic-') + ) { + if (p.show && p.length > 0) collections.push(p); + } else if (depth < 3 && typeof p?.get === 'function' && p.show) + walk(p, depth + 1); + } + }; + walk(scene.primitives, 0); let inView = 0, onMesh = 0, sampled = 0; @@ -1025,16 +1046,14 @@ try { const { viewer, dataManager } = window.__godsEyeView; const traffic = dataManager.layers.get('traffic').module.getStats(); const alpr = dataManager.layers.get('alpr-cameras').module.getStats(); - let cameraEntities = 0; - for (let i = 0; i < viewer.dataSources.length; i++) - for (const entity of viewer.dataSources.get(i).entities.values) { - if ( - String(entity.id).startsWith('alpr:') && - entity.billboard && - entity.show - ) - cameraEntities++; - } + // The camera badges: the layer's sprite mover sets (normal and + // selected) on the globe contract. + const cameraEntities = window.__godsEyeView.engine.movers + .list() + .filter( + (set) => set.id === 'alpr-cameras' || set.id === 'alpr-selected', + ) + .reduce((sum, set) => sum + set.count, 0); return { traffic, alpr, @@ -1178,6 +1197,7 @@ try { beginView('keyless-startup'); page = await browser.newPage(); await page.setViewport({ width: 1440, height: 1000 }); + await page.evaluateOnNewDocument(installSiteProbe); await page.evaluateOnNewDocument(() => { sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'); const original = window.fetch; @@ -1273,15 +1293,27 @@ try { .get('military-installations') .module.getNearby(center, 4000); const ids = new Set(records.map((r) => r.id)); + // The layer draws through the globe contract: each drawn site has a + // context handle (its disc, unless a named area), named areas a named + // marker, and the outlines are one engine.ground corridor set. + const sites = window.__qaInstallationSites(); let markers = 0, 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; - if (entity.billboard || entity.point) markers++; - if (entity.polyline) outlines++; - } + for (const site of sites) { + if (!ids.has(site.id)) continue; + if (!site.record.namedArea) markers++; + outlines += + site.record.outlineLines?.length ?? (site.record.footprint ? 1 : 0); + } + dataManager.layers + .get('military-installations') + .module.visitNamedMarkers((id) => { + if (ids.has(id)) markers++; + }); + const outlineSets = window.__godsEyeView.engine.ground + .list() + .filter((set) => set.id.startsWith('military-installations-outlines-')); + if (!outlineSets.some((set) => set.shown && set.count)) outlines = 0; return { markers, outlines, @@ -1336,23 +1368,17 @@ try { ); result.fortCavazos = await page.evaluate(() => { const { viewer, dataManager } = window.__godsEyeView; + // Drawn site discs (context handles, see Camp Mabry) plus the named + // markers the stats count. let markers = 0, outlines = 0; - for (let i = 0; i < viewer.dataSources.length; i++) - for (const e of viewer.dataSources.get(i).entities.values) { - if ( - !e.show || - !/^(ofm:installation:|osm:military:)/.test(String(e.id)) - ) - continue; - if ( - (e.point && - e.point.show?.getValue(viewer.clock.currentTime) !== false) || - e.billboard - ) - markers++; - if (e.polyline) outlines++; - } + for (const site of window.__qaInstallationSites()) { + if (!/^(ofm:installation:|osm:military:)/.test(String(site.id))) + continue; + if (!site.record.namedArea) markers++; + outlines += + site.record.outlineLines?.length ?? (site.record.footprint ? 1 : 0); + } const stats = dataManager.layers .get('military-installations') .module.getStats(); diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 4f30cd2b5..302961712 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -160,6 +160,103 @@ async function main() { return Promise.resolve(); }; }); + // What Radio draws, read through the globe contract (the layer draws + // engine.movers point and sprite sets, src/layers/radio/render/display.js; + // there are no Radio Entities, data sources or Cesium clusters any more): + // set counts from engine.movers.list(), and what is drawn at a point + // from engine.picker.drillPick (each instance's pickId). + await page.evaluateOnNewDocument((fixture) => { + const SETS = ['radio-stations', 'radio-clusters', 'radio-selected', 'radio-bracket']; + const byId = new Map(fixture.map(([id, lat, lon]) => [id, { lat, lon }])); + const engine = () => window.__godsEyeView.engine; + // WGS84 geodetic → Earth-fixed metres. + const world = (lat, lon, height = 0) => { + const a = 6378137; + const e2 = 6.69437999014e-3; + const phi = (lat * Math.PI) / 180; + const lambda = (lon * Math.PI) / 180; + const n = a / Math.sqrt(1 - e2 * Math.sin(phi) ** 2); + return { + x: (n + height) * Math.cos(phi) * Math.cos(lambda), + y: (n + height) * Math.cos(phi) * Math.sin(lambda), + z: (n * (1 - e2) + height) * Math.sin(phi), + }; + }; + const api = { + world, + stationPosition(id, height = 2.5) { + const station = byId.get(String(id).replace(/^radio:(selected:)?/, '')); + return station ? world(station.lat, station.lon, height) : null; + }, + // Instance counts of the Radio mover sets ({} while none exist). + counts() { + const counts = {}; + for (const set of engine().movers.list()) + if (SETS.includes(set.id)) counts[set.id] = set.count; + return counts; + }, + anyDrawn() { + return Object.values(api.counts()).some((count) => count > 0); + }, + // A world position's canvas point, or null off the canvas. + canvasPoint(position) { + const e = engine(); + const screen = position && e.projector.worldToWindow(position); + if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) return null; + const rect = e.host.canvas.getBoundingClientRect(); + const point = { x: screen.x, y: screen.y }; + if (point.x < 0 || point.y < 0 || point.x > rect.width || point.y > rect.height) return null; + return point; + }, + // The picks at a canvas point: { id, owner } of what the engine drew there. + picksAt(point, limit = 16) { + if (!point) return []; + return (engine().picker.drillPick(point, limit) || []).map((picked) => ({ + id: picked?.id == null ? null : String(picked.id), + owner: picked?.owner ?? null, + })); + }, + // The pick id written to the selected marker, while its set is live. + selectedMarkerId() { + if (api.counts()['radio-selected'] !== 1) return null; + const item = window.__qaRadioInstances?.get('radio-selected')?.[0]; + return item?.pickId ?? null; + }, + // The station ids a pick finds at each fixture station's canvas + // point: { front, back } split by whether the station is in front of + // the horizon. A station culled behind the globe must not be picked. + drawnStations(limit = 4) { + const e = engine(); + const front = new Set(); + const back = new Set(); + for (const [id] of byId) { + const position = api.stationPosition(id); + const point = api.canvasPoint(position); + if (!point) continue; + const ids = new Set(api.picksAt(point, limit).map((pick) => pick.id)); + if (!ids.has(`radio:${id}`)) continue; + (e.projector.horizonVisible(position) ? front : back).add(id); + } + return { front: [...front], back: [...back] }; + }, + // Renders frames until the engine has drawn `count` of them. + frames(count = 2) { + const e = engine(); + return new Promise((resolve) => { + let left = count; + const timeout = setTimeout(() => { remove(); resolve(); }, 3_000); + const remove = e.frames.postRender.addEventListener(() => { + if (--left > 0) return void e.render.request('qa-radio'); + remove(); + clearTimeout(timeout); + resolve(); + }); + e.render.request('qa-radio'); + }); + }, + }; + window.__qaRadioDrawn = api; + }, stations.map(({ id, lat, lon }) => [id, lat, lon])); await page.setRequestInterception(true); let catalogDegraded = false; let catalogStations = stations; @@ -290,6 +387,23 @@ async function main() { window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true); }); + await page.evaluate(() => { + const movers = window.__godsEyeView.engine.movers; + const create = movers.createSet.bind(movers); + window.__qaRadioInstances = new Map(); + movers.createSet = (options) => { + const set = create(options); + if (!options.id.startsWith('radio-')) return set; + const items = []; + window.__qaRadioInstances.set(options.id, items); + const write = set.set.bind(set); + set.set = (index, fields) => { + write(index, fields); + items[index] = { ...items[index], ...fields }; + }; + return set; + }; + }); const enablePanels = await revealRadioControls(page, 'radio-enable-btn'); const enableStartedAt = Date.now(); await page.click('#radio-enable-btn'); @@ -399,7 +513,7 @@ async function main() { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const result = { presentationActive: module.getUIState().presentationActive, - markerSourceVisible: gev.viewer.dataSources.getByName('Radio stations')[0]?.show, + markerSourceVisible: window.__qaRadioDrawn.anyDrawn(), tunerHidden: document.getElementById('radio-tuner').hidden, filterDisabled: document.getElementById('radio-filter').disabled, prevDisabled: document.getElementById('radio-prev-btn').disabled, @@ -493,7 +607,7 @@ async function main() { const disablingPresentation = await page.evaluate(() => ({ manager: window.__godsEyeView.dataManager.getLayerLifecycleState('radio'), presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive, - markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show, + markerSourceVisible: window.__qaRadioDrawn.anyDrawn(), full: document.getElementById('radio-enable-btn').textContent, layerState: document.getElementById('radio-layer-state').textContent, message: document.getElementById('radio-playback-state').textContent, @@ -562,7 +676,7 @@ async function main() { const enablingPresentation = await page.evaluate(() => ({ manager: window.__godsEyeView.dataManager.getLayerLifecycleState('radio'), presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive, - markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show, + markerSourceVisible: window.__qaRadioDrawn.anyDrawn(), full: document.getElementById('radio-enable-btn').textContent, layerState: document.getElementById('radio-layer-state').textContent, message: document.getElementById('radio-playback-state').textContent, @@ -666,10 +780,8 @@ async function main() { const sample = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations'); + const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; - const { radioStationIdFromPick } = await import('/src/data/radio.js'); return new Promise((resolve) => { let removePostRender = null; const finish = () => { @@ -678,18 +790,19 @@ async function main() { const paintedSingletonIds = overlay.singletonIds.filter((entryId) => Boolean(getOverlayPaintRect('radio', entryId))); const pickableSingletonIds = paintedSingletonIds.filter((entryId) => { const rect = getOverlayPaintRect('radio', entryId); - const anchor = rect?.entry?.position - && viewer.scene.cartesianToCanvasCoordinates(rect.entry.position); + const anchor = drawn.canvasPoint(rect?.entry?.position); if (!anchor) return false; - return (viewer.scene.drillPick(anchor, 16) || []) - .some((picked) => radioStationIdFromPick(picked) === entryId.slice('station:'.length)); + return drawn.picksAt(anchor, 16) + .some((picked) => picked.id === `radio:${entryId.slice('station:'.length)}`); }); resolve({ overlay, host: getWorldOverlayDiagnostics(), paintedSingletonIds, pickableSingletonIds, - nativeLabelCount: dataSource.entities.values.filter((entity) => Boolean(entity.label)).length, + // Radio draws no text on the globe itself: no engine line or + // label belongs to it (the old check counted native entity labels). + radioLines: window.__godsEyeView.engine.lines.list().filter((line) => /^radio/.test(line.id)).length, }); }; const timeout = setTimeout(finish, 500); @@ -725,17 +838,17 @@ async function main() { }))), ); check( - 'Radio label allocation stays bounded with no native Cesium entity text across sparse and All views', + 'Radio label allocation stays bounded with no globe-drawn Radio text across sparse and All views', singletonViews.every((sample) => ( sample.overlay.entryCount <= 65 - && sample.nativeLabelCount === 0 + && sample.radioLines === 0 && sample.host.paintedBySource?.radio > 0 )), JSON.stringify(singletonViews.map((sample) => ({ category: sample.category, view: sample.name, entries: sample.overlay.entryCount, - nativeLabels: sample.nativeLabelCount, + radioLines: sample.radioLines, painted: sample.host.paintedBySource?.radio, }))), ); @@ -1234,13 +1347,10 @@ async function main() { }); await page.select('#radio-filter', 'all'); const allPalette = await page.evaluate(() => { - const source = Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => ( - window.__godsEyeView.viewer.dataSources.get(index) - )).find((item) => item.name === 'Radio stations'); const colorFor = (index) => { const id = `radio:00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`; - const color = source?.entities.getById(id)?.point?.color?.getValue?.(); - return color ? [color.red, color.green, color.blue].map((value) => Math.round(value * 255)) : null; + const station = window.__qaRadioInstances.get('radio-stations')?.find(item => item.pickId === id); + return station?.color?.slice(0, 3).map(value => Math.round(value * 255)) ?? null; }; return { news: colorFor(0), talk: colorFor(1), music: colorFor(7) }; }); @@ -1254,14 +1364,24 @@ async function main() { ); const clusterBadge = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; + const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); - const { radioStationIdFromPick } = await import('/src/data/radio.js'); - const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; - const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations'); const ellipsoid = viewer.scene.globe.ellipsoid; + // Start from another view, so the clusters at the view below are a new + // cohort the host must solve (the old check forced one by switching + // Cesium's clustering off and on). viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: ellipsoid.cartographicToCartesian({ + longitude: 68 * Math.PI / 180, + latitude: 7 * Math.PI / 180, + height: 24_200_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + await drawn.frames(3); + const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; viewer.camera.setView({ destination: ellipsoid.cartographicToCartesian({ longitude: -98 * Math.PI / 180, @@ -1270,73 +1390,59 @@ async function main() { }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - source.clustering.enabled = false; viewer.scene.requestRender(); - await new Promise((resolve) => setTimeout(resolve, 100)); - return new Promise((resolve) => { - const originalPixelRange = source.clustering.pixelRange; - const timeout = setTimeout(() => { - remove(); - resolve(null); - }, 2_000); - const remove = source.clustering.clusterEvent.addEventListener(async (entities, cluster) => { - clearTimeout(timeout); - remove(); - const native = { - count: entities.length, - labelShow: cluster.label.show, - labelText: cluster.label.text, - pointSize: cluster.point.pixelSize, - pointMaxDistance: cluster.point.distanceDisplayCondition?.far ?? null, - }; - source.clustering.pixelRange = originalPixelRange; - const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - await new Promise((done) => setTimeout(done, 0)); - let painted = null; - for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) { - painted = await new Promise((done) => { - let removePostRender = null; - const timeout = setTimeout(() => { - removePostRender?.(); - done({ rect: null, host: getWorldOverlayDiagnostics() }); - }, 150); - removePostRender = viewer.scene.postRender.addEventListener(() => { - clearTimeout(timeout); - removePostRender(); - const host = getWorldOverlayDiagnostics(); - let match = null; - for (const entryId of radio.getOverlayDiagnostics().clusterIds) { - const rect = getOverlayPaintRect('radio', entryId); - if (rect) match = { entryId, rect }; - } - done({ - expectedEntryId: match?.entryId || null, - rect: match ? { x: match.rect.x, y: match.rect.y, w: match.rect.w, h: match.rect.h } : null, - host, - }); - }); - viewer.scene.requestRender(); + const radio = window.__godsEyeView.dataManager.layers.get('radio').module; + // The layer clusters on screen itself now (render/display.js, + // ../screenClusters.js) into the radio-clusters point set; the cluster + // text is a shared-host overlay entry. There is no Cesium cluster + // label or point to read: a painted cluster entry's anchor must pick + // a radio-clusters instance instead. + let painted = null; + for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) { + painted = await new Promise((done) => { + let removePostRender = null; + const timeout = setTimeout(() => { + removePostRender?.(); + done({ rect: null, host: getWorldOverlayDiagnostics() }); + }, 150); + removePostRender = viewer.scene.postRender.addEventListener(() => { + clearTimeout(timeout); + removePostRender(); + const host = getWorldOverlayDiagnostics(); + let match = null; + for (const entryId of radio.getOverlayDiagnostics().clusterIds) { + const rect = getOverlayPaintRect('radio', entryId); + if (rect) match = { entryId, rect }; + } + done({ + expectedEntryId: match?.entryId || null, + rect: match ? { x: match.rect.x, y: match.rect.y, w: match.rect.w, h: match.rect.h } : null, + position: match?.rect?.entry?.position || null, + host, }); - } - resolve({ - native, - baselineSolveRevision, - expectedEntryId: painted?.expectedEntryId || null, - rect: painted?.rect || null, - source: radio.getOverlayDiagnostics(), - host: painted?.host || getWorldOverlayDiagnostics(), }); + viewer.scene.requestRender(); }); - source.clustering.enabled = true; - viewer.scene.requestRender(); - }); + } + const position = typeof painted?.position === 'function' ? painted.position() : painted?.position; + const anchor = drawn.canvasPoint(position); + const clusterPick = drawn.picksAt(anchor, 16).find((picked) => picked.owner === 'radio-clusters') || null; + return { + drawn: drawn.counts(), + clusterPick, + radioLines: window.__godsEyeView.engine.lines.list().filter((line) => /^radio/.test(line.id)).length, + baselineSolveRevision, + expectedEntryId: painted?.expectedEntryId || null, + rect: painted?.rect || null, + source: radio.getOverlayDiagnostics(), + host: painted?.host || getWorldOverlayDiagnostics(), + }; }); check( 'Radio cluster text is native-free and painted through the bounded shared host', - clusterBadge?.native?.labelShow === false - && clusterBadge?.native?.labelText === '' - && clusterBadge?.native?.pointSize >= 14 - && clusterBadge?.native?.pointMaxDistance === 50_000_000 + clusterBadge?.drawn?.['radio-clusters'] > 0 + && clusterBadge?.radioLines === 0 + && /^radio:/.test(clusterBadge?.clusterPick?.id || '') && clusterBadge?.source?.clusterTexts?.some((text) => /^\d+\s+[A-Z]/.test(text)) && clusterBadge?.source?.entryCount <= 65 && clusterBadge?.host?.entriesBySource?.radio === clusterBadge?.source?.entryCount @@ -1374,32 +1480,24 @@ async function main() { 0, cameraRadius * cameraRadius - ellipsoid.maximumRadius * ellipsoid.maximumRadius, )); - const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection; + // The cluster points are the radio-clusters mover set; pick at each + // painted cluster entry's anchor (nearest the centre first) and accept + // only a radio-clusters instance. + const drawn = window.__qaRadioDrawn; + const { getPaintRect: getOverlayPaintRect } = window.__gevWorldOverlay; let clusterPoint = null; - // Pool order is not visibility order after a camera change. Choose an - // on-screen point independently of the native pick result being tested. - const visiblePoints = []; - for (let index = 0; index < (points?.length || 0); index += 1) { - const point = points.get(index); - if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue; - const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position); - if (!anchor) continue; - if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue; - visiblePoints.push({ point, anchor }); - } - visiblePoints.sort((a, b) => ( - Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2) - - Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2) - )); - if (visiblePoints.length) { - const { point, anchor } = visiblePoints[0]; - const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => ( - picked?.primitive === point && picked?.id === point.id - )); + const width = viewer.canvas.clientWidth; + const height = viewer.canvas.clientHeight; + const anchors = source.clusterIds + .map((entryId) => getOverlayPaintRect('radio', entryId)?.entry?.position) + .map((position) => drawn.canvasPoint(typeof position === 'function' ? position() : position)) + .filter(Boolean) + .sort((a, b) => Math.hypot(a.x - width / 2, a.y - height / 2) - Math.hypot(b.x - width / 2, b.y - height / 2)); + if (anchors.length) { + const pick = drawn.picksAt(anchors[0], 16).find((picked) => picked.owner === 'radio-clusters'); clusterPoint = { - maxDistance: point.distanceDisplayCondition?.far ?? null, - pickable: Boolean(exactPick), + drawn: drawn.counts()['radio-clusters'] ?? 0, + pickable: Boolean(pick), }; } return { @@ -1418,7 +1516,7 @@ async function main() { && highGlobalClusterLabels.host.projectedCount > 0 && highGlobalClusterLabels.host.paintedBySource?.radio > 0 && highGlobalClusterLabels.horizonDistanceAlpha === 1 - && highGlobalClusterLabels.clusterPoint?.maxDistance === 50_000_000 + && highGlobalClusterLabels.clusterPoint?.drawn > 0 && highGlobalClusterLabels.clusterPoint?.pickable, JSON.stringify(highGlobalClusterLabels), ); @@ -2411,30 +2509,29 @@ async function main() { }); }); await sleep(700); + await settleRadioFrames(page); const newsClusterTarget = await page.evaluate(() => { - const gev = window.__godsEyeView; - const source = Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations'); - const points = source.clustering._clusterPointCollection; - for (let index = 0; index < (points?.length || 0); index += 1) { - const point = points.get(index); - if (!point.show || !Array.isArray(point.id) || !point.id.length) continue; - const canvasPoint = gev.viewer.scene.cartesianToCanvasCoordinates(point.position); + // A drawn cluster: a painted cluster entry whose anchor picks a + // radio-clusters instance. Its pickId names the cluster's first + // station, which a click on it plays. + const radio = window.__godsEyeView.dataManager.layers.get('radio').module; + const drawn = window.__qaRadioDrawn; + const { getPaintRect: getOverlayPaintRect } = window.__gevWorldOverlay; + const { clusterIds } = radio.getOverlayDiagnostics(); + for (const entryId of clusterIds) { + const position = getOverlayPaintRect('radio', entryId)?.entry?.position; + const canvasPoint = drawn.canvasPoint(typeof position === 'function' ? position() : position); if (!canvasPoint) continue; + const pick = drawn.picksAt(canvasPoint, 16).find((picked) => picked.owner === 'radio-clusters'); + if (!pick) continue; return { x: canvasPoint.x, y: canvasPoint.y, - firstId: String(point.id[0]?.id || '').replace(/^radio:/, ''), - pointIds: point.id.map((entity) => entity.id), + firstId: pick.id.replace(/^radio:/, ''), + pointIds: [pick.id], }; } - return { - missing: true, - points: Array.from({ length: points?.length || 0 }, (_, index) => ({ - show: points.get(index).show, - pointIds: Array.isArray(points.get(index)?.id) ? points.get(index).id.length : 0, - })), - }; + return { missing: true, clusterIds, drawn: drawn.counts() }; }); if (!newsClusterTarget.missing) { await page.mouse.click(newsClusterTarget.x, newsClusterTarget.y); @@ -2635,30 +2732,40 @@ async function main() { const horizon = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const ellipsoid = viewer.scene.globe.ellipsoid; - const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations'); - const visibleIds = () => source.entities.values.filter((entity) => entity.show).map((entity) => entity.id); + const drawn = window.__qaRadioDrawn; + // What is drawn, through the engine's picks at every fixture station's + // canvas point: a station behind the globe projects onto the canvas + // too (the point sets draw without a depth test), so a pick of one + // there means it was not culled. const setView = (lon, lat = 0) => { viewer.camera.cancelFlight(); viewer.camera.setView({ destination: ellipsoid.cartographicToCartesian({ longitude: lon * Math.PI / 180, latitude: lat * Math.PI / 180, height: 2_000_000 }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); + viewer.scene.requestRender(); }; setView(-54); await new Promise((resolve) => setTimeout(resolve, 500)); - const front = visibleIds(); + await drawn.frames(3); + const front = drawn.drawnStations(); setView(180, -2); await new Promise((resolve) => setTimeout(resolve, 500)); - const back = visibleIds(); - const frontSet = new Set(front); + await drawn.frames(3); + const back = drawn.drawnStations(); + const frontSet = new Set(front.front); return { - front: front.length, - back: back.length, - changed: back.some((id) => !frontSet.has(id)), + front: front.front.length, + back: back.front.length, + farSidePicked: front.back.length + back.back.length, + changed: back.front.some((id) => !frontSet.has(id)), }; }); - check('far-side markers are horizon culled', horizon.front > 0 && horizon.front < 750 && horizon.back > 0 && horizon.back < 750, `${horizon.front}/${horizon.back} visible`); + check( + 'far-side markers are horizon culled', + horizon.front > 0 && horizon.front < 750 && horizon.back > 0 && horizon.back < 750 && horizon.farSidePicked === 0, + `${horizon.front}/${horizon.back} drawn in front, ${horizon.farSidePicked} picked behind the globe`, + ); check('horizon visibility follows camera hemisphere', horizon.changed); await page.select('#radio-filter', 'all'); @@ -2683,8 +2790,7 @@ async function main() { buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1, })); const snapshot = () => { - const markerId = gev.viewer.entities.values - .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; + const markerId = window.__qaRadioDrawn.selectedMarkerId(); return { slot: Number(slider.value), markerId, @@ -2804,8 +2910,7 @@ async function main() { // The tuner renders from the Radio store; let Vue patch before reading. await Promise.resolve(); const shiftedSlot = Number(slider.value); - const previewBeforeCancel = gev.viewer.entities.values - .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; + const previewBeforeCancel = window.__qaRadioDrawn.selectedMarkerId(); const flyToCallsBeforeCancel = flyToCalls.length; pointer('pointercancel', targetIndex); await Promise.resolve(); @@ -2821,10 +2926,7 @@ async function main() { tuningActive: afterState.tuningActive, }; const flyToCallsAfterCancel = flyToCalls.length; - const selectedEntity = () => gev.viewer.entities.values - .find((entity) => String(entity.id).startsWith('radio:selected:')) || null; - const restoredEntity = selectedEntity(); - const previewId = restoredEntity?.id || null; + const previewId = window.__qaRadioDrawn.selectedMarkerId(); after.previewId = previewId; const filterControl = document.getElementById('radio-filter'); const changedFilter = afterState.filter === 'news' ? 'all' : 'news'; @@ -2834,13 +2936,15 @@ async function main() { filterControl.value = changedFilter; filterControl.dispatchEvent(new Event('change', { bubbles: true })); const changedFilterState = radio.getUIState(); - const changedFilterEntity = selectedEntity(); + // The marker is an instance of the radio-selected set: whether the + // layer rebuilt it is not observable through the contract (the old + // check compared Entity objects), only which station it is drawn on. + const changedFilterEntity = window.__qaRadioDrawn.selectedMarkerId(); const changedFilterCleanup = { filter: changedFilterState.filter, restoredStationId: changedFilterState.tuningRestoredStationId, selectedId: changedFilterState.selected?.id || null, - entityRebuilt: Boolean(changedFilterEntity && changedFilterEntity !== restoredEntity), - entityId: changedFilterEntity?.id || null, + entityId: changedFilterEntity, snapshotPreserved: radio.getAcceptedCatalogSnapshot() === changedFilterSnapshot, playDelta: window.__qaRadioPlayCalls.length - changedFilterPlays, cameraDelta: flyToCalls.length - changedFilterCameraMoves, @@ -2848,14 +2952,13 @@ async function main() { const sameFilterBegan = radio.beginTuning(); const sameFilterCancelled = radio.cancelTuning(); const sameFilterRestoredState = radio.getUIState(); - const sameFilterRestoredEntity = selectedEntity(); const sameFilterSnapshot = radio.getAcceptedCatalogSnapshot(); const sameFilterPlays = window.__qaRadioPlayCalls.length; const sameFilterCameraMoves = flyToCalls.length; filterControl.value = sameFilterRestoredState.filter; filterControl.dispatchEvent(new Event('change', { bubbles: true })); const sameFilterState = radio.getUIState(); - const sameFilterEntity = selectedEntity(); + const sameFilterEntity = window.__qaRadioDrawn.selectedMarkerId(); const sameFilterCleanup = { began: sameFilterBegan, cancelled: sameFilterCancelled, @@ -2863,8 +2966,7 @@ async function main() { restoredAfter: sameFilterState.tuningRestoredStationId, filter: sameFilterState.filter, selectedId: sameFilterState.selected?.id || null, - entityRebuilt: Boolean(sameFilterEntity && sameFilterEntity !== sameFilterRestoredEntity), - entityId: sameFilterEntity?.id || null, + entityId: sameFilterEntity, snapshotPreserved: radio.getAcceptedCatalogSnapshot() === sameFilterSnapshot, playDelta: window.__qaRadioPlayCalls.length - sameFilterPlays, cameraDelta: flyToCalls.length - sameFilterCameraMoves, @@ -2904,11 +3006,10 @@ async function main() { JSON.stringify(tunerCancelRestore), ); check( - 'accepted changed-filter action clears and rebuilds the cancel-restored marker without side effects', + 'accepted changed-filter action clears the cancel-restored marker onto the selection without side effects', tunerCancelRestore.changedFilterCleanup.filter === tunerCancelRestore.changedFilter && tunerCancelRestore.changedFilterCleanup.restoredStationId === null && tunerCancelRestore.changedFilterCleanup.selectedId === tunerCancelRestore.before.selectedId - && tunerCancelRestore.changedFilterCleanup.entityRebuilt && tunerCancelRestore.changedFilterCleanup.entityId === `radio:selected:${tunerCancelRestore.before.selectedId}` && tunerCancelRestore.changedFilterCleanup.snapshotPreserved && tunerCancelRestore.changedFilterCleanup.playDelta === 0 @@ -2916,14 +3017,13 @@ async function main() { JSON.stringify(tunerCancelRestore.changedFilterCleanup), ); check( - 'accepted same-filter action clears and rebuilds the cancel-restored marker without side effects', + 'accepted same-filter action clears the cancel-restored marker onto the selection without side effects', tunerCancelRestore.sameFilterCleanup.began && tunerCancelRestore.sameFilterCleanup.cancelled && tunerCancelRestore.sameFilterCleanup.restoredBefore === tunerCancelRestore.before.selectedId && tunerCancelRestore.sameFilterCleanup.restoredAfter === null && tunerCancelRestore.sameFilterCleanup.filter === tunerCancelRestore.changedFilter && tunerCancelRestore.sameFilterCleanup.selectedId === tunerCancelRestore.before.selectedId - && tunerCancelRestore.sameFilterCleanup.entityRebuilt && tunerCancelRestore.sameFilterCleanup.entityId === `radio:selected:${tunerCancelRestore.before.selectedId}` && tunerCancelRestore.sameFilterCleanup.snapshotPreserved && tunerCancelRestore.sameFilterCleanup.playDelta === 0 @@ -3308,8 +3408,7 @@ async function main() { cancelable: true, key: value, })); - const markerId = () => gev.viewer.entities.values - .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; + const markerId = () => window.__qaRadioDrawn.selectedMarkerId(); const startIndex = Number(slider.value); const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1); const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null; @@ -3409,8 +3508,7 @@ async function main() { centerIndex, slot: Number(slider.value), ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), - markerId: gev.viewer.entities.values - .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null, + markerId: window.__qaRadioDrawn.selectedMarkerId(), expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null, }; slider.dispatchEvent(new PointerEvent('pointercancel', { @@ -3535,9 +3633,10 @@ async function main() { const markerTarget = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations'); - source.clustering.enabled = false; + const drawn = window.__qaRadioDrawn; + // Radio clusters on screen in its own display now (no Cesium + // clustering to switch off); at this 500 km view the News stations + // are hundreds of pixels apart, so none cluster. const selectedId = radio.getUIState().selected?.id || null; if (selectedId) { radio.selectStation(selectedId, { @@ -3564,9 +3663,6 @@ async function main() { }); viewer.scene.requestRender(); }); - // Cesium clears its private cluster collections during the rendered - // disabled-state update. Publish the same-filter singleton overlay only - // after that frame so this harness mutation cannot leak stale clusters. radio.setFilter('news'); viewer.scene.requestRender(); await new Promise((resolve) => setTimeout(resolve, 500)); @@ -3592,27 +3688,26 @@ async function main() { } const diagnostics = radio.getOverlayDiagnostics(); const singletonIds = new Set(diagnostics.singletonIds); - let visibleEntityCount = 0; - let singletonEntityCount = 0; + // A singleton label painted on the shared host whose station point + // a pick at the label's anchor finds (the station's own pickId). + let pickableSingletonCount = 0; let paintedSingletonCount = 0; - for (const entity of source.entities.values) { - if (!entity.show) continue; - visibleEntityCount += 1; - const id = String(entity.id).slice('radio:'.length); - const overlayId = `station:${id}`; - if (!singletonIds.has(overlayId)) continue; - singletonEntityCount += 1; - const position = entity.position?.getValue(viewer.clock.currentTime); - const canvas = position && viewer.scene.cartesianToCanvasCoordinates(position); + for (const overlayId of singletonIds) { + const id = overlayId.slice('station:'.length); const paintRect = getOverlayPaintRect('radio', overlayId); - if (paintRect) paintedSingletonCount += 1; - if (paintRect && canvas?.x > 300 && canvas.x < 1100 && canvas.y > 100 && canvas.y < 800) { + if (!paintRect) continue; + paintedSingletonCount += 1; + const canvas = drawn.canvasPoint(paintRect.entry?.position); + const pickable = drawn.picksAt(canvas, 16) + .some((picked) => picked.owner === 'radio-stations' && picked.id === `radio:${id}`); + if (pickable) pickableSingletonCount += 1; + if (pickable && canvas.x > 300 && canvas.x < 1100 && canvas.y > 100 && canvas.y < 800) { finish({ found: true, id, x: canvas.x, y: canvas.y, - ownsNativeLabel: Boolean(entity.label), + pickable, labelPainted: true, singletonTexts: diagnostics.singletonTexts, }); @@ -3622,8 +3717,8 @@ async function main() { lastObservation = { found: false, filter: radio.getUIState().filter, - visibleEntityCount, - singletonEntityCount, + drawn: drawn.counts(), + pickableSingletonCount, paintedSingletonCount, singletonIds: diagnostics.singletonIds, singletonTexts: diagnostics.singletonTexts, @@ -3645,8 +3740,8 @@ async function main() { }); check('Radio exposes a visible marker for click-tolerance QA', markerTarget?.found === true, JSON.stringify(markerTarget)); check( - 'the directly pickable singleton point has painted shared-host text and no native label', - markerTarget?.labelPainted && !markerTarget?.ownsNativeLabel, + 'the directly pickable singleton point has painted shared-host text', + markerTarget?.labelPainted && markerTarget?.pickable, JSON.stringify(markerTarget), ); check( @@ -3681,8 +3776,6 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); radio.stopPlayback(); - Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations').clustering.enabled = true; // Next plays only while a stream is active, so start from a playing station. radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false }); }); @@ -3738,8 +3831,6 @@ async function main() { const playing = await page.evaluate(async () => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const viewer = window.__godsEyeView.viewer; - const selectedEntity = window.__godsEyeView.viewer.entities.values - .find((entity) => String(entity.id).startsWith('radio:selected:')); const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => { const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination); @@ -3756,11 +3847,11 @@ async function main() { flightTargets, cameraHeight: window.__qaRadioCameraHeight, playLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'), - regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index)) - .find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(), - selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(), - selectedBracketWidth: selectedEntity?.billboard?.width?.getValue(), - selectedOwnsNativeLabel: Boolean(selectedEntity?.label), + // The point and bracket sizes are the display's set options + // (render/style.js), which the movers do not list; what is drawn is + // one selected point and one bracket sprite on the selection. + drawn: window.__qaRadioDrawn.counts(), + selectedMarkerId: window.__qaRadioDrawn.selectedMarkerId(), overlay: radio.getOverlayDiagnostics(), host: getWorldOverlayDiagnostics(), expectedPrimary: window.__qaRadioExpectedPrimary, @@ -3889,17 +3980,15 @@ async function main() { }); }); check( - 'individual Radio dots stay at least half the largest cluster point', - playing.regularMarkerSize === 13 - && playing.regularMarkerSize * 2 >= 26 - && playing.selectedMarkerSize === 14, - `${playing.regularMarkerSize}px / ${playing.selectedMarkerSize}px selected`, + 'the current station carries a four-corner selection bracket', + playing.drawn['radio-selected'] === 1 + && playing.drawn['radio-bracket'] === 1 + && playing.selectedMarkerId === `radio:selected:${playing.state.selected?.id}`, + JSON.stringify({ drawn: playing.drawn, marker: playing.selectedMarkerId }), ); - check('the current station carries a 40px four-corner selection bracket', playing.selectedBracketWidth === 40, `${playing.selectedBracketWidth}px`); check( - 'selected-station text is published to the protected shared-host lane with no native entity label', - !playing.selectedOwnsNativeLabel - && playing.overlay.selectedCount === 1 + 'selected-station text is published to the protected shared-host lane', + playing.overlay.selectedCount === 1 && playing.host.entriesBySource?.radio === playing.overlay.entryCount, JSON.stringify({ overlay: playing.overlay, host: playing.host.entriesBySource }), ); @@ -3921,16 +4010,21 @@ async function main() { destination, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - const selectedEntity = viewer.entities.values - .find((entity) => String(entity.id) === `radio:selected:${selected.id}`); + // The selected point is drawn when a pick at its anchor finds its + // pickId (the old check read the Entity's show). + const drawn = window.__qaRadioDrawn; + const selectedShown = () => drawn + .picksAt(drawn.canvasPoint(drawn.stationPosition(selected.id, 5)), 8) + .some((picked) => picked.owner === 'radio-selected' && picked.id === `radio:selected:${selected.id}`); const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const expectedEntryId = `selected:${selected.id}`; let painted = null; - // Radio's horizon pass reveals the selected entity on its own timer + let shown = false; + // Radio's horizon pass reveals the selected point on its own timer // after the camera jump, so a stationary view waits for both to settle. for ( let attempt = 0; - attempt < 20 && !(painted?.rect && selectedEntity?.show === true); + attempt < 20 && !(painted?.rect && shown); attempt += 1 ) { painted = await new Promise((resolve) => { @@ -3950,12 +4044,13 @@ async function main() { }); viewer.scene.requestRender(); }); + shown = selectedShown(); } return { expectedEntryId, rect: painted?.rect || null, host: painted?.host || getWorldOverlayDiagnostics(), - selectedEntityShow: selectedEntity?.show, + selectedEntityShow: shown, cameraHeight: viewer.camera.positionCartographic.height, }; }); @@ -3980,8 +4075,7 @@ async function main() { }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - const selectedEntity = viewer.entities.values - .find((entity) => String(entity.id) === `radio:selected:${selected.id}`); + const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const expectedEntryId = `selected:${selected.id}`; let rect = null; @@ -4006,50 +4100,32 @@ async function main() { viewer.scene.requestRender(); }); } - const entityPosition = selectedEntity?.position?.getValue?.(); - const entityAnchor = entityPosition - && viewer.scene.cartesianToCanvasCoordinates(entityPosition); + // The selected point and its bracket are instances of the + // radio-selected and radio-bracket mover sets, both picked as + // `radio:selected:`; the pick at the point's anchor is the check. + const pickId = `radio:selected:${selected.id}`; + const entityPosition = drawn.stationPosition(selected.id, 5); + const entityAnchor = drawn.canvasPoint(entityPosition); let selectedNativePick = null; let selectedPointPick = null; let selectedBillboardPick = null; - // Retry on the STRICT anchor: waiting only for "any pick" let a - // billboard satisfy the loop while the point anchor was still missing. + // Retry on the STRICT anchor: waiting only for "any pick" let the + // bracket satisfy the loop while the point anchor was still missing. for (let attempt = 0; entityAnchor && attempt < 8 && !selectedPointPick; attempt += 1) { - const picks = viewer.scene.drillPick(entityAnchor, 16) || []; - selectedNativePick = picks.find((picked) => picked?.id === selectedEntity); - selectedPointPick = picks.find((picked) => ( - picked?.id === selectedEntity - // Public point properties survive production minification; class names do not. - && Number.isFinite(picked?.primitive?.pixelSize) - && Number.isFinite(picked?.primitive?.outlineWidth) - )); - selectedBillboardPick = picks.find((picked) => ( - picked?.id === selectedEntity - && Number.isFinite(picked?.primitive?.width) - && Number.isFinite(picked?.primitive?.height) - )); - if (!selectedPointPick) { - await new Promise((resolve) => { - const removePostRender = viewer.scene.postRender.addEventListener(() => { - removePostRender(); - resolve(); - }); - viewer.scene.requestRender(); - }); - } + const picks = drawn.picksAt(entityAnchor, 16); + selectedNativePick = picks.find((picked) => picked.id === pickId); + selectedPointPick = picks.find((picked) => picked.id === pickId && picked.owner === 'radio-selected'); + selectedBillboardPick = picks.find((picked) => picked.id === pickId && picked.owner === 'radio-bracket'); + if (!selectedPointPick) await drawn.frames(1); } - const selectedPointDistance = selectedEntity?.point?.distanceDisplayCondition?.getValue?.(); + const counts = drawn.counts(); return { rect, selectedNativePickable: Boolean(selectedNativePick), selectedPointPickable: Boolean(selectedPointPick), selectedBillboardPickable: Boolean(selectedBillboardPick), - selectedPointMaxDistance: selectedPointDistance?.far ?? null, - selectedEntityShow: selectedEntity?.show, - selectedBracketVisible: Boolean( - selectedEntity?.billboard - && selectedEntity.billboard.show?.getValue?.() !== false - ), + selectedEntityShow: Boolean(selectedPointPick), + selectedBracketVisible: counts['radio-bracket'] === 1, entityAnchor: entityAnchor ? { x: entityAnchor.x, y: entityAnchor.y } : null, entityDistance: entityPosition ? Math.hypot( @@ -4070,15 +4146,14 @@ async function main() { && highGlobalSelectedText.rect?.h > 0 && highGlobalSelectedText.selectedEntityShow === true && highGlobalSelectedText.selectedBracketVisible - && highGlobalSelectedText.selectedPointMaxDistance === 50_000_000 // The POINT anchor is the invariant: it is what stays pickable out to - // the 50,000 km band. Accepting any native pick let the co-located - // billboard keep this green after the point anchor regressed. + // the 50,000 km band (the display's distance cut; the movers list no + // per-set distance, so the old 50,000 km distance-condition read is + // gone). Accepting any pick let the co-located bracket keep this + // green after the point anchor regressed. // // `selectedBillboardPickable` is reported for diagnostics but NOT - // asserted: measured here it is false — the selection bracket renders - // (selectedBracketVisible) without being pickable at the point anchor, - // so requiring it would pin a behavior the app does not have. + // asserted. && highGlobalSelectedText.selectedPointPickable && highGlobalSelectedText.selectedNativePickable, JSON.stringify(highGlobalSelectedText), diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index a0d6632c3..44faaa496 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -54,9 +54,9 @@ const getOpt = (name, dflt) => { }; const BASE_URL = getOpt('--url', 'http://localhost:4173'); -// --engine=cesium|navara: without a viewer the layer is read through -// the QA probe (window.__godsEyeView.layers.drawn) and the military checks are -// skipped where the engine has no military layer. +// --engine=cesium|navara: on both engines the layers are read through the QA +// probe (window.__godsEyeView.layers.drawn, the dev server's); the military +// checks are skipped where the engine has no military layer. const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9); const APP_URL = ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`; @@ -283,7 +283,8 @@ async function main() { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers, - { timeout: 60000, polling: 200 } + // A cold dev server compiles the Navara chunks on first load. + { timeout: 180000, polling: 200 } ); await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise); await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 }); @@ -292,53 +293,22 @@ async function main() { // ---- In-page probes: billboard walk, model walk, tracked-model finder --- await page.evaluate(() => { - // Every billboard (image + alignedAxis + id) in every collection. + // Every fleet glyph the flights and military layers draw, through the + // engine-neutral probe (layers.drawn: the layers' own sprite handles), + // on either engine. The layers no longer own Cesium billboards: on + // Cesium the contract's batched billboards carry atlas keys, not the + // glyph data-URIs. window.__collectBillboards = function () { - const v = window.__godsEyeView.viewer; - if (!v) - return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? []) - .filter((row) => !row.trackedModel) - .map(({ id, image, scale, show }) => ({ id, image, scale, show })); - const out = []; - const walk = (coll) => { - const n = coll.length; - for (let i = 0; i < n; i++) { - let p; - try { p = coll.get(i); } catch { continue; } - if (!p) continue; - if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.image !== undefined && p.alignedAxis !== undefined) { - out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show }); - } - } - }; - walk(v.scene.primitives); - return out; + return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? []) + .filter((row) => !row.trackedModel) + .map(({ id, image, scale, show }) => ({ id, image, scale, show })); }; - // Every glTF model primitive (modelMatrix + ready flag), with id + scale. + // Every fleet and tracked 3D model, with id + scale (the probe's model + // handles). The world bounding radius is not on the contract: null. window.__collectModels = function () { - const v = window.__godsEyeView.viewer; - if (!v) - return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? []) - .filter((row) => row.model && (row.trackedModel || !row.tracked)) - .map(({ id, model }) => ({ id, ...model, radius: null })); - const out = []; - const walk = (coll) => { - const n = coll.length; - for (let i = 0; i < n; i++) { - let p; - try { p = coll.get(i); } catch { continue; } - if (!p) continue; - if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.modelMatrix && typeof p.ready !== 'undefined' && p.image === undefined) { - let radius = null; - try { radius = p.ready && p.boundingSphere ? p.boundingSphere.radius : null; } catch { /* not ready */ } - out.push({ id: p.id, scale: p.scale, ready: p.ready, show: p.show, radius }); - } - } - }; - walk(v.scene.primitives); - return out; + return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? []) + .filter((row) => row.model && (row.trackedModel || !row.tracked)) + .map(({ id, model }) => ({ id, ...model, radius: null })); }; // Displayed (render-delayed) latitude of a synthetic plane right now. window.__displayedState = function (layer, idx, renderDelaySec) { diff --git a/scripts/qa-traffic-motion.mjs b/scripts/qa-traffic-motion.mjs index af73c8d1c..0d68d411b 100644 --- a/scripts/qa-traffic-motion.mjs +++ b/scripts/qa-traffic-motion.mjs @@ -116,7 +116,19 @@ try { let nextId = 0, rebuilds = 0; const collections = []; + // A rebuild: the traffic display's mover sets made again + // (src/layers/traffic/render/display.js writes its two point sets in + // place every frame; it never empties and refills a collection). + // --baseline records the pre-contract build, whose PointPrimitive + // collections sat in scene.primitives and were rebuilt by removeAll. + const engine = window.__godsEyeView.engine; + const createMoverSet = engine.movers.createSet.bind(engine.movers); + engine.movers.createSet = (options) => { + if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++; + return createMoverSet(options); + }; function discoverCollections() { + if (!baseline) return; for (let i = 0; i < scene.primitives.length; i++) { const p = scene.primitives.get(i); if (!Array.isArray(p._pointPrimitives) || collections.includes(p)) diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index f44408e00..d1456c5d0 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -100,6 +100,13 @@ try { ), orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, }); + // The trail draws through engine.lines (render/display.js); its renderer + // is the handle that shows and hides the whole trail. Keep the one the + // selection below creates. + let trailRenderer = null; + const createTrailRenderer = parts.renderer.createTrailRenderer; + parts.renderer.createTrailRenderer = (...args) => + (trailRenderer = createTrailRenderer.apply(parts.renderer, args)); parts.selection.clearSelection(); parts.ingestion.abortAllInFlight(); state._activeFeeds.clear(); @@ -151,6 +158,7 @@ try { state._floorTimer = null; const started = performance.now(); parts.selection.selectVehicle(entry.key); + parts.renderer.createTrailRenderer = createTrailRenderer; const preparedAtSelection = !!parts.trails.diagnostics()?.body; parts.height.requestHeight = requestHeight; const floor = @@ -177,6 +185,8 @@ try { .find((e) => e.key === entry.key), recoveryMs: performance.now() - started, }; + if (!trailRenderer) + return { ...status, reason: 'the selection created no trail renderer' }; if (!entry.marker.show || !trail?.body?.show || !trail.body.ready) return { ...status, @@ -188,7 +198,16 @@ try { }); parts.rendering.sampleIdle(entry); parts.rendering.schedulePlayback(entry); - const V = entry.marker.position.constructor; + // Marker positions are plain { x, y, z } now (the display's handles), not + // Cesium Cartesian3 instances with static helpers. + const V = { + distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z), + lerp: (a, b, t) => ({ + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + }), + }; const points = entry.trailSegments .filter((s) => s.toT <= entry.sample.displayT) .flatMap((s) => s.positions); @@ -222,7 +241,6 @@ try { points[j - 1], points[j], (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), - new V(), ); const cart = C.fromCartesian(p); cart.height = scene.globe.getHeight(cart) || 0; @@ -273,11 +291,10 @@ try { scene.requestRender(); }); let visible = true; - const remove = scene.preRender.addEventListener(() => { - trail.body.show = visible; - if (trail.headPrimitive) - trail.headPrimitive.show = visible && trail.head.show; - }); + // Body and head together, as the trail's own visibility switch does. + const remove = scene.preRender.addEventListener(() => + trailRenderer.setVisible(visible), + ); const captures = []; try { for (const show of [false, true, false, true]) { @@ -287,6 +304,7 @@ try { } } finally { remove(); + trailRenderer.setVisible(true); parts.trails.update(); } const [off, on, offAgain, onAgain] = captures; @@ -302,10 +320,10 @@ try { scene.globe.tilesLoaded && parts.trails.diagnostics().body === trail.body, elapsedMs: performance.now() - started, - attributes: Array.from({ length: 4 }, (_, i) => { - const a = trail.body.getGeometryInstanceAttributes(`${i}:1`); - return { show: Array.from(a.show), color: Array.from(a.color) }; - }), + // The trail's engine lines (age bands and head, each with a backing). + lines: app.engine.lines + .list() + .filter(({ id }) => id.startsWith('transit-trail-')), }; }); const pixels = reduceTrailPixels(result.on, result.off, result); @@ -319,7 +337,9 @@ try { const pass = result.before?.shown === false && result.before?.phase === 'playing' && - result.preparedAtSelection && + // preparedAtSelection is reported, not required: the trail is engine + // lines in the air now (render/display.js), not a draped ground + // polyline, so it has no geometry until the floors it waits on arrive. result.markerShow && result.body && result.bodyShow && diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index f325cb9f7..6de24858d 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -367,7 +367,7 @@ export async function runBostonMatrix({ route: e.record.routeId, image: e.marker.image, width: e.marker.width, - color: e.marker.color.toCssHexString(), + color: e.marker.color.toCssColorString(), }), ); }); @@ -421,7 +421,7 @@ export async function runBostonMatrix({ return { image: e.marker.image, width: e.marker.width, - color: e.marker.color.toCssHexString(), + color: e.marker.color.toCssColorString(), }; }, target.key); check( @@ -655,6 +655,31 @@ export async function runTrailVisibility({ ), orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 }, }); + // The trail draws through engine.lines (render/display.js); keep the + // renderer the selection below creates, the handle that shows and + // hides the whole trail. + const createLine = app.engine.lines.createLine.bind(app.engine.lines); + window.__qaTransitLineKinds = new Map(); + app.engine.lines.createLine = (options) => { + const line = createLine(options); + if (options.id.startsWith('transit-trail-')) + window.__qaTransitLineKinds.set( + options.id, + options.order >= 2 ? 'head' : 'body', + ); + return line; + }; + window.__restoreTransitLines = () => { + app.engine.lines.createLine = createLine; + }; + const createTrailRenderer = parts.renderer.createTrailRenderer; + parts.renderer.createTrailRenderer = (...args) => { + parts.renderer.createTrailRenderer = createTrailRenderer; + return (window.__qaTransitTrail = createTrailRenderer.apply( + parts.renderer, + args, + )); + }; parts.selection.clearSelection(); parts.ingestion.abortAllInFlight(); state._activeFeeds.clear(); @@ -711,6 +736,8 @@ export async function runTrailVisibility({ check(name, false, JSON.stringify(setup), { unexercised: true }); return; } + // Measure the engine's native uploads, attributing its line primitives to + // the head or body by the draw order supplied at contract creation. const cost = await page.evaluate(async (key) => { const app = window.__godsEyeView, scene = app.viewer.scene; @@ -766,9 +793,15 @@ export async function runTrailVisibility({ } const pre = scene.preRender.addEventListener(() => { headBytes = bodyBytes = 0; - const trail = parts.trails.diagnostics(); - wrap(trail?.body, 'body'); - wrap(trail?.headPrimitive || trail?.head?._polylineCollection, 'head'); + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const primitive = collection.get(i); + const kind = window.__qaTransitLineKinds.get(primitive?.gevLine); + if (kind) wrap(primitive, kind); + else if (typeof primitive?.get === 'function') walk(primitive); + } + }; + walk(scene.primitives); }); const post = scene.postRender.addEventListener(() => { frames++; @@ -792,6 +825,7 @@ export async function runTrailVisibility({ } finally { pre(); post(); + window.__restoreTransitLines(); for (const [name, original] of originals) gl[name] = original; for (const [primitive, update] of wrapped) if (!primitive.isDestroyed()) primitive.update = update; @@ -855,7 +889,32 @@ export async function runTrailVisibility({ if (points.length < 2) return { ...setup, present: 0, reason: 'no completed path' }; const C = scene.camera.positionCartographic.constructor; - const V = entry.marker.position.constructor; + // Marker positions are plain { x, y, z } now (the display's handles), not + // Cesium Cartesian3 instances with static helpers. + const V = { + distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z), + lerp: (a, b, t) => ({ + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + }), + }; + // What the floor samples leave out: the Cesium primitives the engine + // draws the transit mover sets and trail lines with. + const drawnByTransit = []; + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if ( + String(item?.gevMoverSet ?? '').startsWith('transit') || + String(item?.gevLine ?? '').startsWith('transit-trail-') + ) + drawnByTransit.push(item); + else if (typeof item?.get === 'function' && 'length' in item) + walk(item); + } + }; + walk(scene.primitives); const lengths = [0]; for (let i = 1; i < points.length; i++) lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); @@ -872,13 +931,9 @@ export async function runTrailVisibility({ points[j - 1], points[j], (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), - new V(), ); const cart = C.fromCartesian(p); - const height = scene.sampleHeight( - cart, - [entry.marker, trail.body, trail.headPrimitive].filter(Boolean), - ); + const height = scene.sampleHeight(cart, drawnByTransit); if (Number.isFinite(height)) cart.height = height; const surface = C.toCartesian(cart); const q = mul( @@ -941,18 +996,12 @@ export async function runTrailVisibility({ }); scene.requestRender(); }); - const bodyShow = trail.body?.show, - headShow = trail.head?.show, - headPrimitiveShow = trail.headPrimitive?.show, - backingShow = trail.backing?.show; + const trailRenderer = window.__qaTransitTrail; + if (!trailRenderer) + return { ...setup, present: 0, reason: 'no trail renderer captured' }; let showTrail = false; - const applyVisibility = () => { - if (trail.body) trail.body.show = showTrail && bodyShow; - if (trail.headPrimitive) - trail.headPrimitive.show = showTrail && headPrimitiveShow; - if (trail.head) trail.head.show = showTrail && headShow; - if (trail.backing) trail.backing.show = showTrail && backingShow; - }; + // Body and head together, as the trail's own visibility switch does. + const applyVisibility = () => trailRenderer.setVisible(showTrail); // Override the layer's frame update in both states; selection stays fixed. const removeVisibility = scene.preRender.addEventListener(applyVisibility); const captures = []; @@ -971,10 +1020,7 @@ export async function runTrailVisibility({ } } finally { removeVisibility(); - if (trail.body) trail.body.show = bodyShow; - if (trail.headPrimitive) trail.headPrimitive.show = headPrimitiveShow; - if (trail.head) trail.head.show = headShow; - if (trail.backing) trail.backing.show = backingShow; + trailRenderer.setVisible(true); await frame(); } const [off, on, offAgain, onAgain] = captures.map((patches) => @@ -994,8 +1040,8 @@ export async function runTrailVisibility({ tilesReady: captureReady, elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT, movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320, - body: trail.body?.constructor.name, - headStrategy: trail.headStrategy || 'PolylineCollection', + lines: trail.body?.lines ?? 0, + headStrategy: trail.headStrategy, headRebuilds: trail.headRebuilds ?? null, }; }, setup); diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7a632ff5e..9d7329eb6 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -98,9 +98,11 @@ const renderingSource = await readFile( new URL('../src/layers/transit/rendering.js', import.meta.url), 'utf8', ); +// The body of onPreRender: up to the closing brace at its own indentation +// (the visibility sweep that follows it may scan the fleet; the frame may not). const frameSource = renderingSource .split('function onPreRender()')[1] - .split('const occluder')[0]; + .split('\n }\n')[0]; check( 'frame pass does not scan the fleet or copy the moving set', !/_vehicles\.values|\[\.\.\.state\._moving\]/.test(frameSource), @@ -825,15 +827,16 @@ try { color: entry?.marker.color.toCssColorString(), width: entry?.marker.width, pickOwner: (() => { - const p = window.__godsEyeView.viewer.scene.pick( - entry.marker.computeScreenSpacePosition( - window.__godsEyeView.viewer.scene, - ), + // The marker is a mover instance: project it and pick through + // the engine, whose result names the instance's pickId. + const { engine } = window.__godsEyeView; + const screen = engine.projector.worldToWindow( + entry.marker.position, ); + const p = screen ? engine.picker.pick(screen) : undefined; return { id: typeof p?.id === 'string' ? p.id : null, - primitiveId: - typeof p?.primitive?.id === 'string' ? p.primitive.id : null, + primitiveId: null, collection: entry.markerCollection === state._animatedMarkers ? 'animated' @@ -1595,7 +1598,7 @@ try { y >= canvas.clientHeight ) return null; - const picked = scene.pick({ x, y }); + const picked = app.engine.picker.pick({ x, y }); return { entityCount: app.viewer.entities.values.length, key: entry.key, diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index 0a1673c47..4a61b4337 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -19,10 +19,11 @@ * node scripts/qa-vessel-datum.mjs --ports rotterdam --url http://localhost:4173 * node scripts/qa-vessel-datum.mjs --engine navara --url 'http://localhost:PORT/?qaFeed=vessels:fixture' * - * --engine cesium|navara: the engine-neutral path on either engine, - * through window.__godsEyeView.engine and the QA probe (layers.drawn: each - * chevron's drawn position and depth-test flag) instead of the Cesium - * viewer's primitives; the URL gets ?engine=. Without it, as before. + * Both engines take the engine-neutral path, through window.__godsEyeView.engine + * and the dev server's QA probe (layers.drawn: each chevron's drawn position + * and depth-test flag); the vessels layer owns no Cesium billboards any more. + * --engine cesium|navara puts ?engine= on the URL; without it, the app's + * default engine. */ import fs from 'node:fs'; import path from 'node:path'; @@ -89,62 +90,6 @@ function findChromeExecutable() { return null; } -/** - * In-page probe: locate the vessel BillboardCollection (duck-typed — its - * billboards carry the vessel record as `id`, so `id.mmsi` exists), then - * report depth-test flags across a sample and ellipsoidal heights of - * near-port billboards. - */ -function probeVessels({ portLat, portLon, nearDeg, sampleCap }) { - const gev = window.__godsEyeView; - const viewer = gev?.viewer; - if (!viewer) return { error: 'no viewer' }; - const ellipsoid = viewer.scene.globe.ellipsoid; - const prims = viewer.scene.primitives; - - let collection = null; - for (let i = 0; i < prims.length; i += 1) { - const p = prims.get(i); - if (!p || typeof p.get !== 'function' || typeof p.length !== 'number' || p.length === 0) continue; - const first = p.get(0); - if (first && first.id && typeof first.id === 'object' && 'mmsi' in first.id - && first.disableDepthTestDistance !== undefined) { - collection = p; - break; - } - } - if (!collection) return { error: 'vessel billboard collection not found' }; - - let depthFreeCount = 0; - let depthTestedCount = 0; - const nearHeights = []; - const step = Math.max(1, Math.floor(collection.length / sampleCap)); - for (let i = 0; i < collection.length; i += step) { - const b = collection.get(i); - if (!b) continue; - if (b.disableDepthTestDistance === Number.POSITIVE_INFINITY) depthFreeCount += 1; - else depthTestedCount += 1; - const rec = b.id; - if (Number.isFinite(rec?.lat) && Number.isFinite(rec?.lon) - && Math.abs(rec.lat - portLat) <= nearDeg && Math.abs(rec.lon - portLon) <= nearDeg) { - const carto = ellipsoid.cartesianToCartographic(b.position); - if (carto) nearHeights.push(carto.height); - } - } - nearHeights.sort((a, b) => a - b); - const median = nearHeights.length ? nearHeights[Math.floor(nearHeights.length / 2)] : null; - return { - total: collection.length, - sampled: depthFreeCount + depthTestedCount, - depthFreeCount, - depthTestedCount, - nearCount: nearHeights.length, - medianHeight: median, - minHeight: nearHeights[0] ?? null, - maxHeight: nearHeights[nearHeights.length - 1] ?? null, - }; -} - /** * In-page probe on either engine (the QA probe): the chevrons the layer * draws, their depth-test flag and the ellipsoidal heights of those near the @@ -201,7 +146,7 @@ async function frameEngine(p, enable) { async function main() { console.log(`\nAIS Vessel Datum — assertion harness`); console.log(` App URL : ${APP_URL}`); - console.log(` Engine : ${ENGINE ?? 'viewer (Cesium)'}`); + console.log(` Engine : ${ENGINE ?? 'the app default'}`); console.log(` Ports : ${PORT_KEYS.join(', ')}\n`); try { @@ -247,29 +192,10 @@ async function main() { page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`)); await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); - if (ENGINE) - await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 }); - else - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 }); + await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 }); // Frame the port (kill the intro flight first) and enable the layer. - if (ENGINE) await page.evaluate(frameEngine, port, true); - else await page.evaluate(async (p) => { - const gev = window.__godsEyeView; - const v = gev.viewer; - v.camera.cancelFlight?.(); - v.scene.tweens?.removeAll?.(); - const carto = { - longitude: (p.lon * Math.PI) / 180, - latitude: (p.lat * Math.PI) / 180, - height: p.height, - }; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), - orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, - }); - await gev.dataManager.setEnabled('ais-live-vessels', true); - }, port); + await page.evaluate(frameEngine, port, true); // Wait for rows + the geoid re-floor: poll until near-port anchors sit // inside the expected band (both ports' bands exclude the pre-fix 0–3 m @@ -279,7 +205,7 @@ async function main() { let probe = null; let settled = false; while (Date.now() < deadline) { - probe = await page.evaluate(ENGINE ? probeVesselsEngine : probeVessels, probeArgs); + probe = await page.evaluate(probeVesselsEngine, probeArgs); if (probe && !probe.error && probe.nearCount >= 3 && probe.medianHeight !== null && probe.medianHeight >= port.heightBand[0] @@ -316,29 +242,11 @@ async function main() { // Visual proof — re-assert the framing (the app's intro flight can land // mid-probe on slow runs), wait for the photoreal tileset, screenshot. - if (ENGINE) await page.evaluate(frameEngine, port, false); - else await page.evaluate((p) => { - const v = window.__godsEyeView.viewer; - v.camera.cancelFlight?.(); - v.scene.tweens?.removeAll?.(); - const carto = { - longitude: (p.lon * Math.PI) / 180, - latitude: (p.lat * Math.PI) / 180, - height: p.height, - }; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), - orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, - }); - }, port); + await page.evaluate(frameEngine, port, false); await page .waitForFunction( - (engineNeutral) => - engineNeutral - ? window.__godsEyeView?.engine?.surface?.tilesLoaded?.() - : window.__godsEyeView?.tileset?.tilesLoaded, + () => window.__godsEyeView?.engine?.surface?.tilesLoaded?.(), { timeout: 60000, polling: 500 }, - Boolean(ENGINE), ) .catch(() => console.log(' (tileset settle timeout — screenshotting anyway)')); await new Promise((r) => setTimeout(r, 2500)); diff --git a/scripts/qa-weather-teardown.mjs b/scripts/qa-weather-teardown.mjs index bf7b00269..8628acffc 100644 --- a/scripts/qa-weather-teardown.mjs +++ b/scripts/qa-weather-teardown.mjs @@ -5,7 +5,8 @@ * weather layer on and exercises it (history playback, a wind reading, a * storm selection), turns them all off, and asserts the scene is back to the * baseline (imagery layers on the globe and the tileset, primitives, ground - * primitives, data sources, entities, camera/scene listeners, the weather + * primitives, data sources, entities, the globe contract's mover sets, + * lines, ground sets and imagery layers, camera/scene listeners, the weather * panel, the wind reading marker). Then it enables a set of non-weather * layers one at a time and samples the render rate, long tasks and console * errors for each, and repeats the same non-weather sequence in a control @@ -120,11 +121,49 @@ const snapshot = (page) => }; } const wind = dm.layers.get('wind')?.module?.getDiagnostics?.() || {}; + // What layers draw on Cesium directly, excluding collections and the contract's own + // batches: the engine keeps one PrimitiveCollection per primitive kind + // (src/globe/cesium/) for the scene's lifetime, and its mover, line and + // ground batches (tagged gevMoverSet, gevLine, gevGroundSet) are counted + // below through the contract. + const contractOwned = (item) => + item?.gevMoverSet !== undefined || + item?.gevLine !== undefined || + item?.gevGroundSet !== undefined; + const drawn = (collection) => { + let count = 0; + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if (contractOwned(item)) continue; + if (typeof item?.raiseToTop === 'function') count += drawn(item); + else count++; + } + return count; + }; + const { engine } = gev; + const sum = (rows, key) => rows.reduce((n, row) => n + (row[key] || 0), 0); + const movers = engine.movers.list(); + const lines = engine.lines.list(); + const ground = engine.ground.list(); return { globeImagery: v.imageryLayers.length, tilesetImagery: t?.imageryLayers?.length ?? null, - primitives: s.primitives.length, - groundPrimitives: s.groundPrimitives.length, + primitives: drawn(s.primitives), + groundPrimitives: drawn(s.groundPrimitives), + // The globe contract's own drawing (cyclone points and fills draw + // through it; render/display.js). + // Drawn instances, not sets: a disabled layer may keep an emptied set + // for its next enable (the cyclone points do), which draws nothing. + contract: { + moverInstances: sum(movers, 'count'), + lines: lines.filter((line) => line.shown && line.points > 0).length, + groundItems: sum( + ground.filter((set) => set.shown), + 'count', + ), + imageryLayers: engine.imagery.list().filter((layer) => layer.shown) + .length, + }, dataSources: v.dataSources.length, entities: v.entities.values.length, listeners: {