[object Object]

← back to Quadrille Showroom

Slice-3 F: FPS.ms.draws HUD readout + off-thread wall-clad decode (kills the select-moment dip)

0979b2b1017c832837a34e78da584b50624f1d47 · 2026-06-28 19:10:43 -0700 · steve

MEASURE: added window._perf {fps,ms,draws,pixelRatio} + 'N FPS . M ms . D draws'
readout to the bottom HUD (measurable on demand; read by the proof harness).
Real-GPU (Apple M2 Max ANGLE/Metal via system Chrome) nav sweep BEFORE fix held
72 FPS everywhere EXCEPT one 51-FPS select-moment dip (4-wall clad upload + hero
flip on the same frame). FIX (low-risk): cladWalls() now decodes the wall image
OFF-thread via createImageBitmap (was a main-thread new Image()), mirroring the
hero path; applyWallImage uses CanvasTexture for ImageBitmap sources (correct
flipY — walls verified right-side-up). AFTER: nav sweep pins at 72 FPS / 13.9ms
on EVERY sample incl. the select frame — gate >=55 met, errorCount 0.

Headless SwiftShader can't measure real FPS so the gate is verified on real GPU;
the headless harness gates the structural H + Slice 1/2 invariants.

Files touched

Diff

commit 0979b2b1017c832837a34e78da584b50624f1d47
Author: steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 19:10:43 2026 -0700

    Slice-3 F: FPS.ms.draws HUD readout + off-thread wall-clad decode (kills the select-moment dip)
    
    MEASURE: added window._perf {fps,ms,draws,pixelRatio} + 'N FPS . M ms . D draws'
    readout to the bottom HUD (measurable on demand; read by the proof harness).
    Real-GPU (Apple M2 Max ANGLE/Metal via system Chrome) nav sweep BEFORE fix held
    72 FPS everywhere EXCEPT one 51-FPS select-moment dip (4-wall clad upload + hero
    flip on the same frame). FIX (low-risk): cladWalls() now decodes the wall image
    OFF-thread via createImageBitmap (was a main-thread new Image()), mirroring the
    hero path; applyWallImage uses CanvasTexture for ImageBitmap sources (correct
    flipY — walls verified right-side-up). AFTER: nav sweep pins at 72 FPS / 13.9ms
    on EVERY sample incl. the select frame — gate >=55 met, errorCount 0.
    
    Headless SwiftShader can't measure real FPS so the gate is verified on real GPU;
    the headless harness gates the structural H + Slice 1/2 invariants.
---
 public/js/showroom.js                 |  35 ++++-
 scripts/shot-clad-realgpu.mjs         |  26 ++++
 scripts/shot-numbers-realgpu.mjs      |  36 +++++
 scripts/shot-pins-tight.mjs           |  33 +++++
 scripts/verify-slice3-fps-realgpu.mjs |  73 +++++++++
 scripts/verify-slice3.mjs             | 270 ++++++++++++++++++++++++++++++++++
 6 files changed, 468 insertions(+), 5 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index 9649cb3..32ba091 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -773,6 +773,15 @@ function cladWalls(imageUrl, isSeamlessTile) {
   const cached = imageTexCache[imageUrl];
   if (cached && cached.texture && cached.texture.image) { if (_cladStillWanted(token)) applyWallImage(cached.texture.image, isSeamlessTile); return; }
   const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
+  // Slice-3 / chunk F: decode OFF the main thread (createImageBitmap) for local images so
+  // the wall-clad upload no longer competes with the hero flip on the SAME frame (the
+  // single ~51-FPS select-moment dip). Falls back to a plain Image() for proxied/remote.
+  if (typeof createImageBitmap === 'function' && src.charAt(0) === '/') {
+    fetch(src).then(r => r.blob()).then(b => createImageBitmap(b))
+      .then(bmp => { if (_cladStillWanted(token)) applyWallImage(bmp, isSeamlessTile); })
+      .catch(() => { const img = new Image(); img.onload = () => { if (_cladStillWanted(token)) applyWallImage(img, isSeamlessTile); }; img.src = src; });
+    return;
+  }
   const img = new Image();
   img.onload = () => { if (_cladStillWanted(token)) applyWallImage(img, isSeamlessTile); };
   img.src = src;
@@ -780,13 +789,21 @@ function cladWalls(imageUrl, isSeamlessTile) {
 
 function applyWallImage(img, isSeamlessTile) {
   const cladSides = new Set();
+  const div = isSeamlessTile ? PHYS_TILE_M : PHYS_TILE_M * 1.6;
+  // Per-wall texture (walls have genuinely different widths → different repeats, so they
+  // can't all share one texture object). The image itself is decoded ONCE off-thread by
+  // cladWalls() (createImageBitmap), so all 4 uploads share one already-decoded bitmap —
+  // the JPEG decode no longer lands on the hero-flip frame (the select-moment FPS dip).
+  // ImageBitmap sources must use CanvasTexture (correct flipY) — same as the hero path;
+  // a plain Texture(bitmap) would render the wall upside-down.
+  const isBitmap = (typeof ImageBitmap !== 'undefined' && img instanceof ImageBitmap);
   roomWalls.forEach(w => {
-    const tex = srgb(new THREE.Texture(img));
+    const tex = srgb(isBitmap ? new THREE.CanvasTexture(img) : new THREE.Texture(img));
+    tex.image = img;
     tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
     tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter;
     if (renderer.capabilities) tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
     // Seamless tiles already repeat; raw swatches get a coarser repeat so seams hide in the architecture.
-    const div = isSeamlessTile ? PHYS_TILE_M : PHYS_TILE_M * 1.6;
     tex.repeat.set(Math.max(1, Math.round(w.w / div)), Math.max(1, Math.round(w.h / div)));
     tex.needsUpdate = true;
     if (!w.cladMat) w.cladMat = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0.0 });
@@ -2380,14 +2397,22 @@ function animate() {
   // Update minimap every 10 frames (cheap but no need every frame)
   if (frameCount % 10 === 0) updateMinimap();
 
-  // FPS
+  // FPS / ms / draw-calls HUD readout (Slice-3 / chunk F: measurable on demand).
+  // FPS + frame-ms (1000/fps) + this frame's GPU draw-call count, so a nav sweep's
+  // cost is visible without external tooling. window._perf carries the last sample
+  // for the headless proof harness to read.
   frameCount++;
   const now = performance.now();
   if (now - lastFpsTime > 1000) {
     const fps = Math.round(frameCount * 1000 / (now - lastFpsTime));
+    const ms = +(1000 / Math.max(1, fps)).toFixed(1);
+    const draws = renderer.info ? renderer.info.render.calls : 0;
+    window._perf = { fps, ms, draws, pixelRatio: renderer.getPixelRatio() };
     const el = document.getElementById('fps-counter');
-    el.textContent = fps + ' FPS';
-    el.style.color = fps >= 50 ? '#4a4' : fps >= 25 ? '#aa4' : '#a44';
+    if (el) {
+      el.textContent = fps + ' FPS · ' + ms + ' ms · ' + draws + ' draws';
+      el.style.color = fps >= 50 ? '#4a4' : fps >= 25 ? '#aa4' : '#a44';
+    }
     // Adaptive resolution — if the GPU can't keep up, ratchet pixelRatio down
     // (1.5 → 1.25 → 1.0). One-way: never climbs back so it can't oscillate.
     if (fps < 40 && renderer.getPixelRatio() > 1.0) {
diff --git a/scripts/shot-clad-realgpu.mjs b/scripts/shot-clad-realgpu.mjs
new file mode 100644
index 0000000..fa78684
--- /dev/null
+++ b/scripts/shot-clad-realgpu.mjs
@@ -0,0 +1,26 @@
+// Real-GPU headed screenshot of a SELECTED state — proves the off-thread clad path
+// renders the walls right-side-up (CanvasTexture flipY) + the hero opens. Also a
+// room-preview shot so the side/back clad walls fill frame for visual inspection.
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+const { chromium } = pw;
+import { mkdirSync } from 'fs';
+const OUT = '/Users/stevestudio2/Projects/quadrille-showroom/recordings/slice3';
+mkdirSync(OUT, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome', headless: false, args: ['--use-angle=metal', '--ignore-gpu-blocklist', '--window-size=1440,900'] });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+await page.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length >= 50, { timeout: 25000 });
+await page.waitForTimeout(3000);
+
+// select a board → clad all 4 walls
+await page.evaluate(() => { const wb = window._qh.wingBoards; window._qh.focusOnWing(wb[Math.floor(wb.length / 2) + 3]); });
+await page.waitForTimeout(4000);   // carousel + flip + clad load
+await page.screenshot({ path: OUT + '/03-clad-selected-realgpu.png' });
+
+// room preview so the clad SIDE/BACK walls fill frame for upside-down inspection
+await page.evaluate(() => { if (window._viewmode && window._viewmode.set) window._viewmode.set('room'); });
+await page.waitForTimeout(2800);
+await page.screenshot({ path: OUT + '/04-clad-room-realgpu.png' });
+console.log('shots written to', OUT);
+await browser.close();
diff --git a/scripts/shot-numbers-realgpu.mjs b/scripts/shot-numbers-realgpu.mjs
new file mode 100644
index 0000000..bece053
--- /dev/null
+++ b/scripts/shot-numbers-realgpu.mjs
@@ -0,0 +1,36 @@
+// Real-GPU headed screenshot of "① Numbers ON" — pins visibly bearing their
+// globally-unique 1000QH<version> code chips. Also a tight DOM crop of one pin so
+// the code text is unambiguous in the proof, plus a JSON dump of the rendered codes.
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+const { chromium } = pw;
+import { mkdirSync } from 'fs';
+const OUT = '/Users/stevestudio2/Projects/quadrille-showroom/recordings/slice3';
+mkdirSync(OUT, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome', headless: false, args: ['--use-angle=metal', '--ignore-gpu-blocklist', '--window-size=1440,900'] });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+await page.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length >= 50, { timeout: 25000 });
+await page.waitForTimeout(3000);
+
+// Force every pin's label+code chip visible (hover-reveal off in a screenshot) by
+// adding the show-label class so labels are open too, then turn Numbers ON.
+await page.evaluate(() => window._versions.set('V1'));
+await page.waitForTimeout(1200);
+await page.evaluate(() => window._versions.overlay(true));
+await page.waitForTimeout(1000);
+await page.evaluate(() => document.querySelectorAll('#pin-layer .el-pin').forEach(p => p.classList.add('show-label')));
+await page.waitForTimeout(500);
+
+// choose one so the proof also shows a chosen pin + tray chip carrying the code
+await page.evaluate(() => { const c = window._versions.codeFor('V1', 4); const pin = document.querySelector('#pin-layer .el-pin[data-code="' + c + '"]'); if (pin) pin.click(); });
+// open the tray
+await page.evaluate(() => { const t = document.getElementById('chosen-tray'); if (t && !t.classList.contains('open')) document.getElementById('chosen-tray-head').click(); });
+await page.waitForTimeout(700);
+
+await page.screenshot({ path: OUT + '/05-numbers-on-realgpu.png' });
+
+// tight crop of the pin layer for legibility
+const codes = await page.evaluate(() => Array.from(document.querySelectorAll('#pin-layer .el-pin .pin-code')).map(s => s.textContent));
+console.log(JSON.stringify({ renderedCodes: codes, copyText: await page.evaluate(() => window._versions.chosenText()) }, null, 2));
+await browser.close();
diff --git a/scripts/shot-pins-tight.mjs b/scripts/shot-pins-tight.mjs
new file mode 100644
index 0000000..11e39bb
--- /dev/null
+++ b/scripts/shot-pins-tight.mjs
@@ -0,0 +1,33 @@
+// Tight, legible real-GPU proof of the code chips: open all V1 pin labels, read each
+// pin's on-screen bounding box, screenshot the union region (clip) so the 1000QHV1…
+// code chips are unambiguous. Native res, no upscale.
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+const { chromium } = pw;
+const OUT = '/Users/stevestudio2/Projects/quadrille-showroom/recordings/slice3';
+const browser = await chromium.launch({ channel: 'chrome', headless: false, args: ['--use-angle=metal', '--ignore-gpu-blocklist', '--window-size=1600,1000'] });
+const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
+await page.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length >= 50, { timeout: 25000 });
+await page.waitForTimeout(3000);
+await page.evaluate(() => window._versions.set('V1'));
+await page.waitForTimeout(1200);
+await page.evaluate(() => window._versions.overlay(true));
+await page.waitForTimeout(900);
+await page.evaluate(() => document.querySelectorAll('#pin-layer .el-pin').forEach(p => p.classList.add('show-label')));
+await page.waitForTimeout(700);
+
+const box = await page.evaluate(() => {
+  const pins = Array.from(document.querySelectorAll('#pin-layer .el-pin')).filter(p => p.style.display !== 'none');
+  if (!pins.length) return null;
+  let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
+  pins.forEach(p => { const r = p.getBoundingClientRect(); x0 = Math.min(x0, r.left); y0 = Math.min(y0, r.top); x1 = Math.max(x1, r.right); y1 = Math.max(y1, r.bottom); });
+  return { x: Math.max(0, Math.floor(x0 - 16)), y: Math.max(0, Math.floor(y0 - 16)), width: Math.ceil(x1 - x0 + 32), height: Math.ceil(y1 - y0 + 32), n: pins.length };
+});
+console.log('pin union box', JSON.stringify(box));
+if (box && box.width > 10 && box.height > 10) {
+  await page.screenshot({ path: OUT + '/06-pins-codes-tight.png', clip: { x: box.x, y: box.y, width: Math.min(box.width, 1600 - box.x), height: Math.min(box.height, 1000 - box.y) } });
+  console.log('wrote 06-pins-codes-tight.png');
+} else {
+  await page.screenshot({ path: OUT + '/06-pins-codes-tight.png' });
+}
+await browser.close();
diff --git a/scripts/verify-slice3-fps-realgpu.mjs b/scripts/verify-slice3-fps-realgpu.mjs
new file mode 100644
index 0000000..4fab056
--- /dev/null
+++ b/scripts/verify-slice3-fps-realgpu.mjs
@@ -0,0 +1,73 @@
+// Slice-3 chunk F — REAL-GPU FPS under nav sweep.
+// The main verify-slice3.mjs runs headless SwiftShader (software WebGL) which caps
+// at single-digit FPS regardless of the app — useless for the ≥55 gate. This launches
+// a HEADED Chromium with the real Apple GPU (Metal/ANGLE) so window._perf reports the
+// genuine frame rate, then runs the same spin+select+version-switch sweep.
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+const { chromium } = pw;
+
+// Use the SYSTEM Chrome (real Apple GPU via Metal/ANGLE) — the bundled Playwright
+// chromium is a software-only headless shell that can't report true FPS.
+const browser = await chromium.launch({
+  channel: 'chrome',
+  headless: false,
+  args: ['--use-angle=metal', '--ignore-gpu-blocklist', '--enable-gpu-rasterization', '--window-size=1440,900'],
+});
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+const errors = [];
+page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+await page.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length >= 50, { timeout: 25000 });
+await page.waitForTimeout(3000);   // let it warm up + thumbs stream + pixelRatio settle
+
+// renderer / GL info to PROVE it's not software
+const gl = await page.evaluate(() => {
+  try {
+    const r = window._qh.renderer;
+    const ctx = r.getContext();
+    const dbg = ctx.getExtension('WEBGL_debug_renderer_info');
+    return {
+      vendor: dbg ? ctx.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : ctx.getParameter(ctx.VENDOR),
+      renderer: dbg ? ctx.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : ctx.getParameter(ctx.RENDERER),
+      pixelRatio: r.getPixelRatio(),
+    };
+  } catch (e) { return { err: String(e) }; }
+});
+
+const samples = [];
+async function sample(ms) {
+  const reps = Math.max(1, Math.round(ms / 1100));
+  for (let i = 0; i < reps; i++) { await page.waitForTimeout(1100); const p = await page.evaluate(() => window._perf || null); if (p) samples.push(p); }
+}
+async function holdYaw(key, ms) {
+  await page.evaluate(k => window.dispatchEvent(new KeyboardEvent('keydown', { key: k, code: k, bubbles: true })), key);
+  await page.waitForTimeout(ms);
+  await page.evaluate(k => window.dispatchEvent(new KeyboardEvent('keyup', { key: k, code: k, bubbles: true })), key);
+}
+
+// idle baseline
+await sample(2200);
+// SWEEP
+await holdYaw('ArrowRight', 1400); await sample(1200);
+await holdYaw('ArrowLeft', 1400);  await sample(1200);
+await holdYaw('ArrowLeft', 1400);  await sample(1200);
+await page.evaluate(() => { const wb = window._qh.wingBoards; window._qh.focusOnWing(wb[Math.floor(wb.length / 2) + 4]); });
+await sample(2400);
+await page.evaluate(() => window._versions.set('V3')); await sample(1200);
+await page.evaluate(() => window._versions.set('V5')); await sample(1200);
+await page.evaluate(() => window._versions.set('V1')); await sample(1500);
+
+const fps = samples.map(s => s.fps);
+console.log(JSON.stringify({
+  gl,
+  samples: samples.length,
+  fpsMin: Math.min(...fps), fpsMax: Math.max(...fps),
+  fpsAvg: +(fps.reduce((a, b) => a + b, 0) / fps.length).toFixed(1),
+  gate55_met: Math.min(...fps) >= 55,
+  drawsMin: Math.min(...samples.map(s => s.draws)), drawsMax: Math.max(...samples.map(s => s.draws)),
+  allSamples: samples,
+  errorCount: errors.length,
+}, null, 2));
+await browser.close();
diff --git a/scripts/verify-slice3.mjs b/scripts/verify-slice3.mjs
new file mode 100644
index 0000000..2c15010
--- /dev/null
+++ b/scripts/verify-slice3.mjs
@@ -0,0 +1,270 @@
+// ============================================================================
+// Slice-3 (chunk H + chunk F) verification.
+//
+// H — globally-unique element build codes (1000QH<version>):
+//   • toggle "① Numbers ON", screenshot — pins visibly bear unique 1000QH… codes.
+//   • assert ACROSS ALL 11 versions the full set of element `code`s is GLOBALLY
+//     UNIQUE (no dup) and STABLE across TWO page loads (same element → same code).
+//   • a chosen element's Copy-as-text output contains its code (1000QHV…).
+//
+// F — performance / load time (measure-first):
+//   • confirm the FPS·ms·draws readout (window._perf) is present + populated.
+//   • measure first-interactive-paint + time-to-50-boards.
+//   • measure FPS under a nav sweep (spin via ArrowL/R + select + version-switch);
+//     gate ≥55.
+//   • confirm Slice-1/2 invariants still hold (posDelta 0, yaw clamp ±72°, 50 boards,
+//     click → 4-wall clad + hero).
+// ============================================================================
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+const { chromium } = pw;
+import { mkdirSync } from 'fs';
+
+const OUT = '/Users/stevestudio2/Projects/quadrille-showroom/recordings/slice3';
+mkdirSync(OUT, { recursive: true });
+
+const result = {};
+const errors = [];
+
+const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] });
+
+// ---- snapshot the full code map for a given page (used twice for the stability check) ----
+async function snapshotCodes(page) {
+  return page.evaluate(() => {
+    const V = window._versions;
+    const all = V.allCodes();                 // [{version,n,code,label}, …] across all 11
+    const map = {};
+    all.forEach(e => { map[e.version + '.' + e.n] = e.code; });
+    return { count: all.length, map, buildCode: V.BUILD_CODE };
+  });
+}
+
+// ========================== LOAD 1 (timing + H) ==========================
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+const tNav = Date.now();
+await page.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
+
+// first-interactive-paint: engine API live + first board exists
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0, { timeout: 20000 });
+const tFirstInteractive = Date.now() - tNav;
+
+// time-to-50-boards: the full 50-wing bank built
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length >= 50, { timeout: 20000 });
+const tTo50Boards = Date.now() - tNav;
+
+await page.waitForTimeout(2500);   // boot resting-open ease + first thumbs
+
+const boardCount = await page.evaluate(() => window._qh.wingBoards.length);
+
+// ---------- H: code map (load 1) + global-uniqueness ----------
+const snap1 = await snapshotCodes(page);
+// uniqueness: every code distinct
+const codeVals1 = Object.values(snap1.map);
+const uniqueSet1 = new Set(codeVals1);
+const allUnique = uniqueSet1.size === codeVals1.length;
+// format sanity: each code is `${1000+seq}${BUILD_CODE}${version}` and seq runs 1000..N-1
+const fmtRe = new RegExp('^(\\d{4,})' + snap1.buildCode + '(V\\d+)$');
+let fmtOk = true, minSeq = Infinity, maxSeq = -Infinity;
+codeVals1.forEach(c => {
+  const m = fmtRe.exec(c);
+  if (!m) { fmtOk = false; return; }
+  const seq = parseInt(m[1], 10); minSeq = Math.min(minSeq, seq); maxSeq = Math.max(maxSeq, seq);
+});
+result.H_uniqueness = {
+  totalElements: codeVals1.length,
+  distinctCodes: uniqueSet1.size,
+  allUnique,
+  buildCode: snap1.buildCode,
+  formatOk: fmtOk,
+  seqMin: minSeq, seqMax: maxSeq,
+  baseIs1000: minSeq === 1000,
+  example_V2_n1: snap1.map['V2.1'] || null,   // canonical: should be 1000QHV2 (V1 has 7 elts before V2 → not 1000)
+  example_V1_n1: snap1.map['V1.1'] || null,    // first ever element → 1000QHV1
+};
+
+// ---------- H: turn Numbers ON, screenshot pins bearing codes ----------
+await page.evaluate(() => window._versions.overlay(true));
+await page.waitForTimeout(1200);   // pins build + reproject
+const pinsOn = await page.evaluate(() => {
+  const pins = Array.from(document.querySelectorAll('#pin-layer .el-pin'));
+  const visible = pins.filter(p => p.style.display !== 'none');
+  const codeChips = Array.from(document.querySelectorAll('#pin-layer .el-pin .pin-code')).map(s => s.textContent);
+  return {
+    overlayOn: window._versions.overlayOn,
+    pinCount: pins.length,
+    visiblePinCount: visible.length,
+    sampleCodes: codeChips.slice(0, 8),
+    everyPinHasCode: pins.length > 0 && pins.every(p => {
+      const cc = p.querySelector('.pin-code');
+      return cc && /\d{4,}QHV\d+/.test(cc.textContent);
+    }),
+  };
+});
+result.H_pinsOn = pinsOn;
+await page.screenshot({ path: OUT + '/01-numbers-on.png' });
+
+// ---------- H: choose an element → Copy-as-text contains the code ----------
+const copyText = await page.evaluate(() => {
+  const V = window._versions;
+  // Clear any prior chosen, then choose V1 element n=4 by clicking its pin (real path).
+  // Find the V1.4 pin's code and click it.
+  const cur = V.current;
+  // ensure we're on a 3D version with visible pins (V1)
+  if (cur !== 'V1') V.set('V1');
+  return new Promise(resolve => setTimeout(() => {
+    const code = V.codeFor('V1', 4);
+    const pin = document.querySelector('#pin-layer .el-pin[data-code="' + code + '"]');
+    if (pin) pin.click();
+    setTimeout(() => {
+      resolve({ chosenCode: code, copyText: V.chosenText(), chosen: V.chosen });
+    }, 300);
+  }, 800));
+});
+result.H_copyText = {
+  chosenCode: copyText.chosenCode,
+  copyText: copyText.copyText,
+  copyContainsCode: copyText.copyText.includes(copyText.chosenCode),
+  copyHasQHToken: /\d{4,}QHV\d+/.test(copyText.copyText),
+};
+
+// ========================== F: NAV SWEEP FPS ==========================
+// Turn Numbers OFF (pins reproject every frame; sweep the raw scene), then run a
+// spin + select + version-switch sweep and sample the lowest FPS seen.
+await page.evaluate(() => window._versions.overlay(false));
+await page.waitForTimeout(400);
+// Return to V1 fixed-centre clean.
+await page.evaluate(() => { window._versions.set('V1'); });
+await page.waitForTimeout(1500);
+
+async function holdYaw(key, ms) {
+  await page.evaluate(k => window.dispatchEvent(new KeyboardEvent('keydown', { key: k, code: k, bubbles: true })), key);
+  await page.waitForTimeout(ms);
+  await page.evaluate(k => window.dispatchEvent(new KeyboardEvent('keyup', { key: k, code: k, bubbles: true })), key);
+}
+
+const fpsSamples = [];
+async function sampleFps(ms) {
+  // window._perf updates ~1×/sec; sample a few times over the window.
+  const reps = Math.max(1, Math.round(ms / 1100));
+  for (let i = 0; i < reps; i++) {
+    await page.waitForTimeout(1100);
+    const p = await page.evaluate(() => window._perf || null);
+    if (p) fpsSamples.push(p);
+  }
+}
+
+// confirm the readout exists at all
+const perfPresent = await page.evaluate(() => {
+  const el = document.getElementById('fps-counter');
+  return { hasEl: !!el, text: el ? el.textContent : null, perf: window._perf || null };
+});
+result.F_readout = perfPresent;
+
+// SWEEP: spin right, spin left, select a board, switch versions, back to V1.
+await holdYaw('ArrowRight', 1300); await sampleFps(1200);
+await holdYaw('ArrowLeft', 1300);  await sampleFps(1200);
+await holdYaw('ArrowLeft', 1300);  await sampleFps(1200);
+// select via real focus path
+await page.evaluate(() => { const wb = window._qh.wingBoards; window._qh.focusOnWing(wb[Math.floor(wb.length / 2) + 4]); });
+await sampleFps(2400);   // carousel + flip + clad load is the heaviest moment
+// version switch sweep (3D presentation versions only — overlays are iframes)
+await page.evaluate(() => window._versions.set('V3')); await sampleFps(1200);
+await page.evaluate(() => window._versions.set('V5')); await sampleFps(1200);
+await page.evaluate(() => window._versions.set('V1')); await sampleFps(1200);
+
+const fpsVals = fpsSamples.map(s => s.fps);
+const drawVals = fpsSamples.map(s => s.draws);
+result.F_navSweep = {
+  samples: fpsSamples.length,
+  fpsMin: fpsVals.length ? Math.min(...fpsVals) : null,
+  fpsMax: fpsVals.length ? Math.max(...fpsVals) : null,
+  fpsAvg: fpsVals.length ? +(fpsVals.reduce((a, b) => a + b, 0) / fpsVals.length).toFixed(1) : null,
+  drawsMin: drawVals.length ? Math.min(...drawVals) : null,
+  drawsMax: drawVals.length ? Math.max(...drawVals) : null,
+  gate55_met: fpsVals.length ? Math.min(...fpsVals) >= 55 : false,
+  allSamples: fpsSamples,
+};
+result.F_timing = { firstInteractiveMs: tFirstInteractive, to50BoardsMs: tTo50Boards, boardCount };
+
+// ========================== SLICE 1/2 INVARIANTS ==========================
+// Select state: 4-wall clad + hero open.
+const selInv = await page.evaluate(() => {
+  const wb = window._qh.wingBoards;
+  window._qh.focusOnWing(wb[Math.floor(wb.length / 2) + 3]);
+  return new Promise(res => setTimeout(() => {
+    const cladSides = window._qh.cladSides;
+    const wallSides = window._qh.wallSides;
+    res({
+      focused: !!window._qh.focusedWing,
+      allFourClad: wallSides.length === 4 && wallSides.every(s => cladSides.includes(s)),
+      cladSides, wallSides,
+      heroFlip: window._qh.focusedWing ? +window._qh.focusedWing.userData.flip.toFixed(3) : null,
+    });
+  }, 3500));
+});
+result.S2_selection = selInv;
+
+// posDelta 0 + yaw clamp ±72 (re-use the Slice-2 surface).
+const camInv = await page.evaluate(() => {
+  if (window._qh.enterFixedCentre) window._qh.enterFixedCentre();
+  return new Promise(res => setTimeout(() => {
+    const p0 = window._qh.camera.position.clone();
+    const ct = window._qh.testYawClamp ? window._qh.testYawClamp() : null;
+    const p1 = window._qh.camera.position;
+    const d = Math.sqrt((p1.x - p0.x) ** 2 + (p1.y - p0.y) ** 2 + (p1.z - p0.z) ** 2);
+    res({
+      posDelta: +d.toFixed(4),
+      yawMaxDeg: ct ? +(ct.clamp * 180 / Math.PI).toFixed(2) : null,
+      cappedRightDeg: ct ? +(ct.right * 180 / Math.PI).toFixed(2) : null,
+      cappedLeftDeg: ct ? +(ct.left * 180 / Math.PI).toFixed(2) : null,
+    });
+  }, 2200));
+});
+result.S1_camera = camInv;
+await page.screenshot({ path: OUT + '/02-after-sweep.png' });
+await page.close();
+
+// ========================== LOAD 2 (stability) ==========================
+const page2 = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+page2.on('console', m => { if (m.type() === 'error') errors.push('L2:' + m.text()); });
+page2.on('pageerror', e => errors.push('L2 PAGEERROR: ' + e.message));
+await page2.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
+await page2.waitForFunction(() => window._versions && window._versions.allCodes, { timeout: 20000 });
+await page2.waitForTimeout(800);
+const snap2 = await snapshotCodes(page2);
+await page2.close();
+
+// stability: same {version.n} → same code across both loads
+let stable = true, drift = [];
+Object.keys(snap1.map).forEach(k => {
+  if (snap1.map[k] !== snap2.map[k]) { stable = false; drift.push({ key: k, load1: snap1.map[k], load2: snap2.map[k] }); }
+});
+result.H_stability = {
+  load1Count: snap1.count, load2Count: snap2.count,
+  identical: stable,
+  driftCount: drift.length,
+  drift: drift.slice(0, 5),
+};
+
+result.errorCount = errors.length;
+result.errors = errors.slice(0, 8);
+
+// ---- top-line PASS/FAIL ----
+result.VERDICT = {
+  H_globally_unique: result.H_uniqueness.allUnique && result.H_uniqueness.baseIs1000 && result.H_uniqueness.formatOk,
+  H_stable_across_loads: result.H_stability.identical,
+  H_copy_contains_code: result.H_copyText.copyContainsCode,
+  H_pins_show_codes: result.H_pinsOn.everyPinHasCode,
+  F_readout_present: result.F_readout.hasEl && !!result.F_readout.perf,
+  F_fps_gate_55: result.F_navSweep.gate55_met,
+  S1_posDelta0: result.S1_camera.posDelta < 0.01,
+  S1_yawClamp72: result.S1_camera.yawMaxDeg === 72,
+  S1_50boards: result.F_timing.boardCount === 50,
+  S2_4wall_clad: result.S2_selection.allFourClad,
+  errorCount0: result.errorCount === 0,
+};
+
+console.log(JSON.stringify(result, null, 2));
+await browser.close();

← 1b5eb93 Slice-3 H: globally-unique element build codes (1000QH<versi  ·  back to Quadrille Showroom  ·  Slice3 gate hygiene: annotate headless FPS flag as HEADLESS- 229a7b0 →