← back to Dw Showroom
public/js/showroom.js
1316 lines
(function() {
'use strict';
// ============================================================
// CONFIG
// ============================================================
const CONFIG = {
room: { width: 7.5, depth: 6.0, height: 2.75 },
wing: {
height: 1.83,
totalCount: 50,
wallMargin: 0.25,
animSpeed: 0.06,
fanCascade: {
center: Math.PI / 2, // 90° for clicked wing
ring1: Math.PI / 3, // 60° for ±1
ring2: 0.7, // ~40° for ±2
ring3: 0.35 // ~20° for ±3
}
},
camera: { startPos: { x: 0, y: 1.6, z: 1.0 }, fov: 55 }
};
// ============================================================
// STATE
// ============================================================
let scene, camera, renderer, controls, clock, raycaster, mouse;
let wingBoards = [], wingRacks = [], sampleTray = [], products = [], vendors = [];
let focusedWing = null, animatingWings = [], frameCount = 0, lastFpsTime = 0;
let fanWings = [], fanCenterIndex = -1; // Fan cascade state
let controlsLocked = false, lockedCamPos = null, lockedCamTarget = null, previousCamPos = null, previousCamTarget = null;
let cameraAnim = null; // { startPos, endPos, startTarget, endTarget, progress, duration, onComplete }
// SHARED materials — key perf optimization
const MAT = {};
// Pre-generated pattern texture pool (shared across wings)
let TEXTURE_POOL = [];
// Keyboard state for WASD navigation
const keysDown = {};
const WALK_SPEED = 2.5; // meters per second
// ============================================================
// INIT
// ============================================================
function init() {
clock = new THREE.Clock();
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2(-99, -99);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0c0c14);
scene.fog = new THREE.FogExp2(0x0c0c14, 0.02);
camera = new THREE.PerspectiveCamera(CONFIG.camera.fov, window.innerWidth / window.innerHeight, 0.1, 40);
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.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(1.0);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
controls = new THREE.OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.maxPolarAngle = Math.PI * 0.55;
controls.minPolarAngle = Math.PI * 0.25;
controls.minDistance = 1.0;
controls.maxDistance = 8.0;
controls.target.set(0, 1.2, -0.5);
controls.panSpeed = 0.5;
updateLoadStatus('Building materials...', 10);
initMaterials();
initTexturePool();
updateLoadStatus('Building showroom...', 30);
buildRoom();
buildLighting();
buildFurniture();
updateLoadStatus('Loading products...', 60);
loadProducts();
canvas.addEventListener('click', onCanvasClick, false);
canvas.addEventListener('mousemove', onCanvasMouseMove, false);
window.addEventListener('resize', onResize, false);
window.addEventListener('keydown', e => {
keysDown[e.code] = true;
if (e.code === 'KeyM') {
minimapVisible = !minimapVisible;
const mc = document.getElementById('minimap');
if (mc) mc.classList.toggle('hidden', !minimapVisible);
}
});
window.addEventListener('keyup', e => { keysDown[e.code] = false; });
initHUD();
animate();
}
// ============================================================
// 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.lightPanel = new THREE.MeshBasicMaterial({ color: 0xfffff5 });
MAT.baseboard = new THREE.MeshLambertMaterial({ color: 0x2a2a30 });
// Shared book materials — 8 colors
MAT.books = [0x1a5276, 0x7d3c98, 0x1e8449, 0xb9770e, 0x922b21, 0x2c3e50, 0xd4ac0d, 0xa04000]
.map(c => new THREE.MeshLambertMaterial({ color: c }));
// Brick texture
const bc = document.createElement('canvas');
bc.width = 128; bc.height = 128;
const bx = bc.getContext('2d');
bx.fillStyle = '#6b3a2a'; bx.fillRect(0, 0, 128, 128);
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 2; col++) {
bx.fillStyle = `rgb(${100+Math.random()*40|0},${50+Math.random()*25|0},${35+Math.random()*20|0})`;
bx.fillRect(col * 64 + (row % 2 ? 32 : 0) + 2, row * 32 + 2, 60, 28);
}
}
const bt = new THREE.CanvasTexture(bc);
bt.wrapS = bt.wrapT = THREE.RepeatWrapping; bt.repeat.set(3, 2);
MAT.brick = new THREE.MeshLambertMaterial({ map: bt });
}
// ============================================================
// TEXTURE POOL — 24 shared patterns (instead of 1 per wing)
// ============================================================
function initTexturePool() {
const patterns = ['Damask', 'Grasscloth', 'Stripe', 'Geometric', 'Floral', 'Texture'];
const colors = [
{ name: 'Navy', hex: '#1a2744' }, { name: 'Sage', hex: '#6b7f5e' },
{ name: 'Cream', hex: '#f0ead6' }, { name: 'Gold', hex: '#c9a96e' },
{ name: 'Silver', hex: '#b8b8c0' }, { name: 'Blush', hex: '#d4a0a0' },
{ name: 'Charcoal', hex: '#3a3a42' }, { name: 'Ivory', hex: '#f5f0e8' },
{ name: 'Slate', hex: '#5a6068' }, { name: 'Teal', hex: '#2a6b6b' },
{ name: 'Coral', hex: '#cd6858' }, { name: 'Burgundy', hex: '#6b2040' }
];
// Generate 12 unique textures (6 patterns × 2 colors each) — fewer for better FPS
for (let p = 0; p < patterns.length; p++) {
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);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide });
TEXTURE_POOL.push({ material: mat, pattern: patterns[p], color: color.name });
}
}
}
function generatePatternCanvas(patternType, baseHex) {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 256;
const ctx = canvas.getContext('2d');
const r = parseInt(baseHex.slice(1,3), 16);
const g = parseInt(baseHex.slice(3,5), 16);
const b = parseInt(baseHex.slice(5,7), 16);
const lighter = `rgb(${Math.min(255,r+35)},${Math.min(255,g+35)},${Math.min(255,b+35)})`;
const darker = `rgb(${Math.max(0,r-30)},${Math.max(0,g-30)},${Math.max(0,b-30)})`;
ctx.fillStyle = baseHex; ctx.fillRect(0, 0, 128, 256);
switch(patternType) {
case 'Damask':
ctx.globalAlpha = 0.3; ctx.fillStyle = lighter;
for (let y = 0; y < 4; y++) for (let x = 0; x < 2; x++) {
const cx = x * 64 + 32, cy = y * 64 + 32;
ctx.beginPath(); ctx.moveTo(cx, cy-20); ctx.quadraticCurveTo(cx+20,cy,cx,cy+20);
ctx.quadraticCurveTo(cx-20,cy,cx,cy-20); ctx.fill();
}
break;
case 'Grasscloth':
for (let i = 0; i < 80; i++) {
ctx.strokeStyle = i%2 ? lighter : darker; ctx.globalAlpha = 0.15; ctx.lineWidth = 1;
const y = Math.random()*256;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(128, y+(Math.random()-0.5)*6); ctx.stroke();
}
break;
case 'Stripe':
ctx.globalAlpha = 0.25; ctx.fillStyle = lighter;
for (let x = 0; x < 128; x += 32) ctx.fillRect(x, 0, 14, 256);
break;
case 'Geometric':
ctx.globalAlpha = 0.2; ctx.strokeStyle = lighter; ctx.lineWidth = 1.5;
for (let y = 0; y < 8; y++) for (let x = 0; x < 4; x++) {
ctx.strokeRect(x*32+4, y*32+4, 24, 24);
if ((x+y)%2===0) { ctx.fillStyle = lighter; ctx.fillRect(x*32+8, y*32+8, 16, 16); }
}
break;
case 'Floral':
ctx.globalAlpha = 0.25; ctx.fillStyle = lighter;
for (let y = 0; y < 4; y++) for (let x = 0; x < 2; x++) {
const fx = x*64+32+(y%2?32:0), fy = y*64+32;
for (let p = 0; p < 5; p++) {
const a = (p/5)*Math.PI*2;
ctx.beginPath(); ctx.ellipse(fx+Math.cos(a)*8, fy+Math.sin(a)*8, 6, 4, a, 0, Math.PI*2); ctx.fill();
}
ctx.fillStyle = darker; ctx.beginPath(); ctx.arc(fx, fy, 3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = lighter;
}
break;
default: // Texture
for (let i = 0; i < 150; i++) {
ctx.fillStyle = i%2 ? lighter : darker; ctx.globalAlpha = 0.08;
ctx.fillRect(Math.random()*128, Math.random()*256, 3+Math.random()*3, 3+Math.random()*3);
}
}
ctx.globalAlpha = 1;
return canvas;
}
// ============================================================
// ROOM
// ============================================================
function buildRoom() {
const W = CONFIG.room.width, D = CONFIG.room.depth, H = CONFIG.room.height;
// Floor — polished concrete with subtle grain
const fc = document.createElement('canvas'); fc.width = 256; fc.height = 256;
const fx = fc.getContext('2d');
fx.fillStyle = '#3a3a42'; fx.fillRect(0, 0, 256, 256);
// Grain texture
for (let i = 0; i < 4000; i++) { fx.fillStyle = `rgba(${50+Math.random()*30|0},${50+Math.random()*30|0},${55+Math.random()*30|0},0.25)`; fx.fillRect(Math.random()*256, Math.random()*256, 2, 2); }
// 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);
// 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 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);
// Walls
addWall(0, H/2, -D/2, W, H, 0, MAT.wall); // Back
addWall(-W/2, H/2, 0, D, H, Math.PI/2, MAT.brick); // Left (brick)
addWall(W/2, H/2, 0, D, H, -Math.PI/2, MAT.wall); // Right
// Front (with doorway)
addWall(-W/4-0.3, H/2, D/2, W/2-0.6, H, Math.PI, MAT.wall);
addWall(W/4+0.3, H/2, D/2, W/2-0.6, H, Math.PI, MAT.wall);
addWall(0, H-(H-2.1)/2, D/2, 1.2, H-2.1, Math.PI, MAT.wall);
// Baseboards
[[0, 0.04, -D/2+0.02, W, 0.08, 0.04], [-W/2+0.02, 0.04, 0, 0.04, 0.08, D], [W/2-0.02, 0.04, 0, 0.04, 0.08, D]].forEach(b => {
const m = new THREE.Mesh(new THREE.BoxGeometry(b[3],b[4],b[5]), MAT.baseboard);
m.position.set(b[0],b[1],b[2]); scene.add(m);
});
// Crown molding — thin profile along ceiling edges
const crownMat = new THREE.MeshLambertMaterial({ color: 0xe8e4dc });
const crownData = [
[0, H-0.025, -D/2+0.03, W, 0.05, 0.06], // Back wall
[-W/2+0.03, H-0.025, 0, 0.06, 0.05, D], // Left wall
[W/2-0.03, H-0.025, 0, 0.06, 0.05, D], // Right wall
[0, H-0.025, D/2-0.03, W, 0.05, 0.06] // Front wall
];
crownData.forEach(([cx, cy, cz, cw, ch, cd]) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(cw, ch, cd), crownMat);
m.position.set(cx, cy, cz); scene.add(m);
});
// DW entrance sign above doorway
const signCanvas = document.createElement('canvas');
signCanvas.width = 512; signCanvas.height = 128;
const sx = signCanvas.getContext('2d');
sx.fillStyle = '#0c0c14'; sx.fillRect(0, 0, 512, 128);
sx.fillStyle = '#c9a96e'; sx.font = 'bold 54px sans-serif'; sx.textAlign = 'center'; sx.textBaseline = 'middle';
sx.fillText('DESIGNER WALLCOVERINGS', 256, 50);
sx.font = '22px sans-serif'; sx.fillStyle = '#888888'; sx.fillText('SHERMAN OAKS SHOWROOM', 256, 95);
const signTex = new THREE.CanvasTexture(signCanvas);
const signMesh = new THREE.Mesh(
new THREE.PlaneGeometry(1.6, 0.4),
new THREE.MeshBasicMaterial({ map: signTex, transparent: true })
);
signMesh.position.set(0, H - 0.35, D/2 - 0.02);
signMesh.rotation.y = Math.PI;
scene.add(signMesh);
}
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);
}
// ============================================================
// 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);
// Light panel meshes (visual only, no light cost)
const H = CONFIG.room.height;
[[-1.5, -1.0], [1.5, -1.0], [-1.5, 1.5], [1.5, 1.5]].forEach(([x, z]) => {
const p = new THREE.Mesh(new THREE.PlaneGeometry(0.5, 0.5), MAT.lightPanel);
p.rotation.x = Math.PI/2; p.position.set(x, H-0.05, z); scene.add(p);
});
}
// ============================================================
// FURNITURE — simplified
// ============================================================
function buildFurniture() {
const W = CONFIG.room.width, D = CONFIG.room.depth;
// Table
const tt = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.04, 0.9), MAT.wood);
tt.position.set(-1.0, 0.75, 1.5); scene.add(tt);
[[-0.8,-0.35],[0.8,-0.35],[-0.8,0.35],[0.8,0.35]].forEach(([lx,lz]) => {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.02,0.02,0.74,6), MAT.chrome);
leg.position.set(-1.0+lx, 0.37, 1.5+lz); scene.add(leg);
});
// 4 Chairs (simplified — just seat + back, shared material)
[[-1.6,1.5,0],[-0.4,1.5,Math.PI],[-1.0,1.0,Math.PI/2],[-1.0,2.0,-Math.PI/2]].forEach(([cx,cz,rot]) => {
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.04, 0.4), MAT.chair);
seat.position.set(cx, 0.46, cz); seat.rotation.y = rot; scene.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.4, 0.03), MAT.chair);
back.position.set(cx - Math.sin(rot)*0.19, 0.68, cz - Math.cos(rot)*0.19); back.rotation.y = rot; scene.add(back);
});
// TV on brick wall with dynamic slideshow
const tv = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.55, 0.9), MAT.dark);
tv.position.set(-W/2+0.06, 1.5, 0.5); scene.add(tv);
// TV bezel
const bezel = new THREE.Mesh(new THREE.BoxGeometry(0.01, 0.52, 0.87), new THREE.MeshLambertMaterial({ color: 0x1a1a1a }));
bezel.position.set(-W/2+0.075, 1.5, 0.5); scene.add(bezel);
// Screen canvas
const tvCanvas = document.createElement('canvas'); tvCanvas.width = 480; tvCanvas.height = 270;
const tvCtx = tvCanvas.getContext('2d');
const tvTex = new THREE.CanvasTexture(tvCanvas);
const scr = new THREE.Mesh(new THREE.PlaneGeometry(0.44, 0.78), new THREE.MeshBasicMaterial({ map: tvTex }));
scr.rotation.y = Math.PI/2; scr.position.set(-W/2+0.085, 1.5, 0.5); scene.add(scr);
// Store for slideshow updates
window._tvScreen = { canvas: tvCanvas, ctx: tvCtx, texture: tvTex, slideIdx: 0 };
drawTVSlide(0);
// Bookshelves — near front wall, flanking doorway (don't block wing racks)
buildBookShelf(-2.8, D/2-0.3, Math.PI, 1.2);
buildBookShelf(2.8, D/2-0.3, Math.PI, 1.2);
// Rug under meeting table
const rugCanvas = document.createElement('canvas'); rugCanvas.width = 256; rugCanvas.height = 128;
const rc = rugCanvas.getContext('2d');
rc.fillStyle = '#4a3a2a'; rc.fillRect(0, 0, 256, 128);
// Border pattern
rc.strokeStyle = '#c9a96e'; rc.lineWidth = 6;
rc.strokeRect(8, 8, 240, 112);
rc.strokeStyle = '#8a7a5a'; rc.lineWidth = 2;
rc.strokeRect(16, 16, 224, 96);
// 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 }));
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 }));
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 }));
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);
f1.position.set(px, 0.45, pz); scene.add(f1);
const f2 = new THREE.Mesh(new THREE.SphereGeometry(0.1, 6, 5), foliageMat);
f2.position.set(px+0.08, 0.55, pz-0.05); scene.add(f2);
});
// Framed art on right wall
const artCanvas = document.createElement('canvas'); artCanvas.width = 256; artCanvas.height = 192;
const ax = artCanvas.getContext('2d');
// Generate an abstract wallcovering pattern
ax.fillStyle = '#1a2744'; ax.fillRect(0, 0, 256, 192);
ax.globalAlpha = 0.3; ax.fillStyle = '#c9a96e';
for (let y = 0; y < 6; y++) for (let x = 0; x < 4; x++) {
const cx = x*64+32+(y%2?32:0), cy = y*32+16;
ax.beginPath(); ax.moveTo(cx, cy-12); ax.quadraticCurveTo(cx+14, cy, cx, cy+12);
ax.quadraticCurveTo(cx-14, cy, cx, cy-12); ax.fill();
}
ax.globalAlpha = 1;
// Frame border
ax.strokeStyle = '#c9a96e'; ax.lineWidth = 4; ax.strokeRect(2, 2, 252, 188);
const artTex = new THREE.CanvasTexture(artCanvas);
const artFrame = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.55, 0.72), MAT.frame);
artFrame.position.set(W/2-0.04, 1.5, -0.5); scene.add(artFrame);
const artFace = new THREE.Mesh(new THREE.PlaneGeometry(0.5, 0.65), new THREE.MeshBasicMaterial({ map: artTex }));
artFace.rotation.y = -Math.PI/2; artFace.position.set(W/2-0.06, 1.5, -0.5); scene.add(artFace);
}
function buildBookShelf(x, z, rot, unitW) {
const g = new THREE.Group();
g.position.set(x, 0, z); g.rotation.y = rot;
// Frame verticals
[[-unitW/2], [unitW/2]].forEach(([fx]) => {
const v = new THREE.Mesh(new THREE.BoxGeometry(0.03, 1.5, 0.03), MAT.frame);
v.position.set(fx, 0.75, 0); g.add(v);
});
// 4 shelves + books (merged blocks — fewer meshes for better FPS)
for (let s = 0; s < 4; s++) {
const sy = s * 0.35 + 0.05;
const shelf = new THREE.Mesh(new THREE.BoxGeometry(unitW, 0.012, 0.32), MAT.frame);
shelf.position.set(0, sy, 0); g.add(shelf);
// Books — use 3 wide blocks per shelf instead of individual books
const blockW = (unitW - 0.06) / 3;
for (let b = 0; b < 3; b++) {
const bh = 0.25 + Math.random() * 0.06;
const book = new THREE.Mesh(new THREE.BoxGeometry(blockW - 0.02, bh, 0.27), MAT.books[Math.floor(Math.random() * 8)]);
book.position.set(-unitW/2 + 0.03 + b * blockW + blockW/2, sy + 0.01 + bh/2, 0);
g.add(book);
}
}
scene.add(g);
}
// ============================================================
// PRODUCTS + WING BOARDS — optimized with shared texture pool
// ============================================================
// XHR wrapper — fetch() fails when page URL contains embedded credentials
function xhrGet(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.setRequestHeader('Authorization', 'Basic ' + btoa('admin:DWSecure2024!'));
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
xhr.onerror = () => reject(new Error('XHR failed'));
xhr.timeout = 15000;
xhr.ontimeout = () => reject(new Error('XHR timeout'));
xhr.send();
});
}
async function loadProducts() {
try {
const data = await xhrGet('/api/showroom/products');
products = data.products || [];
} catch (e) { console.warn('Products load error:', e.message); products = []; }
try {
const data = await xhrGet('/api/showroom/vendors');
vendors = data.vendors || [];
} catch (e) { console.warn('Vendors load error:', e.message); vendors = []; }
updateLoadStatus('Building wing wall...', 80);
buildWingWall();
updateLoadStatus('Ready!', 100);
setTimeout(() => {
document.getElementById('loading-screen').classList.add('fade-out');
// Show welcome tooltip, auto-fade after 6 seconds
setTimeout(() => {
const tip = document.getElementById('welcome-tip');
if (tip) tip.classList.add('fade-out');
}, 6000);
}, 400);
populateVendorSidebar();
startTVSlideshow();
}
function buildWingWall() {
const W = CONFIG.room.width, D = CONFIG.room.depth;
const WC = CONFIG.wing;
const margin = WC.wallMargin;
const availW = W - 2 * margin; // 7.0m usable
const totalWings = WC.totalCount;
const slotW = availW / totalWings; // 0.14m per slot
const wingW = slotW - 0.02; // 0.12m wing face width
const backZ = -D / 2 + 0.2; // 0.2m off back wall
// Group products by vendor
const vendorOrder = ['Thibaut', 'Schumacher', 'Arte', 'Elitis'];
const vendorGroups = {};
products.forEach(p => {
const v = p.vendor;
if (!vendorGroups[v]) vendorGroups[v] = [];
vendorGroups[v].push(p);
});
// Build flat product array in vendor order
let allProds = [];
const vendorSections = [];
vendorOrder.forEach(v => {
const prods = vendorGroups[v] || [];
if (prods.length === 0) return;
const startIdx = allProds.length;
allProds = allProds.concat(prods);
vendorSections.push({ vendor: v, startIdx, endIdx: allProds.length - 1 });
});
// Fill remaining slots with texture pool synthetics if < 50
while (allProds.length < totalWings) {
const i = allProds.length;
const poolIdx = i % TEXTURE_POOL.length;
allProds.push({
pattern_name: TEXTURE_POOL[poolIdx].pattern,
color_name: TEXTURE_POOL[poolIdx].color,
sku: `DW-SYN-${1000 + i}`,
vendor: 'Showroom Collection',
width: 27,
material: ['Vinyl','Non-woven','Paper','Grasscloth','Fabric'][i % 5],
_poolIdx: poolIdx
});
}
allProds = allProds.slice(0, totalWings);
// Single wall group at back wall
const wallGroup = new THREE.Group();
wallGroup.position.set(0, 0, backZ);
// Chrome rail spanning full width
const rail = new THREE.Mesh(
new THREE.BoxGeometry(availW + 0.04, 0.015, 0.015), MAT.chrome
);
rail.position.set(0, WC.height + 0.08, 0);
wallGroup.add(rail);
// Rail end caps
[-1, 1].forEach(side => {
const cap = new THREE.Mesh(new THREE.SphereGeometry(0.012, 8, 6), MAT.chrome);
cap.position.set(side * (availW / 2 + 0.02), WC.height + 0.08, 0);
wallGroup.add(cap);
});
// Build each wing
for (let i = 0; i < totalWings; i++) {
const product = allProds[i];
const wingX = -availW / 2 + i * slotW + slotW / 2;
const pivot = new THREE.Group();
pivot.position.set(wingX, 0, 0);
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);
if (product.image) {
face.userData.pendingImage = product.image;
face.userData.imageLoaded = false;
}
let widthInches = 27;
if (typeof product.width === 'string') {
const m = product.width.match(/([\d.]+)/);
if (m) widthInches = parseFloat(m[1]);
} else if (typeof product.width === 'number') {
widthInches = product.width;
}
const normalizedProduct = {
pattern_name: product.pattern_name || product.name || 'Pattern',
color: product.color_name || product.color || '',
sku: product.sku || product.mfr_sku || '',
vendor: product.vendor || 'Unknown',
width_inches: widthInches,
material: product.material || 'Non-woven',
name: product.pattern_name || product.name || 'Pattern',
image: product.image || null,
collection: product.collection || ''
};
pivot.userData = {
product: normalizedProduct, index: i,
isOpen: false, targetAngle: 0, currentAngle: 0,
boardWidth: wingW, wallGroup, wingDirection: 1
};
wallGroup.add(pivot);
wingBoards.push(pivot);
}
// Vendor section labels and dividers
const vendorColors = { 'Thibaut':'#1a5276','Schumacher':'#922b21','Arte':'#d4ac0d','Elitis':'#a04000' };
vendorSections.forEach(section => {
const vColor = vendorColors[section.vendor] || '#555555';
const startX = -availW / 2 + section.startIdx * slotW + slotW / 2;
const endX = -availW / 2 + section.endIdx * slotW + slotW / 2;
const centerX = (startX + endX) / 2;
const sectionW = (section.endIdx - section.startIdx + 1) * slotW;
// Vendor label canvas
const lc = document.createElement('canvas'); lc.width = 512; lc.height = 48;
const lx = lc.getContext('2d');
lx.fillStyle = vColor; lx.fillRect(0, 0, 512, 48);
const grad = lx.createLinearGradient(0, 0, 0, 48);
grad.addColorStop(0, 'rgba(255,255,255,0.15)'); grad.addColorStop(1, 'rgba(0,0,0,0.2)');
lx.fillStyle = grad; lx.fillRect(0, 0, 512, 48);
lx.fillStyle = '#ffffff'; lx.font = 'bold 26px sans-serif'; lx.textAlign = 'center'; lx.textBaseline = 'middle';
lx.fillText(section.vendor.toUpperCase(), 256, 26);
const lt = new THREE.CanvasTexture(lc);
const labelW = Math.max(sectionW * 0.8, 0.3);
const label = new THREE.Mesh(new THREE.PlaneGeometry(labelW, 0.07), new THREE.MeshBasicMaterial({ map: lt }));
label.position.set(centerX, WC.height + 0.15, 0.01);
wallGroup.add(label);
// Thin vertical divider at section start (except first)
if (section.startIdx > 0) {
const divX = -availW / 2 + section.startIdx * slotW - slotW * 0.1;
const divider = new THREE.Mesh(
new THREE.BoxGeometry(0.003, WC.height * 0.6, 0.003),
new THREE.MeshBasicMaterial({ color: parseInt(vColor.replace('#',''), 16) })
);
divider.position.set(divX, WC.height * 0.3 + 0.04, 0);
wallGroup.add(divider);
}
});
scene.add(wallGroup);
wingRacks.push({ group: wallGroup, vendor: 'All Vendors', products: allProds });
}
// Shared texture loader + image cache
const texLoader = new THREE.TextureLoader();
const imageTexCache = {};
// buildWingRack removed — replaced by buildWingWall() above
function loadWingTexture(faceMesh, imageUrl, onDone) {
// Use image proxy to avoid CORS
const proxyUrl = '/api/proxy/image?url=' + encodeURIComponent(imageUrl);
// Check cache first
if (imageTexCache[imageUrl]) {
faceMesh.material = new THREE.MeshLambertMaterial({ map: imageTexCache[imageUrl] });
if (onDone) onDone();
return;
}
texLoader.load(proxyUrl, (texture) => {
texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
imageTexCache[imageUrl] = texture;
faceMesh.material = new THREE.MeshLambertMaterial({ map: texture });
if (onDone) onDone();
}, undefined, () => {
if (onDone) onDone();
});
}
// ============================================================
// WING INTERACTION — Two-step: click to move close, buttons to open
// ============================================================
function onCanvasClick(event) {
// If controls are locked (focused on a wing), ignore canvas clicks
if (controlsLocked) return;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const faces = [];
wingBoards.forEach(p => p.children.forEach(c => { if (c.userData && c.userData.isWingFace) faces.push(c); }));
const hits = raycaster.intersectObjects(faces, false);
if (hits.length > 0) {
focusOnWing(hits[0].object.userData.parentPivot);
}
}
// STEP 1: Click on wing — fly camera close, then HARD LOCK position
function focusOnWing(pivot) {
const product = pivot.userData.product;
// Close any active fan cascade
if (fanWings.length > 0) closeFanCascade(true);
// If re-clicking same focused wing, unfocus
if (focusedWing === pivot) { unfocusWing(); return; }
// Close previous focus
if (focusedWing) unfocusWing(true);
// Save current camera to restore later
previousCamPos = camera.position.clone();
previousCamTarget = controls.target.clone();
focusedWing = pivot;
// Calculate world position of the wing
const wingWorldPos = new THREE.Vector3();
pivot.getWorldPosition(wingWorldPos);
// All wings face forward (+Z) on back wall — camera goes 1.2m in front
const camTarget = new THREE.Vector3(wingWorldPos.x, 1.2, wingWorldPos.z);
const camPos = new THREE.Vector3(wingWorldPos.x, 1.6, wingWorldPos.z + 1.2);
// Fly camera there, then hard-lock
smoothCameraTo(camPos, camTarget, () => {
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);
}
// Show detail panel with product info
showWingDetail(product);
updateOpenBtnState(false);
document.getElementById('info-text').textContent = `Wing: ${product.pattern_name || product.name} by ${product.vendor} | Fan open to browse adjacent patterns`;
}
// STEP 2: Fan Cascade — open center wing + 3 adjacent on each side
function fanCascadeOpen(direction) {
if (!focusedWing) return;
const centerIdx = focusedWing.userData.index;
const fc = CONFIG.wing.fanCascade;
// If fan already open on this wing in this direction, close it
if (fanCenterIndex === centerIdx && fanWings.length > 0 && focusedWing.userData.wingDirection === direction) {
closeFanCascade();
return;
}
// Close any existing fan first
if (fanWings.length > 0) closeFanCascade(true);
fanCenterIndex = centerIdx;
fanWings = [];
focusedWing.userData.wingDirection = direction;
// Cascade rings: [offset, angle]
const rings = [
[0, fc.center], // 90° center
[1, fc.ring1], // 60° ±1
[2, fc.ring2], // 40° ±2
[3, fc.ring3] // 20° ±3
];
rings.forEach(([offset, angle]) => {
if (offset === 0) {
const pivot = wingBoards[centerIdx];
pivot.userData.targetAngle = direction * angle;
if (!animatingWings.includes(pivot)) animatingWings.push(pivot);
fanWings.push(pivot);
} else {
// Left neighbor
const leftIdx = centerIdx - offset;
if (leftIdx >= 0) {
const lp = wingBoards[leftIdx];
lp.userData.targetAngle = direction * angle;
if (!animatingWings.includes(lp)) animatingWings.push(lp);
fanWings.push(lp);
}
// Right neighbor
const rightIdx = centerIdx + offset;
if (rightIdx < wingBoards.length) {
const rp = wingBoards[rightIdx];
rp.userData.targetAngle = direction * angle;
if (!animatingWings.includes(rp)) animatingWings.push(rp);
fanWings.push(rp);
}
}
});
updateOpenBtnState(direction);
}
function closeFanCascade(skipBtnUpdate) {
fanWings.forEach(pivot => {
pivot.userData.targetAngle = 0;
if (!animatingWings.includes(pivot)) animatingWings.push(pivot);
});
fanWings = [];
fanCenterIndex = -1;
if (!skipBtnUpdate) updateOpenBtnState(0);
}
// Back button — close fan, unlock controls, fly camera back
function unfocusWing(skipPanel) {
if (fanWings.length > 0) closeFanCascade();
focusedWing = null;
if (!skipPanel) document.getElementById('wing-detail').classList.add('hidden');
// Unlock first so smoothCameraTo can move
unlockControls();
// Fly back to previous position
if (previousCamPos && previousCamTarget) {
smoothCameraTo(previousCamPos, previousCamTarget);
previousCamPos = null;
previousCamTarget = null;
}
document.getElementById('info-text').textContent = 'WASD to walk | Click a wing board to view pattern | M for minimap';
}
function showWingDetail(product) {
const panel = document.getElementById('wing-detail');
panel.classList.remove('hidden');
document.getElementById('detail-pattern').textContent = product.pattern_name || product.name || 'Pattern';
document.getElementById('detail-color').textContent = product.color || product.color_name || '';
document.getElementById('detail-vendor').textContent = product.vendor || '';
document.getElementById('detail-sku').textContent = product.sku || '';
const w = product.width_inches || 27;
document.getElementById('detail-specs').textContent = `${w}" wide | ${product.material||'Non-woven'}${product.collection ? ' | ' + product.collection : ''}`;
}
function updateOpenBtnState(dir) {
const btnL = document.getElementById('btn-fan-left');
const btnR = document.getElementById('btn-fan-right');
if (btnL) {
btnL.classList.toggle('active', dir === 1);
btnL.textContent = dir === 1 ? '\u25AE\u25AE Close' : '\u25C4 Fan Left';
}
if (btnR) {
btnR.classList.toggle('active', dir === -1);
btnR.textContent = dir === -1 ? '\u25AE\u25AE Close' : 'Fan Right \u25BA';
}
}
function lockControls(pos, target) {
controlsLocked = true;
controls.enabled = false;
controls.enableRotate = false;
controls.enablePan = false;
controls.enableZoom = false;
lockedCamPos = pos ? pos.clone() : camera.position.clone();
lockedCamTarget = target ? target.clone() : controls.target.clone();
document.getElementById('showroom-canvas').style.cursor = 'default';
}
function unlockControls() {
controlsLocked = false;
controls.enabled = true;
controls.enableRotate = true;
controls.enablePan = true;
controls.enableZoom = true;
lockedCamPos = null;
lockedCamTarget = null;
}
let _lastMoveRaycast = 0;
function onCanvasMouseMove(event) {
if (controlsLocked) return;
// Throttle raycasting to every 100ms — saves huge CPU on mousemove
const now = performance.now();
if (now - _lastMoveRaycast < 100) return;
_lastMoveRaycast = now;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const faces = [];
wingBoards.forEach(p => p.children.forEach(c => { if (c.userData && c.userData.isWingFace) faces.push(c); }));
document.getElementById('showroom-canvas').style.cursor = raycaster.intersectObjects(faces, false).length > 0 ? 'pointer' : 'default';
}
// ============================================================
// WASD WALKING — first-person navigation
// ============================================================
function updateWASD(dt) {
if (controlsLocked || cameraAnim) return; // no walking when focused on wing or animating
let moveX = 0, moveZ = 0;
if (keysDown['KeyW'] || keysDown['ArrowUp']) moveZ = -1;
if (keysDown['KeyS'] || keysDown['ArrowDown']) moveZ = 1;
if (keysDown['KeyA'] || keysDown['ArrowLeft']) moveX = -1;
if (keysDown['KeyD'] || keysDown['ArrowRight']) moveX = 1;
if (moveX === 0 && moveZ === 0) return;
// Get camera forward/right vectors projected onto XZ plane
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0; forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
// Movement delta
const speed = WALK_SPEED * dt;
const delta = new THREE.Vector3();
delta.addScaledVector(forward, -moveZ * speed);
delta.addScaledVector(right, moveX * speed);
// Move both camera and orbit target together (preserves view direction)
camera.position.add(delta);
controls.target.add(delta);
}
// ============================================================
// LAZY TEXTURE LOADING — only load images for nearby racks
// ============================================================
let _lazyLoadQueue = [];
let _lazyLoading = 0;
const MAX_CONCURRENT_LOADS = 3;
function lazyLoadNearbyTextures() {
const camPos = camera.position;
const loadRadius = 3.5; // Load textures within 3.5m of camera
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--; });
}
});
});
}
// ============================================================
// ANIMATION LOOP
// ============================================================
function animate() {
requestAnimationFrame(animate);
// Time delta — MUST call every frame to avoid huge spikes after pauses
const dt = Math.min(clock.getDelta(), 0.1); // cap at 100ms to handle tab focus
// Advance camera animation (time-based, runs in main loop)
if (cameraAnim) {
cameraAnim.progress += dt / cameraAnim.duration;
if (cameraAnim.progress >= 1) {
camera.position.copy(cameraAnim.endPos);
controls.target.copy(cameraAnim.endTarget);
const cb = cameraAnim.onComplete;
cameraAnim = null;
if (cb) cb();
} else {
const e = 1 - Math.pow(1 - cameraAnim.progress, 3); // cubic ease-out
camera.position.lerpVectors(cameraAnim.startPos, cameraAnim.endPos, e);
controls.target.lerpVectors(cameraAnim.startTarget, cameraAnim.endTarget, e);
}
}
// HARD LOCK: if controls are locked, force camera position every frame
if (controlsLocked && lockedCamPos && lockedCamTarget) {
camera.position.copy(lockedCamPos);
controls.target.copy(lockedCamTarget);
} else if (!cameraAnim) {
updateWASD(dt);
controls.update();
// Collision detection — prevent camera from walking through walls
const margin = 0.3;
const hw = CONFIG.room.width / 2 - margin;
const hd = CONFIG.room.depth / 2 - margin;
camera.position.x = Math.max(-hw, Math.min(hw, camera.position.x));
camera.position.z = Math.max(-hd, Math.min(CONFIG.room.depth / 2 + 0.5, camera.position.z));
camera.position.y = Math.max(0.5, Math.min(CONFIG.room.height - 0.2, camera.position.y));
controls.target.x = Math.max(-hw, Math.min(hw, controls.target.x));
controls.target.z = Math.max(-hd, Math.min(CONFIG.room.depth / 2 + 0.5, controls.target.z));
controls.target.y = Math.max(0.3, Math.min(CONFIG.room.height - 0.3, controls.target.y));
}
// Animate wings
for (let i = animatingWings.length - 1; i >= 0; i--) {
const p = animatingWings[i], ud = p.userData;
const diff = ud.targetAngle - ud.currentAngle;
if (Math.abs(diff) < 0.005) {
ud.currentAngle = ud.targetAngle;
p.rotation.y = ud.currentAngle;
animatingWings.splice(i, 1);
} else {
ud.currentAngle += diff * CONFIG.wing.animSpeed * 3;
p.rotation.y = ud.currentAngle;
}
}
// Lazy texture loading — check every 30 frames for nearby racks
if (frameCount % 30 === 0) {
lazyLoadNearbyTextures();
}
// Update minimap every 10 frames (cheap but no need every frame)
if (frameCount % 10 === 0) updateMinimap();
// FPS
frameCount++;
const now = performance.now();
if (now - lastFpsTime > 1000) {
const fps = Math.round(frameCount * 1000 / (now - lastFpsTime));
const el = document.getElementById('fps-counter');
el.textContent = fps + ' FPS';
el.style.color = fps >= 50 ? '#4a4' : fps >= 25 ? '#aa4' : '#a44';
frameCount = 0; lastFpsTime = now;
}
renderer.render(scene, camera);
}
// ============================================================
// HUD
// ============================================================
function initHUD() {
document.querySelectorAll('.nav-btn').forEach(btn => btn.addEventListener('click', () => {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
navigateToSection(btn.dataset.section);
}));
document.getElementById('close-detail').addEventListener('click', () => unfocusWing());
document.getElementById('btn-fan-left').addEventListener('click', () => fanCascadeOpen(1));
document.getElementById('btn-fan-right').addEventListener('click', () => fanCascadeOpen(-1));
document.getElementById('btn-back-wing').addEventListener('click', () => unfocusWing());
document.getElementById('btn-music').addEventListener('click', () => document.getElementById('music-player').classList.toggle('hidden'));
document.getElementById('close-music').addEventListener('click', () => document.getElementById('music-player').classList.add('hidden'));
let audioCtx, musicPlaying = false;
document.getElementById('music-play').addEventListener('click', () => {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
musicPlaying = !musicPlaying;
document.getElementById('music-play').innerHTML = musicPlaying ? '▮▮' : '▶';
if (musicPlaying) playAmbientMusic(audioCtx);
});
let lightMode = 0;
const modes = ['Showroom', 'Gallery', 'Evening'];
document.getElementById('btn-lighting').addEventListener('click', () => {
lightMode = (lightMode + 1) % 3;
applyLightingMode(modes[lightMode]);
document.getElementById('info-text').textContent = `Lighting: ${modes[lightMode]}`;
});
document.getElementById('btn-fullscreen').addEventListener('click', () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
});
document.getElementById('btn-add-sample').addEventListener('click', () => {
if (focusedWing) {
const p = focusedWing.userData.product;
if (!sampleTray.find(s => s.sku === p.sku)) { sampleTray.push(p); updateSampleTray(); }
}
});
}
function navigateToSection(section) {
// If focused on a wing, release it first
if (focusedWing) unfocusWing();
if (controlsLocked) unlockControls();
const D = CONFIG.room.depth, W = CONFIG.room.width;
// Bookshelves are on front wall flanking the doorway (x=±2.8, z=D/2-0.3)
const t = { overview: [[0,1.6,1.0],[0,1.2,-0.5]], wings: [[0,1.6,-D/2+3.0],[0,1.2,-D/2+0.2]], books: [[1.5,1.4,1.8],[2.8,0.9,D/2-0.3]], table: [[-1,1.8,2.0],[-1,0.75,1.0]] }[section] || [[0,1.6,1.0],[0,1.2,-0.5]];
smoothCameraTo(new THREE.Vector3(...t[0]), new THREE.Vector3(...t[1]));
}
function populateVendorSidebar() {
const list = document.getElementById('vendor-list');
const vc = { 'Thibaut':'#1a5276','Schumacher':'#922b21','Arte':'#d4ac0d','Elitis':'#a04000','Showroom Collection':'#888888' };
// Build vendor sections from wingBoards array
const sections = [];
let cur = null;
wingBoards.forEach((pivot, i) => {
const v = pivot.userData.product.vendor;
if (v !== cur) {
if (cur) sections[sections.length-1].end = i - 1;
sections.push({ vendor: v, start: i, end: i });
cur = v;
}
});
if (sections.length > 0) sections[sections.length-1].end = wingBoards.length - 1;
const D = CONFIG.room.depth;
const backZ = -D / 2 + 0.2;
sections.forEach(sec => {
const count = sec.end - sec.start + 1;
const div = document.createElement('div');
div.className = 'vendor-item';
const dot = document.createElement('span');
dot.className = 'vendor-dot';
dot.style.background = vc[sec.vendor] || '#888';
div.appendChild(dot);
div.appendChild(document.createTextNode(sec.vendor));
const cnt = document.createElement('span');
cnt.className = 'vendor-count';
cnt.textContent = count;
div.appendChild(cnt);
div.addEventListener('click', () => {
if (focusedWing) unfocusWing();
document.querySelectorAll('.vendor-item').forEach(i => i.classList.remove('active'));
div.classList.add('active');
// Center camera on vendor section
const midIdx = Math.floor((sec.start + sec.end) / 2);
const midWing = wingBoards[midIdx];
const wp = new THREE.Vector3();
midWing.getWorldPosition(wp);
smoothCameraTo(new THREE.Vector3(wp.x, 1.6, backZ + 2.0), new THREE.Vector3(wp.x, 1.2, backZ));
});
list.appendChild(div);
});
}
function smoothCameraTo(pos, target, onComplete) {
// Clock-based animation — runs inside main animate() loop, not a competing rAF chain
cameraAnim = {
startPos: camera.position.clone(),
endPos: pos.clone(),
startTarget: controls.target.clone(),
endTarget: target.clone(),
progress: 0,
duration: 0.8, // seconds
onComplete: onComplete || null
};
}
function updateSampleTray() {
const tray = document.getElementById('sample-tray'), items = document.getElementById('tray-items');
document.getElementById('tray-count').textContent = sampleTray.length;
if (sampleTray.length > 0) {
tray.classList.add('visible'); items.innerHTML = '';
const cc = {'Navy':'#1a2744','Sage':'#6b7f5e','Cream':'#f0ead6','Gold':'#c9a96e','Silver':'#b8b8c0','Blush':'#d4a0a0','Charcoal':'#3a3a42','Ivory':'#f5f0e8','Slate':'#5a6068','Teal':'#2a6b6b','Coral':'#cd6858','Burgundy':'#6b2040'};
sampleTray.forEach(p => { const d = document.createElement('div'); d.className = 'tray-item'; d.style.background = cc[p.color]||'#d8d4cc'; d.title = `${p.pattern_name} - ${p.color}`; items.appendChild(d); });
} else tray.classList.remove('visible');
}
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;
});
scene.fog.density = mode==='Evening' ? 0.06 : mode==='Gallery' ? 0.03 : 0.02;
}
function playAmbientMusic(ctx) {
const o1 = ctx.createOscillator(), o2 = ctx.createOscillator(), g = ctx.createGain();
o1.type = 'sine'; o1.frequency.value = 220; o2.type = 'sine'; o2.frequency.value = 329.63;
g.gain.value = 0.04;
const vol = document.getElementById('music-volume');
vol.addEventListener('input', () => g.gain.value = (parseInt(vol.value)/100)*0.08);
o1.connect(g); o2.connect(g); g.connect(ctx.destination); o1.start(); o2.start();
}
function drawTVSlide(idx) {
const tv = window._tvScreen; if (!tv) return;
const ctx = tv.ctx, w = 480, h = 270;
// Color palette for slides
const bgColors = ['#0f1923','#1a0f23','#0f2318','#23190f','#0f1a23','#1f0f23'];
const accentColors = ['#c9a96e','#6eafc9','#c96e8a','#6ec99b','#c9b56e','#9b6ec9'];
ctx.fillStyle = bgColors[idx % bgColors.length]; ctx.fillRect(0, 0, w, h);
// Get product data or use fallback
const allProds = products.length > 0 ? products : [];
if (allProds.length > 0) {
const p = allProds[idx % allProds.length];
const accent = accentColors[idx % accentColors.length];
// Decorative line
ctx.fillStyle = accent; ctx.fillRect(30, 60, 3, 100);
// Vendor
ctx.fillStyle = accent; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left';
ctx.fillText((p.vendor || '').toUpperCase(), 45, 78);
// Pattern name
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 28px sans-serif';
ctx.fillText(p.pattern_name || p.name || 'Pattern', 45, 115);
// Color name
ctx.fillStyle = '#888888'; ctx.font = '18px sans-serif';
ctx.fillText(p.color_name || p.color || '', 45, 145);
// SKU
ctx.fillStyle = '#555555'; ctx.font = '12px monospace';
ctx.fillText(p.sku || '', 45, 170);
// DW branding
ctx.fillStyle = '#c9a96e'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'right';
ctx.fillText('DW', w - 30, h - 20);
ctx.fillStyle = '#666'; ctx.font = '10px sans-serif';
ctx.fillText('DESIGNER WALLCOVERINGS', w - 55, h - 20);
} else {
// Loading placeholder
ctx.fillStyle = '#c9a96e'; ctx.font = 'bold 24px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('DESIGNER WALLCOVERINGS', w/2, h/2 - 10);
ctx.fillStyle = '#666'; ctx.font = '14px sans-serif';
ctx.fillText('Sherman Oaks Showroom', w/2, h/2 + 20);
}
tv.texture.needsUpdate = true;
}
// Start TV slideshow after products load
function startTVSlideshow() {
if (!window._tvScreen) return;
setInterval(() => {
window._tvScreen.slideIdx++;
drawTVSlide(window._tvScreen.slideIdx);
}, 5000);
}
// ============================================================
// MINIMAP — 2D floor plan overlay
// ============================================================
let minimapVisible = true;
function updateMinimap() {
const mc = document.getElementById('minimap');
if (!mc || !minimapVisible) return;
const ctx = mc.getContext('2d');
const mw = 160, mh = 128;
const W = CONFIG.room.width, D = CONFIG.room.depth;
// Scale: room space → minimap pixels
const sx = (mw - 20) / W, sy = (mh - 20) / D;
const ox = mw/2, oy = mh/2; // center offset
ctx.clearRect(0, 0, mw, mh);
// Room outline
ctx.strokeStyle = 'rgba(201,169,110,0.4)'; ctx.lineWidth = 1;
ctx.strokeRect(ox - W/2*sx, oy - D/2*sy, W*sx, D*sy);
// Door opening (front wall center)
ctx.strokeStyle = 'rgba(10,10,15,1)'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(ox - 0.6*sx, oy + D/2*sy); ctx.lineTo(ox + 0.6*sx, oy + D/2*sy); ctx.stroke();
// Wing wall — single gold bar along back wall with vendor-colored sections
const backY = oy + (-D/2 + 0.2) * sy;
const vc = { 'Thibaut':'#1a5276','Schumacher':'#922b21','Arte':'#d4ac0d','Elitis':'#a04000' };
// Draw vendor sections from wingBoards data
let curVendor = null, secStart = 0;
for (let i = 0; i <= wingBoards.length; i++) {
const v = i < wingBoards.length ? wingBoards[i].userData.product.vendor : null;
if (v !== curVendor) {
if (curVendor) {
const x0 = ox + (-CONFIG.room.width/2 + CONFIG.wing.wallMargin + secStart * (CONFIG.room.width - 2*CONFIG.wing.wallMargin)/CONFIG.wing.totalCount) * sx;
const x1 = ox + (-CONFIG.room.width/2 + CONFIG.wing.wallMargin + i * (CONFIG.room.width - 2*CONFIG.wing.wallMargin)/CONFIG.wing.totalCount) * sx;
ctx.fillStyle = vc[curVendor] || '#c9a96e';
ctx.fillRect(x0, backY - 2, x1 - x0, 4);
}
curVendor = v;
secStart = i;
}
}
// Table
ctx.fillStyle = 'rgba(92,64,51,0.6)';
ctx.fillRect(ox + (-1.0)*sx - 9, oy + 1.5*sy - 5, 18, 10);
// Camera position + direction
const cx = ox + camera.position.x * sx, cy = oy + camera.position.z * sy;
// Direction arrow
const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd);
ctx.strokeStyle = '#c9a96e'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + fwd.x*12, cy + fwd.z*12); ctx.stroke();
// Dot
ctx.fillStyle = '#c9a96e'; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI*2); ctx.fill();
}
function updateLoadStatus(text, pct) {
const f = document.getElementById('loadFill'), s = document.getElementById('loadStatus');
if (f) f.style.width = pct + '%'; if (s) s.textContent = text;
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Expose camera/controls for programmatic walkthrough scripts
window.addEventListener('DOMContentLoaded', function() {
init();
window._cam = camera;
window._ctrl = controls;
window._scene = scene;
window._renderer = renderer;
window._wingBoards = wingBoards;
window._wingRacks = wingRacks;
});
// Diagnostics: expose scene stats
window._getSceneStats = function() {
if (!scene || !renderer) return 'Scene not ready';
let meshes = 0, groups = 0, lights = 0, total = 0;
scene.traverse(obj => { total++; if (obj.isMesh) meshes++; if (obj.isGroup) groups++; if (obj.isLight) lights++; });
const ri = renderer.info;
return { total, meshes, groups, lights, drawCalls: ri.render.calls, triangles: ri.render.triangles, textures: ri.memory.textures, geometries: ri.memory.geometries, programs: ri.programs ? ri.programs.length : 'n/a' };
};
})();