mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Read QA drawing evidence through the globe contract
Port the layer probes in twenty-six browser scripts to engine primitive lists, drawn rows and layer diagnostics. Preserve Cesium as the reference and keep the supported Navara paths on the same observations. This changes QA observation only, with no application behaviour change. Review the interrupted infrastructure group and retain its readiness, drape, selection, label and floor checks. Match both installation outline buffers. Frame each CCTV city before reading its visible coverage. Read Radio colours and selected identities from actual mover writes. Retain transit's GPU upload budgets using engine-owned line primitives rather than substituting rebuild counts. Keep native inspection where a check specifically measures GPU uploads, loaded meshes or ground readiness. The preceding combined B/C commit owns all shared manifests, docs, locale catalogs and the coupling baseline; this commit changes only its own twenty-six QA scripts. Coupling remains unchanged at 344 Cesium renderer lines, 1294 Navara renderer lines, 9 Cesium import files, 1026 viewer lines, 2 Navara kind checks, 0 engine sniff calls, 9 extension reads, 1 ground pass, 3 SDK loads, 4 screen-handler files and 42 engine-name lines. The full unit suite and the boundary, format and coupling checks pass. Browser validation remains to be run. Browser checks to run GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291 node scripts/qa-alpr-journey.mjs http://localhost:4291 node scripts/qa-alpr-journey.mjs 'http://localhost:4291/?engine=navara' node scripts/qa-cables-overlay.mjs --url http://localhost:4291 node scripts/qa-cctv-v2.mjs --url http://localhost:4291 --fixture --terrain-globe node scripts/qa-context-arrivals.mjs http://localhost:4291 node scripts/qa-directions.mjs --url http://localhost:4291 QA_BASE_URL=http://localhost:4291 node scripts/qa-draw-tool.mjs QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-hold.mjs node scripts/qa-ghostmaps.mjs --url http://localhost:4291 node scripts/qa-gnss.mjs --url http://localhost:4291 node scripts/qa-height-datum.mjs --url http://localhost:4291 node scripts/qa-infra-lod.mjs --url http://localhost:4291 node scripts/qa-installation-navigation.mjs http://localhost:4291 node scripts/qa-military-names.mjs http://localhost:4291 --no-video node scripts/qa-overpass-offload.mjs --url http://localhost:4291 node scripts/qa-radio.mjs --url http://localhost:4291 node scripts/qa-traffic-motion.mjs http://localhost:4291 --mode=osm QA_BASE_URL=http://localhost:4291 node scripts/qa-transit-recovery.mjs QA_BASE_URL=http://localhost:4291 node scripts/qa-transit.mjs node scripts/qa-weather-teardown.mjs --url http://localhost:4291 --headless for engine in cesium navara; do node scripts/qa-enrich-ambient.mjs --url http://localhost:4291 --engine=$engine QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-verify.mjs --engine=$engine node scripts/qa-heading-b3.mjs --url http://localhost:4291 --engine=$engine node scripts/qa-overlay-baseline.mjs --url http://localhost:4291 --engine=$engine node scripts/qa-sprites-b5.mjs --url http://localhost:4291 --engine=$engine node scripts/qa-vessel-datum.mjs --url 'http://localhost:4291/?qaFeed=vessels:fixture' --engine $engine done The military-names driver exercises installation-polish; the transit driver exercises transit-scenes. Use only the keyless server. Cases that require photoreal tiles or unavailable providers remain unverified.
This commit is contained in:
1 parent
97c4dc76ce
commit
3907b7084e
26 files changed
+1429
-1075
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+169
-69
@@ -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-<camera>-<role>` and `cctv-gizmo-<part>`, 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'),
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
+111
-68
@@ -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(
|
||||
|
||||
+119
-90
@@ -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(),
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-16
@@ -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));
|
||||
|
||||
+93
-58
@@ -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) => {
|
||||
|
||||
+44
-24
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
+22
-56
@@ -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,
|
||||
|
||||
+44
-17
@@ -169,29 +169,36 @@ function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
|
||||
/**
|
||||
* Reads a camera's placed mount-point cartographic height off its
|
||||
* `cctv-<id>-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-<id>-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,
|
||||
|
||||
+29
-12
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
+323
-248
@@ -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:<id>`; 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),
|
||||
|
||||
+18
-48
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
+11
-8
@@ -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,
|
||||
|
||||
+11
-103
@@ -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));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user