Add raster imagery to the globe contract and draw FIRMS heat and Recent Imagery through it

engine.imagery draws raster layers over the globe: Web Mercator tile
templates and single images or canvases stretched over a rectangle, with
alpha, show, a left/right split against the engine's split position and
an order. A layer's source can be replaced in place.

- Cesium puts one ImageryLayer per layer on the globe's imagery stack,
  or on the shown 3D tileset's when the globe is hidden, and moves it
  when the surface changes; the Recent Imagery request gate moved here.
- Navara draws them in one screen pass after tone mapping that samples up
  to eight layers by geodetic position from mipmapped textures and a tile
  atlas, under the other drapes and engine.ground.
- The fake engine keeps each layer's state.

The FIRMS far-zoom heat raster and Recent Imagery now draw through it, so
FIRMS' heat seam and both its backends, and Recent Imagery's Cesium and
Navara renderers and tile plan, go. Against HEAD, Cesium's pixels are
identical in all four compared scenes; Navara's orbital heat changes by a
mean of 3.8 from new mipmapping, slightly towards Cesium. The contract
suite passes on both engines (Cesium 68/68, Navara 71/71).

engine.maps.addOverlay stays a Navara-only extension for Sea Marks and
Ski Pistes until imagery has a finer tile path for oblique global views.
This commit is contained in:
German Khokhlov committed 2026-11-07 08:07:57 +04:00
1 parent 1559bd57f4
commit 406dd9122e
52 files changed
+4171 -1988

No files matched your search

+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env node
/**
* Draw the rasters that moved to engine.imagery — the Active Fires far-zoom
* heatmap and Recent Imagery — on both real engines, read the canvas back,
* and compare two runs pixel by pixel (before and after a change), and the
* two engines against each other. Run it against a keyless dev server of
* each version, then compare:
*
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
* node scripts/qa-imagery-compare.mjs shoot http://localhost:4291 --out=DIR
* node scripts/qa-imagery-compare.mjs compare BEFORE_DIR AFTER_DIR
*
* Every scene freezes the app clock at local noon (Navara lights the scene
* with the app clock's sun) and waits for the map tiles. The heatmap is a
* fixed set of fires through the layer's own raster builder; Recent Imagery
* draws generated tiles (a gradient per tile, intercepted at qa-tiles.test)
* through the layer's renderer from the registry, falling back to the
* renderers of older versions. Each scene is shot with and without the
* raster: `overlay` is where the raster changed the frame. Google and
* Cesium ion are blocked, so no engine draws photoreal tiles.
*/
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 TILE_HOST = 'qa-tiles.test';
const args = process.argv.slice(2);
const [command, first, second] = args.filter((arg) => !arg.startsWith('--'));
const option = (name) =>
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const SIZE = { width: 1280, height: 800 };
// Fires on three continents, a dense cluster and a sparse field each.
const FIRES = [
...Array.from({ length: 400 }, (_, i) => ({
lat: 8 + (i % 20) * 0.08,
lon: 18 + Math.floor(i / 20) * 0.09,
frp: 40 + (i % 7) * 30,
})),
...Array.from({ length: 120 }, (_, i) => ({
lat: -12 + (i % 12) * 0.9,
lon: 22 + Math.floor(i / 12) * 1.1,
frp: 8 + (i % 5) * 6,
})),
...Array.from({ length: 60 }, (_, i) => ({
lat: 4 + (i % 6) * 0.5,
lon: 30 + Math.floor(i / 6) * 0.4,
frp: 200,
})),
];
const SCENES = [
{
id: 'heat-orbit',
kind: 'heat',
view: { lon: 22, lat: 2, height: 12_000_000 },
noonUtc: 10.5,
},
{
id: 'heat-regional',
kind: 'heat',
view: { lon: 19, lat: 8.8, height: 1_500_000 },
noonUtc: 10.5,
},
{
id: 'imagery-box',
kind: 'imagery',
view: { lon: -97.75, lat: 30.25, height: 16_000 },
noonUtc: 18.5,
slots: [{ id: 'a', product: 'S30', alpha: 1, split: 'none' }],
},
{
id: 'imagery-swipe',
kind: 'imagery',
view: { lon: -97.75, lat: 30.25, height: 16_000 },
noonUtc: 18.5,
split: 0.5,
slots: [
{ id: 'a', product: 'S30', alpha: 0.7, split: 'left' },
{ id: 'b', product: 'VIIRS', alpha: 1, split: 'right' },
],
},
];
const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
// A 256 px tile: red across, green down, blue by the tile's parity; the
// VIIRS product (JPEG on GIBS) is a darker variant.
const tileCache = new Map();
async function tile(z, x, y, dark) {
const key = `${z}/${x}/${y}/${dark}`;
if (tileCache.has(key)) return tileCache.get(key);
const data = Buffer.alloc(256 * 256 * 4);
const parity = (x + y) % 2 ? 200 : 40;
for (let row = 0; row < 256; row++)
for (let column = 0; column < 256; column++) {
const i = (row * 256 + column) * 4;
const scale = dark ? 0.6 : 1;
data[i] = column * scale;
data[i + 1] = row * scale;
data[i + 2] = parity * scale;
data[i + 3] = 255;
}
const png = await sharp(data, {
raw: { width: 256, height: 256, channels: 4 },
})
.png()
.toBuffer();
tileCache.set(key, png);
return png;
}
async function shootScene(browser, origin, kind, scene) {
const page = await browser.newPage();
await page.setViewport(SIZE);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.setRequestInterception(true);
let tiles = 0;
page.on('request', async (request) => {
const url = new URL(request.url());
if (url.hostname === TILE_HOST) {
tiles++;
const [, product, z, y, x] = url.pathname.match(
/^\/(\w+)\/(\d+)\/(\d+)\/(\d+)\.png$/,
);
await request.respond({
status: 200,
contentType: 'image/png',
headers: { 'Access-Control-Allow-Origin': '*' },
body: await tile(+z, +x, +y, product === 'VIIRS'),
});
} else if (BLOCKED_HOSTS.test(url.hostname)) request.abort();
else request.continue();
});
const { lon, lat, height } = scene.view;
await page.goto(
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${lat}&lon=${lon}&alt=${height}&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 scene: the view, noon, and helpers the shots below use.
await page.evaluate(
async ({ scene, fires, box, tileHost }) => {
const { engine } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
const { appClock } = await import('/src/time/clock.js');
const noon = Date.UTC(2026, 10, 7) + scene.noonUtc * 3_600_000;
appClock.freeze(noon);
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(
scene.view.lon,
scene.view.lat,
scene.view.height,
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
// `count` frames, each asked for (a scene at rest draws only on request).
const frames = (count) =>
new Promise((resolve) => {
let left = count;
const remove = engine.frames.postRender.addEventListener(() => {
if (--left > 0) {
setTimeout(() => engine.render.request('qa-imagery'), 0);
return;
}
remove();
resolve();
});
engine.render.request('qa-imagery');
});
const { rendererFor } = await import('/src/app/layers/renderers.js');
const qa = { frames, raster: null };
window.__qaImagery = qa;
if (scene.kind === 'heat') {
const [{ buildHeatRaster }, { HEAT_RASTER }] = await Promise.all([
import('/src/data/firmsHeatRaster.js'),
import('/src/layers/firms/policy.js'),
]);
const display = rendererFor('local-firms', engine, { id: 'qa-firms' });
const heat = display.createHeatImagery();
const raster = buildHeatRaster(fires, HEAT_RASTER);
qa.show = () => {
heat.setRaster(raster);
heat.setOpacity(1);
heat.setShow(true);
};
qa.hide = () => heat.setShow(false);
return;
}
const options = {
tileTemplate: (product) =>
`https://${tileHost}/${product}/{z}/{y}/{x}.png`,
credit: 'QA tiles',
};
let renderer = rendererFor('recent-imagery', engine, options);
const viewer = engine.extensions?.cesium?.viewer;
if (!renderer) {
// Versions before engine.imagery: Cesium's own renderer on the globe.
const { createRecentImageryRenderer } =
await import('/src/layers/recentImagery/rendering.js');
renderer = createRecentImageryRenderer({
...options,
requestRender: () => engine.render.request('qa-imagery'),
});
renderer.rebind({ collection: viewer.imageryLayers, kind: 'globe' });
} else renderer.rebind(renderer.resolveHost());
const candidate = (product) => ({
key: `${product}:2026-10-28`,
product,
day: '2026-10-28',
});
qa.show = () => {
if (scene.split != null) {
renderer.setSplit?.(scene.split);
if (viewer) viewer.scene.splitPosition = scene.split;
}
for (const slot of scene.slots)
renderer.showSlot(slot.id, candidate(slot.product), box, {
alpha: slot.alpha,
splitDirection: slot.split,
});
};
qa.hide = () => {
for (const slot of scene.slots) renderer.hideSlot(slot.id);
};
},
{ scene, fires: FIRES, box: BOX, tileHost: TILE_HOST },
);
// Settle: the map tiles loaded, then tile requests quiet for 2 s.
const settle = async () => {
for (let round = 0; round < 120; round++) {
const before = tiles;
await page.evaluate(() => window.__qaImagery.frames(10));
await new Promise((resolve) => setTimeout(resolve, 500));
const loaded = await page.evaluate(() =>
window.__godsEyeView.engine.surface.tilesLoaded(),
);
if (loaded && tiles === before && round >= 4) break;
}
await page.evaluate(() => window.__qaImagery.frames(20));
};
const read = () =>
page.evaluate(() => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const scratch = document.createElement('canvas');
scratch.width = canvas.clientWidth;
scratch.height = canvas.clientHeight;
const context = scratch.getContext('2d');
context.drawImage(canvas, 0, 0, scratch.width, scratch.height);
return scratch.toDataURL('image/png');
});
await settle();
const without = await read();
await page.evaluate(() => window.__qaImagery.show());
await settle();
const withRaster = await read();
await page.close();
return { without, with: withRaster, errors };
}
async function shoot(origin, out) {
fs.mkdirSync(out, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
protocolTimeout: 600_000,
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
});
const engines = (option('engine') ?? 'cesium,navara').split(',');
const only = option('only')?.split(',') ?? null;
try {
for (const kind of engines)
for (const scene of SCENES) {
if (only && !only.includes(scene.id)) continue;
const shot = await shootScene(browser, origin, kind, scene);
for (const name of ['without', 'with'])
fs.writeFileSync(
path.join(out, `${kind}-${scene.id}-${name}.png`),
Buffer.from(shot[name].split(',')[1], 'base64'),
);
console.log(
`${kind} ${scene.id}: shot${shot.errors.length ? ` (page errors: ${shot.errors.join('; ')})` : ''}`,
);
}
} finally {
await browser.close();
}
}
const raw = async (file) =>
(await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }))
.data;
// Where the raster changed the frame (any channel by more than 6).
function overlayMask(without, withRaster) {
const mask = new Uint8Array(without.length / 4);
for (let p = 0; p < mask.length; p++)
for (let k = 0; k < 3; k++)
if (Math.abs(without[p * 4 + k] - withRaster[p * 4 + k]) > 6) {
mask[p] = 1;
break;
}
return mask;
}
// Per-pixel differences of two frames inside `mask` (or everywhere).
function difference(a, b, mask) {
let count = 0,
sum = 0,
over8 = 0,
over24 = 0,
max = 0;
for (let p = 0; p < a.length / 4; p++) {
if (mask && !mask[p]) continue;
let worst = 0;
for (let k = 0; k < 3; k++)
worst = Math.max(worst, Math.abs(a[p * 4 + k] - b[p * 4 + k]));
count++;
sum += worst;
if (worst > 8) over8++;
if (worst > 24) over24++;
max = Math.max(max, worst);
}
return {
pixels: count,
meanMaxChannel: count ? +(sum / count).toFixed(2) : 0,
over8: count ? +((100 * over8) / count).toFixed(2) : 0,
over24: count ? +((100 * over24) / count).toFixed(2) : 0,
max,
};
}
// The raster's own change of the frame: with minus without, per channel.
function contribution(without, withRaster) {
const out = new Int16Array(without.length);
for (let i = 0; i < without.length; i++) out[i] = withRaster[i] - without[i];
return out;
}
async function compare(beforeDir, afterDir) {
const report = {};
const engines = ['cesium', 'navara'];
for (const scene of SCENES) {
const row = (report[scene.id] = {});
const loaded = {};
for (const engine of engines)
for (const [version, dir] of [
['before', beforeDir],
['after', afterDir],
])
for (const name of ['without', 'with']) {
const file = path.join(dir, `${engine}-${scene.id}-${name}.png`);
if (fs.existsSync(file))
loaded[`${engine}-${version}-${name}`] = await raw(file);
}
for (const engine of engines) {
const before = loaded[`${engine}-before-with`];
const after = loaded[`${engine}-after-with`];
if (!before || !after) continue;
const mask = overlayMask(loaded[`${engine}-before-without`], before);
row[`${engine}: before vs after, where the raster draws`] = difference(
before,
after,
mask,
);
row[`${engine}: before vs after, whole frame`] = difference(
before,
after,
);
row[`${engine}: base map before vs after`] = difference(
loaded[`${engine}-before-without`],
loaded[`${engine}-after-without`],
);
}
// Navara against Cesium: the raster's change of each frame (the base
// maps are drawn differently), where Cesium's raster draws.
const reference = loaded['cesium-after-with'];
const navara = loaded['navara-after-with'];
if (reference && navara) {
const mask = overlayMask(loaded['cesium-after-without'], reference);
const a = contribution(loaded['cesium-after-without'], reference);
const b = contribution(loaded['navara-after-without'], navara);
const shift = (values) =>
Uint8Array.from(values, (value) =>
Math.max(0, Math.min(255, value + 128)),
);
row['navara vs cesium after: the raster’s change of the frame'] =
difference(shift(a), shift(b), mask);
row['navara vs cesium after: frames with the raster'] = difference(
reference,
navara,
mask,
);
const beforeReference = loaded['cesium-before-with'];
const beforeNavara = loaded['navara-before-with'];
if (beforeReference && beforeNavara)
row['navara vs cesium before: frames with the raster'] = difference(
beforeReference,
beforeNavara,
overlayMask(loaded['cesium-before-without'], beforeReference),
);
}
}
return report;
}
if (command === 'shoot' && first) {
await shoot(first, option('out') ?? 'qa-imagery');
} else if (command === 'compare' && first && second) {
const report = await compare(first, second);
const json = option('json');
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
for (const [scene, rows] of Object.entries(report)) {
console.log(scene);
for (const [label, stats] of Object.entries(rows))
console.log(
` ${label}: ${stats.pixels} px, mean ${stats.meanMaxChannel}, >8 ${stats.over8}%, >24 ${stats.over24}%, max ${stats.max}`,
);
}
} else {
console.error(
'Usage: node scripts/qa-imagery-compare.mjs shoot <origin> [--out=DIR] [--engine=cesium,navara] [--only=scene,...]\n' +
' node scripts/qa-imagery-compare.mjs compare <beforeDir> <afterDir> [--json=FILE]',
);
process.exit(2);
}