← 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
M public/js/showroom.jsM public/js/viewmodes.jsA scripts/test-versions.js
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 →