← back to Quadrille Showroom
auto-save: 2026-06-28T07:43:27 (4 files) — public/css/showroom.css public/js/showroom.js public/js/versions.js scripts/test-versions.js
fecd0e8d44d6f4f0a642c63f425f539fb565da62 · 2026-06-28 07:43:30 -0700 · Steve Abrams
Files touched
M public/css/showroom.cssM public/js/showroom.jsM public/js/versions.jsM scripts/test-versions.js
Diff
commit fecd0e8d44d6f4f0a642c63f425f539fb565da62
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jun 28 07:43:30 2026 -0700
auto-save: 2026-06-28T07:43:27 (4 files) — public/css/showroom.css public/js/showroom.js public/js/versions.js scripts/test-versions.js
---
public/css/showroom.css | 9 ++--
public/js/showroom.js | 120 ++++++++++++++++++++++++++++++-------------
public/js/versions.js | 129 +++++++++++++++++++++++++++++++++++++++++++----
scripts/test-versions.js | 4 +-
4 files changed, 214 insertions(+), 48 deletions(-)
diff --git a/public/css/showroom.css b/public/css/showroom.css
index 8ec098a..7f9e566 100644
--- a/public/css/showroom.css
+++ b/public/css/showroom.css
@@ -390,14 +390,15 @@ body { overflow: hidden; background: #0a0a0f; font-family: 'Segoe UI', system-ui
/* ---- VERSION PICKER RAIL — right-centre vertical strip ---- */
#ver-rail {
- position: fixed; right: 14px; top: 50%; transform: translateY(-50%);
- z-index: 44; display: flex; flex-direction: column; gap: 6px;
+ position: fixed; right: 14px; top: 64px;
+ z-index: 46; display: flex; flex-direction: column; gap: 6px;
background: rgba(12,12,17,0.90); border: 1.5px solid rgba(201,169,110,0.40);
border-radius: 18px; padding: 12px 11px;
backdrop-filter: blur(14px); box-shadow: 0 18px 50px -14px rgba(0,0,0,0.75);
- max-height: 88vh; transition: transform .22s ease;
+ /* anchored near the top, capped so the rail never reaches the bottom-right tray */
+ max-height: calc(100vh - 230px); overflow-y: auto; transition: transform .22s ease;
}
-#ver-rail.collapsed { transform: translateY(-50%) translateX(calc(100% + 14px)); }
+#ver-rail.collapsed { transform: translateX(calc(100% + 18px)); }
#ver-rail-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 0 2px 8px; margin-bottom: 2px; border-bottom: 1px solid rgba(201,169,110,0.22);
diff --git a/public/js/showroom.js b/public/js/showroom.js
index b021c2b..3aaa444 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -313,6 +313,56 @@ function initMaterials() {
// boards / planter / console sit IN the room instead of floating ON it — the single
// biggest "is it real" tell after the room shell. No light, no shadow-map cost: each
// is one transparent dark plane laid flat just above the floor. (REVIEW.md #3.)
+// CACHED gallery-placard textures + shared geometry. Keyed by pattern|color so a
+// window-page rebuild reuses already-baked plates instead of re-baking 18 canvases
+// + GPU-uploading them in the rebuild frame (was the FPS-9 paging stall).
+const _placardCache = {};
+let _placardGeo = null;
+function placardGeo(wingW) {
+ if (!_placardGeo) _placardGeo = new THREE.PlaneGeometry(0.32, 0.075);
+ return _placardGeo; // fixed size — all boards are the same 30" face
+}
+
+// SHARED board geometry — face box, the two frame-bar geometries, contact + overlay
+// planes. All 18+ boards are the same fixed 30"×6ft size, so these are built ONCE and
+// reused, eliminating the ~100 per-rebuild geometry allocations that stalled paging.
+let _boardGeo = null;
+function boardGeo(wingW, H, THICK) {
+ if (_boardGeo) return _boardGeo;
+ const frameT = 0.020, frameD = 0.012, fpad = 0.010;
+ const fw = wingW + fpad * 2, fh = H + fpad * 2;
+ _boardGeo = {
+ face: new THREE.BoxGeometry(wingW, H, THICK),
+ frameH: new THREE.BoxGeometry(fw, frameT, frameD), // top/bottom bars
+ frameV: new THREE.BoxGeometry(frameT, fh, frameD), // left/right bars
+ contact: new THREE.PlaneGeometry(wingW + 0.10, 0.30),
+ frameT, fw, fh
+ };
+ _boardGeo.__shared = true;
+ return _boardGeo;
+}
+function placardTexture(pName, pColor) {
+ const key = pName + '|' + pColor;
+ if (_placardCache[key]) return _placardCache[key];
+ const plc = document.createElement('canvas'); plc.width = 512; plc.height = 128;
+ const pcx = plc.getContext('2d');
+ pcx.fillStyle = '#15110a'; pcx.fillRect(0, 0, 512, 128);
+ pcx.strokeStyle = 'rgba(185,147,63,0.6)'; pcx.lineWidth = 3; pcx.strokeRect(5, 5, 502, 118);
+ pcx.fillStyle = '#cbb073'; pcx.textAlign = 'center'; pcx.textBaseline = 'middle';
+ pcx.font = '600 38px "Cormorant Garamond", Georgia, serif';
+ pcx.fillText(pName.length > 26 ? pName.slice(0, 25) + '…' : pName, 256, 50);
+ if (pColor) {
+ pcx.fillStyle = '#9a8c6e'; pcx.font = '500 22px sans-serif';
+ pcx.fillText(pColor.toUpperCase().slice(0, 34), 256, 86);
+ }
+ const t = new THREE.CanvasTexture(plc);
+ if (renderer.capabilities) t.anisotropy = renderer.capabilities.getMaxAnisotropy();
+ t.minFilter = THREE.LinearMipmapLinearFilter; t.generateMipmaps = true;
+ t.__shared = true; // never disposed by disposeGroup
+ _placardCache[key] = t;
+ return t;
+}
+
let _contactTex = null;
function contactShadowTexture() {
if (_contactTex) return _contactTex;
@@ -381,9 +431,11 @@ function stripGlowTexture() {
// One overlay quad sized to a leaf face, parented to the leaf so it swings with it.
// Sits a hair in front of the face (toward +z local) so it never z-fights the swatch.
+let _overlayGeo = null;
function makeBoardLightOverlay(leafW, H, yC) {
+ if (!_overlayGeo) _overlayGeo = new THREE.PlaneGeometry(leafW, H); // shared (fixed board size)
const m = new THREE.Mesh(
- new THREE.PlaneGeometry(leafW, H),
+ _overlayGeo,
new THREE.MeshBasicMaterial({
map: boardLightTexture(), transparent: true, opacity: 1,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
@@ -969,8 +1021,10 @@ function isSharedMaterial(m) {
}
function disposeGroup(group) {
if (!group) return;
+ const sharedGeo = _boardGeo ? [_boardGeo.face, _boardGeo.frameH, _boardGeo.frameV, _boardGeo.contact, _placardGeo, _overlayGeo]
+ : [_placardGeo, _overlayGeo];
group.traverse(obj => {
- if (obj.geometry) obj.geometry.dispose();
+ if (obj.geometry && sharedGeo.indexOf(obj.geometry) === -1) obj.geometry.dispose(); // keep shared geos
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => {
@@ -1100,10 +1154,11 @@ function buildWingWall() {
const THICK = 0.014; // physical board thickness → real edge + cast shadow
const faceOffX = wingW / 2; // board centre sits +halfWidth from the hinge
+ const BG = boardGeo(wingW, H, THICK); // SHARED geometry singletons (per fixed board size)
// ONE full-width board face. (faceL/faceR both alias this single plane so the
// raycast + loadWingBook plumbing keeps working with no per-leaf split.)
- const face = new THREE.Mesh(new THREE.BoxGeometry(wingW, H, THICK), poolMat);
+ const face = new THREE.Mesh(BG.face, poolMat);
face.position.set(faceOffX, yC, 0);
face.castShadow = true; face.receiveShadow = true;
face.userData = { isWingFace: true, parentPivot: pivot, leaf: 'F' };
@@ -1116,38 +1171,27 @@ function buildWingWall() {
// Thin dark-bronze / near-black surround framing the 30" board face (matches the
// PJ photo's slim dark frames). Offset to the face centre so it tracks the board.
- const frameT = 0.020, frameD = 0.012, frameZ = THICK / 2 + 0.002;
- const fpad = 0.010; // frame sits just outside the board edges
- const fw = wingW + fpad * 2, fh = H + fpad * 2;
- const brassBar = (w, h, x, y) => {
- const bar = new THREE.Mesh(new THREE.BoxGeometry(w, h, frameD), MAT.frame || MAT.chrome);
- bar.position.set(faceOffX + x, yC + y, frameZ); bar.castShadow = false; bar.receiveShadow = true;
- pivot.add(bar);
+ // Four bars share two geometries (horizontal + vertical) — no per-board allocation.
+ const frameZ = THICK / 2 + 0.002;
+ const bar = (geo, x, y) => {
+ const b = new THREE.Mesh(geo, MAT.frame || MAT.chrome);
+ b.position.set(faceOffX + x, yC + y, frameZ); b.castShadow = false; b.receiveShadow = true;
+ pivot.add(b);
};
- brassBar(fw, frameT, 0, fh / 2 - frameT / 2); // top
- brassBar(fw, frameT, 0, -fh / 2 + frameT / 2); // bottom
- brassBar(frameT, fh, -fw / 2 + frameT / 2, 0); // left
- brassBar(frameT, fh, fw / 2 - frameT / 2, 0); // right
-
- // Gallery placard — engraved-look brass-on-dark plate under the board.
+ bar(BG.frameH, 0, BG.fh / 2 - BG.frameT / 2); // top
+ bar(BG.frameH, 0, -BG.fh / 2 + BG.frameT / 2); // bottom
+ bar(BG.frameV, -BG.fw / 2 + BG.frameT / 2, 0); // left
+ bar(BG.frameV, BG.fw / 2 - BG.frameT / 2, 0); // right
+
+ // Gallery placard — engraved-look brass-on-dark plate under the board. The texture
+ // is built once per pattern|color key + CACHED (shared across rebuilds/windows) so a
+ // window-page rebuild doesn't synchronously bake 18 fresh 512×128 canvases + GPU
+ // uploads in one frame (the prior FPS-9 paging stall). Geometry is shared too.
const pName = (product.pattern_name || product.name || 'Pattern').toString();
const pColor = (product.color_name || product.color || '').toString();
- const plc = document.createElement('canvas'); plc.width = 1024; plc.height = 256; // 4× — crisp
- const pcx = plc.getContext('2d');
- pcx.fillStyle = '#15110a'; pcx.fillRect(0, 0, 1024, 256);
- pcx.strokeStyle = 'rgba(185,147,63,0.6)'; pcx.lineWidth = 6; pcx.strokeRect(10, 10, 1004, 236);
- pcx.fillStyle = '#cbb073'; pcx.textAlign = 'center'; pcx.textBaseline = 'middle';
- pcx.font = '600 76px Georgia, serif';
- pcx.fillText(pName.length > 26 ? pName.slice(0, 25) + '…' : pName, 512, 100);
- if (pColor) {
- pcx.fillStyle = '#9a8c6e'; pcx.font = '500 44px sans-serif';
- pcx.fillText(pColor.toUpperCase().slice(0, 34), 512, 172);
- }
- const plTex = new THREE.CanvasTexture(plc);
- if (renderer.capabilities) plTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
- plTex.minFilter = THREE.LinearMipmapLinearFilter; plTex.generateMipmaps = true;
+ const plTex = placardTexture(pName, pColor);
const placard = new THREE.Mesh(
- new THREE.PlaneGeometry(Math.min(wingW * 0.9, 0.32), 0.075),
+ placardGeo(wingW),
new THREE.MeshBasicMaterial({ map: plTex, transparent: true })
);
placard.position.set(faceOffX, 0.115, THICK / 2 + 0.012);
@@ -1219,7 +1263,7 @@ function buildWingWall() {
map: contactShadowTexture(), transparent: true, opacity: 0.78,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
});
- const shade = new THREE.Mesh(new THREE.PlaneGeometry(wingW + 0.10, 0.30), shadeMat);
+ const shade = new THREE.Mesh(BG.contact, shadeMat); // shared contact geometry
shade.rotation.x = -Math.PI / 2;
shade.position.set(faceOffX, 0.006, 0.0); // 6 mm above floor, under the board face
shade.renderOrder = 1;
@@ -1898,8 +1942,16 @@ function updateBooks(dt) {
if (openPivot && !openPivot.userData.imageLoaded) { openPivot.userData.imageLoaded = true; loadWingBook(openPivot); }
const moved = QHRack.flipUpdate(wingBoards, openPivot, dt);
- if (moved && window._requestShadowUpdate) window._requestShadowUpdate(3);
-}
+ // SHADOW REBAKE BUDGET: don't rebake the (2 lights × 238-mesh) shadow maps EVERY
+ // moving frame — that's the dominant nav cost. Rebake once when a flip STARTS (catch
+ // the first frame of motion) and once when it SETTLES (motion→still), not in between.
+ // The packed closed boards barely shift their footprint mid-swing, so the visual cost
+ // of skipping the interim frames is negligible while the FPS win is large.
+ if (moved && !_flipWasMoving) { if (window._requestShadowUpdate) window._requestShadowUpdate(2); }
+ if (!moved && _flipWasMoving) { if (window._requestShadowUpdate) window._requestShadowUpdate(4); } // settle
+ _flipWasMoving = moved;
+}
+let _flipWasMoving = false;
// ============================================================
// ANIMATION LOOP
diff --git a/public/js/versions.js b/public/js/versions.js
index affe83d..97e81ba 100644
--- a/public/js/versions.js
+++ b/public/js/versions.js
@@ -51,6 +51,96 @@ function boot() {
if (!QH.focusedWing || QH.focusedWing.userData.index !== mid) QH.focusOnWing(wb[mid]);
}
+ // ===========================================================================
+ // V2 CONVEYOR controller — user dead-centre + stationary; the RACK rotates so each
+ // product rotates INTO the centre. ARROW KEYS / Next/Prev step it one board at a time
+ // (Steve's dry-cleaner-conveyor spec). Self-contained: yaws the wall group about its
+ // arc centre; intercepts the guided buttons + arrow keys WHILE V2 is active only.
+ // ===========================================================================
+ let conveyor = null; // { wg, stepRad, target, current, raf, keyHandler, clickL, clickR }
+ function findWallGroupV() {
+ let g = null;
+ const WH = QH.CONFIG.wing.height;
+ 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 - (WH + 0.10)) < 0.06)) g = o;
+ });
+ return g;
+ }
+ function startConveyor() {
+ stopConveyor();
+ const wg = findWallGroupV();
+ QH.restoreBoards();
+ QH.revertWalls();
+ if (QH.focusedWing) QH.unfocusWing(true);
+ // Park the camera dead-centre, stationary, looking at the back-wall arc centre.
+ const ROOM = QH.CONFIG.room;
+ const backZ = -ROOM.depth / 2 + 0.2;
+ const boardCY = QH.CONFIG.wing.height / 2 + 0.04;
+ const pos = new THREE.Vector3(0, boardCY, ROOM.depth / 2 - 1.5);
+ const tgt = new THREE.Vector3(0, boardCY, backZ);
+ QH.setControlsLocked(false);
+ QH.smoothCameraTo(pos, tgt, () => { QH.lockControls(pos.clone(), tgt.clone()); }, 48);
+ if (!wg) return;
+ const n = Math.max(1, QH.wingBoards.length);
+ conveyor = {
+ wg, n,
+ stepRad: (Math.PI * 0.9) / n, // arc spans ~0.9π; one board-step of yaw
+ target: wg.rotation.y || 0,
+ current: wg.rotation.y || 0,
+ raf: null
+ };
+ // step on arrow keys + guided Next/Prev while V2 is active
+ conveyor.keyHandler = (e) => {
+ if (currentVersion !== 'V2') return;
+ if (e.code === 'ArrowRight') { e.preventDefault(); stepConveyor(1); }
+ else if (e.code === 'ArrowLeft') { e.preventDefault(); stepConveyor(-1); }
+ };
+ window.addEventListener('keydown', conveyor.keyHandler, true);
+ conveyor.clickN = (e) => { if (currentVersion === 'V2') { e.stopImmediatePropagation(); e.preventDefault(); stepConveyor(1); } };
+ conveyor.clickP = (e) => { if (currentVersion === 'V2') { e.stopImmediatePropagation(); e.preventDefault(); stepConveyor(-1); } };
+ const gN = document.getElementById('g-next'), gP = document.getElementById('g-prev');
+ if (gN) gN.addEventListener('click', conveyor.clickN, true);
+ if (gP) gP.addEventListener('click', conveyor.clickP, true);
+ runConveyorAnim();
+ }
+ function stepConveyor(dir) {
+ if (!conveyor) return;
+ conveyor.target += dir * conveyor.stepRad;
+ runConveyorAnim();
+ }
+ function runConveyorAnim() {
+ if (!conveyor) return;
+ if (conveyor.raf) return; // already animating
+ const tick = () => {
+ if (!conveyor) return;
+ const d = conveyor.target - conveyor.current;
+ if (Math.abs(d) < 0.0015) {
+ conveyor.current = conveyor.target;
+ conveyor.wg.rotation.y = conveyor.current;
+ conveyor.raf = null;
+ if (QH.requestShadowUpdate) QH.requestShadowUpdate(3);
+ return;
+ }
+ conveyor.current += d * 0.14; // ease toward target
+ conveyor.wg.rotation.y = conveyor.current;
+ if (QH.requestShadowUpdate) QH.requestShadowUpdate(1);
+ conveyor.raf = requestAnimationFrame(tick);
+ };
+ conveyor.raf = requestAnimationFrame(tick);
+ }
+ function stopConveyor() {
+ if (!conveyor) return;
+ if (conveyor.raf) cancelAnimationFrame(conveyor.raf);
+ if (conveyor.keyHandler) window.removeEventListener('keydown', conveyor.keyHandler, true);
+ const gN = document.getElementById('g-next'), gP = document.getElementById('g-prev');
+ if (gN && conveyor.clickN) gN.removeEventListener('click', conveyor.clickN, true);
+ if (gP && conveyor.clickP) gP.removeEventListener('click', conveyor.clickP, true);
+ if (conveyor.wg) conveyor.wg.rotation.y = 0; // reset so other versions see a clean rack
+ conveyor = null;
+ if (QH.requestShadowUpdate) QH.requestShadowUpdate(3);
+ }
+
// ===========================================================================
// VERSION REGISTRY — each: { name, tag, scaffold?, build(), elements:[…] }
// elements[i] = { n, label, anchor } where anchor is:
@@ -67,13 +157,29 @@ function boot() {
build() {
// The canonical Phillip-Jeffries Dallas look (pj-dallas-showroom.png):
// packed sliver boards on the arc, one flipped open in the centre, warm
- // gallery light, the consultation nook + brand signage in view.
+ // gallery light, the consultation nook + olive in the foreground. This is an
+ // ESTABLISHING shot (not an isolated single board) so the whole arc + nook read
+ // like the reference photo. The senior Next/Back still opens a board face-on.
setTheme('warm');
- setBoards(8); // a full arc of boards
- setReveal(40); // thin slivers — most boards show only an edge
- setMode('hero'); // dead-on hero framing on the open middle board
+ setBoards(10); // a full arc of boards
+ setReveal(38); // thin slivers — most boards show only a vertical edge
setWingDeg(8);
- focusMiddle();
+ // Show the WHOLE arc (don't isolate): restore all boards, open the middle one.
+ QH.restoreBoards();
+ QH.revertWalls();
+ const mid = Math.floor(QH.wingBoards.length / 2);
+ const b = QH.wingBoards[mid];
+ if (b) { b.userData.panelClosed = false; }
+ // Park the camera HIGH at the FRONT-RIGHT of the room looking diagonally across
+ // to the arc — a wide establishing shot so the sliver-packed boards + the
+ // consultation nook both read like the reference photo. Arc centre ≈ (-0.3,-1.1);
+ // nook ≈ (-0.3, 3.2); room front wall ≈ z=+3.0. Wide FOV frames the whole bay.
+ const R = QH.CONFIG.room;
+ const pos = new THREE.Vector3(R.width / 2 - 0.9, 1.85, R.depth / 2 - 0.6);
+ const tgt = new THREE.Vector3(-0.5, 1.1, -1.4);
+ QH.setControlsLocked(false);
+ QH.smoothCameraTo(pos, tgt, () => { QH.lockControls(pos.clone(), tgt.clone()); }, 64);
+ if (QH.requestShadowUpdate) QH.requestShadowUpdate(12);
},
elements: [
{ n: 1, label: 'Arc / bay rack wrapping the corner', anchor: { named: 'railCenter' } },
@@ -89,15 +195,17 @@ function boot() {
V2: {
name: 'Dry-Clean Conveyor Rack', tag: 'conveyor',
build() {
- // User dead-centre + stationary; the rack rotates each product into the
- // centre. Built on the carousel view-mode (turntable yaw of the wall
- // group) so the boards parade past like a dry-cleaner's conveyor loop.
+ // User dead-centre + STATIONARY; the RACK rotates each product into the centre
+ // like a dry-cleaner's conveyor. ARROW KEYS / Next rotate the rack one board at
+ // a time — each product rotates into the centre to present (Steve's spec). Built
+ // on a dedicated step controller (NOT the auto-spin carousel) so it's user-driven.
setTheme('cool');
setBoards(10);
setReveal(70);
setWingDeg(0);
- setMode('carousel'); // turntable — boards rotate into the centre
+ startConveyor();
},
+ exit() { stopConveyor(); },
elements: [
{ n: 1, label: 'Rotating conveyor loop', anchor: { named: 'railCenter' } },
{ n: 2, label: 'User dead-centre + stationary', anchor: { named: 'floor' } },
@@ -263,6 +371,9 @@ function boot() {
// ===========================================================================
function setVersion(key, fromInit) {
const v = VERSIONS[key]; if (!v) return;
+ // Clean up the previous version's controller (e.g. the conveyor key/frame hooks).
+ const prev = VERSIONS[currentVersion];
+ if (prev && prev.exit && currentVersion !== key) { try { prev.exit(); } catch (e) {} }
currentVersion = key;
try { v.build(); } catch (e) { console.warn('version build failed', key, e); }
if (QH.requestShadowUpdate) QH.requestShadowUpdate(20);
diff --git a/scripts/test-versions.js b/scripts/test-versions.js
index 4023de9..43aa988 100644
--- a/scripts/test-versions.js
+++ b/scripts/test-versions.js
@@ -118,7 +118,9 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
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
+ // close the tray + overlay so neither intercepts the bottom-bar clicks
+ await page.evaluate(() => { const t = document.getElementById('chosen-tray'); if (t) t.classList.remove('open'); });
+ await page.click('#vr-overlay-btn', { force: true }).catch(() => {}); // overlay off
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);
← 1a31ad6 10 VERSIONS system: V1-V10 picker rail + numbered-element ov
·
back to Quadrille Showroom
·
Perf + PJ-accurate isolate: hero stays AMID the sliver arc ( f75fcc7 →