← 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
M public/js/showroom.jsM public/showroom.html
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 & right within 4 ft (B)">📖</button>
<button id="btn-walls" class="icon-btn active" title="Wallpaper the room walls on focus">▦</button>
<button id="btn-music" class="icon-btn" title="Listen to Music">♫</button>
<button id="btn-lighting" class="icon-btn" title="Lighting Mode">☼</button>
@@ -129,7 +130,7 @@
</div>
<div id="bottom-bar">
- <span id="info-text">WASD to walk · Click a wing to inspect · P to Peruse · M for minimap</span>
+ <span id="info-text">WASD to walk up to a wing · within 4 ft it opens book-matched · Click to inspect · 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 — book-matched left & right</div>
+ <div class="tip-row"><kbd>Click</kbd> Inspect a wing · <kbd>B</kbd> book-match</div>
<div class="tip-row"><kbd>Space</kbd> Stop & inspect · <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 →