[object Object]

← back to Quadrille Showroom

Realism pass + proximity book-match wings

8a4789706fa2e7ecb7537be815994e9b44d01f96 · 2026-06-26 12:59:24 -0700 · Steve

Renderer/materials/lighting → PBR:
- antialias on, adaptive pixelRatio (1.5→1.0 auto-ratchet guard), sRGB output,
  exposure 1.2→1.0, PCF shadow map (1024) — real key-light contact shadows
- MeshLambert/Phong → MeshStandard across floor/walls/chrome/wood/furniture
- PMREM studio-gradient environment for believable chrome/floor reflections
- color maps tagged sRGB + max anisotropy (kills slat moiré)

Wings rebuilt as two-leaf BOOK-MATCH boards (thin boxes, cast+receive shadow):
- walk within 4 ft (1.22m) of the wall → nearest wing opens like a sample book,
  left leaf mirrored / right leaf normal across a center spine
- updateBooks() proximity driver; focused/clicked wing also opens
- B key + book HUD toggle to enable/disable; loadWingBook() applies the
  normal+mirrored texture pair (cached per URL)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8a4789706fa2e7ecb7537be815994e9b44d01f96
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 26 12:59:24 2026 -0700

    Realism pass + proximity book-match wings
    
    Renderer/materials/lighting → PBR:
    - antialias on, adaptive pixelRatio (1.5→1.0 auto-ratchet guard), sRGB output,
      exposure 1.2→1.0, PCF shadow map (1024) — real key-light contact shadows
    - MeshLambert/Phong → MeshStandard across floor/walls/chrome/wood/furniture
    - PMREM studio-gradient environment for believable chrome/floor reflections
    - color maps tagged sRGB + max anisotropy (kills slat moiré)
    
    Wings rebuilt as two-leaf BOOK-MATCH boards (thin boxes, cast+receive shadow):
    - walk within 4 ft (1.22m) of the wall → nearest wing opens like a sample book,
      left leaf mirrored / right leaf normal across a center spine
    - updateBooks() proximity driver; focused/clicked wing also opens
    - B key + book HUD toggle to enable/disable; loadWingBook() applies the
      normal+mirrored texture pair (cached per URL)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/js/showroom.js | 345 +++++++++++++++++++++++++++++++++++++++-----------
 public/showroom.html  |   6 +-
 2 files changed, 273 insertions(+), 78 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index c36bc4e..747120b 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -40,6 +40,11 @@ const PERUSE_DWELL = 3000; // ms per wing
 // Wall-cladding: render the focused pattern onto the actual room walls
 let roomWalls = [];          // [{ mesh, w, h, origMat, cladMat }]
 let wallsAutoClad = true;    // focusing a wing clads the room walls in its pattern
+// Proximity book-match: walk within ~4 ft of the wall and the nearest wing opens
+// like a sample book — left leaf mirrored, right leaf normal (same pattern, both sides).
+let proximityEnabled = true;
+const PROX_FT = 1.22;        // 4 feet, in meters
+const BOOK_OPEN = 0.66;      // leaf hinge angle when open (~38°)
 
 // SHARED materials — key perf optimization
 const MAT = {};
@@ -67,11 +72,18 @@ function init() {
   camera.position.set(CONFIG.camera.startPos.x, CONFIG.camera.startPos.y, CONFIG.camera.startPos.z);
 
   const canvas = document.getElementById('showroom-canvas');
-  renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
+  renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
   renderer.setSize(window.innerWidth, window.innerHeight);
-  renderer.setPixelRatio(1.0);
+  // Start crisp; the adaptive guard in animate() ratchets this down if FPS dips,
+  // so strong GPUs stay sharp and weak ones stay smooth (PBR+shadows are fragment-heavy).
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
+  if (THREE.sRGBEncoding) renderer.outputEncoding = THREE.sRGBEncoding; // correct gamma → no washed-out look
   renderer.toneMapping = THREE.ACESFilmicToneMapping;
-  renderer.toneMappingExposure = 1.2;
+  renderer.toneMappingExposure = 1.0; // was 1.2 — that overexposure flattened everything
+  renderer.shadowMap.enabled = true;             // contact shadows = the #1 realism lever
+  renderer.shadowMap.type = THREE.PCFShadowMap;  // PCF (not Soft) — far cheaper per fragment
+  // PBR environment so chrome/floor pick up believable reflections
+  buildEnvironment();
 
   controls = new THREE.OrbitControls(camera, canvas);
   controls.enableDamping = true;
@@ -91,6 +103,7 @@ function init() {
   buildRoom();
   buildLighting();
   buildFurniture();
+  enableStaticShadows();
 
   updateLoadStatus('Loading products...', 60);
   loadProducts();
@@ -108,6 +121,8 @@ function init() {
       e.preventDefault(); togglePeruse();
     } else if (e.code === 'Space') {
       if (peruseActive) { e.preventDefault(); stopPeruse(true); }
+    } else if (e.code === 'KeyB') {
+      e.preventDefault(); toggleProximity();
     } else if (e.code === 'Escape') {
       if (peruseActive) stopPeruse();
       else if (focusedWing) unfocusWing();
@@ -120,24 +135,52 @@ function init() {
   animate();
 }
 
+// ============================================================
+// ENVIRONMENT — PMREM-baked studio gradient for PBR reflections
+// (no external HDR needed; a vertical canvas gradient reads as a softbox ceiling)
+// ============================================================
+function buildEnvironment() {
+  const c = document.createElement('canvas'); c.width = 32; c.height = 128;
+  const x = c.getContext('2d');
+  const g = x.createLinearGradient(0, 0, 0, 128);
+  g.addColorStop(0.00, '#2c2e34'); // upper sky (dim)
+  g.addColorStop(0.42, '#8f8b80'); // wall horizon
+  g.addColorStop(0.48, '#e8e3d6'); // bright softbox band (ceiling light)
+  g.addColorStop(0.55, '#9a968b');
+  g.addColorStop(1.00, '#1f1f25'); // floor falloff
+  x.fillStyle = g; x.fillRect(0, 0, 32, 128);
+  const tex = new THREE.CanvasTexture(c);
+  tex.mapping = THREE.EquirectangularReflectionMapping;
+  try {
+    const pmrem = new THREE.PMREMGenerator(renderer);
+    const rt = pmrem.fromEquirectangular(tex);
+    scene.environment = rt.texture;
+    pmrem.dispose();
+  } catch (e) { console.warn('env build failed', e.message); }
+  tex.dispose();
+}
+
+// Tag a texture as color data so the renderer gamma-corrects it (legacy three API)
+function srgb(tex) { if (tex && THREE.sRGBEncoding) tex.encoding = THREE.sRGBEncoding; return tex; }
+
 // ============================================================
 // MATERIALS — all shared
 // ============================================================
 function initMaterials() {
-  MAT.floor = new THREE.MeshLambertMaterial({ color: 0x3a3a42 });
-  MAT.ceiling = new THREE.MeshLambertMaterial({ color: 0xd8d8d0 });
-  MAT.wall = new THREE.MeshLambertMaterial({ color: 0xc8c4bc });
-  MAT.chrome = new THREE.MeshLambertMaterial({ color: 0xbbbbbb, emissive: 0x222222 });
-  MAT.wood = new THREE.MeshLambertMaterial({ color: 0x5c4033 });
-  MAT.chair = new THREE.MeshLambertMaterial({ color: 0x444450 });
-  MAT.dark = new THREE.MeshLambertMaterial({ color: 0x111111 });
-  MAT.frame = new THREE.MeshLambertMaterial({ color: 0x3a3a3a });
+  MAT.floor = new THREE.MeshStandardMaterial({ color: 0x33333b, roughness: 0.35, metalness: 0.0, envMapIntensity: 0.7 }); // polished concrete
+  MAT.ceiling = new THREE.MeshStandardMaterial({ color: 0xdcdcd4, roughness: 0.95, metalness: 0.0 });
+  MAT.wall = new THREE.MeshStandardMaterial({ color: 0xccc7bc, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.4 }); // matte gallery paint
+  MAT.chrome = new THREE.MeshStandardMaterial({ color: 0xcdcdd2, roughness: 0.22, metalness: 0.95, envMapIntensity: 1.0 }); // real brushed metal
+  MAT.wood = new THREE.MeshStandardMaterial({ color: 0x6b4a32, roughness: 0.5, metalness: 0.0, envMapIntensity: 0.5 }); // satin walnut
+  MAT.chair = new THREE.MeshStandardMaterial({ color: 0x3a3a44, roughness: 0.7, metalness: 0.1 });
+  MAT.dark = new THREE.MeshStandardMaterial({ color: 0x0d0d10, roughness: 0.4, metalness: 0.3 });
+  MAT.frame = new THREE.MeshStandardMaterial({ color: 0x32323a, roughness: 0.55, metalness: 0.3 });
   MAT.lightPanel = new THREE.MeshBasicMaterial({ color: 0xfffff5 });
-  MAT.baseboard = new THREE.MeshLambertMaterial({ color: 0x2a2a30 });
+  MAT.baseboard = new THREE.MeshStandardMaterial({ color: 0x26262c, roughness: 0.7, metalness: 0.0 });
 
-  // Shared book materials — 8 colors
+  // Shared book materials — 8 colors (matte cloth spines)
   MAT.books = [0x1a5276, 0x7d3c98, 0x1e8449, 0xb9770e, 0x922b21, 0x2c3e50, 0xd4ac0d, 0xa04000]
-    .map(c => new THREE.MeshLambertMaterial({ color: c }));
+    .map(c => new THREE.MeshStandardMaterial({ color: c, roughness: 0.8, metalness: 0.0 }));
 
   // Brick texture
   const bc = document.createElement('canvas');
@@ -150,9 +193,9 @@ function initMaterials() {
       bx.fillRect(col * 64 + (row % 2 ? 32 : 0) + 2, row * 32 + 2, 60, 28);
     }
   }
-  const bt = new THREE.CanvasTexture(bc);
+  const bt = srgb(new THREE.CanvasTexture(bc));
   bt.wrapS = bt.wrapT = THREE.RepeatWrapping; bt.repeat.set(3, 2);
-  MAT.brick = new THREE.MeshLambertMaterial({ map: bt });
+  MAT.brick = new THREE.MeshStandardMaterial({ map: bt, roughness: 0.95, metalness: 0.0 });
 }
 
 // ============================================================
@@ -174,9 +217,9 @@ function initTexturePool() {
     for (let c = 0; c < 2; c++) {
       const color = colors[(p * 4 + c) % colors.length];
       const canvas = generatePatternCanvas(patterns[p], color.hex);
-      const tex = new THREE.CanvasTexture(canvas);
+      const tex = srgb(new THREE.CanvasTexture(canvas));
       tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
-      const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide });
+      const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85, metalness: 0.0, envMapIntensity: 0.4 });
       TEXTURE_POOL.push({ material: mat, pattern: patterns[p], color: color.name });
     }
   }
@@ -258,16 +301,17 @@ function buildRoom() {
   // Subtle tile grid
   fx.strokeStyle = 'rgba(80,80,88,0.15)'; fx.lineWidth = 1;
   for (let i = 0; i <= 4; i++) { fx.beginPath(); fx.moveTo(i*64,0); fx.lineTo(i*64,256); fx.stroke(); fx.beginPath(); fx.moveTo(0,i*64); fx.lineTo(256,i*64); fx.stroke(); }
-  const ft = new THREE.CanvasTexture(fc); ft.wrapS = ft.wrapT = THREE.RepeatWrapping; ft.repeat.set(4, 4);
-  const floorMesh = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshPhongMaterial({ map: ft, shininess: 15, specular: new THREE.Color(0x222222) }));
-  floorMesh.rotation.x = -Math.PI/2; scene.add(floorMesh);
+  const ft = srgb(new THREE.CanvasTexture(fc)); ft.wrapS = ft.wrapT = THREE.RepeatWrapping; ft.repeat.set(4, 4);
+  if (renderer.capabilities) ft.anisotropy = renderer.capabilities.getMaxAnisotropy();
+  const floorMesh = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshStandardMaterial({ map: ft, roughness: 0.34, metalness: 0.0, envMapIntensity: 0.7 }));
+  floorMesh.rotation.x = -Math.PI/2; floorMesh.receiveShadow = true; scene.add(floorMesh);
 
   // Ceiling
   const cc = document.createElement('canvas'); cc.width = 64; cc.height = 64;
   const cx = cc.getContext('2d'); cx.fillStyle = '#d5d5cd'; cx.fillRect(0,0,64,64);
   cx.strokeStyle = '#b8b8b0'; cx.lineWidth = 1;
   for (let i = 0; i <= 2; i++) { cx.beginPath(); cx.moveTo(i*32,0); cx.lineTo(i*32,64); cx.stroke(); cx.beginPath(); cx.moveTo(0,i*32); cx.lineTo(64,i*32); cx.stroke(); }
-  const ct = new THREE.CanvasTexture(cc); ct.wrapS = ct.wrapT = THREE.RepeatWrapping; ct.repeat.set(6,5);
+  const ct = srgb(new THREE.CanvasTexture(cc)); ct.wrapS = ct.wrapT = THREE.RepeatWrapping; ct.repeat.set(6,5);
   const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshLambertMaterial({ map: ct }));
   ceil.rotation.x = Math.PI/2; ceil.position.y = H; scene.add(ceil);
 
@@ -321,7 +365,7 @@ function buildRoom() {
 
 function addWall(x, y, z, w, h, rotY, mat) {
   const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
-  m.position.set(x, y, z); m.rotation.y = rotY; scene.add(m);
+  m.position.set(x, y, z); m.rotation.y = rotY; m.receiveShadow = true; scene.add(m);
   return m;
 }
 
@@ -341,14 +385,15 @@ function cladWalls(imageUrl, isSeamlessTile) {
 
 function applyWallImage(img, isSeamlessTile) {
   roomWalls.forEach(w => {
-    const tex = new THREE.Texture(img);
+    const tex = srgb(new THREE.Texture(img));
     tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
     tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter;
+    if (renderer.capabilities) tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
     // Seamless tiles already repeat; raw swatches get a coarser repeat so seams hide in the architecture.
     const div = isSeamlessTile ? PHYS_TILE_M : PHYS_TILE_M * 1.6;
     tex.repeat.set(Math.max(1, Math.round(w.w / div)), Math.max(1, Math.round(w.h / div)));
     tex.needsUpdate = true;
-    if (!w.cladMat) w.cladMat = new THREE.MeshLambertMaterial();
+    if (!w.cladMat) w.cladMat = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0.0 });
     if (w.cladMat.map) w.cladMat.map.dispose();
     w.cladMat.map = tex; w.cladMat.needsUpdate = true;
     w.mesh.material = w.cladMat;
@@ -366,16 +411,28 @@ function revertWalls() {
 // LIGHTING — minimal: ambient + hemi + 4 point
 // ============================================================
 function buildLighting() {
-  // Ambient fills shadow areas — cheap
-  scene.add(new THREE.AmbientLight(0xfff5e6, 0.7));
-  // Hemisphere gives sky/ground color variation — cheap
-  scene.add(new THREE.HemisphereLight(0xfff8f0, 0x303038, 0.5));
-
-  // 2 DirectionalLights replace 6 PointLights — MUCH cheaper (no per-pixel distance calc)
-  const d1 = new THREE.DirectionalLight(0xfff5e6, 0.6);
-  d1.position.set(2, CONFIG.room.height - 0.2, 1); scene.add(d1);
-  const d2 = new THREE.DirectionalLight(0xfff0dd, 0.4);
-  d2.position.set(-2, CONFIG.room.height - 0.2, -1); scene.add(d2);
+  // Lower fills — the environment map + key light now carry the scene, so heavy
+  // ambient would just wash out the new contrast/shadows.
+  scene.add(new THREE.AmbientLight(0xfff5e6, 0.32));
+  scene.add(new THREE.HemisphereLight(0xfff8f0, 0x2a2a32, 0.32));
+
+  // KEY light — warm, casts soft shadows (the wings drop real shadows on wall + floor)
+  const key = new THREE.DirectionalLight(0xfff3e2, 0.95);
+  key.position.set(2.4, CONFIG.room.height + 1.4, 2.2);
+  key.target.position.set(0, 1.0, -CONFIG.room.depth / 2);
+  key.castShadow = true;
+  key.shadow.mapSize.set(1024, 1024);
+  const sc = key.shadow.camera;
+  sc.near = 0.5; sc.far = 14; sc.left = -5; sc.right = 5; sc.top = 4; sc.bottom = -3;
+  key.shadow.bias = -0.0005;
+  if ('normalBias' in key.shadow) key.shadow.normalBias = 0.02;
+  if ('radius' in key.shadow) key.shadow.radius = 2; // mild PCF penumbra (cheap)
+  scene.add(key); scene.add(key.target);
+  window._keyLight = key;
+
+  // FILL — cool, no shadow, lifts the back/left so the key has contrast not blackness
+  const d2 = new THREE.DirectionalLight(0xdfe6ff, 0.3);
+  d2.position.set(-2.6, CONFIG.room.height - 0.2, -1.5); scene.add(d2);
 
   // Light panel meshes (visual only, no light cost)
   const H = CONFIG.room.height;
@@ -385,6 +442,16 @@ function buildLighting() {
   });
 }
 
+// Boxes/cylinders/spheres cast shadows; flat planes (walls/floor/ceiling/screens) only receive.
+// One pass over the static room so the new key light reads dimensionally.
+function enableStaticShadows() {
+  scene.traverse(o => {
+    if (!o.isMesh || !o.geometry) return;
+    o.receiveShadow = true;
+    o.castShadow = o.geometry.type !== 'PlaneGeometry';
+  });
+}
+
 // ============================================================
 // FURNITURE — simplified
 // ============================================================
@@ -439,23 +506,23 @@ function buildFurniture() {
   // Center medallion
   rc.fillStyle = '#6a5a3a'; rc.beginPath(); rc.ellipse(128, 64, 40, 25, 0, 0, Math.PI*2); rc.fill();
   rc.strokeStyle = '#c9a96e'; rc.lineWidth = 1.5; rc.beginPath(); rc.ellipse(128, 64, 40, 25, 0, 0, Math.PI*2); rc.stroke();
-  const rugTex = new THREE.CanvasTexture(rugCanvas);
-  const rug = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 1.4), new THREE.MeshLambertMaterial({ map: rugTex }));
+  const rugTex = srgb(new THREE.CanvasTexture(rugCanvas));
+  const rug = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 1.4), new THREE.MeshStandardMaterial({ map: rugTex, roughness: 0.95, metalness: 0.0 }));
   rug.rotation.x = -Math.PI/2; rug.position.set(-1.0, 0.005, 1.5); scene.add(rug);
 
   // Potted plants — 2 corners
   [[-W/2+0.4, D/2-0.4], [W/2-0.4, -D/2+0.4]].forEach(([px, pz]) => {
     // Pot
-    const pot = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.08, 0.2, 8), new THREE.MeshLambertMaterial({ color: 0x8b5e3c }));
+    const pot = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.08, 0.2, 16), new THREE.MeshStandardMaterial({ color: 0x8b5e3c, roughness: 0.85, metalness: 0.0 }));
     pot.position.set(px, 0.1, pz); scene.add(pot);
     // Soil
-    const soil = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.02, 8), new THREE.MeshLambertMaterial({ color: 0x3a2a1a }));
+    const soil = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.02, 16), new THREE.MeshStandardMaterial({ color: 0x3a2a1a, roughness: 1.0, metalness: 0.0 }));
     soil.position.set(px, 0.21, pz); scene.add(soil);
     // Foliage (sphere cluster)
-    const foliageMat = new THREE.MeshLambertMaterial({ color: 0x2d5a1e });
-    const f1 = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 6), foliageMat);
+    const foliageMat = new THREE.MeshStandardMaterial({ color: 0x2d5a1e, roughness: 0.9, metalness: 0.0 });
+    const f1 = new THREE.Mesh(new THREE.SphereGeometry(0.15, 12, 9), foliageMat);
     f1.position.set(px, 0.45, pz); scene.add(f1);
-    const f2 = new THREE.Mesh(new THREE.SphereGeometry(0.1, 6, 5), foliageMat);
+    const f2 = new THREE.Mesh(new THREE.SphereGeometry(0.1, 10, 7), foliageMat);
     f2.position.set(px+0.08, 0.55, pz-0.05); scene.add(f2);
   });
 
@@ -659,6 +726,7 @@ function buildWingWall() {
     new THREE.BoxGeometry(availW + 0.04, 0.015, 0.015), MAT.chrome
   );
   rail.position.set(0, WC.height + 0.08, 0);
+  rail.castShadow = true; rail.receiveShadow = true;
   wallGroup.add(rail);
 
   // Rail end caps
@@ -678,16 +746,30 @@ function buildWingWall() {
 
     const poolIdx = product._poolIdx !== undefined ? product._poolIdx : (i % TEXTURE_POOL.length);
     const poolMat = TEXTURE_POOL[poolIdx].material;
-    const face = new THREE.Mesh(new THREE.PlaneGeometry(wingW, WC.height), poolMat);
-    face.position.set(wingW / 2, WC.height / 2 + 0.04, 0);
-    face.userData = { isWingFace: true, parentPivot: pivot };
-    pivot.add(face);
+
+    // BOOK-MATCH WING — two leaves hinged at a center spine (x=0).
+    // Closed: coplanar on the wall (a flat mirrored pair). Open (proximity ≤4ft):
+    // both leaves swing forward like an opening sample book — left mirrored, right normal.
+    const leafW = wingW / 2;
+    const H = WC.height;
+    const yC = H / 2 + 0.04;
+    const THICK = 0.014;            // physical board thickness → real edge + cast shadow
+
+    const leafL = new THREE.Group();        // left page, hinge at spine
+    const planeL = new THREE.Mesh(new THREE.BoxGeometry(leafW, H, THICK), poolMat);
+    planeL.position.set(-leafW / 2, yC, 0);
+    planeL.castShadow = true; planeL.receiveShadow = true;
+    planeL.userData = { isWingFace: true, parentPivot: pivot, leaf: 'L' };
+    leafL.add(planeL); pivot.add(leafL);
+
+    const leafR = new THREE.Group();        // right page, hinge at spine
+    const planeR = new THREE.Mesh(new THREE.BoxGeometry(leafW, H, THICK), poolMat);
+    planeR.position.set(leafW / 2, yC, 0);
+    planeR.castShadow = true; planeR.receiveShadow = true;
+    planeR.userData = { isWingFace: true, parentPivot: pivot, leaf: 'R' };
+    leafR.add(planeR); pivot.add(leafR);
 
     const wingImg = product.tile || product.image;
-    if (wingImg) {
-      face.userData.pendingImage = wingImg;
-      face.userData.imageLoaded = false;
-    }
 
     let widthInches = 27;
     if (typeof product.width === 'string') {
@@ -728,7 +810,10 @@ function buildWingWall() {
     pivot.userData = {
       product: normalizedProduct, index: i,
       isOpen: false, targetAngle: 0, currentAngle: 0,
-      boardWidth: wingW, wallGroup, wingDirection: 1
+      boardWidth: wingW, wallGroup, wingDirection: 1,
+      leafL, leafR, faceL: planeL, faceR: planeR,
+      pendingImage: wingImg || null, imageLoaded: false,
+      bookOpen: 0, bookTarget: 0           // book-match leaf-hinge state (separate from fan)
     };
     wallGroup.add(pivot);
     wingBoards.push(pivot);
@@ -883,6 +968,55 @@ function loadWingTexture(faceMesh, imageUrl, onDone) {
   img.src = src;
 }
 
+// Load a wing's pattern onto BOTH leaves — right leaf normal, left leaf mirrored
+// across the spine = a true book-match. Cached per-URL (shares one decoded image).
+function loadWingBook(pivot, onDone) {
+  const ud = pivot.userData;
+  const imageUrl = ud.pendingImage;
+  if (!imageUrl || !ud.faceL || !ud.faceR) { if (onDone) onDone(); return; }
+  const product = ud.product;
+  const wingW = ud.boardWidth || 0.12;
+  const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
+  const maxAniso = (renderer.capabilities && renderer.capabilities.getMaxAnisotropy) ? renderer.capabilities.getMaxAnisotropy() : 1;
+
+  const leafMat = (tex) => new THREE.MeshStandardMaterial({ map: tex, roughness: 0.82, metalness: 0.0, envMapIntensity: 0.45 });
+  const apply = (cached) => {
+    ud.faceR.material = leafMat(cached.texture);
+    ud.faceL.material = leafMat(cached.mirror);
+    if (product) product.isFullDesign = cached.isFullDesign;
+    setWingBanner(pivot, cached.isFullDesign === false);
+    if (onDone) onDone();
+  };
+
+  if (imageTexCache[imageUrl] && imageTexCache[imageUrl].mirror) { apply(imageTexCache[imageUrl]); return; }
+
+  const img = new Image();
+  img.onload = () => {
+    const isFD = (product && product.isSeamlessTile) ? true : detectFullDesign(img).isFullDesign;
+    const make = (mirror) => {
+      const t = new THREE.Texture(img);
+      t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
+      t.anisotropy = maxAniso; srgb(t);
+      if (isFD) {
+        const { rx, ry } = computeRepeat(wingW, product, img);
+        t.wrapS = t.wrapT = THREE.RepeatWrapping;
+        t.repeat.set(mirror ? -rx : rx, ry);
+        if (mirror) t.offset.x = rx;       // U' = rx·(1−U): mirror about the spine
+      } else {
+        t.wrapT = THREE.ClampToEdgeWrapping;
+        if (mirror) { t.wrapS = THREE.RepeatWrapping; t.repeat.x = -1; t.offset.x = 1; }
+        else { t.wrapS = THREE.ClampToEdgeWrapping; }
+      }
+      t.needsUpdate = true; return t;
+    };
+    const cached = { texture: make(false), mirror: make(true), isFullDesign: isFD };
+    imageTexCache[imageUrl] = cached;
+    apply(cached);
+  };
+  img.onerror = () => { if (onDone) onDone(); };
+  img.src = src;
+}
+
 // ============================================================
 // WING INTERACTION — Two-step: click to move close, buttons to open
 // ============================================================
@@ -897,7 +1031,7 @@ function onCanvasClick(event) {
   raycaster.setFromCamera(mouse, camera);
 
   const faces = [];
-  wingBoards.forEach(p => p.children.forEach(c => { if (c.userData && c.userData.isWingFace) faces.push(c); }));
+  wingBoards.forEach(p => { if (p.userData.faceL) faces.push(p.userData.faceL); if (p.userData.faceR) faces.push(p.userData.faceR); });
 
   const hits = raycaster.intersectObjects(faces, false);
   if (hits.length > 0) {
@@ -936,11 +1070,10 @@ function focusOnWing(pivot) {
     lockControls(camPos, camTarget);
   });
 
-  // Lazy-load this wing's image immediately on focus
-  const face = pivot.children[0];
-  if (face && face.userData.pendingImage && !face.userData.imageLoaded) {
-    face.userData.imageLoaded = true;
-    loadWingTexture(face, face.userData.pendingImage);
+  // Lazy-load this wing's book-matched pattern immediately on focus
+  if (pivot.userData.pendingImage && !pivot.userData.imageLoaded) {
+    pivot.userData.imageLoaded = true;
+    loadWingBook(pivot);
   }
 
   // Show detail panel with product info
@@ -1111,7 +1244,7 @@ function onCanvasMouseMove(event) {
   raycaster.setFromCamera(mouse, camera);
 
   const faces = [];
-  wingBoards.forEach(p => p.children.forEach(c => { if (c.userData && c.userData.isWingFace) faces.push(c); }));
+  wingBoards.forEach(p => { if (p.userData.faceL) faces.push(p.userData.faceL); if (p.userData.faceR) faces.push(p.userData.faceR); });
 
   document.getElementById('showroom-canvas').style.cursor = raycaster.intersectObjects(faces, false).length > 0 ? 'pointer' : 'default';
 }
@@ -1159,22 +1292,59 @@ function lazyLoadNearbyTextures() {
   const _worldPos = new THREE.Vector3();
 
   wingBoards.forEach(pivot => {
-    pivot.children.forEach(face => {
-      if (!face.userData || !face.userData.pendingImage || face.userData.imageLoaded) return;
-      // Get wing world position directly (all wings in single wallGroup)
-      pivot.getWorldPosition(_worldPos);
-      const dx = _worldPos.x - camPos.x;
-      const dz = _worldPos.z - camPos.z;
-      const dist = Math.sqrt(dx*dx + dz*dz);
-      if (dist < loadRadius && _lazyLoading < MAX_CONCURRENT_LOADS) {
-        face.userData.imageLoaded = true;
-        _lazyLoading++;
-        loadWingTexture(face, face.userData.pendingImage, () => { _lazyLoading--; });
-      }
-    });
+    const ud = pivot.userData;
+    if (!ud.pendingImage || ud.imageLoaded) return;
+    pivot.getWorldPosition(_worldPos);
+    const dx = _worldPos.x - camPos.x;
+    const dz = _worldPos.z - camPos.z;
+    const dist = Math.sqrt(dx*dx + dz*dz);
+    if (dist < loadRadius && _lazyLoading < MAX_CONCURRENT_LOADS) {
+      ud.imageLoaded = true;
+      _lazyLoading++;
+      loadWingBook(pivot, () => { _lazyLoading--; });
+    }
   });
 }
 
+// ============================================================
+// PROXIMITY BOOK-MATCH — the nearest wing opens like a sample book at ≤4 ft
+// ============================================================
+const _bookWP = new THREE.Vector3();
+function updateBooks(dt) {
+  if (!wingBoards.length) return;
+  const backZ = -CONFIG.room.depth / 2 + 0.2;
+  const interactive = proximityEnabled && !controlsLocked && !peruseActive && !cameraAnim;
+
+  // Find the single wing nearest the camera, but only once we're close to the wall.
+  let nearest = null;
+  if (interactive) {
+    const camX = camera.position.x, camZ = camera.position.z;
+    if (camZ - backZ < PROX_FT + 0.5) {
+      let best = PROX_FT;
+      for (let i = 0; i < wingBoards.length; i++) {
+        wingBoards[i].getWorldPosition(_bookWP);
+        const d = Math.hypot(_bookWP.x - camX, _bookWP.z - camZ);
+        if (d < best) { best = d; nearest = wingBoards[i]; }
+      }
+    }
+  }
+
+  const k = Math.min(1, dt * 6); // ease toward target
+  for (let i = 0; i < wingBoards.length; i++) {
+    const p = wingBoards[i], ud = p.userData;
+    const wantOpen = (p === nearest) || (p === focusedWing);
+    const target = wantOpen ? BOOK_OPEN : 0;
+    if (wantOpen && !ud.imageLoaded) { ud.imageLoaded = true; loadWingBook(p); }
+    if (Math.abs(ud.bookOpen - target) > 0.0008) {
+      ud.bookOpen += (target - ud.bookOpen) * k;
+    } else if (ud.bookOpen !== target) {
+      ud.bookOpen = target;
+    } else continue;
+    ud.leafL.rotation.y = ud.bookOpen;   // left page opens +θ
+    ud.leafR.rotation.y = -ud.bookOpen;  // right page opens −θ (symmetric butterfly)
+  }
+}
+
 // ============================================================
 // ANIMATION LOOP
 // ============================================================
@@ -1220,7 +1390,7 @@ function animate() {
     controls.target.y = Math.max(0.3, Math.min(CONFIG.room.height - 0.3, controls.target.y));
   }
 
-  // Animate wings
+  // Animate wings (fan cascade — pivot rotation)
   for (let i = animatingWings.length - 1; i >= 0; i--) {
     const p = animatingWings[i], ud = p.userData;
     const diff = ud.targetAngle - ud.currentAngle;
@@ -1234,6 +1404,9 @@ function animate() {
     }
   }
 
+  // Proximity book-match — nearest wing opens its two leaves when you're within 4 ft
+  updateBooks(dt);
+
   // Lazy texture loading — check every 30 frames for nearby racks
   if (frameCount % 30 === 0) {
     lazyLoadNearbyTextures();
@@ -1250,6 +1423,12 @@ function animate() {
     const el = document.getElementById('fps-counter');
     el.textContent = fps + ' FPS';
     el.style.color = fps >= 50 ? '#4a4' : fps >= 25 ? '#aa4' : '#a44';
+    // Adaptive resolution — if the GPU can't keep up, ratchet pixelRatio down
+    // (1.5 → 1.25 → 1.0). One-way: never climbs back so it can't oscillate.
+    if (fps < 40 && renderer.getPixelRatio() > 1.0) {
+      const next = Math.max(1.0, renderer.getPixelRatio() - 0.25);
+      renderer.setPixelRatio(next);
+    }
     frameCount = 0; lastFpsTime = now;
   }
 
@@ -1261,6 +1440,16 @@ function animate() {
 // ============================================================
 function togglePeruse() { peruseActive ? stopPeruse() : startPeruse(); }
 
+function toggleProximity() {
+  proximityEnabled = !proximityEnabled;
+  const b = document.getElementById('btn-bookmatch');
+  if (b) { b.classList.toggle('active', proximityEnabled); b.title = proximityEnabled ? 'Book-match on (B)' : 'Book-match off (B)'; }
+  const t = document.getElementById('info-text');
+  if (t) t.textContent = proximityEnabled
+    ? 'Book-match ON — walk within 4 ft of a wing and it opens left & right'
+    : 'Book-match OFF — wings stay closed';
+}
+
 function startPeruse() {
   if (!wingBoards.length) return;
   peruseActive = true;
@@ -1362,6 +1551,9 @@ function initHUD() {
   const pb = document.getElementById('btn-peruse');
   if (pb) pb.addEventListener('click', () => togglePeruse());
 
+  const bm = document.getElementById('btn-bookmatch');
+  if (bm) bm.addEventListener('click', () => toggleProximity());
+
   const wb = document.getElementById('btn-walls');
   if (wb) wb.addEventListener('click', () => {
     wallsAutoClad = !wallsAutoClad;
@@ -1492,11 +1684,12 @@ function updateSampleTray() {
 
 function applyLightingMode(mode) {
   scene.children.filter(c => c.isLight).forEach(l => {
-    if (l.isAmbientLight) l.intensity = mode==='Gallery' ? 0.45 : mode==='Evening' ? 0.2 : 0.7;
-    else if (l.isHemisphereLight) l.intensity = mode==='Gallery' ? 0.35 : mode==='Evening' ? 0.15 : 0.5;
-    else if (l.isDirectionalLight) l.intensity = mode==='Gallery' ? 0.4 : mode==='Evening' ? 0.15 : 0.6;
+    if (l.isAmbientLight) l.intensity = mode==='Gallery' ? 0.22 : mode==='Evening' ? 0.14 : 0.32;
+    else if (l.isHemisphereLight) l.intensity = mode==='Gallery' ? 0.22 : mode==='Evening' ? 0.12 : 0.32;
+    else if (l.isDirectionalLight && l.castShadow) l.intensity = mode==='Gallery' ? 1.1 : mode==='Evening' ? 0.55 : 0.95; // key
+    else if (l.isDirectionalLight) l.intensity = mode==='Gallery' ? 0.2 : mode==='Evening' ? 0.12 : 0.3; // fill
   });
-  scene.fog.density = mode==='Evening' ? 0.06 : mode==='Gallery' ? 0.03 : 0.02;
+  scene.fog.density = mode==='Evening' ? 0.05 : mode==='Gallery' ? 0.022 : 0.018;
 }
 
 function playAmbientMusic(ctx) {
diff --git a/public/showroom.html b/public/showroom.html
index adf3013..e6475e0 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -69,6 +69,7 @@
         <button class="nav-btn active" data-section="overview" title="Full View">Overview</button>
       </div>
       <div class="top-right">
+        <button id="btn-bookmatch" class="icon-btn active" title="Book-match on — wings open left &amp; right within 4 ft (B)">&#128214;</button>
         <button id="btn-walls" class="icon-btn active" title="Wallpaper the room walls on focus">&#9638;</button>
         <button id="btn-music" class="icon-btn" title="Listen to Music">&#9835;</button>
         <button id="btn-lighting" class="icon-btn" title="Lighting Mode">&#9788;</button>
@@ -129,7 +130,7 @@
     </div>
 
     <div id="bottom-bar">
-      <span id="info-text">WASD to walk &middot; Click a wing to inspect &middot; P to Peruse &middot; M for minimap</span>
+      <span id="info-text">WASD to walk up to a wing &middot; within 4 ft it opens book-matched &middot; Click to inspect &middot; P to Peruse</span>
       <span id="fps-counter">60 FPS</span>
     </div>
   </div>
@@ -139,7 +140,8 @@
     <div class="tip-title">SHOWROOM CONTROLS</div>
     <div class="tip-row"><kbd>W A S D</kbd> Walk around</div>
     <div class="tip-row"><kbd>P</kbd> Peruse the collection (auto-tour)</div>
-    <div class="tip-row"><kbd>Click</kbd> Inspect a wing</div>
+    <div class="tip-row"><kbd>Walk up</kbd> Within 4 ft a wing opens &mdash; book-matched left &amp; right</div>
+    <div class="tip-row"><kbd>Click</kbd> Inspect a wing &middot; <kbd>B</kbd> book-match</div>
     <div class="tip-row"><kbd>Space</kbd> Stop &amp; inspect &middot; <kbd>Esc</kbd> back</div>
   </div>
 

← c208bf2 auto-save: 2026-06-26T12:33:24 (1 files) — server.js  ·  back to Quadrille Showroom  ·  China Seas specs (27"×27" / Grasscloth 36") + spec-sheet det 72bec59 →