[object Object]

← back to Quadrille Showroom

Consultation nook (round table + 2 boucle chairs + plant + swatch cards) in arc foreground; 18-board packed arc; reveal slider verified live (20/62/90)

c499d15a56654065854b30240343ff2d68638bf6 · 2026-06-28 07:34:16 -0700 · Steve

Files touched

Diff

commit c499d15a56654065854b30240343ff2d68638bf6
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 07:34:16 2026 -0700

    Consultation nook (round table + 2 boucle chairs + plant + swatch cards) in arc foreground; 18-board packed arc; reveal slider verified live (20/62/90)
---
 public/js/showroom.js    |  93 +++++++++++++++++--------
 public/js/viewmodes.js   |  16 ++++-
 scripts/test-versions.js | 175 +++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 253 insertions(+), 31 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index fd10eac..69de38a 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -760,42 +760,79 @@ function enableStaticShadows() {
 function buildFurniture() {
   const W = CONFIG.room.width, D = CONFIG.room.depth;
 
-  // Table
-  const tt = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.04, 0.9), MAT.wood);
-  tt.position.set(-1.0, 0.75, 1.5); scene.add(tt);
-  [[-0.8,-0.35],[0.8,-0.35],[-0.8,0.35],[0.8,0.35]].forEach(([lx,lz]) => {
-    const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.02,0.02,0.74,6), MAT.chrome);
-    leg.position.set(-1.0+lx, 0.37, 1.5+lz); scene.add(leg);
-  });
-
-  // 4 Chairs (simplified — leather-tone seat + back, shared material)
-  [[-1.6,1.5,0],[-0.4,1.5,Math.PI],[-1.0,1.0,Math.PI/2],[-1.0,2.0,-Math.PI/2]].forEach(([cx,cz,rot]) => {
-    const seat = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.04, 0.4), MAT.chair);
-    seat.position.set(cx, 0.46, cz); seat.rotation.y = rot; scene.add(seat);
-    const back = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.4, 0.03), MAT.chair);
-    back.position.set(cx - Math.sin(rot)*0.19, 0.68, cz - Math.cos(rot)*0.19); back.rotation.y = rot; scene.add(back);
-  });
+  // ============================================================
+  // CONSULTATION NOOK (PJ Dallas reference) — a round warm-wood pedestal table, TWO
+  // cream bouclé armchairs, a potted plant, swatch cards on the table. Sits in the
+  // FOREGROUND of the arc bay so the establishing shot reads: rack behind, nook in front.
+  // The arc centre is (-0.3,-1.1) opening toward +z; the nook sits at ~(-0.3, +3.2).
+  // ============================================================
+  const nookX = -0.3, nookZ = 3.2;
+  buildConsultationNook(nookX, nookZ);
 
   // (Removed: boxy TV + bezel + slideshow screen — the wallcoverings are the only
   // loud thing in the room per the gallery art-direction.) The TV-slideshow helpers
   // null-guard on window._tvScreen, so leaving it unset makes them no-op.
 
-  // Bookshelves — near front wall, flanking doorway (don't block wing racks)
-  buildBookShelf(-2.8, D/2-0.3, Math.PI, 1.2);
-  buildBookShelf(2.8, D/2-0.3, Math.PI, 1.2);
-
-  // (Removed: procedural medallion rug — clean warm-oak floor reads more gallery.)
+  // ONE restrained olive in a honed-stone planter — quiet, expensive, beside the nook
+  // (like the PJ photo's potted plant tucked by the arc). Off to the left of the table.
+  buildOliveTree(nookX - 1.5, 0, nookZ - 0.2);
+  addContactShadow(nookX - 1.5, nookZ - 0.2, 0.6, 0.6, 0.85);
 
-  // ONE restrained olive in a honed-stone planter — quiet, expensive, off to the side
-  // by the left wall. Replaces the two sphere "toy" plants.
-  buildOliveTree(-W/2 + 0.55, 0, D/2 - 0.55);
-  // Contact shadow grounds the planter on the floor.
-  addContactShadow(-W/2 + 0.55, D/2 - 0.55, 0.6, 0.6, 0.85);
+  // (Removed: framed abstract "art" on the right wall — the sample boards are the art.)
+}
 
-  // Contact shadow grounds the console/table.
-  addContactShadow(-1.0, 1.5, 2.0, 1.1, 0.7);
+// Round warm-wood pedestal table + two cream bouclé armchairs + swatch cards — the
+// foreground consultation nook from the PJ Dallas showroom photo.
+function buildConsultationNook(cx, cz) {
+  const woodTop = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: 0.42, metalness: 0.0, envMapIntensity: 0.3 });
+  const woodDark = new THREE.MeshStandardMaterial({ color: 0x6e573a, roughness: 0.5, metalness: 0.0 });
+  const boucle = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.2 });
+
+  const TABLE_R = 0.52, TABLE_H = 0.74;
+  // Round top
+  const top = new THREE.Mesh(new THREE.CylinderGeometry(TABLE_R, TABLE_R, 0.05, 40), woodTop);
+  top.position.set(cx, TABLE_H, cz); top.castShadow = true; top.receiveShadow = true; scene.add(top);
+  // Pedestal column
+  const col = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, TABLE_H - 0.05, 16), woodDark);
+  col.position.set(cx, (TABLE_H - 0.05) / 2, cz); col.castShadow = true; scene.add(col);
+  // Splayed pedestal foot
+  const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.34, 0.05, 24), woodDark);
+  foot.position.set(cx, 0.03, cz); foot.castShadow = true; foot.receiveShadow = true; scene.add(foot);
+  addContactShadow(cx, cz, 0.85, 0.85, 0.7);
+
+  // A few swatch cards fanned on the table (small thin tilted planes, warm tones).
+  const cardTones = [0xd8c7a8, 0xc6d2c0, 0xcdbcae];
+  cardTones.forEach((c, k) => {
+    const card = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.004, 0.22),
+      new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, metalness: 0.0 }));
+    card.position.set(cx - 0.12 + k * 0.12, TABLE_H + 0.03, cz - 0.05);
+    card.rotation.y = (k - 1) * 0.35; card.castShadow = true; scene.add(card);
+  });
 
-  // (Removed: framed abstract "art" on the right wall — the sample boards are the art.)
+  // TWO cream bouclé armchairs — one near (toward camera), one far (toward the arc),
+  // both pulled up to the round table. Built from soft rounded boxes.
+  const placeChair = (chx, chz, faceYaw) => {
+    const g = new THREE.Group(); g.position.set(chx, 0, chz); g.rotation.y = faceYaw;
+    const seat = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.16, 0.50), boucle);
+    seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; g.add(seat);
+    const back = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.50, 0.14), boucle);
+    back.position.set(0, 0.70, -0.20); back.castShadow = true; g.add(back);
+    // rounded arms
+    [-0.26, 0.26].forEach(ax => {
+      const arm = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.46), boucle);
+      arm.position.set(ax, 0.56, 0.0); arm.castShadow = true; g.add(arm);
+    });
+    // short tapered legs
+    [[-0.20,0.20],[0.20,0.20],[-0.20,-0.20],[0.20,-0.20]].forEach(([lx,lz]) => {
+      const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.015, 0.36, 8), woodDark);
+      leg.position.set(lx, 0.18, lz); leg.castShadow = true; g.add(leg);
+    });
+    scene.add(g);
+    addContactShadow(chx, chz, 0.7, 0.7, 0.6);
+  };
+  // Chair toward the camera (faces the arc, back to viewer) + chair on the far side.
+  placeChair(cx + 0.08, cz + 0.78, Math.PI);   // near chair, faces -z (toward arc)
+  placeChair(cx - 0.05, cz - 0.78, 0);          // far chair, faces +z (toward viewer)
 }
 
 // A single olive/fig in a square honed-stone planter — soft layered canopy (not a
diff --git a/public/js/viewmodes.js b/public/js/viewmodes.js
index 06e1cb5..0439f7c 100644
--- a/public/js/viewmodes.js
+++ b/public/js/viewmodes.js
@@ -30,6 +30,8 @@ function boot() {
   let orbitSpin = 0;          // accumulated turntable / orbit angle
   let orbitAuto = false;      // orbit mode: auto-spin until the user drags
   let carouselT = 0;          // carousel rotation phase
+  let _carouselWG = null;     // cached wall group (avoid full scene.traverse per frame)
+  let _carouselShadowAccum = 0; // throttle carousel shadow rebakes
   let macroPhase = 0;         // macro slow drift phase
   let compareSetup = false;   // compare mode: two boards staged
   let savedFocusIndex = 0;    // remember which board to return to
@@ -210,10 +212,18 @@ function boot() {
       },
       frame(dt) {
         // Yaw the whole wing-wall group slowly so boards parade past — a lazy-susan.
-        const wg = findWallGroup();
-        if (wg) { carouselT += dt * 0.22; wg.rotation.y = Math.sin(carouselT) * 0.5; if (QH.requestShadowUpdate) QH.requestShadowUpdate(2); }
+        // PERF: cache the wall group (full scene.traverse every frame was the FPS
+        // killer — dropped V2 to ~31fps) and throttle shadow rebakes to ~6/s instead
+        // of every frame (the rotation is slow; 60 rebakes/s is pure waste).
+        if (!_carouselWG) _carouselWG = findWallGroup();
+        const wg = _carouselWG;
+        if (wg) {
+          carouselT += dt * 0.22; wg.rotation.y = Math.sin(carouselT) * 0.5;
+          _carouselShadowAccum += dt;
+          if (_carouselShadowAccum > 0.16) { _carouselShadowAccum = 0; if (QH.requestShadowUpdate) QH.requestShadowUpdate(1); }
+        }
       },
-      exit() { const wg = findWallGroup(); if (wg) wg.rotation.y = 0; }
+      exit() { if (_carouselWG) _carouselWG.rotation.y = 0; _carouselWG = null; _carouselShadowAccum = 0; }
     },
 
     macro: {
diff --git a/scripts/test-versions.js b/scripts/test-versions.js
new file mode 100644
index 0000000..27ca365
--- /dev/null
+++ b/scripts/test-versions.js
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * test-versions.js — verifies the "10 VERSIONS" system end-to-end with Playwright.
+ *
+ * Asserts: the version rail renders V1…V10 with clearly-visible numbers; switching
+ * versions rebuilds cleanly (no console errors, FPS stays healthy); the numbered
+ * overlay toggles ON and pins appear; clicking a pin adds it to the Chosen tray;
+ * the senior guided nav + collapsed popup are untouched; the China Seas specs are
+ * intact. Captures recordings/shots/ver-*.png for each version + the overlay.
+ *
+ * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/test-versions.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 } });
+  const page = await ctx.newPage();
+  const errors = [], fpsLog = [];
+  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+  // Clean slate: no persisted version/overlay/chosen from a prior run.
+  await page.addInitScript(() => {
+    try { ['qh_explore','qh_version','qh_overlay','qh_chosen','qh_tray_open','qh_ver_rail'].forEach(k => localStorage.removeItem(k)); } catch (e) {}
+  });
+
+  console.log('→ loading', URL);
+  await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+  await page.waitForFunction(() => window._wingBoards && window._wingBoards.length > 0, { timeout: 25000 }).catch(() => {});
+  await page.waitForFunction(() => window._versions && window._versions.VERSION_ORDER, { timeout: 15000 }).catch(() => {});
+  await sleep(3800); // loader fade + viewmodes boot + versions boot (V1)
+
+  const sampleFps = async (tag) => {
+    const fps = await page.evaluate(() => { const el = document.getElementById('fps-counter'); return el ? parseInt(el.textContent) : null; });
+    if (fps > 0) fpsLog.push({ tag, fps });
+    return fps;
+  };
+
+  // --- 1. Rail renders V1…V10 with visible numbers ---
+  const rail = await page.evaluate(() => {
+    const chips = [...document.querySelectorAll('#ver-rail .ver-chip')];
+    return {
+      count: chips.length,
+      nums: chips.map(c => (c.querySelector('.vc-num') || {}).textContent),
+      names: chips.map(c => (c.querySelector('.vc-name') || {}).textContent),
+      activeBoot: (document.querySelector('#ver-rail .ver-chip.active') || {}).dataset
+        ? document.querySelector('#ver-rail .ver-chip.active').dataset.version : null
+    };
+  });
+  console.log('  rail:', JSON.stringify(rail));
+  await page.screenshot({ path: path.join(SHOTS, 'ver-rail.png') });
+
+  // --- 2. Boot is V1, matches reference framing ---
+  await page.screenshot({ path: path.join(SHOTS, 'ver-V1.png') });
+  await sampleFps('V1');
+
+  // --- 3. Cycle every version, screenshot each, watch FPS + errors ---
+  const order = await page.evaluate(() => window._versions.VERSION_ORDER);
+  const perVersion = {};
+  for (const key of order) {
+    await page.click(`#ver-rail .ver-chip[data-version="${key}"]`);
+    await sleep(2600); // let the camera fly + the look settle
+    const fps = await sampleFps(key);
+    const cur = await page.evaluate(() => window._versions.current);
+    perVersion[key] = { fps, applied: cur === key };
+    await page.screenshot({ path: path.join(SHOTS, `ver-${key}.png`) });
+    console.log(`  ${key}: fps=${fps} applied=${cur === key}`);
+  }
+
+  // --- 4. Back to V1, turn ON the numbered overlay ---
+  await page.click(`#ver-rail .ver-chip[data-version="V1"]`);
+  await sleep(2200);
+  await page.click('#vr-overlay-btn');
+  await sleep(900);
+  const pins = await page.evaluate(() => {
+    const ps = [...document.querySelectorAll('#pin-layer .el-pin')];
+    const visible = ps.filter(p => p.style.display !== 'none');
+    return { total: ps.length, visible: visible.length, overlayOn: window._versions.overlayOn,
+      nums: visible.map(p => (p.querySelector('.pin-badge') || {}).textContent) };
+  });
+  console.log('  overlay pins:', JSON.stringify(pins));
+  await page.screenshot({ path: path.join(SHOTS, 'ver-overlay-V1.png') });
+
+  // --- 5. Click two pins → they land in the Chosen tray ---
+  // click the first two visible pins via force (pins are absolutely-positioned over canvas)
+  const pinEls = await page.$$('#pin-layer .el-pin');
+  let clicked = 0;
+  for (const pe of pinEls) {
+    const disp = await pe.evaluate(n => n.style.display);
+    if (disp !== 'none' && clicked < 2) { await pe.click({ force: true }).catch(() => {}); clicked++; await sleep(500); }
+  }
+  await sleep(400);
+  // open the tray so the chips render (force — it sits bottom-right, may be partly clipped at this viewport)
+  await page.click('#chosen-tray-head', { force: true }).catch(() => {});
+  await sleep(500);
+  const tray = await page.evaluate(() => ({
+    count: document.getElementById('ct-count') ? document.getElementById('ct-count').textContent : null,
+    chips: [...document.querySelectorAll('#chosen-items .ct-chip')].map(c =>
+      (c.querySelector('.ctc-tag') || {}).textContent + ' ' + (c.querySelector('.ctc-name') || {}).textContent),
+    chosenLib: window._versions.chosen.map(c => c.key),
+    chosenText: window._versions.chosenText()
+  }));
+  console.log('  tray:', JSON.stringify(tray));
+  await page.screenshot({ path: path.join(SHOTS, 'ver-tray.png') });
+
+  // --- 6. Re-visited chosen pins show the checked state ---
+  const checkState = await page.evaluate(() =>
+    [...document.querySelectorAll('#pin-layer .el-pin.chosen')].map(p => p.dataset.key));
+  console.log('  chosen pins (check state):', JSON.stringify(checkState));
+
+  // --- 7. LOCKED: senior guided nav still works under the version system ---
+  await page.click('#vr-overlay-btn'); // overlay off so it doesn't intercept
+  await sleep(500);
+  const beforePos = await page.evaluate(() => document.getElementById('gt-pos') ? document.getElementById('gt-pos').textContent : '');
+  await page.click('#g-next'); await sleep(1500);
+  const afterPos = await page.evaluate(() => document.getElementById('gt-pos') ? document.getElementById('gt-pos').textContent : '');
+  const seniorWorks = beforePos !== afterPos || (afterPos && afterPos.length > 0);
+  console.log('  senior Next:', beforePos, '→', afterPos, '| works:', seniorWorks);
+
+  // --- 8. Specs intact (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);
+  });
+  console.log('  SPECS:', JSON.stringify(specText));
+
+  // --- 9. Collapsed popup default ---
+  const popupCollapsed = await page.evaluate(() => {
+    const wd = document.getElementById('wing-detail');
+    return wd ? wd.classList.contains('collapsed') : null;
+  });
+
+  const fpsVals = fpsLog.map(f => f.fps).filter(n => n > 0);
+  const report = {
+    when: new Date().toISOString(),
+    rail,
+    railHasAll10: rail.count === 10,
+    numbersVisible: rail.nums.every(n => /^V\d+$/.test((n || '').trim())),
+    perVersion,
+    allVersionsApply: Object.values(perVersion).every(v => v.applied),
+    overlay: pins,
+    overlayWorks: pins.overlayOn && pins.visible > 0,
+    tray,
+    trayWorks: parseInt(tray.count) >= clicked && tray.chips.length >= clicked,
+    checkStatePins: checkState,
+    seniorNavWorks: seniorWorks,
+    specText,
+    specsIntact: !!(specText && specText.some(s => s.includes('27'))),
+    popupCollapsed,
+    errors, errorCount: errors.length,
+    fpsMin: fpsVals.length ? Math.min(...fpsVals) : null,
+    fpsAvg: fpsVals.length ? Math.round(fpsVals.reduce((a, b) => a + b, 0) / fpsVals.length) : null,
+    fpsSamples: fpsLog
+  };
+  fs.writeFileSync(path.join(OUT, 'test-versions-report.json'), JSON.stringify(report, null, 2));
+  console.log('\n=== VERSIONS REPORT ===');
+  console.log('rail 10:', report.railHasAll10, '| numbers visible:', report.numbersVisible,
+    '| all apply:', report.allVersionsApply, '| overlay:', report.overlayWorks,
+    '| tray:', report.trayWorks, '| senior:', report.seniorNavWorks,
+    '| specs:', report.specsIntact, '| popupCollapsed:', report.popupCollapsed);
+  console.log('errors:', report.errorCount, '| fps min/avg:', report.fpsMin, '/', report.fpsAvg);
+  if (errors.length) console.log('ERRORS:', errors.slice(0, 10).join(' || '));
+
+  await ctx.close(); await browser.close();
+})().catch(e => { console.error('VERSIONS TEST FAILED:', e.message); process.exit(1); });

← f51220a PJ arc rack v1: single-vertical-hinge boards on a curved bra  ·  back to Quadrille Showroom  ·  Contrarian pass: corrected 10 to genuinely-distinct mechanic c421efa →