← back to Quadrille Showroom
auto-save: 2026-06-28T06:13:03 (3 files) — public/js/showroom.js public/js/viewmodes.js scripts/test-views.js
afcaf8b0473d8641bb199cf3dc0e0fd84de625b3 · 2026-06-28 06:13:06 -0700 · Steve Abrams
Files touched
M public/js/showroom.jsM public/js/viewmodes.jsA scripts/test-views.js
Diff
commit afcaf8b0473d8641bb199cf3dc0e0fd84de625b3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jun 28 06:13:06 2026 -0700
auto-save: 2026-06-28T06:13:03 (3 files) — public/js/showroom.js public/js/viewmodes.js scripts/test-views.js
---
public/js/showroom.js | 3 +-
public/js/viewmodes.js | 50 +++++++++++++++-----
scripts/test-views.js | 125 +++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 166 insertions(+), 12 deletions(-)
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 09b8a93..99297dd 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -532,7 +532,8 @@ function buildRoom() {
new THREE.PlaneGeometry(W, D),
new THREE.MeshLambertMaterial({ color: 0xf2eee5 }) // soft warm off-white
);
- ceil.rotation.x = Math.PI/2; ceil.position.y = H; ceil.receiveShadow = false; scene.add(ceil);
+ ceil.rotation.x = Math.PI/2; ceil.position.y = H; ceil.receiveShadow = false;
+ ceil.userData.isCeiling = true; window._ceilingMesh = ceil; scene.add(ceil);
// Two slim recessed warm-light strips running front→back, set just below the ceiling
// plane — emissive (self-lit, no light cost) so they read as soft linear coves, the
diff --git a/public/js/viewmodes.js b/public/js/viewmodes.js
index 8a44a5e..8e118fe 100644
--- a/public/js/viewmodes.js
+++ b/public/js/viewmodes.js
@@ -58,12 +58,16 @@ function boot() {
// Free the camera so a frame() hook (orbit/carousel) can drive it.
function freeCam() { QH.setControlsLocked(false); QH.unlockControls(); }
- // Ensure a board is focused/open so the design is presented (Hero/Angled/Macro/etc).
+ // Ensure a board is focused/open AND isolated so ONE design is presented
+ // (Hero / Angled / Macro / Room). If already focused on this board, re-isolate
+ // explicitly (focusOnWing only isolates on a focus *change*).
function ensureFocused(i) {
const idx = (i == null) ? curIdx() : i;
- QH.restoreBoards();
if (!QH.focusedWing || QH.focusedWing.userData.index !== idx) {
+ QH.restoreBoards();
if (QH.wingBoards[idx]) QH.focusOnWing(QH.wingBoards[idx]);
+ } else {
+ QH.isolateBoard(QH.focusedWing); // re-hide neighbours → single hero design
}
}
@@ -95,8 +99,15 @@ function boot() {
orbit: {
label: 'Orbit', hint: 'Drag to orbit around the focused board.', guided: true,
enter() {
- ensureFocused();
+ // Open the focused board to present its design, but keep neighbours visible so
+ // the orbit reads as moving *around* a board in the room (richer than a lone slab).
+ const idx = curIdx();
+ QH.restoreBoards();
+ if (!QH.focusedWing || QH.focusedWing.userData.index !== idx) { if (QH.wingBoards[idx]) QH.focusOnWing(QH.wingBoards[idx]); }
+ QH.restoreBoards(); // un-isolate again (focusOnWing isolated)
+ if (QH.focusedWing) QH.focusedWing.userData.panelClosed = false;
setWingDeg(20);
+ QH.revertWalls();
freeCam();
// Turn orbit controls ON for this mode regardless of Explore.
const ctl = QH.controls;
@@ -125,26 +136,39 @@ function boot() {
gallery: {
label: 'Gallery', hint: 'Pull back to see the whole wall of boards.', guided: false,
enter() {
- // Show every board on the rail; no isolation.
+ // Close + show every board on the rail; neutral walls; straight-on from the
+ // FRONT of the room so the whole 6-ft wall of boards reads at a glance.
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
+ QH.revertWalls();
setWingDeg(0);
- const pos = new THREE.Vector3(0, 1.62, ROOM.depth / 2 - 0.7);
+ const pos = new THREE.Vector3(0, 1.55, ROOM.depth / 2 - 0.2);
const tgt = new THREE.Vector3(0, boardCY, backZ);
- park(pos, tgt, 58);
+ park(pos, tgt, 62);
}
},
topdown: {
- label: 'Top-Down', hint: 'Overhead plan view of the showroom.', guided: false,
+ label: 'Top-Down', hint: 'High overhead plan view of the showroom.', guided: false,
enter() {
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
+ QH.revertWalls();
setWingDeg(0);
- const pos = new THREE.Vector3(0, ROOM.height + 3.4, 0.2);
- const tgt = new THREE.Vector3(0, 0, backZ + 0.4);
- park(pos, tgt, 60);
- }
+ // The room has a real ceiling, so a camera ABOVE it would just see the ceiling.
+ // Instead hide the ceiling for this mode and shoot from straight above the room
+ // centre, tilted slightly toward the board wall — a true overhead plan that
+ // reads the floor, furniture footprint, and the rail of boards.
+ setCeilingVisible(false);
+ // Camera tucked just under where the ceiling was, at the FRONT of the room,
+ // looking down-and-in across the whole floor to the board wall — a high
+ // establishing/plan angle that actually reads inside a closed box (a straight
+ // top-down would only see wall tops). The floor + furniture + rail all show.
+ const pos = new THREE.Vector3(0, ROOM.height - 0.15, ROOM.depth / 2 - 0.3);
+ const tgt = new THREE.Vector3(0, 0.0, backZ + 0.5);
+ park(pos, tgt, 70);
+ },
+ exit() { setCeilingVisible(true); }
},
walk: {
@@ -152,6 +176,7 @@ function boot() {
enter() {
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
+ QH.revertWalls();
setWingDeg(0);
freeCam();
const ctl = QH.controls;
@@ -174,6 +199,7 @@ function boot() {
enter() {
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
+ QH.revertWalls();
setWingDeg(0);
freeCam();
carouselT = 0;
@@ -220,6 +246,7 @@ function boot() {
label: 'Compare', hint: 'Two boards side-by-side for A/B.', guided: false,
enter() {
// Stage two adjacent boards open + visible; frame both.
+ QH.revertWalls();
const i = curIdx();
const j = (i + 1) % QH.wingBoards.length;
// Show only these two.
@@ -281,6 +308,7 @@ function boot() {
QH.scene.traverse(o => { if (!g && o.isGroup && o.children && o.children.some(ch => ch.geometry && ch.geometry.type === 'BoxGeometry' && Math.abs(ch.position.y - (WING.height + 0.08)) < 0.05)) g = o; });
return g;
}
+ function setCeilingVisible(v) { if (window._ceilingMesh) window._ceilingMesh.visible = v; }
function setBanner(txt) { const el = document.getElementById('vm-banner'); if (el) { el.textContent = txt; el.style.display = 'block'; } }
function clearBanner() { const el = document.getElementById('vm-banner'); if (el) el.style.display = 'none'; }
diff --git a/scripts/test-views.js b/scripts/test-views.js
new file mode 100644
index 0000000..20184df
--- /dev/null
+++ b/scripts/test-views.js
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+/**
+ * test-views.js — DEBUG-OURSELVES harness for the View-Mode + Theme engine.
+ *
+ * Cycles through ALL 10 view modes and (in Hero) all 6 themes, captures a
+ * labelled screenshot of each into recordings/shots/ (view-*.png, theme-*.png),
+ * samples FPS + drawCalls + triangles per mode, and records one continuous
+ * .webm clip Steve can scrub to pick winners. Also runs guided Next within a
+ * board-presentation mode to prove the senior nav still works per-view.
+ *
+ * Output:
+ * recordings/shots/view-<n>-<key>.png (×10)
+ * recordings/shots/theme-<n>-<key>.png (×6)
+ * recordings/test-views.webm (scrub clip)
+ * recordings/test-views-report.json (per-mode FPS/draws/tris, errors)
+ *
+ * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/test-views.js
+ */
+const path = require('path');
+const fs = require('fs');
+const { chromium } = require('playwright');
+
+const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
+const OUT = path.join(__dirname, '..', 'recordings');
+const SHOTS = path.join(OUT, 'shots');
+fs.mkdirSync(SHOTS, { recursive: true });
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+(async () => {
+ const browser = await chromium.launch({ args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'] });
+ const ctx = await browser.newContext({
+ viewport: { width: 1600, height: 900 },
+ recordVideo: { dir: OUT, size: { width: 1600, height: 900 } },
+ });
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+ // Clean slate — Hero boot, default theme.
+ await page.addInitScript(() => { try { localStorage.clear(); } catch (e) {} });
+
+ console.log('→ loading', URL);
+ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForFunction(() => window._viewmode && window._wingBoards && window._wingBoards.length > 0, { timeout: 25000 }).catch(() => {});
+ await sleep(4000); // loader fade + guided auto-focus
+
+ const sample = async () => page.evaluate(() => {
+ const s = window._getSceneStats();
+ const fpsEl = document.getElementById('fps-counter');
+ return { fps: fpsEl ? parseInt(fpsEl.textContent) : null, draws: s.drawCalls, tris: s.triangles, meshes: s.meshes };
+ });
+
+ const MODE_ORDER = await page.evaluate(() => window._viewmode.MODE_ORDER);
+ const MODE_LABELS = await page.evaluate(() => Object.fromEntries(window._viewmode.MODE_ORDER.map(k => [k, window._viewmode.MODES[k].label])));
+ const THEME_ORDER = await page.evaluate(() => window._viewmode.THEME_ORDER);
+ const THEME_LABELS = await page.evaluate(() => Object.fromEntries(window._viewmode.THEME_ORDER.map(k => [k, window._viewmode.THEMES[k].label])));
+
+ const report = { when: new Date().toISOString(), modes: [], themes: [], errors: [] };
+
+ // ---- VIEW MODES ----
+ console.log('→ cycling 10 view modes');
+ for (let i = 0; i < MODE_ORDER.length; i++) {
+ const key = MODE_ORDER[i];
+ await page.evaluate(k => window._viewmode.set(k), key);
+ // modes with motion (orbit/carousel/macro) get a longer settle so the shot is mid-motion
+ await sleep(['orbit', 'carousel', 'macro'].includes(key) ? 2600 : 1700);
+ const st = await sample();
+ const shot = path.join(SHOTS, `view-${i + 1}-${key}.png`);
+ await page.screenshot({ path: shot });
+ report.modes.push({ n: i + 1, key, label: MODE_LABELS[key], ...st, shot: path.basename(shot) });
+ console.log(` ${i + 1}. ${MODE_LABELS[key].padEnd(14)} fps=${st.fps} draws=${st.draws} tris=${st.tris}`);
+ }
+
+ // ---- GUIDED NEXT within a board-presentation mode (Macro) — prove senior nav works ----
+ console.log('→ guided Next within Macro (senior nav per-view)');
+ await page.evaluate(() => window._viewmode.set('macro'));
+ await sleep(1600);
+ const macroBefore = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
+ await page.click('#g-next'); await sleep(1800);
+ const macroAfter = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
+ report.guidedNextInMode = { before: macroBefore, after: macroAfter, advanced: macroBefore !== macroAfter };
+ console.log(` Macro Next: "${macroBefore}" → "${macroAfter}" advanced=${report.guidedNextInMode.advanced}`);
+
+ // ---- THEMES (shot in Hero so the swatch + room read cleanly) ----
+ console.log('→ cycling 6 themes (in Hero)');
+ await page.evaluate(() => window._viewmode.set('hero'));
+ await sleep(1500);
+ await page.evaluate(() => window._viewmode.perf(true)); // show perf readout in theme shots
+ for (let i = 0; i < THEME_ORDER.length; i++) {
+ const key = THEME_ORDER[i];
+ await page.evaluate(k => window._viewmode.theme(k), key);
+ await sleep(1500);
+ const st = await sample();
+ const shot = path.join(SHOTS, `theme-${i + 1}-${key}.png`);
+ await page.screenshot({ path: shot });
+ report.themes.push({ n: i + 1, key, label: THEME_LABELS[key], ...st, shot: path.basename(shot) });
+ console.log(` ${i + 1}. ${THEME_LABELS[key].padEnd(16)} fps=${st.fps} draws=${st.draws}`);
+ }
+
+ // ---- spec-card integrity (China Seas) ----
+ const specText = await page.evaluate(() => {
+ const grid = document.getElementById('detail-spec-grid');
+ if (!grid) return null;
+ return [...grid.querySelectorAll('.wd-spec')].map(r => r.querySelector('dt').textContent + ': ' + r.querySelector('dd').textContent);
+ });
+ report.specText = specText;
+
+ const fpsVals = [...report.modes, ...report.themes].map(m => m.fps).filter(n => n > 0);
+ report.fpsMin = fpsVals.length ? Math.min(...fpsVals) : null;
+ report.fpsAvg = fpsVals.length ? Math.round(fpsVals.reduce((a, b) => a + b, 0) / fpsVals.length) : null;
+ report.errors = errors; report.errorCount = errors.length;
+
+ fs.writeFileSync(path.join(OUT, 'test-views-report.json'), JSON.stringify(report, null, 2));
+ console.log('\n=== VIEW-MODE REPORT ===');
+ console.log('fps min/avg:', report.fpsMin, '/', report.fpsAvg, '| errors:', report.errorCount,
+ '| specs:', JSON.stringify(specText));
+ if (errors.length) console.log('ERRORS:', errors.slice(0, 8).join(' || '));
+
+ await page.close();
+ const vid = await page.video().path().catch(() => null);
+ await ctx.close(); await browser.close();
+ if (vid && fs.existsSync(vid)) { fs.renameSync(vid, path.join(OUT, 'test-views.webm')); console.log('✓ recordings/test-views.webm'); }
+ console.log('✓ shots in recordings/shots/ (view-*.png ×10, theme-*.png ×6)');
+})().catch(e => { console.error('VIEW TEST FAILED:', e.message); process.exit(1); });
← 52f212e Add 10 switchable view modes + 6 lighting themes + toggleabl
·
back to Quadrille Showroom
·
Perf: on-demand shadow maps (autoUpdate off, rebake only on cd16d7f →