mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Add ground fills to the globe contract
engine.ground draws sets of polygons (with holes), discs, cells and
corridors draped on the surface, with fill and outline colours, order and
picks, written in bulk and committed. Every engine implements it:
- Cesium batches each set into one GroundPrimitive for fills and one
GroundPolylinePrimitive for outlines and pixel corridors, classified
against terrain and 3D tiles; sets that are not pickable stay out of the
pick pass.
- Navara draws them in one screen pass after tone mapping that finds each
pixel's ground from the scene depth and tests the items in its screen
cell; long rings are split into edge bins. Measured at Dubai against
SDK vector layers, the pass matched Cesium's frame time up to 10,000
polygons with a few megabytes instead of 70 to 146, drew exact colours
and outlines, and hid reliably across an engine switch, where vector
layers hidden with update({ show: false }) never hid. Dense corridors
remain its limit (10,000 cost 135 ms a frame).
- The fake engine keeps the same registry and CPU picks.
The contract suite checks it on both engines (Cesium 66/66, Navara 69/69).
Earthquakes and fire perimeters have unwired ground prototypes whose
drawing matches today's Cesium one (0.998 on Cesium, 0.976 on Navara).
docs/GLOBE-PRIMITIVES.md describes the primitives and when to use each.
This commit is contained in:
1 parent
6dc643182e
commit
974a0b6361
27 files changed
+5256
-685
No files matched your search
@@ -1609,5 +1609,22 @@
|
||||
"src/layers/transit/render/style.js",
|
||||
"src/layers/transit/render/testAtlas.mjs",
|
||||
"src/testSupport/cctvEngines.mjs",
|
||||
"src/testSupport/radioTestEngine.mjs"
|
||||
"src/testSupport/radioTestEngine.mjs",
|
||||
"src/globe/ground.js",
|
||||
"src/globe/ground.test.mjs",
|
||||
"src/globe/cesium/ground.js",
|
||||
"src/globe/cesium/ground.test.mjs",
|
||||
"src/globe/navara/ground/index.js",
|
||||
"src/globe/navara/ground/index.test.mjs",
|
||||
"src/globe/navara/ground/pack.js",
|
||||
"src/globe/navara/ground/pack.test.mjs",
|
||||
"src/globe/navara/ground/pass.js",
|
||||
"src/globe/navara/ground/vector.js",
|
||||
"src/layers/earthquakes/render/ground.js",
|
||||
"src/layers/perimeters/render/ground.js",
|
||||
"scripts/qa-ground-suite.mjs",
|
||||
"scripts/qa-ground-bench.mjs",
|
||||
"scripts/qa-ground-prototypes.mjs",
|
||||
"scripts/qa-ground-switch.mjs",
|
||||
"docs/GLOBE-PRIMITIVES.md"
|
||||
]
|
||||
+206
-671
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,556 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Measure engine.ground at Dubai (1280×800, keyless) with N polygons:
|
||||
* Cesium (GroundPrimitive), Navara's screen pass (engine.ground,
|
||||
* src/globe/navara/ground/pass.js) and the Navara vector-layer prototype
|
||||
* (src/globe/navara/ground/vector.js). For each N: the time from commit to
|
||||
* the first item drawn, the frame time with the camera still and turning
|
||||
* (preUpdate to postRender, closed by a 1-pixel readPixels so the GPU work
|
||||
* of the frame is in it), the JS heap after a GC and the WebGL bytes the
|
||||
* page allocated (a tally of bufferData, texImage/texStorage and
|
||||
* renderbuffer storage, less deletions), each against the empty scene; then
|
||||
* one polygon over the Hajar mountains, obliquely, for the edge quality.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-bench.mjs http://localhost:4271 [--counts=10,1000,10000]
|
||||
* [--paths=cesium,navara-pass,navara-vector] [--out=DIR]
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const args = process.argv.slice(2);
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
if (!origin) {
|
||||
console.error('Usage: node scripts/qa-ground-bench.mjs <origin> [options]');
|
||||
process.exit(2);
|
||||
}
|
||||
const counts = (option('counts') ?? '10,1000,10000').split(',').map(Number);
|
||||
// polygons: N small polygons; corridors: N 40-point lines across the view,
|
||||
// 2.5 px; outline: one polygon of N vertices round the view.
|
||||
const shape = option('shape') ?? 'polygons';
|
||||
const paths = (option('paths') ?? 'cesium,navara-pass,navara-vector').split(
|
||||
',',
|
||||
);
|
||||
const out = option('out') ?? null;
|
||||
if (out) fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
// The page's WebGL allocations, before any script of the app runs.
|
||||
function installGpuTally() {
|
||||
const proto = WebGL2RenderingContext.prototype;
|
||||
const sizes = new Map();
|
||||
let total = 0;
|
||||
const boundBuffers = new WeakMap();
|
||||
const boundTextures = new WeakMap();
|
||||
const boundRenderbuffers = new WeakMap();
|
||||
const set = (key, bytes) => {
|
||||
total += bytes - (sizes.get(key) ?? 0);
|
||||
sizes.set(key, bytes);
|
||||
};
|
||||
const drop = (key) => {
|
||||
total -= sizes.get(key) ?? 0;
|
||||
sizes.delete(key);
|
||||
};
|
||||
const per = (map, gl) => {
|
||||
if (!map.has(gl)) map.set(gl, new Map());
|
||||
return map.get(gl);
|
||||
};
|
||||
const FLOAT = 0x1406;
|
||||
const HALF_FLOAT = 0x140b;
|
||||
const internalBytes = {
|
||||
0x8814: 16, // RGBA32F
|
||||
0x881a: 8, // RGBA16F
|
||||
0x8815: 12, // RGB32F
|
||||
0x822e: 4, // R32F
|
||||
0x8230: 8, // RG32F
|
||||
0x8058: 4, // RGBA8
|
||||
0x8c43: 4, // SRGB8_ALPHA8
|
||||
0x88f0: 4, // DEPTH24_STENCIL8
|
||||
0x8cad: 8, // DEPTH32F_STENCIL8
|
||||
0x8cac: 4, // DEPTH_COMPONENT32F
|
||||
0x81a6: 4, // DEPTH_COMPONENT24
|
||||
0x81a5: 2, // DEPTH_COMPONENT16
|
||||
0x8229: 1, // R8
|
||||
0x822b: 2, // RG8
|
||||
};
|
||||
const texelBytes = (internal, type) =>
|
||||
internalBytes[internal] ??
|
||||
(type === FLOAT ? 16 : type === HALF_FLOAT ? 8 : 4);
|
||||
const wrap = (name, after) => {
|
||||
const original = proto[name];
|
||||
proto[name] = function (...call) {
|
||||
const result = original.apply(this, call);
|
||||
try {
|
||||
after.call(this, call, result);
|
||||
} catch {
|
||||
// A tally error never breaks the page.
|
||||
}
|
||||
return result;
|
||||
};
|
||||
};
|
||||
wrap('bindBuffer', function ([target, buffer]) {
|
||||
per(boundBuffers, this).set(target, buffer);
|
||||
});
|
||||
wrap('bufferData', function ([target, data]) {
|
||||
const buffer = per(boundBuffers, this).get(target);
|
||||
if (buffer)
|
||||
set(buffer, typeof data === 'number' ? data : (data?.byteLength ?? 0));
|
||||
});
|
||||
wrap('deleteBuffer', ([buffer]) => drop(buffer));
|
||||
wrap('bindTexture', function ([target, texture]) {
|
||||
per(boundTextures, this).set(target, texture);
|
||||
});
|
||||
wrap('texImage2D', function (call) {
|
||||
const texture = per(boundTextures, this).get(call[0]);
|
||||
if (!texture || call[1] !== 0) return;
|
||||
let bytes;
|
||||
if (call.length >= 8 && typeof call[3] === 'number')
|
||||
bytes = call[3] * call[4] * texelBytes(call[2], call[7]);
|
||||
else {
|
||||
const source = call[5];
|
||||
bytes = (source?.width ?? 0) * (source?.height ?? 0) * 4;
|
||||
}
|
||||
set(texture, bytes);
|
||||
});
|
||||
wrap('texStorage2D', function ([target, levels, internal, width, height]) {
|
||||
const texture = per(boundTextures, this).get(target);
|
||||
if (texture)
|
||||
set(
|
||||
texture,
|
||||
width * height * texelBytes(internal) * (levels > 1 ? 4 / 3 : 1),
|
||||
);
|
||||
});
|
||||
wrap('texImage3D', function (call) {
|
||||
const texture = per(boundTextures, this).get(call[0]);
|
||||
if (texture && call[1] === 0)
|
||||
set(texture, call[3] * call[4] * call[5] * texelBytes(call[2], call[8]));
|
||||
});
|
||||
wrap('texStorage3D', function ([target, , internal, width, height, depth]) {
|
||||
const texture = per(boundTextures, this).get(target);
|
||||
if (texture) set(texture, width * height * depth * texelBytes(internal));
|
||||
});
|
||||
wrap('deleteTexture', ([texture]) => drop(texture));
|
||||
wrap('bindRenderbuffer', function ([, renderbuffer]) {
|
||||
boundRenderbuffers.set(this, renderbuffer);
|
||||
});
|
||||
wrap('renderbufferStorage', function ([, internal, width, height]) {
|
||||
const renderbuffer = boundRenderbuffers.get(this);
|
||||
if (renderbuffer) set(renderbuffer, width * height * texelBytes(internal));
|
||||
});
|
||||
wrap(
|
||||
'renderbufferStorageMultisample',
|
||||
function ([, samples, internal, width, height]) {
|
||||
const renderbuffer = boundRenderbuffers.get(this);
|
||||
if (renderbuffer)
|
||||
set(
|
||||
renderbuffer,
|
||||
width * height * texelBytes(internal) * Math.max(1, samples),
|
||||
);
|
||||
},
|
||||
);
|
||||
wrap('deleteRenderbuffer', ([renderbuffer]) => drop(renderbuffer));
|
||||
window.__gpuBytes = () => total;
|
||||
}
|
||||
|
||||
const DUBAI = { lon: 55.27, lat: 25.15, alt: 70000 };
|
||||
|
||||
async function openEngine(browser, kind) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (error) => console.log(' page error:', error.message));
|
||||
await page.evaluateOnNewDocument(installGpuTally);
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${DUBAI.lat}&lon=${DUBAI.lon}&alt=${DUBAI.alt}&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
// Daylight in Dubai (Navara lights the scene with the app clock's sun).
|
||||
await page.evaluate(async () => {
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7, 8, 0));
|
||||
window.__godsEyeView.engine.render.request('ground-bench-daylight');
|
||||
});
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('HeapProfiler.enable');
|
||||
// Helpers in the page: polygons, the approach under test, timing.
|
||||
await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const random = (seed) => () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 2 ** 32;
|
||||
};
|
||||
const PALETTE = [
|
||||
[1, 0.2, 0.2],
|
||||
[1, 0.6, 0],
|
||||
[1, 1, 0.2],
|
||||
[0.2, 0.9, 0.3],
|
||||
[0, 0.8, 1],
|
||||
[0.3, 0.4, 1],
|
||||
[0.7, 0.3, 1],
|
||||
[0.9, 0.9, 0.9],
|
||||
];
|
||||
window.__bench = {
|
||||
engine,
|
||||
// N polygons over Dubai (deterministic): 10 vertices, 100–400 m;
|
||||
// item 0 a 3 km magenta polygon at the view centre.
|
||||
polygons(count) {
|
||||
const next = random(7);
|
||||
const items = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const centre =
|
||||
index === 0
|
||||
? [55.27, 25.15]
|
||||
: [54.95 + next() * 0.65, 24.95 + next() * 0.4];
|
||||
const radius = index === 0 ? 3000 : 100 + next() * 300;
|
||||
const ring = [];
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const angle = (k / 10) * 2 * Math.PI;
|
||||
const r = radius * (0.75 + 0.25 * (index === 0 ? 1 : next()));
|
||||
ring.push([
|
||||
centre[0] +
|
||||
(r * Math.cos(angle)) /
|
||||
(111320 * Math.cos((centre[1] * Math.PI) / 180)),
|
||||
centre[1] + (r * Math.sin(angle)) / 110540,
|
||||
]);
|
||||
}
|
||||
items.push({
|
||||
rings: [ring],
|
||||
// Eight colours, as the layers use a handful (depth bands,
|
||||
// levels); the vector prototype makes one layer per style.
|
||||
fill:
|
||||
index === 0
|
||||
? [1, 0, 1, 0.9]
|
||||
: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.35],
|
||||
outline: { color: [1, 1, 1, 0.9], widthPx: 1 },
|
||||
pickId: index,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
corridors(count) {
|
||||
const next = random(11);
|
||||
const items = [window.__bench.centreDisc()];
|
||||
for (let index = 1; index < count; index++) {
|
||||
let lon = 54.95 + next() * 0.65;
|
||||
let lat = 24.95 + next() * 0.4;
|
||||
const heading = next() * 2 * Math.PI;
|
||||
const positions = [];
|
||||
for (let k = 0; k < 40; k++) {
|
||||
positions.push([lon, lat]);
|
||||
const turn = heading + (next() - 0.5) * 1.2;
|
||||
lon += 0.004 * Math.cos(turn);
|
||||
lat += 0.004 * Math.sin(turn);
|
||||
}
|
||||
items.push({
|
||||
positions,
|
||||
color: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.9],
|
||||
widthPx: 2.5,
|
||||
pickId: index,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
// The magenta mark of the corridors scenario: a 40 px corridor.
|
||||
centreDisc: () => ({
|
||||
positions: [
|
||||
[55.255, 25.15],
|
||||
[55.285, 25.15],
|
||||
],
|
||||
color: [1, 0, 1, 0.9],
|
||||
widthPx: 40,
|
||||
pickId: 0,
|
||||
}),
|
||||
outline(count) {
|
||||
const ring = [];
|
||||
for (let k = 0; k < count; k++) {
|
||||
const angle = (k / count) * 2 * Math.PI;
|
||||
const wobble = 1 + 0.03 * Math.sin(angle * 37);
|
||||
ring.push([
|
||||
55.27 + 0.28 * wobble * Math.cos(angle),
|
||||
25.15 + 0.2 * wobble * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
return [
|
||||
{
|
||||
rings: [ring],
|
||||
fill: [1, 0, 1, 0.5],
|
||||
outline: { color: [1, 1, 1, 1], widthPx: 2 },
|
||||
pickId: 0,
|
||||
},
|
||||
];
|
||||
},
|
||||
async ground(kind) {
|
||||
if (kind !== 'navara-vector') return engine.ground;
|
||||
const { createNavaraVectorGround } =
|
||||
await import('/src/globe/navara/ground/vector.js');
|
||||
window.__bench.vector ??= createNavaraVectorGround(
|
||||
engine.extensions.navara.view,
|
||||
);
|
||||
return window.__bench.vector.ground;
|
||||
},
|
||||
frame: () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('ground-bench');
|
||||
}),
|
||||
// Frame times in ms (preUpdate → postRender + 1 px readPixels).
|
||||
async frameTimes(frames, turn = false) {
|
||||
const gl = engine.host.canvas.getContext('webgl2');
|
||||
const pixel = new Uint8Array(4);
|
||||
const times = [];
|
||||
let start = 0;
|
||||
const removeStart = engine.frames.preUpdate.addEventListener(() => {
|
||||
start = performance.now();
|
||||
});
|
||||
const removeEnd = engine.frames.postRender.addEventListener(() => {
|
||||
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
|
||||
times.push(performance.now() - start);
|
||||
});
|
||||
const pose = engine.camera.pose();
|
||||
try {
|
||||
for (let index = 0; index < frames; index++) {
|
||||
if (turn)
|
||||
engine.camera.setView({
|
||||
destination: pose.position,
|
||||
orientation: {
|
||||
heading: pose.heading + (index % 2 ? 0.002 : -0.002),
|
||||
pitch: pose.pitch,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
await window.__bench.frame();
|
||||
}
|
||||
} finally {
|
||||
removeStart();
|
||||
removeEnd();
|
||||
engine.camera.setView({
|
||||
destination: pose.position,
|
||||
orientation: { heading: pose.heading, pitch: pose.pitch, roll: 0 },
|
||||
});
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
return {
|
||||
mean,
|
||||
median: times[Math.floor(times.length / 2)],
|
||||
p95: times[Math.floor(times.length * 0.95)],
|
||||
};
|
||||
},
|
||||
// The centre pixel shows the magenta item.
|
||||
centreMagenta() {
|
||||
const canvas = engine.host.canvas;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 4;
|
||||
c.height = 4;
|
||||
const context = c.getContext('2d');
|
||||
context.drawImage(
|
||||
canvas,
|
||||
canvas.width / 2 - 2,
|
||||
canvas.height / 2 - 2,
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
);
|
||||
const [r, g, b] = context.getImageData(1, 1, 1, 1).data;
|
||||
// Lit (the vector layers) or exact (the pass, Cesium).
|
||||
return r > 150 && b > 150 && g < Math.min(r, b) - 60;
|
||||
},
|
||||
};
|
||||
});
|
||||
return { page, cdp };
|
||||
}
|
||||
|
||||
async function settleTiles(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { engine, frame } = window.__bench;
|
||||
const started = performance.now();
|
||||
let loadedFrames = 0;
|
||||
while (performance.now() - started < 60000 && loadedFrames < 30) {
|
||||
await frame();
|
||||
loadedFrames = engine.surface.tilesLoaded() ? loadedFrames + 1 : 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function memory(page, cdp) {
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
return page.evaluate(() => ({
|
||||
js: performance.memory.usedJSHeapSize,
|
||||
gpu: window.__gpuBytes(),
|
||||
navara:
|
||||
window.__bench.engine.extensions?.navara?.view?.memoryStats?.()
|
||||
?.gpuBytesEst ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
const mb = (bytes) => (bytes / 2 ** 20).toFixed(2);
|
||||
const rows = [];
|
||||
|
||||
async function measure(browser, kind) {
|
||||
const engineKind = kind === 'cesium' ? 'cesium' : 'navara';
|
||||
const { page, cdp } = await openEngine(browser, engineKind);
|
||||
const renderer = await page.evaluate(() => {
|
||||
const gl = window.__bench.engine.host.canvas.getContext('webgl2');
|
||||
const info = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : 'unknown';
|
||||
});
|
||||
console.log(`${kind}: ${renderer}`);
|
||||
await settleTiles(page);
|
||||
// Warm the approach (shader compile) with one item, then measure empty.
|
||||
await page.evaluate(async (kind) => {
|
||||
const ground = await window.__bench.ground(kind);
|
||||
const set = ground.createSet({ id: 'bench-warm', kind: 'polygon' });
|
||||
set.replace(window.__bench.polygons(1));
|
||||
set.commit();
|
||||
const started = performance.now();
|
||||
while (
|
||||
!window.__bench.centreMagenta() &&
|
||||
performance.now() - started < 30000
|
||||
)
|
||||
await window.__bench.frame();
|
||||
set.destroy();
|
||||
for (let index = 0; index < 10; index++) await window.__bench.frame();
|
||||
}, kind);
|
||||
const emptyFrames = {
|
||||
still: await page.evaluate(() => window.__bench.frameTimes(90)),
|
||||
turning: await page.evaluate(() => window.__bench.frameTimes(90, true)),
|
||||
};
|
||||
rows.push({ path: kind, shape, count: 0, ...emptyFrames });
|
||||
for (const count of counts) {
|
||||
const before = await memory(page, cdp);
|
||||
const built = await page.evaluate(
|
||||
async (kind, count, shape) => {
|
||||
const ground = await window.__bench.ground(kind);
|
||||
const items =
|
||||
shape === 'corridors'
|
||||
? window.__bench.corridors(count)
|
||||
: shape === 'outline'
|
||||
? window.__bench.outline(count)
|
||||
: window.__bench.polygons(count);
|
||||
const set = ground.createSet({
|
||||
id: 'bench',
|
||||
kind: shape === 'corridors' ? 'corridor' : 'polygon',
|
||||
});
|
||||
const started = performance.now();
|
||||
set.replace(items);
|
||||
set.commit();
|
||||
const committed = performance.now() - started;
|
||||
while (
|
||||
!window.__bench.centreMagenta() &&
|
||||
performance.now() - started < 120000
|
||||
)
|
||||
await window.__bench.frame();
|
||||
window.__bench.set = set;
|
||||
const drawnMs = performance.now() - started;
|
||||
for (let index = 0; index < 10; index++) await window.__bench.frame();
|
||||
return { commitMs: committed, drawnMs };
|
||||
},
|
||||
kind,
|
||||
count,
|
||||
shape,
|
||||
);
|
||||
// Right after the build, before tiles stream again.
|
||||
const loaded = await memory(page, cdp);
|
||||
const passBytes = await page.evaluate(
|
||||
() => window.__bench.engine.groundDiagnostics?.()?.textureBytes ?? null,
|
||||
);
|
||||
await settleTiles(page);
|
||||
const frames = {
|
||||
still: await page.evaluate(() => window.__bench.frameTimes(90)),
|
||||
turning: await page.evaluate(() => window.__bench.frameTimes(90, true)),
|
||||
};
|
||||
if (out)
|
||||
await page.screenshot({
|
||||
path: path.join(out, `dubai-${shape}-${kind}-${count}.png`),
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
window.__bench.set.destroy();
|
||||
for (let index = 0; index < 20; index++) await window.__bench.frame();
|
||||
});
|
||||
const released = await memory(page, cdp);
|
||||
const row = {
|
||||
path: kind,
|
||||
shape,
|
||||
count,
|
||||
...built,
|
||||
...frames,
|
||||
jsMB: mb(loaded.js - before.js),
|
||||
gpuMB: mb(loaded.gpu - before.gpu),
|
||||
navaraGpuMB:
|
||||
loaded.navara == null ? null : mb(loaded.navara - before.navara),
|
||||
passTexturesMB: passBytes == null ? null : mb(passBytes),
|
||||
jsAfterDestroyMB: mb(released.js - before.js),
|
||||
gpuAfterDestroyMB: mb(released.gpu - before.gpu),
|
||||
};
|
||||
rows.push(row);
|
||||
console.log(
|
||||
` N=${count}: drawn ${row.drawnMs.toFixed(0)} ms (commit ${row.commitMs.toFixed(0)}), frame still ${frames.still.median.toFixed(2)} ms (empty ${emptyFrames.still.median.toFixed(2)}), turning ${frames.turning.median.toFixed(2)} ms (empty ${emptyFrames.turning.median.toFixed(2)}), JS +${row.jsMB} MB, WebGL +${row.gpuMB} MB${row.navaraGpuMB == null ? '' : `, Navara gpuBytesEst +${row.navaraGpuMB} MB`}${row.passTexturesMB == null || kind !== 'navara-pass' ? '' : `, pass textures ${row.passTexturesMB} MB`}; after destroy JS +${row.jsAfterDestroyMB}, WebGL +${row.gpuAfterDestroyMB} MB`,
|
||||
);
|
||||
}
|
||||
// Edge quality over terrain: one polygon on the Hajar mountains, oblique.
|
||||
if (out && shape === 'polygons') {
|
||||
await page.evaluate(async (kind) => {
|
||||
const { engine, frame } = window.__bench;
|
||||
const ground = await window.__bench.ground(kind);
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(56.05, 24.98, 9000),
|
||||
orientation: { heading: 0.5, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
const set = ground.createSet({ id: 'bench-terrain', kind: 'polygon' });
|
||||
const ring = [];
|
||||
for (let k = 0; k < 24; k++) {
|
||||
const angle = (k / 24) * 2 * Math.PI;
|
||||
ring.push([
|
||||
56.12 + 0.03 * Math.cos(angle),
|
||||
25.07 + 0.022 * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
set.replace([
|
||||
{
|
||||
rings: [ring],
|
||||
fill: [1, 0.3, 0, 0.45],
|
||||
outline: { color: [1, 1, 0, 1], widthPx: 2 },
|
||||
},
|
||||
]);
|
||||
set.commit();
|
||||
for (let index = 0; index < 20; index++) await frame();
|
||||
}, kind);
|
||||
await settleTiles(page);
|
||||
await page.screenshot({ path: path.join(out, `terrain-${kind}.png`) });
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
try {
|
||||
for (const kind of paths) await measure(browser, kind);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (out)
|
||||
fs.writeFileSync(
|
||||
path.join(out, `bench-${shape}.json`),
|
||||
JSON.stringify(rows, null, 2),
|
||||
);
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The engine.ground prototypes of earthquakes and fire perimeters
|
||||
* (src/layers/earthquakes/render/ground.js,
|
||||
* src/layers/perimeters/render/ground.js) against what each engine draws
|
||||
* today, on fixed data over California in daylight. Per engine: the canvas
|
||||
* without the layers, with today's renderers, with the ground prototypes;
|
||||
* the pixels each drawing changed (a channel off by more than 24), and
|
||||
* their overlap (intersection over union) with today's Cesium drawing; a
|
||||
* pick of a quake and of a perimeter through engine.picker. Writes the
|
||||
* images and a side-by-side to --out.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-prototypes.mjs http://localhost:4271 --out=DIR
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const args = process.argv.slice(2);
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const out = option('out') ?? 'qa-ground-prototypes';
|
||||
if (!origin) {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-ground-prototypes.mjs <origin> --out=DIR',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const VIEW = { lon: -119.6, lat: 37.2, alt: 700000 };
|
||||
|
||||
async function capture(page, kind) {
|
||||
const ui = await page.evaluate(
|
||||
async (kind, view) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const frame = () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('ground-prototypes');
|
||||
});
|
||||
const settle = async () => {
|
||||
let loaded = 0;
|
||||
const started = performance.now();
|
||||
while (loaded < 20 && performance.now() - started < 60000) {
|
||||
await frame();
|
||||
loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0;
|
||||
}
|
||||
};
|
||||
const shot = async () => {
|
||||
await settle();
|
||||
const canvas = engine.host.canvas;
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = canvas.clientWidth;
|
||||
copy.height = canvas.clientHeight;
|
||||
copy.getContext('2d').drawImage(canvas, 0, 0, copy.width, copy.height);
|
||||
return copy.toDataURL('image/png');
|
||||
};
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { depthColor } = await import('/src/layers/earthquakes/model.js');
|
||||
// Fixed quakes and perimeters (one with a hole) in the view.
|
||||
const quakes = [
|
||||
[-121.8, 36.6, 3.1, 8],
|
||||
[-120.6, 35.9, 4.6, 12],
|
||||
[-118.4, 37.6, 5.4, 9],
|
||||
[-117.6, 36.2, 2.8, 120],
|
||||
[-121.0, 38.4, 3.9, 400],
|
||||
[-119.0, 38.9, 6.0, 15],
|
||||
].map(([lon, lat, mag, depthKm], index) => ({
|
||||
stableId: `q${index}`,
|
||||
usgsId: `q${index}`,
|
||||
position: geo.cartesian3.fromDegrees(lon, lat),
|
||||
baseRadius: 2 ** mag * 1000,
|
||||
color: depthColor(depthKm),
|
||||
isSignificant: mag >= 5,
|
||||
fillAlpha: mag >= 5 ? 0.4 : 0.3,
|
||||
outlineAlpha: mag >= 5 ? 1 : 0.8,
|
||||
mag,
|
||||
place: `fixture ${index}`,
|
||||
time: 0,
|
||||
depthKm,
|
||||
}));
|
||||
const blob = (lon, lat, rx, ry, count = 40) => {
|
||||
const ring = [];
|
||||
for (let k = 0; k < count; k++) {
|
||||
const angle = (k / count) * 2 * Math.PI;
|
||||
const wobble = 1 + 0.18 * Math.sin(angle * 5);
|
||||
ring.push([
|
||||
lon + rx * wobble * Math.cos(angle),
|
||||
lat + ry * wobble * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
ring.push(ring[0]);
|
||||
return ring;
|
||||
};
|
||||
const perimeters = [
|
||||
{
|
||||
stableId: 'p0',
|
||||
containedPct: 10,
|
||||
polygons: [
|
||||
[blob(-120.2, 37.9, 0.35, 0.25), blob(-120.2, 37.9, 0.12, 0.08)],
|
||||
],
|
||||
},
|
||||
{
|
||||
stableId: 'p1',
|
||||
containedPct: 60,
|
||||
polygons: [[blob(-118.9, 36.6, 0.25, 0.3)]],
|
||||
},
|
||||
{
|
||||
stableId: 'p2',
|
||||
containedPct: 100,
|
||||
polygons: [
|
||||
[blob(-121.4, 37.4, 0.15, 0.12)],
|
||||
[blob(-121.1, 37.1, 0.1, 0.1)],
|
||||
],
|
||||
},
|
||||
];
|
||||
// A restored view settles after the loading screen: take the camera.
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(view.lon, view.lat, view.alt),
|
||||
});
|
||||
const images = { base: await shot() };
|
||||
// Today's renderers on this engine.
|
||||
let quakeRenderer;
|
||||
let perimeterRenderer;
|
||||
if (kind === 'cesium') {
|
||||
const { viewer } = engine.extensions.cesium;
|
||||
const { createEarthquakesCesiumRenderer } =
|
||||
await import('/src/layers/earthquakes/render/cesium.js');
|
||||
const { createPerimetersCesiumRenderer } =
|
||||
await import('/src/layers/perimeters/render/cesium.js');
|
||||
quakeRenderer = createEarthquakesCesiumRenderer(engine, { viewer });
|
||||
perimeterRenderer = createPerimetersCesiumRenderer({
|
||||
viewer,
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const navara = engine.extensions.navara;
|
||||
const { createEarthquakesNavaraRenderer } =
|
||||
await import('/src/layers/earthquakes/render/navara.js');
|
||||
const { createPerimetersNavaraRenderer } =
|
||||
await import('/src/layers/perimeters/render/navara.js');
|
||||
quakeRenderer = createEarthquakesNavaraRenderer(engine, navara);
|
||||
perimeterRenderer = createPerimetersNavaraRenderer(engine, navara);
|
||||
}
|
||||
quakeRenderer.replace(quakes);
|
||||
quakeRenderer.setVisible(true);
|
||||
perimeterRenderer.replace(perimeters);
|
||||
perimeterRenderer.setVisible(true);
|
||||
for (let index = 0; index < 60; index++) await frame();
|
||||
images.today = await shot();
|
||||
quakeRenderer.destroy();
|
||||
perimeterRenderer.destroy();
|
||||
// The prototypes on engine.ground.
|
||||
const { createEarthquakesGroundRenderer } =
|
||||
await import('/src/layers/earthquakes/render/ground.js');
|
||||
const { createPerimetersGroundRenderer } =
|
||||
await import('/src/layers/perimeters/render/ground.js');
|
||||
const quakesOnGround = createEarthquakesGroundRenderer(engine);
|
||||
const perimetersOnGround = createPerimetersGroundRenderer(engine);
|
||||
quakesOnGround.replace(quakes);
|
||||
quakesOnGround.setVisible(true);
|
||||
perimetersOnGround.replace(perimeters);
|
||||
perimetersOnGround.setVisible(true);
|
||||
const ready = () =>
|
||||
engine.ground.list().every(({ id }) => id) &&
|
||||
['earthquakes', 'fire-perimeters'].every((id) =>
|
||||
engine.ground.list().some((entry) => entry.id === id),
|
||||
);
|
||||
while (!ready()) await frame();
|
||||
for (let index = 0; index < 60; index++) await frame();
|
||||
images.ground = await shot();
|
||||
const screen = (lon, lat) =>
|
||||
engine.projector.worldToWindow(geo.cartesian3.fromDegrees(lon, lat));
|
||||
const picks = {
|
||||
quake: engine.picker.pick(screen(-118.4, 37.6))?.id ?? null,
|
||||
perimeter: engine.picker.pick(screen(-118.9, 36.6))?.id ?? null,
|
||||
hole: engine.picker.pick(screen(-120.2, 37.9))?.id ?? null,
|
||||
records: quakesOnGround.records(10).length,
|
||||
};
|
||||
quakesOnGround.destroy();
|
||||
perimetersOnGround.destroy();
|
||||
return { images, picks };
|
||||
},
|
||||
kind,
|
||||
VIEW,
|
||||
);
|
||||
return ui;
|
||||
}
|
||||
|
||||
async function openEngine(browser, kind) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (error) => console.log(' page error:', error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
// Daylight over California (Navara lights the scene by the app clock).
|
||||
await page.evaluate(async () => {
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7, 20, 0));
|
||||
});
|
||||
return page;
|
||||
}
|
||||
|
||||
const decode = async (dataUrl) => {
|
||||
const { data, info } = await sharp(
|
||||
Buffer.from(dataUrl.split(',')[1], 'base64'),
|
||||
)
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
return { data, width: info.width, height: info.height };
|
||||
};
|
||||
// Pixels where `image` differs from `base` by more than 24 on a channel.
|
||||
const changed = (base, image) => {
|
||||
const mask = new Uint8Array(base.width * base.height);
|
||||
for (let i = 0; i < mask.length; i++)
|
||||
for (let c = 0; c < 3; c++)
|
||||
if (Math.abs(base.data[i * 4 + c] - image.data[i * 4 + c]) > 24) {
|
||||
mask[i] = 1;
|
||||
break;
|
||||
}
|
||||
return mask;
|
||||
};
|
||||
const iou = (a, b) => {
|
||||
let both = 0;
|
||||
let either = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
both += a[i] & b[i];
|
||||
either += a[i] | b[i];
|
||||
}
|
||||
return either ? both / either : 1;
|
||||
};
|
||||
const count = (mask) => mask.reduce((total, value) => total + value, 0);
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
const results = {};
|
||||
try {
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
const page = await openEngine(browser, kind);
|
||||
results[kind] = await capture(page, kind);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
const masks = {};
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
const { images, picks } = results[kind];
|
||||
const base = await decode(images.base);
|
||||
masks[kind] = {
|
||||
today: changed(base, await decode(images.today)),
|
||||
ground: changed(base, await decode(images.ground)),
|
||||
};
|
||||
for (const name of ['base', 'today', 'ground'])
|
||||
fs.writeFileSync(
|
||||
path.join(out, `${kind}-${name}.png`),
|
||||
Buffer.from(images[name].split(',')[1], 'base64'),
|
||||
);
|
||||
console.log(
|
||||
`${kind}: today changed ${count(masks[kind].today)} px, ground ${count(masks[kind].ground)} px; picks ${JSON.stringify(picks)}`,
|
||||
);
|
||||
}
|
||||
const reference = masks.cesium.today;
|
||||
console.log(
|
||||
`overlap with today's Cesium drawing (IoU of changed pixels): Cesium ground ${iou(reference, masks.cesium.ground).toFixed(3)}, Navara ground ${iou(reference, masks.navara.ground).toFixed(3)}, Navara today ${iou(reference, masks.navara.today).toFixed(3)}`,
|
||||
);
|
||||
// Side by side: Cesium today, Cesium ground, Navara today, Navara ground.
|
||||
const tiles = await Promise.all(
|
||||
['cesium-today', 'cesium-ground', 'navara-today', 'navara-ground'].map(
|
||||
(name) =>
|
||||
sharp(path.join(out, `${name}.png`))
|
||||
.resize(640, 400)
|
||||
.png()
|
||||
.toBuffer(),
|
||||
),
|
||||
);
|
||||
await sharp({
|
||||
create: { width: 1280, height: 800, channels: 3, background: '#000' },
|
||||
})
|
||||
.composite(
|
||||
tiles.map((input, index) => ({
|
||||
input,
|
||||
left: (index % 2) * 640,
|
||||
top: Math.floor(index / 2) * 400,
|
||||
})),
|
||||
)
|
||||
.png()
|
||||
.toFile(path.join(out, 'side-by-side.png'));
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run the contract suite's engine.ground checks in headless Chrome on both
|
||||
* real engines, with what qa-contract-suite.mjs does not give the suite: a
|
||||
* readRegion of the engine canvas (the pixel check) besides the real
|
||||
* pointer (the click pick). Serve the app keyless:
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-suite.mjs http://localhost:4271 [--engine=cesium,navara] [--only=ground:]
|
||||
*
|
||||
* Exits 1 when a check fails on any engine.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const args = process.argv.slice(2);
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
if (!origin) {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-ground-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||
const only = (option('only') ?? 'ground:').split(',');
|
||||
|
||||
async function runOn(browser, kind) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.engine.camera.flightActive(),
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
);
|
||||
// The globe drawn and its tiles loaded: a restored view hides the globe
|
||||
// for a moment after the loading screen goes.
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const probe = document.createElement('canvas');
|
||||
probe.width = 1;
|
||||
probe.height = 1;
|
||||
const context = probe.getContext('2d', { willReadFrequently: true });
|
||||
const started = performance.now();
|
||||
let settled = 0;
|
||||
while (performance.now() - started < 120_000 && settled < 20) {
|
||||
await new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('ground-suite-settle');
|
||||
});
|
||||
context.drawImage(
|
||||
canvas,
|
||||
canvas.width / 2,
|
||||
canvas.height / 2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
settled =
|
||||
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
|
||||
}
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.engine.host.canvas.setAttribute(
|
||||
'data-contract-target',
|
||||
'',
|
||||
),
|
||||
);
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
|
||||
});
|
||||
await page.exposeFunction('__contractPointer', async (type, x, y) => {
|
||||
const box = await page.evaluate(() => {
|
||||
const rect =
|
||||
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
|
||||
return { left: rect.left, top: rect.top };
|
||||
});
|
||||
const at = [box.left + x, box.top + y];
|
||||
if (type === 'click') await page.mouse.click(...at);
|
||||
else {
|
||||
await page.mouse.move(at[0] - 1, at[1]);
|
||||
await page.mouse.move(...at);
|
||||
}
|
||||
});
|
||||
const results = await page.evaluate(async (only) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const waitFrames = (count) =>
|
||||
new Promise((resolve) => {
|
||||
let left = count;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--left > 0) return;
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('ground-suite');
|
||||
});
|
||||
const pointer = {
|
||||
click: (x, y) => window.__contractPointer('click', x, y),
|
||||
move: (x, y) => window.__contractPointer('move', x, y),
|
||||
};
|
||||
// RGBA of the last drawn frame (both engines keep their drawing buffer).
|
||||
const scratch = document.createElement('canvas');
|
||||
const context = scratch.getContext('2d', { willReadFrequently: true });
|
||||
const readRegion = (x, y, width, height) => {
|
||||
const canvas = engine.host.canvas;
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
scratch.width = width;
|
||||
scratch.height = height;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(
|
||||
canvas,
|
||||
x * scale,
|
||||
y * scale,
|
||||
width * scale,
|
||||
height * scale,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
return context.getImageData(0, 0, width, height).data;
|
||||
};
|
||||
const makeCanvas = (width, height) =>
|
||||
Object.assign(document.createElement('canvas'), { width, height });
|
||||
const { createContractSuite } =
|
||||
await import('/src/globe/testing/contractSuite.mjs');
|
||||
const outcome = [];
|
||||
for (const { name, run } of createContractSuite(engine, {
|
||||
waitFrames,
|
||||
pointer,
|
||||
readRegion,
|
||||
makeCanvas,
|
||||
})) {
|
||||
if (!only.some((prefix) => name.startsWith(prefix))) continue;
|
||||
try {
|
||||
await run();
|
||||
outcome.push({ name, ok: true });
|
||||
} catch (error) {
|
||||
outcome.push({
|
||||
name,
|
||||
ok: false,
|
||||
error: String(error?.message ?? error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}, only);
|
||||
await page.close();
|
||||
return { engine: kind, results, pageErrors };
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
let failed = false;
|
||||
try {
|
||||
for (const kind of engines) {
|
||||
const { results, pageErrors } = await runOn(browser, kind);
|
||||
const passed = results.filter((result) => result.ok).length;
|
||||
console.log(`${kind}: ${passed}/${results.length} checks pass`);
|
||||
for (const result of results)
|
||||
if (!result.ok) {
|
||||
failed = true;
|
||||
console.log(` FAIL ${result.name}: ${result.error}`);
|
||||
} else console.log(` ok ${result.name}`);
|
||||
for (const message of pageErrors) console.log(` page error: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Show and hide of ground fills on Navara before and after an engine switch
|
||||
* (Navara → Cesium → Navara, the parked view taken up again): Navara 0.2.1
|
||||
* dropped the first `show: false` of a vector layer added right after a
|
||||
* switch. For engine.ground's screen pass and the vector-layer prototype
|
||||
* (hidden by layer.update with show false, or by deleting its layers): a
|
||||
* magenta cell hidden right after its commit must not show, then show, then
|
||||
* hide again. The pixels are read from the engine canvas.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-switch.mjs http://localhost:4271
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { pickEngine, waitForEngine } from './qa-strict-harness.mjs';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const origin = process.argv[2];
|
||||
if (!origin) {
|
||||
console.error('Usage: node scripts/qa-ground-switch.mjs <origin>');
|
||||
process.exit(2);
|
||||
}
|
||||
const APPROACHES = ['pass', 'vector-update', 'vector-delete'];
|
||||
|
||||
async function trial(page, approach, round) {
|
||||
return page.evaluate(
|
||||
async (approach, round) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const frame = () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('ground-switch');
|
||||
});
|
||||
let ground = engine.ground;
|
||||
let owner = null;
|
||||
if (approach !== 'pass') {
|
||||
const { createNavaraVectorGround } =
|
||||
await import('/src/globe/navara/ground/vector.js');
|
||||
owner = createNavaraVectorGround(engine.extensions.navara.view, {
|
||||
hide: approach === 'vector-update' ? 'update' : 'delete',
|
||||
});
|
||||
ground = owner.ground;
|
||||
}
|
||||
const lon = 20 + round * 1.5 + APPROACH_INDEX[approach] * 0.5;
|
||||
const spot = engine.projector.worldToWindow(
|
||||
geo.cartesian3.fromDegrees(lon + 0.15, 10),
|
||||
);
|
||||
const magenta = () => {
|
||||
const canvas = engine.host.canvas;
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = 16;
|
||||
copy.height = 16;
|
||||
const context = copy.getContext('2d');
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
context.drawImage(
|
||||
canvas,
|
||||
(spot.x - 8) * scale,
|
||||
(spot.y - 8) * scale,
|
||||
16 * scale,
|
||||
16 * scale,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
);
|
||||
const data = context.getImageData(0, 0, 16, 16).data;
|
||||
let lit = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
if (
|
||||
data[i] > 150 &&
|
||||
data[i + 2] > 150 &&
|
||||
data[i + 1] < Math.min(data[i], data[i + 2]) - 60
|
||||
)
|
||||
lit++;
|
||||
return lit / 256;
|
||||
};
|
||||
// Frames until `want` (shown or not) holds, up to 4 s.
|
||||
const until = async (want) => {
|
||||
const started = performance.now();
|
||||
while (performance.now() - started < 4000) {
|
||||
await frame();
|
||||
if (magenta() > 0.5 === want) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const set = ground.createSet({
|
||||
id: `switch-${approach}-${round}`,
|
||||
kind: 'cell',
|
||||
});
|
||||
set.replace([
|
||||
{
|
||||
west: lon,
|
||||
south: 9.85,
|
||||
east: lon + 0.3,
|
||||
north: 10.15,
|
||||
fill: [1, 0, 1, 1],
|
||||
},
|
||||
]);
|
||||
set.commit();
|
||||
set.show(false);
|
||||
// Hidden right after the commit: never drawn.
|
||||
let drawnWhileHidden = false;
|
||||
for (let index = 0; index < 90; index++) {
|
||||
await frame();
|
||||
if (magenta() > 0.5) drawnWhileHidden = true;
|
||||
}
|
||||
set.show(true);
|
||||
const shows = await until(true);
|
||||
set.show(false);
|
||||
const hides = await until(false);
|
||||
set.destroy();
|
||||
owner?.destroy();
|
||||
return { hiddenAfterCommit: !drawnWhileHidden, shows, hides };
|
||||
},
|
||||
approach,
|
||||
round,
|
||||
);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
let failed = false;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (error) => console.log(' page error:', error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.evaluateOnNewDocument((approaches) => {
|
||||
window.APPROACH_INDEX = Object.fromEntries(
|
||||
approaches.map((name, index) => [name, index]),
|
||||
);
|
||||
}, APPROACHES);
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=navara#v=2&lat=10&lon=21&alt=900000&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await waitForEngine(page, 'navara', { timeout: 300_000 });
|
||||
await page.evaluate(async () => {
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7, 11, 0));
|
||||
});
|
||||
const report = (label, result) => {
|
||||
const ok = result.hiddenAfterCommit && result.shows && result.hides;
|
||||
if (!ok) failed = true;
|
||||
console.log(
|
||||
` ${label}: hidden right after commit ${result.hiddenAfterCommit ? 'yes' : 'NO'}, shows ${result.shows ? 'yes' : 'NO'}, hides again ${result.hides ? 'yes' : 'NO'}`,
|
||||
);
|
||||
};
|
||||
console.log('before a switch:');
|
||||
for (const approach of APPROACHES)
|
||||
report(approach, await trial(page, approach, 0));
|
||||
await pickEngine(page, 'cesium');
|
||||
await waitForEngine(page, 'cesium', { timeout: 300_000 });
|
||||
await pickEngine(page, 'navara');
|
||||
await waitForEngine(page, 'navara', { timeout: 300_000 });
|
||||
await page.evaluate(async () => {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(21, 10, 900000),
|
||||
});
|
||||
});
|
||||
console.log('after Navara → Cesium → Navara:');
|
||||
for (const approach of APPROACHES)
|
||||
report(approach, await trial(page, approach, 1));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in new issue
Block a user