← 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
M public/js/showroom.jsA scripts/shot-clad-realgpu.mjsA scripts/shot-numbers-realgpu.mjsA scripts/shot-pins-tight.mjsA scripts/verify-slice3-fps-realgpu.mjsA scripts/verify-slice3.mjs
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 →