[object Object]

← 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

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 →