/** * 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) => ``, ) .join('')}
${r.status}${r.id}${escape(r.detail ?? '')}
`; 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 };