/**
* The conformance page: runs the logic suite in the browser, and draws the
* reference scenes on a renderer to check them against the stored
* references (or record new ones). Driven by hand or through the
* chrome-devtools MCP; `window.conformance` exposes the same actions.
*/
import {
DEFAULT_TOLERANCE,
runConformance,
} from '../../src/render/conformance/index.js';
import {
REFERENCE_SCENES,
sceneSupport,
} from '../../src/render/conformance/scenes.js';
import {
compareImages,
drawScene,
referenceName,
} from '../../src/render/conformance/visual.js';
import { createStubHarness } from '../../src/render/stub/harness.js';
import { toEcef } from '../../src/render/stub/math.js';
import { createCesiumSceneRenderer } from '../../src/render/cesium/sceneRenderer.js';
import {
CESIUM_CHECKS,
createCesiumHarness,
} from '../../src/render/cesium/harness.js';
const REFERENCES = '/src/render/conformance/goldens/visual/';
const SAVE = '/__conformance/references/';
const ctx = { toEcef, assets: { model: '/models/c172.glb' } };
const $ = (id) => document.getElementById(id);
const status = (text) => ($('status').textContent = text);
const RENDERERS = {
cesium: () => createCesiumSceneRenderer({ container: $('stage') }),
};
const HARNESSES = {
stub: () => [createStubHarness('stub (browser)'), {}],
cesium: () => [
createCesiumHarness({ model: ctx.assets.model }),
{ only: (id) => CESIUM_CHECKS.has(id) },
],
};
/** Runs the logic suite on `which`; `all` runs every check, listed or not. */
async function runLogic(which = 'stub', { all = false } = {}) {
status(`running the logic suite on ${which}…`);
const [harness, options] = HARNESSES[which]();
const report = await runConformance(harness, all ? {} : options);
$('logicResults').innerHTML =
`
${report.harness}: ${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped
${report.results
.filter((r) => r.status !== 'pass')
.map(
(r) =>
`| ${r.status} | ${r.id} | ${escape(r.detail ?? '')} |
`,
)
.join('')}
`;
status('');
return {
passed: report.passed,
failed: report.failed,
skipped: report.skipped,
failures: report.results.filter((r) => r.status === 'fail'),
};
}
const escape = (s) =>
s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
async function pixels(blob) {
const bitmap = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const g = canvas.getContext('2d');
g.drawImage(bitmap, 0, 0);
return g.getImageData(0, 0, bitmap.width, bitmap.height);
}
// Object URLs shown for the current run, revoked when the next one starts.
let objectUrls = [];
function objectUrl(blob) {
const url = URL.createObjectURL(blob);
objectUrls.push(url);
return url;
}
async function toUrl(imageData) {
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
canvas.getContext('2d').putImageData(imageData, 0, 0);
return objectUrl(await canvas.convertToBlob());
}
/** The views the stored manifest lists, or none. */
async function storedViews() {
try {
const res = await fetch(REFERENCES + 'manifest.json', {
cache: 'no-store',
});
return res.ok ? ((await res.json()).views ?? []) : [];
} catch {
return [];
}
}
async function reference(name) {
const res = await fetch(REFERENCES + name, { cache: 'no-store' });
return res.ok && res.headers.get('content-type')?.startsWith('image/')
? res.blob()
: null;
}
/** Draws every scene; records references or compares against them. */
async function runScenes({ record = false, only } = {}) {
const which = $('renderer').value;
const renderer = await RENDERERS[which]();
const results = [];
$('views').innerHTML = '';
for (const url of objectUrls) URL.revokeObjectURL(url);
objectUrls = [];
try {
for (const scene of REFERENCE_SCENES) {
if (only && !only.includes(scene.id)) continue;
const unsupported = sceneSupport(scene, renderer, ctx);
if (unsupported) {
results.push({ scene: scene.id, status: 'skip', detail: unsupported });
continue;
}
status(`${scene.id}…`);
await drawScene(renderer, scene, ctx, async (view, capture, settle) => {
const name = referenceName(scene.id, view.name);
const figure = document.createElement('figure');
$('views').append(figure);
if (settle.state !== 'settled') {
const detail = `${settle.state}: ${settle.blockers.join(', ')}`;
results.push({
scene: scene.id,
view: view.name,
status: 'fail',
detail,
});
figure.innerHTML = `
${name}: ${escape(detail)}`;
return;
}
if (record) {
const res = await fetch(SAVE + name, {
method: 'PUT',
body: capture,
});
results.push({
scene: scene.id,
view: view.name,
status: res.ok ? 'recorded' : 'fail',
detail: res.ok ? undefined : `save ${res.status}`,
});
figure.innerHTML = `
${name}: recorded`;
return;
}
const refBlob = await reference(name);
if (!refBlob) {
results.push({
scene: scene.id,
view: view.name,
status: 'fail',
detail: 'no reference',
});
figure.innerHTML = `
${name}: no reference`;
return;
}
const [actual, expected] = await Promise.all([
pixels(capture),
pixels(refBlob),
]);
const diff = new Uint8ClampedArray(actual.data.length);
const outcome = compareImages(actual, expected, { diff });
results.push({
scene: scene.id,
view: view.name,
status: outcome.match ? 'pass' : 'fail',
diffFraction: outcome.diffFraction,
solidPixels: outcome.solidPixels,
mismatchedPixels: outcome.mismatchedPixels,
...(outcome.reason ? { detail: outcome.reason } : {}),
});
const diffUrl = outcome.reason
? ''
: await toUrl(new ImageData(diff, actual.width, actual.height));
figure.innerHTML = `
${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}`;
});
}
if (record) {
// A partial recording keeps the other scenes' entries.
const kept = only
? (await storedViews()).filter(
(view) => !only.some((id) => view.startsWith(`${id}-`)),
)
: [];
const recorded = results
.filter((r) => r.status === 'recorded')
.map((r) => referenceName(r.scene, r.view));
await fetch(SAVE + 'manifest.json', {
method: 'PUT',
body:
JSON.stringify(
{
renderer: which,
size: {
width: renderer.canvas.width,
height: renderer.canvas.height,
},
views: [...kept, ...recorded],
},
null,
2,
) + '\n',
});
}
} finally {
renderer.destroy();
status('');
}
return results;
}
$('logic').onclick = () => runLogic('stub');
$('logicCesium').onclick = () => runLogic('cesium');
$('check').onclick = () => runScenes();
$('record').onclick = () => runScenes({ record: true });
window.conformance = { runLogic, runScenes };