[object Object]

← 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

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 →