← back to Quadrille Showroom
public/js/showroom.js
5597 lines
(function() {
'use strict';
// ============================================================
// CONFIG
// ============================================================
const CONFIG = {
// 20'×20' room (~6.10m square, ~9.5' ceiling). The viewer sits DEAD CENTER and the
// concave ARC of 50 wingboards wraps the front of the room so a center-fixed, yaw-spin
// camera sees the full fanned bank (Slice-1 canonical spec). Room enlarged from 14'→20'
// (Chunk A′) so the first-person Steve avatar (Chunk K) has believable standing room and
// the bank sits a comfortable arm's-length distance from the centred viewer (not inches).
room: { width: 6.10, depth: 6.10, height: 2.9 },
wing: {
height: 1.8288, // 6 ft tall (real sample-board height)
boardWidthM: 0.762, // 30" wide (real sample-board width)
totalCount: 50, // boards in the arc at once — packed slivers; Peruse pages the 883
wallMargin: 0.3,
animSpeed: 0.06,
panelOpen: 1.35,
// ---- PJ WINGBOARD ARC (canonical target, Image #6) ----
// The rack is a concave ARC wrapping the front of the room. Boards hang from a curved
// brass rail, each hinged on its LEFT vertical edge, raked so packed they show only
// a thin vertical SLIVER of each pattern (occlusion gives the sliver for free).
// Re-fit for the 14'×14' room + 50 boards fanning left+right around the centered viewer.
arcRadius: 3.2, // radius of the curved rail (m) — re-fit for the 20'×20' room (Chunk A′)
// 50 boards × ~2" sliver. Hinge arc-pitch = R·span/(n-1): 3.2·(96°·π/180)/49 ≈ 0.1094m
// (≈4.3") raw hinge spacing; the ~73° closed rake (REVEAL 74) occludes most of that so each
// closed wing still reads as a ~2" leading sliver, fanning left+right around the centred
// viewer (yaw ±48°, well inside the ±72° clamp). With arcCenter.z=0.5 the dead-front centre
// board sits at z≈-2.7m (≈8.9') ahead of the eye — a comfortable showroom distance, NOT
// inches from the viewer; the edge boards swing to x≈±2.4m (room half-width 3.05m, fits).
arcSpanDeg: 96,
arcCentreDeg: -90, // sweep centred on -Z → boards sit in FRONT of the viewer
arcCenter: { x: 0.0, z: 0.5 }, // arc centre just BEHIND the centred viewer → concave bank faces them
sliverPitchScale: 1.0, // multiplier on the pitch (reveal slider can thin)
// ---- STRAIGHT BACK-WALL RAIL (Steve 2026-07-02) ----
// Steve killed the semicircular "dry-cleaner" arc: the wings now hang on a
// STRAIGHT brass rail centred against the back wall, one wing per peg-hole,
// 2" (0.0508m) apart, packed so each closed wing shows a ~2" colour sliver;
// click one and it swings forward off the wall to the dead-on open hero.
layout: 'linear',
pegPitchM: 0.0508, // 2" between peg-holes
backWallZ: -(6.10 / 2) + 0.18 // just in front of the back wall (z = -3.05 + 0.18)
},
// Camera FIXED at room centre, eye height ~1.6m, facing the wing bank (toward arc centre).
// startPos.z pulled back to +0.7 (Steve 2026-06-30: the centred eye at z=0 sat ~2.4m from
// the open book and framed "way too close"). +0.7 keeps the avatar essentially centred
// (room spans z ±3.05) while standing the open book off to a comfortable ~3.1m so the whole
// 6-ft spread frames with headroom instead of filling the screen.
camera: { startPos: { x: 0.0, y: 1.6, z: 0.7 }, fov: 60 }
};
// Boot / return-to-centre look pitch: a gentle downward tilt so the eye (1.6m) frames the
// open book — whose vertical centre sits at ~0.95m — with headroom, reading as "looking
// forward" rather than dead-level (which let the book's top crowd the ceiling) or staring
// at the floor. Used by init, recenterView(), and the guided reset paths.
const BOOT_PITCH = -0.12;
// ============================================================
// STATE
// ============================================================
let scene, camera, renderer, controls, clock, raycaster, mouse;
let wingBoards = [], wingRacks = [], sampleTray = [], products = [], vendors = [];
let visitorAge = null; // optional visitor age captured on the Sample Tray (plain data point)
let focusedWing = null, animatingWings = [], frameCount = 0, lastFpsTime = 0;
let restingOpenWing = null; // Slice-1: the middle board, open-at-angle on boot as the resting visual (NOT a focus/selection)
// BOOT RACE GUARD (contrarian polish): the canvas click listener is wired at init() but
// wingBoards stays EMPTY until the async loadProducts()->buildWingWall() chain finishes
// (~2-3s). A click in that window used to silently no-op (empty raycaster targets) — to a
// senior that reads as "it's broken." wingWallReady goes true the instant buildWingWall()
// has wired the full bank into the raycaster (>=50 boards). A pre-ready click is QUEUED
// (not dropped) and replayed through the normal selection path the moment the bank is ready,
// so the board the user pointed at gets selected — their click is honoured, never ignored.
let wingWallReady = false;
let _pendingBootClick = null; // {clientX, clientY} of a click that landed before the bank was ready
let _bootClickConsumed = false; // true once a queued boot click has been replayed into a real selection
let _bootFixedCentreYield = false; // during boot, makes enterFixedCentre yield to a queued/consumed boot click
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 }
// CAROUSEL (Chunk I — dry-cleaner / tie-rack conveyor). carouselTo(K) eases the rack's
// continuous index-offset (QHRack.setIndexOffset) so board K's effective slot glides to
// the dead-front centre slot, all boards sliding along the curved rail together. The
// CAMERA NEVER MOVES — only the rack shifts. When the shift settles, _carouselPending
// fires the centred board's open + detail + 4-wall clad (the existing focus payload).
let carouselTarget = 0; // the index-offset we're easing toward
let carouselActive = false; // true while the rack is mid-shift
let _carouselPending = null; // pivot to open once the shift settles
// Window paging across the full catalog (50 live wings at a time)
let windowOffset = 0, windowTotal = 0, windowSize = parseInt(localStorage.getItem('qh_density')) || 50; // 50 boards in the packed arc (Slice-1 spec): each closed wing shows a ~2" sliver, fanning left+right around the centred viewer; persisted per standing sort+density rule
let currentBrand = 'all', currentSort = localStorage.getItem('qh_sort') || 'natural', currentWallGroup = null;
// Peruse (endless auto-tour) state
let peruseActive = false, peruseIndex = 0, peruseTimer = null;
const PERUSE_DWELL = 5000; // ms per wing — dwell 5s on each open design
// Wall-cladding: render the focused pattern onto the actual room walls
let roomWalls = []; // [{ mesh, w, h, origMat, cladMat }]
// PJ arc layout: walls stay plain cream plaster (like the reference photo) — the rack is
// the only loud thing. Slice-2 (Steve hard rule): the immersive room-preview is the
// DEFINITIVE default — every selection clads ALL FOUR walls, so this defaults ON. The
// ▦ Walls toggle still lets the user drop back to neutral walls if they prefer.
// (focusOnWing now clads unconditionally, so this flag only drives the toggle/label.)
let wallsAutoClad = true;
// 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
let BOOK_OPEN = 1.134; // 65° leaf splay — the walk-up BROWSE gesture only
// Open-board VIEW angle when focused/perused — USER-ADJUSTABLE (slider, persisted).
// Near-flat default so the design reads as a flat, dead-on PJ hero panel, not a V.
let VIEW_OPEN = (function () {
const v = parseFloat(localStorage.getItem('qh_view_deg'));
return (v >= 0 && v <= 90 ? v : 8) * Math.PI / 180;
})();
// OPEN-ANGLE SLIDER (Slice-1 spec) — the resting MIDDLE board's open angle on boot.
// Measured in DEGREES the board has swung off its packed closed-rake toward open-flat
// (0°=fully closed/raked, rake°=fully open/flat-to-centre). Default 45°, range 20°–90°,
// persisted to localStorage('qh_open_angle'). updateBooks converts this to a per-board
// flip fraction (deg / board-rake-deg) so the open hero rakes to exactly this angle live.
// Resting-open swing of the middle hero board, in degrees OFF the closed rake (rake≈73°).
// At 37° open the board sits ~36° off dead-on (restFlip≈0.5 of the rake) → foreshortened to
// ~24" of its 30" face: UNMISTAKABLY angled amid the slivers, but distinct from a fully-open
// SELECTED board (flip=1, dead-on flat). Earlier 45° (restFlip≈0.62, only ~14.6° off dead-on)
// read as flat at viewing distance — the FIX-3 hole.
let OPEN_ANGLE_DEG = (function () {
const v = parseFloat(localStorage.getItem('qh_open_angle'));
return (v >= 20 && v <= 90) ? v : 37;
})();
// REVEAL SLIDER (Steve's explicit ask) — how much of each closed board's pattern shows
// in the packed arc. Drives the per-board closed RAKE angle in QHRack (smaller reveal =
// wider sliver = more pattern per board / fewer boards; larger reveal = thinner slivers =
// more boards in view). Persisted to localStorage like the Wing°/Boards sliders.
let REVEAL = (function () {
const v = parseFloat(localStorage.getItem('qh_reveal'));
return (v >= 0 && v <= 100) ? v : 74; // default ≈ the photo's RAZOR-THIN slivers (steep rake)
})();
// 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
// GUIDED MODE (DTD verdict A, 2026-06-27) — senior-first default. The boot
// experience is GUIDED: camera flies to the middle board open, big bottom-centre
// Previous/Next/Auto-Tour buttons drive everything, NO keyboard + NO 3D walking
// required. Free-walk (WASD/orbit) is demoted behind an 'Explore' toggle that is
// OFF by default. exploreMode gates all walking/orbit/proximity input.
// Steve takeover (2026-06-29): WALKING is now the DEFAULT. The avatar spawns dead-centre
// facing the wing and the user "takes over the body" — ↑/↓ walk along the facing
// direction, ←/→ turn the body. exploreMode therefore defaults ON (and is persisted ON
// the first time) so boot is the first-person walk experience, not the guided arc browser.
let exploreMode = (localStorage.getItem('qh_explore') !== '0');
if (localStorage.getItem('qh_explore') === null) localStorage.setItem('qh_explore', '1');
let guidedReady = false; // set true once the middle board has been auto-focused on load
// CENTER BOOK (Steve 2026-06-29): the DEFAULT view is ONE open sample book hinged at the
// spine, centred on the spawn's forward axis (-Z), with the LEFT leaf a horizontal MIRROR
// of the right leaf — the same pattern reads symmetrically on both sides. The 50-board arc
// is NOT built in this view; [ ] (or N/P) page through the catalog to change the design.
const BOOK_MODE = true;
let centerBook = null; // { group, leafL, leafR, faceL, faceR } — the single open book
let bookIndex = 0; // which catalog design is shown (within the current window)
// ============================================================
// INIT
// ============================================================
function init() {
// Slice-2 boot guard: the view-mode engine's boot setMode('hero') must NOT auto-focus
// (clad + open panel) until the user actually interacts — keeps the Slice-1 boot pose
// (resting middle, no selection/panel, neutral walls) deterministic, race-free.
window._qhBootSettled = false;
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: true });
renderer.setSize(window.innerWidth, window.innerHeight);
// 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.02; // calm warm gallery exposure (was blowing out the plaster)
renderer.shadowMap.enabled = true; // contact shadows = the #1 realism lever
// Soft PCF — penumbra blur on the wing/furniture shadows reads far more believable than
// hard PCF. Cost is modest at one shadow-casting light; the adaptive guard protects FPS.
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// PERF: the room is mostly static, so re-rendering both shadow maps EVERY frame is
// wasted GPU (the dominant render cost). Switch to ON-DEMAND shadow updates — we flag
// needsUpdate only when geometry actually changes (board opens/closes, focus changes,
// theme/mode switch, camera settle). Measured: drops p95 render-ms ~45% (0.9→0.5ms at
// Retina pixelRatio) with ZERO visual change. requestShadowUpdate() schedules a refresh.
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true; // bake once now that the scene is built
// PBR environment so chrome/floor pick up believable reflections
buildEnvironment();
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;
// Fixed-centre camera looks straight at the wing bank (toward -z, the arc centre),
// aimed at board centre height. Yaw-spin (updateWASD) rotates this target in place.
controls.target.set(0, 1.0, -1.0);
controls.panSpeed = 0.5;
// SINGLE-OWNERSHIP NAVIGATION (Steve 2026-06-30): OrbitControls' own wheel/drag handlers
// self-call scope.update() on each event, dollying the eye in ("way too close") and tipping
// the look aerial / swinging it past the furniture — fighting the first-person rig that
// pins the eye every frame. We now OWN all navigation through the rig (arrow/scroll walk,
// drag/A-D turn, all position-clamped + grounded), so OrbitControls' interactions stay OFF
// in BOTH modes. The controls object survives only as the look-target holder.
controls.enableRotate = false;
controls.enablePan = false;
controls.enableZoom = false;
// FIXED-CENTRE camera: pin the immutable eye point and seed the spin pose facing the bank.
lockedSpinEye = camera.position.clone();
spinYaw = 0; spinPitch = BOOT_PITCH;
applySpinPose();
updateLoadStatus('Building materials...', 10);
initMaterials();
initTexturePool();
updateLoadStatus('Building showroom...', 30);
buildRoom();
buildLighting();
buildFurniture();
buildAvatar(); // Chunk K — first-person Steve body (yaw-only rig under the eye)
enableStaticShadows();
updateLoadStatus('Loading products...', 60);
loadProducts();
canvas.addEventListener('click', onCanvasClick, false);
canvas.addEventListener('mousemove', onCanvasMouseMove, false);
// Scroll-to-walk + drag-to-turn (Steve 2026-06-30: "using arrows or scroll mouse to move
// avatar"). Mouse-only users (all ages) get the same constrained, grounded navigation the
// arrow keys give — never a free orbit. { passive:false } so the wheel can preventDefault
// the page scroll.
canvas.addEventListener('wheel', onCanvasWheel, { passive: false });
canvas.addEventListener('pointerdown', onCanvasPointerDown, false);
canvas.addEventListener('pointermove', onCanvasPointerMove, false);
canvas.addEventListener('pointerup', onCanvasPointerUp, false);
canvas.addEventListener('pointercancel', onCanvasPointerUp, false);
window.addEventListener('resize', onResize, false);
window.addEventListener('keydown', e => {
keysDown[e.code] = true;
// CENTER BOOK paging — ←/→ ARROWS (Steve's pick) are the primary Prev/Next, plus [ / ] and
// N / P. ArrowRight = next design on the open wings, ArrowLeft = previous. (Body-turn moved
// to A/D so the arrows don't both page AND turn.) ↑/↓ still walk.
if (BOOK_MODE && (e.code === 'ArrowRight' || e.code === 'BracketRight' || e.code === 'KeyN')) { e.preventDefault(); pageCenterBook(1); return; }
if (BOOK_MODE && (e.code === 'ArrowLeft' || e.code === 'BracketLeft' || e.code === 'KeyP')) { e.preventDefault(); pageCenterBook(-1); return; }
// Return to centre — 'C' or Home. Always available, every age: glides the avatar back to
// the middle of the room, facing the bank, eye level. Matches the on-screen ⊕ Center button.
if (e.code === 'KeyC' || e.code === 'Home') { e.preventDefault(); requestRecenter(); return; }
if (e.code === 'KeyM') {
minimapVisible = !minimapVisible;
const mc = document.getElementById('minimap');
if (mc) mc.classList.toggle('hidden', !minimapVisible);
} else if (e.code === 'KeyP') {
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') {
const gridOpen = document.getElementById('grid-overlay');
if (gridOpen && gridOpen.classList.contains('open')) { closeAllDesignsGrid(); return; }
if (peruseActive) { stopPeruse(exploreMode ? false : true); if (!exploreMode) enterGuidedDefault(); }
else if (focusedWing) { unfocusWing(); if (!exploreMode) enterGuidedDefault(); } // guided mode never lands on an empty room
}
// Manual walking interrupts an active peruse (only when Explore is on)
if (exploreMode && peruseActive && (e.code.indexOf('Arrow') === 0 || ['KeyW','KeyA','KeyS','KeyD'].indexOf(e.code) >= 0)) stopPeruse();
});
window.addEventListener('keyup', e => { keysDown[e.code] = false; });
initHUD();
animate();
}
// ============================================================
// ENVIRONMENT — PMREM-baked studio gradient for PBR reflections
// (no external HDR needed; a vertical canvas gradient reads as a softbox ceiling)
// ============================================================
// Photoreal IBL — PMREM-bake a small "gallery interior" scene (warm ceiling cap,
// 8 warm track-light spheres, dark polished floor, warm wall bands). Ported from
// the dw-war-room ShowroomEnvironment; gives chrome/floor/board reflections real
// directional warmth instead of a flat gradient. Pure core THREE — no HDR file.
function buildEnvironment() {
try {
const pmrem = new THREE.PMREMGenerator(renderer);
if (pmrem.compileEquirectangularShader) pmrem.compileEquirectangularShader();
const env = new THREE.Scene();
const disposables = [];
const mesh = (geo, mat) => { disposables.push(geo, mat); const m = new THREE.Mesh(geo, mat); env.add(m); return m; };
// Warm dark interior dome
mesh(new THREE.SphereGeometry(50, 32, 16), new THREE.MeshBasicMaterial({ color: 0x1a1714, side: THREE.BackSide }));
// Warm ceiling cap (~3200K gallery light)
const ceil = mesh(new THREE.SphereGeometry(40, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.22),
new THREE.MeshBasicMaterial({ color: 0xf6e8d2, side: THREE.BackSide }));
ceil.rotation.x = Math.PI;
// Dark polished floor band (reflection source)
mesh(new THREE.SphereGeometry(40, 16, 8, 0, Math.PI * 2, Math.PI * 0.78, Math.PI * 0.22),
new THREE.MeshBasicMaterial({ color: 0x0d0c0a, side: THREE.BackSide }));
// 4 warm wall bands
for (let i = 0; i < 4; i++) {
const w = mesh(new THREE.PlaneGeometry(30, 16), new THREE.MeshBasicMaterial({ color: 0x33261d, side: THREE.DoubleSide }));
const a = (i / 4) * Math.PI * 2;
w.position.set(Math.cos(a) * 35, 5, Math.sin(a) * 35); w.lookAt(0, 5, 0);
}
// 8 bright warm track-light spots at ceiling level → crisp specular hits
for (let i = 0; i < 8; i++) {
const s = mesh(new THREE.SphereGeometry(2.2, 8, 8), new THREE.MeshBasicMaterial({ color: 0xffe9c6 }));
const a = (i / 8) * Math.PI * 2;
s.position.set(Math.cos(a) * 15, 18, Math.sin(a) * 15);
}
const rt = pmrem.fromScene(env, 0.02);
scene.environment = rt.texture;
pmrem.dispose();
disposables.forEach(d => d.dispose && d.dispose());
} catch (e) { console.warn('env build failed', e.message); }
}
// 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; }
// Photoreal room textures (Replicate SDXL → public/textures/*.png). Loaded as
// RepeatWrapping maps with max anisotropy so the warm-oak floor + limewash plaster
// read as real PHOTOGRAPHS, not procedural canvas. repeat=(x,y) tiles the swatch.
function loadTex(name, rx, ry) {
const t = new THREE.TextureLoader().load('/textures/' + name + '.png');
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(rx || 1, ry || 1);
t.minFilter = THREE.LinearMipmapLinearFilter;
t.magFilter = THREE.LinearFilter;
t.generateMipmaps = true;
if (renderer && renderer.capabilities && renderer.capabilities.getMaxAnisotropy) {
t.anisotropy = renderer.capabilities.getMaxAnisotropy();
}
return srgb(t);
}
// Data textures (normal / roughness maps) — MUST stay LINEAR (no sRGB) or the
// surface relief + gloss read wrong. Same wrap/repeat/anisotropy as loadTex so the
// derived oak normal+rough tile in lock-step with the albedo and stay seam-free.
function loadDataTex(name, rx, ry) {
const t = new THREE.TextureLoader().load('/textures/' + name + '.png');
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(rx || 1, ry || 1);
t.minFilter = THREE.LinearMipmapLinearFilter;
t.magFilter = THREE.LinearFilter;
t.generateMipmaps = true;
if (THREE.LinearEncoding) t.encoding = THREE.LinearEncoding;
if (renderer && renderer.capabilities && renderer.capabilities.getMaxAnisotropy) {
t.anisotropy = renderer.capabilities.getMaxAnisotropy();
}
return t;
}
// ============================================================
// MATERIALS — all shared
// ============================================================
function initMaterials() {
MAT.floor = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: 0.42, metalness: 0.0, envMapIntensity: 0.5 }); // warm satin oak
MAT.ceiling = new THREE.MeshStandardMaterial({ color: 0xeae4da, roughness: 0.96, metalness: 0.0 }); // bright warm plaster
// PHOTOREAL limewash plaster (Replicate SDXL, public/textures/wall-limewash.png) —
// bright warm off-white with a faint hand-troweled mottle. Replaces the flat
// procedural wall so the room reads as a real PJ white-box gallery. color tint
// keeps it bright; low repeat = large-scale mottle.
// SUBTLE NORMAL + ROUGHNESS maps (derived locally from wall-limewash.png via
// scripts/derive-wall-maps.py) give the plaster faint tactile trowel relief so it
// catches grazing picture-light instead of reading as one flat tiled photo.
// Plaster is smooth, so normalScale is kept ~0.2 — "honed limewash", not stucco.
// Maps tile in lock-step with the albedo (same repeat) → seam-free.
MAT.wall = new THREE.MeshStandardMaterial({
map: loadTex('wall-limewash', 2, 1.4),
normalMap: loadDataTex('wall-limewash-normal', 2, 1.4),
normalScale: new THREE.Vector2(0.2, 0.2),
roughnessMap: loadDataTex('wall-limewash-rough', 2, 1.4),
color: 0xefe6d4, roughness: 1.0, metalness: 0.0, envMapIntensity: 0.3
});
MAT.chrome = new THREE.MeshStandardMaterial({ color: 0xc9a96e, roughness: 0.25, metalness: 0.9, envMapIntensity: 1.0 }); // brushed brass
MAT.wood = new THREE.MeshStandardMaterial({ color: 0x6b4a32, roughness: 0.5, metalness: 0.0, envMapIntensity: 0.5 }); // satin walnut
MAT.chair = new THREE.MeshStandardMaterial({ color: 0x4a3528, roughness: 0.65, metalness: 0.05, envMapIntensity: 0.4 }); // cognac leather
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.MeshStandardMaterial({ color: 0x26262c, roughness: 0.7, metalness: 0.0 });
// Shared book materials — 8 colors (matte cloth spines)
MAT.books = [0x1a5276, 0x7d3c98, 0x1e8449, 0xb9770e, 0x922b21, 0x2c3e50, 0xd4ac0d, 0xa04000]
.map(c => new THREE.MeshStandardMaterial({ color: c, roughness: 0.8, metalness: 0.0 }));
// Left wall — same PHOTOREAL limewash plaster (NOT brick), offset repeat so it
// doesn't read as a clone of the back wall. (MAT.brick name kept for the existing
// roomWalls reference; it is plaster now.) Same subtle trowel-relief maps.
MAT.brick = new THREE.MeshStandardMaterial({
map: loadTex('wall-limewash', 1.6, 1.6),
normalMap: loadDataTex('wall-limewash-normal', 1.6, 1.6),
normalScale: new THREE.Vector2(0.2, 0.2),
roughnessMap: loadDataTex('wall-limewash-rough', 1.6, 1.6),
color: 0xefe6d4, roughness: 1.0, metalness: 0.0, envMapIntensity: 0.3
});
}
// ============================================================
// CONTACT GROUNDING — cheap baked-AO contact shadows under objects
// ============================================================
// A single soft radial-gradient alpha sprite, shared by every contact shadow, so
// boards / planter / console sit IN the room instead of floating ON it — the single
// biggest "is it real" tell after the room shell. No light, no shadow-map cost: each
// is one transparent dark plane laid flat just above the floor. (REVIEW.md #3.)
// CACHED gallery-placard textures + shared geometry. Keyed by pattern|color so a
// window-page rebuild reuses already-baked plates instead of re-baking 18 canvases
// + GPU-uploading them in the rebuild frame (was the FPS-9 paging stall).
const _placardCache = {};
let _placardGeo = null;
function placardGeo(wingW) {
if (!_placardGeo) _placardGeo = new THREE.PlaneGeometry(0.32, 0.075);
return _placardGeo; // fixed size — all boards are the same 30" face
}
// SHARED board geometry — face box, the two frame-bar geometries, contact + overlay
// planes. All 18+ boards are the same fixed 30"×6ft size, so these are built ONCE and
// reused, eliminating the ~100 per-rebuild geometry allocations that stalled paging.
let _boardGeo = null;
function boardGeo(wingW, H, THICK) {
if (_boardGeo) return _boardGeo;
const frameT = 0.020, frameD = 0.012, fpad = 0.010;
const fw = wingW + fpad * 2, fh = H + fpad * 2;
_boardGeo = {
face: new THREE.BoxGeometry(wingW, H, THICK),
frameH: new THREE.BoxGeometry(fw, frameT, frameD), // top/bottom bars
frameV: new THREE.BoxGeometry(frameT, fh, frameD), // left/right bars
contact: new THREE.PlaneGeometry(wingW + 0.10, 0.30),
frameT, fw, fh
};
_boardGeo.__shared = true;
return _boardGeo;
}
function placardTexture(pName, pColor) {
const key = pName + '|' + pColor;
if (_placardCache[key]) return _placardCache[key];
const plc = document.createElement('canvas'); plc.width = 512; plc.height = 128;
const pcx = plc.getContext('2d');
pcx.fillStyle = '#15110a'; pcx.fillRect(0, 0, 512, 128);
pcx.strokeStyle = 'rgba(185,147,63,0.6)'; pcx.lineWidth = 3; pcx.strokeRect(5, 5, 502, 118);
pcx.fillStyle = '#cbb073'; pcx.textAlign = 'center'; pcx.textBaseline = 'middle';
pcx.font = '600 38px "Cormorant Garamond", Georgia, serif';
pcx.fillText(pName.length > 26 ? pName.slice(0, 25) + '…' : pName, 256, 50);
if (pColor) {
pcx.fillStyle = '#9a8c6e'; pcx.font = '500 22px sans-serif';
pcx.fillText(pColor.toUpperCase().slice(0, 34), 256, 86);
}
const t = new THREE.CanvasTexture(plc);
if (renderer.capabilities) t.anisotropy = renderer.capabilities.getMaxAnisotropy();
t.minFilter = THREE.LinearMipmapLinearFilter; t.generateMipmaps = true;
t.__shared = true; // never disposed by disposeGroup
_placardCache[key] = t;
return t;
}
let _contactTex = null;
function contactShadowTexture() {
if (_contactTex) return _contactTex;
const c = document.createElement('canvas'); c.width = c.height = 128;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(64, 64, 4, 64, 64, 62);
g.addColorStop(0.0, 'rgba(20,18,14,0.55)'); // warm charcoal core (never pure black)
g.addColorStop(0.45, 'rgba(20,18,14,0.30)');
g.addColorStop(1.0, 'rgba(20,18,14,0.0)'); // fades to nothing at the rim
ctx.fillStyle = g; ctx.fillRect(0, 0, 128, 128);
_contactTex = new THREE.CanvasTexture(c); _contactTex.__shared = true;
return _contactTex;
}
// PER-BOARD LIGHTING — a subtle top-to-bottom luminance falloff + a faint specular
// sheen band where the picture-light grazes the upper face of each 6-ft board, so the
// board reads as a physical object in a lit room (not an evenly-lit decal). Baked once
// into a shared RGBA gradient sprite that overlays the board face with NormalBlending
// (translucent), so it works identically for the pool material AND the loadWingBook
// material WITHOUT touching the swatch's colour math. Low alphas everywhere keep the
// wallcovering colour true + legible — the swatch is never crushed into shadow.
let _boardLightTex = null;
function boardLightTexture() {
if (_boardLightTex) return _boardLightTex;
const c = document.createElement('canvas'); c.width = 16; c.height = 512; // vertical 1-D gradient
const ctx = c.getContext('2d');
// y=0 is the TOP of the board (picture-light grazes here), y=512 the bottom (floor side).
const g = ctx.createLinearGradient(0, 0, 0, 512);
// Faint warm SHEEN band near the top third where the picture-light catches the face.
g.addColorStop(0.00, 'rgba(255,248,232,0.10)'); // soft warm highlight at the very top
g.addColorStop(0.14, 'rgba(255,248,232,0.055)'); // sheen falls off
g.addColorStop(0.34, 'rgba(0,0,0,0.0)'); // neutral mid — swatch reads TRUE here
g.addColorStop(0.70, 'rgba(28,24,18,0.05)'); // gentle warm-charcoal shading begins
g.addColorStop(1.00, 'rgba(28,24,18,0.16)'); // modest falloff at the bottom (never crushed)
ctx.fillStyle = g; ctx.fillRect(0, 0, 16, 512);
_boardLightTex = new THREE.CanvasTexture(c); _boardLightTex.__shared = true;
_boardLightTex.minFilter = THREE.LinearFilter; _boardLightTex.magFilter = THREE.LinearFilter;
return _boardLightTex;
}
// FAKE-BLOOM glow sprite — a soft warm horizontal-feather halo for the ceiling
// light strips. Bright warm core down the centre line, feathered to nothing at the
// left/right edges (so it bleeds sideways across the ceiling) with a gentle vertical
// taper. AdditiveBlending makes it read as emitted light, not paint. Built once.
let _stripGlowTex = null;
function stripGlowTexture() {
if (_stripGlowTex) return _stripGlowTex;
const c = document.createElement('canvas'); c.width = 128; c.height = 256;
const ctx = c.getContext('2d');
// Horizontal feather: warm core in the middle, fading to transparent at the sides.
const gx = ctx.createLinearGradient(0, 0, 128, 0);
gx.addColorStop(0.00, 'rgba(255,244,222,0)');
gx.addColorStop(0.50, 'rgba(255,246,228,0.9)');
gx.addColorStop(1.00, 'rgba(255,244,222,0)');
ctx.fillStyle = gx; ctx.fillRect(0, 0, 128, 256);
// Vertical taper so the ends of the cove fade a little.
ctx.globalCompositeOperation = 'destination-in';
const gy = ctx.createLinearGradient(0, 0, 0, 256);
gy.addColorStop(0.0, 'rgba(0,0,0,0.35)'); gy.addColorStop(0.12, 'rgba(0,0,0,1)');
gy.addColorStop(0.88, 'rgba(0,0,0,1)'); gy.addColorStop(1.0, 'rgba(0,0,0,0.35)');
ctx.fillStyle = gy; ctx.fillRect(0, 0, 128, 256);
_stripGlowTex = new THREE.CanvasTexture(c); _stripGlowTex.__shared = true;
_stripGlowTex.minFilter = THREE.LinearFilter; _stripGlowTex.magFilter = THREE.LinearFilter;
return _stripGlowTex;
}
// One overlay quad sized to a leaf face, parented to the leaf so it swings with it.
// Sits a hair in front of the face (toward +z local) so it never z-fights the swatch.
let _overlayGeo = null;
function makeBoardLightOverlay(leafW, H, yC) {
if (!_overlayGeo) _overlayGeo = new THREE.PlaneGeometry(leafW, H); // shared (fixed board size)
const m = new THREE.Mesh(
_overlayGeo,
new THREE.MeshBasicMaterial({
map: boardLightTexture(), transparent: true, opacity: 1,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
})
);
m.position.set(0, yC, 0.0085); // just in front of the 14 mm-thick board face
m.renderOrder = 2; // draw after the swatch
m.userData.isBoardLight = true; // so it's never raycast-hit as a face
return m;
}
// WALL DROP-SHADOW — a soft-edged rectangular dark sprite for the shadow each board
// casts onto the wall behind it. A tall vertical soft-box: opaque-ish in the middle,
// feathered to nothing at all four edges, so a plane behind+below each board reads as a
// believable contact-cast shadow off the wall (gives the board depth OFF the wall) at
// zero shadow-map / light cost. Built once, shared.
let _wallDropTex = null;
function wallDropShadowTexture() {
if (_wallDropTex) return _wallDropTex;
const c = document.createElement('canvas'); c.width = 128; c.height = 256;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, 128, 256);
// Horizontal feather (left↔right) × vertical feather (top↔bottom), multiplied, so the
// sprite is darkest in the centre and fades on every edge.
const gx = ctx.createLinearGradient(0, 0, 128, 0);
gx.addColorStop(0.0, 'rgba(20,17,12,0)'); gx.addColorStop(0.5, 'rgba(20,17,12,1)'); gx.addColorStop(1.0, 'rgba(20,17,12,0)');
ctx.fillStyle = gx; ctx.fillRect(0, 0, 128, 256);
ctx.globalCompositeOperation = 'destination-in';
const gy = ctx.createLinearGradient(0, 0, 0, 256);
gy.addColorStop(0.0, 'rgba(0,0,0,0)'); gy.addColorStop(0.18, 'rgba(0,0,0,1)'); gy.addColorStop(0.82, 'rgba(0,0,0,1)'); gy.addColorStop(1.0, 'rgba(0,0,0,0)');
ctx.fillStyle = gy; ctx.fillRect(0, 0, 128, 256);
_wallDropTex = new THREE.CanvasTexture(c); _wallDropTex.__shared = true;
_wallDropTex.minFilter = THREE.LinearFilter; _wallDropTex.magFilter = THREE.LinearFilter;
return _wallDropTex;
}
// Drop a soft contact shadow on the floor. (cx,cz) = world centre; w,d = footprint;
// opacity scales the darkness. Returns the mesh so callers can group/parent it.
function addContactShadow(cx, cz, w, d, opacity) {
const mat = new THREE.MeshBasicMaterial({
map: contactShadowTexture(), transparent: true, opacity: opacity == null ? 1 : opacity,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
});
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), mat);
m.rotation.x = -Math.PI / 2;
m.position.set(cx, 0.006, cz); // 6 mm above the floor → no z-fight, reads as contact
m.renderOrder = 1; // draw after the floor
scene.add(m);
return m;
}
// ============================================================
// 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 = srgb(new THREE.CanvasTexture(canvas));
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
// roughness ~0.6 + a hint of envMap so the picture-light grazes the board face as a
// physical surface (soft satin sheen), not an evenly-lit decal. metalness stays 0.
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.6, metalness: 0.0, envMapIntensity: 0.55 });
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 — PHOTOREAL warm honey-oak wide-plank (Replicate SDXL, public/textures/
// floor-oak.png). Tiled ~2.2× across the room so the real plank grain reads at
// walking scale; max anisotropy keeps it crisp at grazing angle. (Was a
// procedural canvas that read as a cartoon — see REVIEW.md.)
// NORMAL + ROUGHNESS maps (derived locally from floor-oak.png via
// scripts/derive-floor-maps.py) make the plank grain catch grazing light — the
// grooves self-shadow and the satin sheen breaks up across the boards instead of
// reading as one flat painted plane. normalScale kept modest so it's "real wood",
// not "embossed". Same 2.4× repeat as the albedo → tiles in lock-step, seam-free.
const ft = loadTex('floor-oak', 2.4, 2.4);
const fn = loadDataTex('floor-oak-normal', 2.4, 2.4);
const frg = loadDataTex('floor-oak-rough', 2.4, 2.4);
const floorMesh = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshStandardMaterial({
map: ft, normalMap: fn, roughnessMap: frg,
normalScale: new THREE.Vector2(0.55, 0.55),
roughness: 1.0, metalness: 0.0, envMapIntensity: 0.35
}));
floorMesh.rotation.x = -Math.PI/2; floorMesh.receiveShadow = true; scene.add(floorMesh);
window._floorMesh = floorMesh; // tagged so a room type can swap its material + resizeRoom can dispose it
// Décor tracker (baseboards/crown/strips/glows/sign) so resizeRoom can dispose the whole shell.
window._shellDecor = [];
// Ceiling — clean, bright, EVEN soft warm off-white gallery ceiling. (Was a crude
// 64px procedural grid that read as a wireframe — see REVIEW.md.) No texture, no
// harsh lines: a flat matte panel in a soft warm off-white so it reads as a real
// gallery ceiling. Lambert keeps it cheap and reflection-free.
const ceil = new THREE.Mesh(
new THREE.PlaneGeometry(W, D),
new THREE.MeshLambertMaterial({ color: 0xf2eee5 }) // soft warm off-white
);
ceil.rotation.x = Math.PI/2; ceil.position.y = H; ceil.receiveShadow = false;
ceil.userData.isCeiling = true; window._ceilingMesh = ceil; scene.add(ceil);
// Two slim recessed warm-light strips running front→back, set just below the ceiling
// plane — emissive (self-lit, no light cost) so they read as soft linear coves, the
// calm "this is a real gallery ceiling" cue without any hard grid lines.
const stripMat = new THREE.MeshBasicMaterial({ color: 0xfff6e6 });
// FAKE BLOOM — a soft additive glow halo around each emissive strip so the light
// "bleeds" into the ceiling like a real cove fixture (the subtle-bloom-on-the-
// light-strips realism cue) WITHOUT a postprocessing/EffectComposer stack (none
// is bundled; adding it would be a render-loop rewrite). One AdditiveBlending
// gradient quad per strip = near-zero cost (verified FPS-neutral). The glow is
// wider than the strip and fades to nothing at its edges, so it reads as light
// bleed, not a hard panel.
const glowTex = stripGlowTexture();
[-W * 0.22, W * 0.22].forEach(sx => {
const strip = new THREE.Mesh(new THREE.PlaneGeometry(0.10, D * 0.78), stripMat);
strip.rotation.x = Math.PI / 2;
strip.position.set(sx, H - 0.012, 0);
scene.add(strip); window._shellDecor.push(strip);
// Soft bloom halo just below the ceiling, a touch wider, additive + translucent.
const glow = new THREE.Mesh(
new THREE.PlaneGeometry(0.55, D * 0.86),
new THREE.MeshBasicMaterial({
map: glowTex, transparent: true, opacity: 0.6,
blending: THREE.AdditiveBlending, depthWrite: false
})
);
glow.rotation.x = Math.PI / 2;
glow.position.set(sx, H - 0.02, 0); // a hair below the strip so it never z-fights
glow.renderOrder = 3;
scene.add(glow); window._shellDecor.push(glow);
});
// Walls — capture ALL FOUR surfaces so a focused pattern clads the whole room
// (Steve hard rule: every click clads all 4 walls). The FRONT wall is built as
// three segments around the doorway; each segment is a cladable mesh in its own
// right, so all three go into roomWalls[] alongside Back/Left/Right. That fills
// the bare cream side/front walls when the user yaws to a side after selecting.
const fpw = W/2 - 0.6; // front side-panel width (flanking the doorway)
const ftw = 1.2, fth = H - 2.1; // front transom (above the doorway) width/height
roomWalls = [
{ side: 'back', mesh: addWall(0, H/2, -D/2, W, H, 0, MAT.wall), w: W, h: H, origMat: MAT.wall }, // Back
{ side: 'left', mesh: addWall(-W/2, H/2, 0, D, H, Math.PI/2, MAT.brick), w: D, h: H, origMat: MAT.brick }, // Left (brick)
{ side: 'right', mesh: addWall(W/2, H/2, 0, D, H, -Math.PI/2, MAT.wall), w: D, h: H, origMat: MAT.wall }, // Right
// Front (3 segments around the doorway) — all cladable, all tagged the FRONT side
{ side: 'front', mesh: addWall(-W/4-0.3, H/2, D/2, fpw, H, Math.PI, MAT.wall), w: fpw, h: H, origMat: MAT.wall }, // Front-left of door
{ side: 'front', mesh: addWall(W/4+0.3, H/2, D/2, fpw, H, Math.PI, MAT.wall), w: fpw, h: H, origMat: MAT.wall }, // Front-right of door
{ side: 'front', mesh: addWall(0, H-fth/2, D/2, ftw, fth, Math.PI, MAT.wall), w: ftw, h: fth, origMat: MAT.wall }, // Front transom over door
];
// Expose the canonical 4-side set so verification can assert all 4 walls clad.
window._wallSides = ['back', 'left', 'right', 'front'];
// 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); window._shellDecor.push(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); window._shellDecor.push(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); window._shellDecor.push(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; m.receiveShadow = true; scene.add(m);
return m;
}
// ============================================================
// WALL CLADDING — render the selected pattern onto the room walls
// ============================================================
const PHYS_TILE_M = 0.686; // assumed physical tile ≈ 27" so the pattern reads at room scale
// Monotonic clad token: every cladWalls() bumps it; a deferred image onload only paints
// if it's still the latest request AND walls should be clad (a wing is focused). This
// kills the boot race where a transient Hero focus's async onload repainted the walls
// AFTER enterGuidedDefault reverted them back to neutral cream (Slice-1 boot invariant).
let _cladToken = 0;
function _cladStillWanted(token) {
return token === _cladToken && (wallsAutoClad || focusedWing);
}
function cladWalls(imageUrl, isSeamlessTile) {
if (!imageUrl || !roomWalls.length) return;
const token = ++_cladToken;
const cached = imageTexCache[imageUrl];
if (cached && cached.texture && cached.texture.image) { if (_cladStillWanted(token)) applyWallImage(cached.texture.image, isSeamlessTile); return; }
const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
// Slice-3 / chunk F: decode OFF the main thread (createImageBitmap) for local images so
// the wall-clad upload no longer competes with the hero flip on the SAME frame (the
// single ~51-FPS select-moment dip). Falls back to a plain Image() for proxied/remote.
if (typeof createImageBitmap === 'function' && src.charAt(0) === '/') {
fetch(src).then(r => r.blob()).then(b => createImageBitmap(b))
.then(bmp => { if (_cladStillWanted(token)) applyWallImage(bmp, isSeamlessTile); })
.catch(() => { const img = new Image(); img.onload = () => { if (_cladStillWanted(token)) applyWallImage(img, isSeamlessTile); }; img.src = src; });
return;
}
const img = new Image();
img.onload = () => { if (_cladStillWanted(token)) applyWallImage(img, isSeamlessTile); };
img.src = src;
}
function applyWallImage(img, isSeamlessTile) {
const cladSides = new Set();
const div = isSeamlessTile ? PHYS_TILE_M : PHYS_TILE_M * 1.6;
// Per-wall texture (walls have genuinely different widths → different repeats, so they
// can't all share one texture object). The image itself is decoded ONCE off-thread by
// cladWalls() (createImageBitmap), so all 4 uploads share one already-decoded bitmap —
// the JPEG decode no longer lands on the hero-flip frame (the select-moment FPS dip).
// ImageBitmap sources must use CanvasTexture (correct flipY) — same as the hero path;
// a plain Texture(bitmap) would render the wall upside-down.
const isBitmap = (typeof ImageBitmap !== 'undefined' && img instanceof ImageBitmap);
roomWalls.forEach(w => {
const tex = srgb(isBitmap ? new THREE.CanvasTexture(img) : new THREE.Texture(img));
tex.image = 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.
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.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;
if (w.side) cladSides.add(w.side);
});
// Record which of the 4 canonical sides are now clad (read by verification + UI).
window._cladSides = Array.from(cladSides);
}
function revertWalls() {
_cladToken++; // invalidate any in-flight clad onload (boot-race guard)
roomWalls.forEach(w => {
w.mesh.material = w.origMat;
if (w.cladMat && w.cladMat.map) { w.cladMat.map.dispose(); w.cladMat.map = null; }
});
window._cladSides = [];
}
// ============================================================
// LIGHTING — minimal: ambient + hemi + 4 point
// ============================================================
function buildLighting() {
// Bright, EVEN gallery light — Phillip-Jeffries white-box: high ambient + hemi so the
// wallcovering swatches read crisp and true-colour, not greyed by moody low-key shadow.
// Committed to ONE dominant key (below) for photographic modelling; fill kept high
// enough that the wallcovering swatch colour stays true + legible.
// Warmer, slightly calmer wash than a pure white-box so the plaster reads CREAM (like
// the PJ Dallas photo) instead of blown-out white — the swatches still read true.
scene.add(new THREE.AmbientLight(0xfff3e2, 0.42));
scene.add(new THREE.HemisphereLight(0xfff1e0, 0xe4d8c4, 0.42)); // warm sky / warm-stone ground bounce
// KEY light — soft, gentle shadow (clean contact, not dramatic)
const key = new THREE.DirectionalLight(0xfff6ea, 1.05); // dominant warm key — clear light direction
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(1536, 1536); // crisp-yet-soft; smaller than 2048 for rebake headroom
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.0004;
if ('normalBias' in key.shadow) key.shadow.normalBias = 0.025;
if ('radius' in key.shadow) key.shadow.radius = 3.5; // wider PCF penumbra = softer, gallery-grade
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);
// RIM / back light — grazes the wing wall from behind-above so each leaf edge catches a
// highlight and separates from the back wall (the classic 3-point "kicker"). No shadow cost.
const rim = new THREE.DirectionalLight(0xfff0dd, 0.22);
rim.position.set(0, CONFIG.room.height + 0.6, -CONFIG.room.depth / 2 - 1.2);
rim.target.position.set(0, 1.2, 0); scene.add(rim); scene.add(rim.target);
// PICTURE LIGHT — a soft warm wash grazing the board wall from above, like a
// gallery picture light. Wide penumbra so no hard hotspot.
const pic = new THREE.SpotLight(0xfff0d8, 0.55, 8, Math.PI * 0.42, 0.85, 1.2);
pic.position.set(0, CONFIG.room.height - 0.15, -CONFIG.room.depth / 2 + 1.0);
pic.target.position.set(0, 1.2, -CONFIG.room.depth / 2);
// PER-BOARD REAL SHADOW — the picture-light casts a soft, physically-correct shadow of
// each board onto the wall behind it (and a hair onto its neighbours). Only kept if the
// 2-min soak stays ≥55 FPS (gate); a tight shadow camera + 1024 map keeps the cost low.
pic.castShadow = true;
pic.shadow.mapSize.set(1024, 1024);
pic.shadow.camera.near = 0.4; pic.shadow.camera.far = 3.6; // wall is ~1m from the light cone tip
pic.shadow.bias = -0.0006;
if ('normalBias' in pic.shadow) pic.shadow.normalBias = 0.02;
if ('radius' in pic.shadow) pic.shadow.radius = 4; // wide PCF penumbra = soft gallery shadow
scene.add(pic); scene.add(pic.target);
window._picLight = pic;
// 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);
});
}
// 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
// ============================================================
function buildFurniture() {
// ONLOAD MINIMAL (collection-layout-overhaul, Option A): the scene spawns with ONLY
// the essentials — one style's table + chair — of the saved (or default) style. The old
// always-on consultation nook (round table + two bouclé chairs + olive tree + swatch
// cards) is gone from boot; decor pieces appear only when the visitor opts into extras
// via the style-selector's "+ Decor" toggle. swapFurniture() owns build + layout + the
// memory-safe teardown; buildFurniture just seeds the first set.
let saved = null;
try { saved = localStorage.getItem(FURNITURE_LS_KEY); } catch (e) {}
const key = FURNITURE_STYLES[saved] ? saved : FURNITURE_STYLE_ORDER[0];
swapFurniture(key, { includeExtras: false });
}
// ============================================================
// FURNITURE STYLE REGISTRY (Option A — DTD-committed) — procedural, swappable sets.
// ------------------------------------------------------------
// One disposable furniture root group holds the ACTIVE style's table + chair (the
// "essentials") plus optional decor extras. FURNITURE_STYLES keys each style to build
// fns that emit parametric THREE meshes; swapFurniture() tears down the old group with
// full memory hygiene (geometry.dispose + material.dispose across material arrays +
// dispose of any UNIQUE textures — shared canvas textures are tagged __shared and left
// alone), builds the new group, and runs applyLayout() so EVERY style obeys the same
// grounding + room-bounds rules. The four styles read as genuinely distinct IN FORM
// (leg geometry + slab mass), not just colour.
// ============================================================
let furnitureRoot = null; // the ONE active furniture group (also window._qhNookGroup)
let furnitureStyleKey = null; // currently active style key
let furnitureExtras = false; // whether the active set includes decor extras
const FURNITURE_STYLE_ORDER = ['contemporary', 'traditional', 'transitional', 'brutalist'];
const FURNITURE_LS_KEY = 'qh_furniture_style';
// Where the set sits: right + forward of the centred viewer, inside the ±72° yaw clamp
// (yaw to anchor ≈ atan2(1.5, 1.7) ≈ 41°) — a gentle right-turn + look-down lands on it.
const FURNITURE_ANCHOR = { x: 1.5, z: -1.0 };
const FURNITURE_MARGIN = 0.35; // keep-clear from every wall (matches wing.wallMargin feel)
function fmat(color, rough, metal) {
return new THREE.MeshStandardMaterial({ color, roughness: rough == null ? 0.6 : rough, metalness: metal || 0.0, envMapIntensity: 0.3 });
}
// Matte material with NO environment pickup — for dark woods (walnut) that otherwise wash
// out to blonde because the bright showroom env floods a normal 0.3-envMap standard material.
function mattMat(color, rough) {
return new THREE.MeshStandardMaterial({ color, roughness: rough == null ? 0.9 : rough, metalness: 0.0, envMapIntensity: 0.0 });
}
// Local contact shadow parented to the furniture group (LOCAL coords) so teardown
// disposes it with the set. The map is the SHARED contact texture (__shared) — never disposed.
function furnitureShadow(parent, lx, lz, w, d, opacity) {
const mat = new THREE.MeshBasicMaterial({
map: contactShadowTexture(), transparent: true, opacity: opacity == null ? 0.6 : opacity,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
});
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), mat);
m.rotation.x = -Math.PI / 2; m.position.set(lx, 0.006, lz); m.renderOrder = 1;
parent.add(m);
return m;
}
// A lathe-turned leg (traditional) — stacked varying-radius cylinders from floor to the
// table underside at (x,z). `h` = table top-surface height; legs reach ~h-0.04.
function buildTurnedLeg(parent, x, z, mat, h) {
const leg = new THREE.Group(); leg.position.set(x, 0, z);
const segs = [[0.048, 0.10], [0.028, 0.06], [0.052, 0.10], [0.030, h - 0.36], [0.046, 0.06]];
let y = 0;
segs.forEach(([r, sh]) => {
const c = new THREE.Mesh(new THREE.CylinderGeometry(r, r, sh, 14), mat);
c.position.y = y + sh / 2; c.castShadow = true; leg.add(c); y += sh;
});
parent.add(leg);
}
// A tapered square post leg (transitional) — 4-sided cylinder, narrower at the foot.
function buildTaperedLeg(parent, x, z, mat, h) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.046, h - 0.04, 4), mat);
leg.position.set(x, (h - 0.04) / 2, z); leg.rotation.y = Math.PI / 4; leg.castShadow = true;
parent.add(leg);
}
// Béton-brut board-form lines: thin recessed vertical grooves on the two broad faces of a
// concrete block, so a solid plinth reads as poured-and-formed concrete (not styrofoam).
// Cheap — a handful of dark inset strips proud of ±z faces at (cx,cy,cz), block w×h×d.
function buildBoardFormLines(parent, cx, cy, cz, w, h, d, mat, count) {
count = count || 3;
for (let i = 0; i < count; i++) {
const gx = -w / 2 + (w * (i + 1)) / (count + 1);
[1, -1].forEach(s => {
const groove = new THREE.Mesh(new THREE.BoxGeometry(0.012, h * 0.92, 0.012), mat);
groove.position.set(cx + gx, cy, cz + s * (d / 2 - 0.004));
parent.add(groove);
});
}
// one horizontal form-board seam across the front face
const seam = new THREE.Mesh(new THREE.BoxGeometry(w * 0.96, 0.012, 0.012), mat);
seam.position.set(cx, cy + h * 0.12, cz + d / 2 - 0.004);
parent.add(seam);
}
const FURNITURE_STYLES = {
// CONTEMPORARY — clean, low, rectilinear; pale oak top on minimal charcoal blade legs.
contemporary: {
label: 'Contemporary',
buildTable(g) {
// Crisp WHITE lacquer top on steel blade legs — deliberately cool/white so it never
// reads as the warm wood of the Transitional set beside it.
const white = fmat(0xecebe6, 0.35, 0.0), blade = fmat(0x484a4e, 0.45, 0.55);
const top = new THREE.Mesh(new THREE.BoxGeometry(1.12, 0.05, 0.70), white);
top.position.set(0, 0.72, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
[[-0.50, -0.30], [0.50, -0.30], [-0.50, 0.30], [0.50, 0.30]].forEach(([lx, lz]) => {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.045, 0.70, 0.045), blade);
leg.position.set(lx, 0.35, lz); leg.castShadow = true; g.add(leg);
});
furnitureShadow(g, 0, 0, 1.35, 0.95, 0.6);
},
buildChair(g) {
const seatMat = fmat(0xe4e1d8, 0.55, 0.0), blade = fmat(0x484a4e, 0.45, 0.55);
const c = new THREE.Group(); c.position.set(0, 0, 0.68); // faces -z (toward table/boards)
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.08, 0.46), seatMat);
seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.40, 0.06), seatMat);
back.position.set(0, 0.64, -0.20); back.castShadow = true; c.add(back); // low back, behind seat
[[-0.20, -0.20], [0.20, -0.20], [-0.20, 0.20], [0.20, 0.20]].forEach(([lx, lz]) => {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.40, 0.04), blade);
leg.position.set(lx, 0.20, lz); leg.castShadow = true; c.add(leg);
});
g.add(c);
furnitureShadow(g, 0, 0.68, 0.65, 0.65, 0.55);
},
buildExtras(g) {
// A single slim matte vase on the table + a low ceramic dish — minimalist.
const vaseMat = fmat(0x8b8f8a, 0.85, 0.0);
const vase = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.065, 0.26, 20), vaseMat);
vase.position.set(-0.28, 0.745 + 0.13, 0.06); vase.castShadow = true; g.add(vase);
const dish = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.09, 0.03, 24), fmat(0xd8d3c8, 0.8, 0.0));
dish.position.set(0.22, 0.745 + 0.015, -0.02); dish.castShadow = true; g.add(dish);
}
},
// TRADITIONAL — warm walnut, lathe-turned legs, curved-back upholstered chair.
traditional: {
label: 'Traditional',
buildTable(g) {
// Matte, no-env walnut so it reads warm brown instead of washing to blonde.
const walnut = mattMat(0x2e1808, 0.92);
const top = new THREE.Mesh(new THREE.BoxGeometry(1.06, 0.06, 0.70), walnut);
top.position.set(0, 0.74, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
// a subtle apron under the top for weight
const apron = new THREE.Mesh(new THREE.BoxGeometry(0.96, 0.07, 0.60), mattMat(0x241304, 0.92));
apron.position.set(0, 0.685, 0); apron.castShadow = true; g.add(apron);
[[-0.44, -0.28], [0.44, -0.28], [-0.44, 0.28], [0.44, 0.28]].forEach(([lx, lz]) =>
buildTurnedLeg(g, lx, lz, walnut, 0.72));
furnitureShadow(g, 0, 0, 1.30, 0.95, 0.6);
},
buildChair(g) {
// Warm WALNUT frame + a tall curved upholstered back that both read clearly from the
// default camera (was reading as just a plain cream seat).
// Matte, no-env walnut so it reads warm brown instead of washing to blonde under the
// bright showroom env; cream damask cushion sits proud so the walnut frames it.
const walnut = mattMat(0x2e1808, 0.92), damask = fmat(0xd8c49b, 0.8, 0.0);
const c = new THREE.Group(); c.position.set(0, 0, 0.70);
// walnut seat rail (frame) with the damask cushion sitting proud on top → walnut shows
const rail = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.09, 0.50), walnut);
rail.position.set(0, 0.42, 0); rail.castShadow = true; c.add(rail);
const cushion = new THREE.Mesh(new THREE.BoxGeometry(0.46, 0.09, 0.44), damask);
cushion.position.set(0, 0.485, 0); cushion.castShadow = true; cushion.receiveShadow = true; c.add(cushion);
// Tall curved, upholstered back — a partial cylinder shell wrapping the sitter's back.
const back = new THREE.Mesh(
new THREE.CylinderGeometry(0.31, 0.31, 0.60, 24, 1, true, Math.PI * 0.72, Math.PI * 0.56), damask);
back.position.set(0, 0.84, -0.14); back.castShadow = true; c.add(back);
// walnut curved frame hugging the outside of the upholstery + walnut back posts
const frame = new THREE.Mesh(
new THREE.CylinderGeometry(0.335, 0.335, 0.62, 24, 1, true, Math.PI * 0.70, Math.PI * 0.60), walnut);
frame.position.set(0, 0.84, -0.14); frame.castShadow = true; c.add(frame);
[-1, 1].forEach(s => {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.026, 0.03, 0.66, 12), walnut);
post.position.set(s * 0.235, 0.82, -0.06); post.rotation.x = -0.12; post.castShadow = true; c.add(post);
});
// crown rail across the top of the back
const crown = new THREE.Mesh(new THREE.CylinderGeometry(0.032, 0.032, 0.50, 12), walnut);
crown.rotation.z = Math.PI / 2; crown.position.set(0, 1.14, -0.14); crown.castShadow = true; c.add(crown);
[-1, 1].forEach(s => buildTurnedLeg(c, s * 0.20, -0.20, walnut, 0.42)); // front-facing turned legs read fine
[-1, 1].forEach(s => buildTurnedLeg(c, s * 0.20, 0.20, walnut, 0.42));
g.add(c);
furnitureShadow(g, 0, 0.70, 0.72, 0.72, 0.55);
},
buildExtras(g) {
// A short stack of swatch books + a slim brass candlestick.
const bookTones = [0x7a5a3a, 0x8a4a3a, 0x5a5a44];
bookTones.forEach((t, k) => {
const bk = new THREE.Mesh(new THREE.BoxGeometry(0.30, 0.035, 0.22), fmat(t, 0.8, 0.0));
bk.position.set(-0.26, 0.77 + 0.018 + k * 0.036, 0.05); bk.rotation.y = (k - 1) * 0.06;
bk.castShadow = true; g.add(bk);
});
const brass = fmat(0xb8975a, 0.35, 0.6);
const stick = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.03, 0.20, 14), brass);
stick.position.set(0.28, 0.77 + 0.10, -0.02); stick.castShadow = true; g.add(stick);
const cup = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.02, 0.03, 14), brass);
cup.position.set(0.28, 0.77 + 0.205, -0.02); g.add(cup);
}
},
// TRANSITIONAL — a blend: simple frame + softened edges, mid-tone wood, greige seat.
transitional: {
label: 'Transitional',
buildTable(g) {
// DEEP warm mid-oak — pushed well off the Contemporary set's cool white. mattMat
// (envMapIntensity 0) is REQUIRED here: the ~1.6x showroom light-lift + ACES tone-map
// washes any env-lit wood to pale blonde (proven — a 0.15-env honey rendered nearly
// white). A darker matte base survives the lift and reads as a solid oak brown, sitting
// clearly between Contemporary's white and Traditional's dark walnut.
const wood = mattMat(0x4f3a1c, 0.6);
const top = new THREE.Mesh(new THREE.BoxGeometry(1.06, 0.055, 0.70), wood);
top.position.set(0, 0.73, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
// softened edge: a thin inset trim just under the top reads as an eased/beveled edge
const trim = new THREE.Mesh(new THREE.BoxGeometry(1.00, 0.03, 0.64), mattMat(0x3d2c14, 0.65));
trim.position.set(0, 0.695, 0); trim.castShadow = true; g.add(trim);
[[-0.45, -0.29], [0.45, -0.29], [-0.45, 0.29], [0.45, 0.29]].forEach(([lx, lz]) =>
buildTaperedLeg(g, lx, lz, wood, 0.70));
furnitureShadow(g, 0, 0, 1.30, 0.95, 0.6);
},
buildChair(g) {
// Soft, rounded upholstered chair — a curved barrel back + bolstered front, so its
// silhouette reads clearly SOFTER than the Contemporary set's crisp rectilinear chair.
// Upholstery is a DEEP SAGE-OLIVE (not cream/greige) so the chair — the dominant visual
// mass — reads clearly green at room distance and is never confused with Contemporary's
// crisp white seat. mattMat (env 0) + a deliberately dark, saturated base is REQUIRED:
// a pale linen washed to cream under the ~1.6x showroom light-lift (proven). This tone
// survives the lift and lands as a recognisable sage.
const wood = mattMat(0x4f3a1c, 0.6);
const greige = mattMat(0x4c5730, 0.95);
const c = new THREE.Group(); c.position.set(0, 0, 0.69);
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.50, 0.12, 0.48), greige);
seat.position.set(0, 0.45, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
// rounded front bolster cushion so the seat edge reads plump, not sharp
const bolster = new THREE.Mesh(new THREE.CylinderGeometry(0.075, 0.075, 0.50, 20), greige);
bolster.rotation.z = Math.PI / 2; bolster.position.set(0, 0.45, 0.23); bolster.castShadow = true; c.add(bolster);
// curved upholstered barrel back — a gently wrapping cylinder shell (soft, not a flat panel)
const back = new THREE.Mesh(
new THREE.CylinderGeometry(0.33, 0.33, 0.46, 24, 1, true, Math.PI * 0.76, Math.PI * 0.48), greige);
back.position.set(0, 0.70, -0.16); back.castShadow = true; c.add(back);
// fill the barrel-back shell so it reads solid/plush from behind too
const backCore = new THREE.Mesh(
new THREE.CylinderGeometry(0.30, 0.30, 0.44, 20, 1, true, Math.PI * 0.78, Math.PI * 0.44), greige);
backCore.position.set(0, 0.70, -0.16); c.add(backCore);
[[-0.20, -0.20], [0.20, -0.20], [-0.20, 0.20], [0.20, 0.20]].forEach(([lx, lz]) =>
buildTaperedLeg(c, lx, lz, wood, 0.45));
g.add(c);
furnitureShadow(g, 0, 0.69, 0.70, 0.70, 0.55);
},
buildExtras(g) {
// A fan of swatch cards + a compact potted plant — a working-consultation blend.
const cardTones = [0xd8c7a8, 0xc6d2c0, 0xcdbcae];
cardTones.forEach((cc, k) => {
const card = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.004, 0.22), fmat(cc, 0.85, 0.0));
card.position.set(-0.24 + k * 0.11, 0.76 + 0.004, 0.04); card.rotation.y = (k - 1) * 0.30;
card.castShadow = true; g.add(card);
});
const pot = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.055, 0.10, 18), fmat(0xcac3b6, 0.85, 0.0));
pot.position.set(0.30, 0.76 + 0.05, -0.02); pot.castShadow = true; g.add(pot);
const foliage = new THREE.Mesh(new THREE.IcosahedronGeometry(0.10, 0), fmat(0x6f7a5a, 0.92, 0.0));
foliage.position.set(0.30, 0.76 + 0.17, -0.02); foliage.castShadow = true; g.add(foliage);
}
},
// BRUTALIST — heavy blocky slab on a monolithic plinth; solid concrete-look mass.
brutalist: {
label: 'Brutalist',
buildTable(g) {
// RAW CONCRETE (béton brut): warm medium grey slab + a darker poured plinth for tonal
// mass, high roughness, zero metalness — reads as concrete, never white foam.
// Bases run dark because the showroom lighting lifts everything ~1.6x — these READ as
// a warm medium concrete grey (~#8a8a86) on camera, not white foam.
const slab = fmat(0x605f5b, 0.9, 0.0), mass = fmat(0x4e4d49, 0.92, 0.0);
const groove = fmat(0x353430, 0.95, 0.0);
const top = new THREE.Mesh(new THREE.BoxGeometry(1.18, 0.14, 0.78), slab);
top.position.set(0, 0.70, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
// one monolithic plinth base — a single solid block, not legs
const base = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.63, 0.52), mass);
base.position.set(0, 0.315, 0); base.castShadow = true; base.receiveShadow = true; g.add(base);
buildBoardFormLines(g, 0, 0.315, 0, 0.52, 0.63, 0.52, groove, 3);
furnitureShadow(g, 0, 0, 1.35, 1.0, 0.68);
},
buildChair(g) {
const seatMat = fmat(0x605f5b, 0.9, 0.0), backMat = fmat(0x4e4d49, 0.92, 0.0);
const groove = fmat(0x353430, 0.95, 0.0);
const c = new THREE.Group(); c.position.set(0, 0, 0.72);
// blocky monolithic seat plinth straight to the floor (no legs)
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.44, 0.52), seatMat);
seat.position.set(0, 0.22, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
buildBoardFormLines(c, 0, 0.22, 0, 0.52, 0.44, 0.52, groove, 3);
// a thick angular slab back
const back = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.50, 0.14), backMat);
back.position.set(0, 0.67, -0.19); back.castShadow = true; c.add(back);
g.add(c);
furnitureShadow(g, 0, 0.72, 0.70, 0.70, 0.62);
},
buildExtras(g) {
// A heavy concrete monolith object on the slab — a brutalist paperweight.
const monolith = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.30, 0.16), fmat(0x605f5b, 0.9, 0.0));
monolith.position.set(-0.24, 0.77 + 0.15, 0.04); monolith.rotation.y = 0.4; monolith.castShadow = true; g.add(monolith);
const cube = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.14), fmat(0x47463f, 0.92, 0.0));
cube.position.set(0.26, 0.77 + 0.07, -0.02); cube.rotation.y = 0.7; cube.castShadow = true; g.add(cube);
}
}
};
// Dispose a furniture group with full memory hygiene: remove from scene, then traverse
// disposing every geometry + material (handling material arrays) + any UNIQUE texture.
// Shared canvas textures are tagged __shared and left alone (killing them would break the
// board / contact-shadow sprites elsewhere).
const _FURN_TEX_SLOTS = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'bumpMap', 'displacementMap', 'alphaMap'];
function disposeFurniture(group) {
if (!group) return;
if (group.parent) group.parent.remove(group);
group.traverse(o => {
if (o.geometry) o.geometry.dispose();
const m = o.material;
if (!m) return;
(Array.isArray(m) ? m : [m]).forEach(mat => {
_FURN_TEX_SLOTS.forEach(k => { const t = mat[k]; if (t && !t.__shared) t.dispose(); });
mat.dispose();
});
});
}
// Shared layout constraints EVERY style obeys: ground the whole set on the floor (lowest
// point → y=0) and keep its footprint inside the 20'×20' room minus the wall margin. Built
// once here so no style can drift out of bounds or float / sink. Chair-to-table proximity
// is baked into each style's chair position; grounding + bounds are enforced here.
function applyLayout(group) {
group.updateWorldMatrix(true, true);
let box = new THREE.Box3().setFromObject(group);
if (isFinite(box.min.y)) group.position.y -= box.min.y; // rest lowest point on the floor
group.updateWorldMatrix(true, true);
box = new THREE.Box3().setFromObject(group);
const halfW = CONFIG.room.width / 2 - FURNITURE_MARGIN;
const halfD = CONFIG.room.depth / 2 - FURNITURE_MARGIN;
if (box.max.x > halfW) group.position.x -= (box.max.x - halfW);
if (box.min.x < -halfW) group.position.x += (-halfW - box.min.x);
if (box.max.z > halfD) group.position.z -= (box.max.z - halfD);
if (box.min.z < -halfD) group.position.z += (-halfD - box.min.z);
group.updateWorldMatrix(true, true);
}
// Swap the active furniture set to `styleKey`. Disposes the old group (memory hygiene),
// builds the new one from the registry, runs shared layout, and persists the choice.
function swapFurniture(styleKey, opts) {
opts = opts || {};
if (!FURNITURE_STYLES[styleKey]) styleKey = FURNITURE_STYLE_ORDER[0];
const includeExtras = !!opts.includeExtras;
const wasVisible = furnitureRoot ? furnitureRoot.visible : true;
disposeFurniture(furnitureRoot);
furnitureRoot = null;
const g = new THREE.Group();
g.name = 'furnitureRoot';
g.position.set(FURNITURE_ANCHOR.x, 0, FURNITURE_ANCHOR.z);
const style = FURNITURE_STYLES[styleKey];
style.buildTable(g);
style.buildChair(g);
if (includeExtras && style.buildExtras) style.buildExtras(g);
scene.add(g);
applyLayout(g);
furnitureRoot = g;
furnitureRoot.visible = wasVisible;
furnitureStyleKey = styleKey;
furnitureExtras = includeExtras;
window._qhNookGroup = g; // the "At the Table" view hides the set via this handle
try { localStorage.setItem(FURNITURE_LS_KEY, styleKey); } catch (e) {}
if (typeof requestShadowUpdate === 'function') requestShadowUpdate(6);
return g;
}
// ============================================================
// CHUNK K — FIRST-PERSON STEVE AVATAR BODY ("BE Steve")
// ------------------------------------------------------------
// The camera IS Steve's eyes (~1.6m at room centre). We render his BODY from the
// neck down — torso, shoulders, two arms with hands, legs, feet on the floor — but
// NOT a head/face (you're inside it; a head mesh would clip the near plane).
//
// PARENTING (critical, per spec): the body hangs off a YAW-ONLY rig
// (avatarRig.rotation.y = spinYaw, avatarRig.position = lockedSpinEye projected to
// the floor). So:
// • YAW (ArrowLeft/Right): the rig turns → the body turns WITH you (it's YOUR body).
// • PITCH (ArrowUp/Down): the rig does NOT tilt → look down and you see a stationary
// torso/hands/legs/feet below you (proving "BE Steve"); look up and they fall away.
// We deliberately do NOT rigid-parent to the camera (that would tilt the whole body
// when you pitch down — wrong). Position tracks lockedSpinEye so any future eye move
// carries the body; pitch is read ONLY by the camera, never by the rig.
//
// $0 — built entirely from THREE primitives (capsule-ish stacked cylinders + boxes),
// no GLTF, no model-gen. Tasteful, realistic proportions, simple casual Steve clothing
// (charcoal sweater + indigo jeans + dark shoes). Shoulders/torso sit LOW and to the
// sides so the forward wing-bank view stays clean; the body only fills the lower frame
// when you glance/pitch down.
// ============================================================
let avatarRig = null; // yaw-only group the whole body hangs from
let avatarWanted = false; // the first-person body renders ONLY in Walk-Through; every
// framed view (Hero/Angled/Orbit/…) hides it so the camera
// never looks straight through your own torso. Default OFF
// because boot view is Hero. viewmodes flips this per mode.
const AVATAR_EYE_Y = 1.62; // eye height (matches camera y) — body origin is the floor under it
function capsuleMesh(radius, length, mat, radialSegs) {
// THREE r-legacy has no CapsuleGeometry in the bundled build — compose a cylinder
// shaft + two hemisphere caps into a small group so limbs read soft & rounded, not
// like cut pipes. `length` = straight shaft length (caps add `radius` at each end).
const g = new THREE.Group();
const rs = radialSegs || 12;
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, rs, 1, true), mat);
g.add(shaft);
const capGeo = new THREE.SphereGeometry(radius, rs, Math.max(6, rs / 2));
const top = new THREE.Mesh(capGeo, mat); top.position.y = length / 2; g.add(top);
const bot = new THREE.Mesh(capGeo, mat); bot.position.y = -length / 2; g.add(bot);
g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
return g;
}
// ============================================================
// Chunk K′ — AGE-RIG: continuous age morph of the primitive body (12→85).
// Returns a set of multipliers/offsets the rig consumes so the SAME primitive
// build ages smoothly. The ANCHOR at age 28 returns the identity set
// (all scalars 1.0 / leanDeg 0 / baseline materials) so buildAvatar(28) — and
// the zero-arg call (default 28) — reproduce today's rig BYTE-FOR-BYTE.
// This is the regression guard: Age View OFF never reaches the non-28 branches.
//
// Anchors (AGE-VIEWS-SPEC §2b) are expressed RELATIVE to the current adult rig
// (eyeY 1.62, shoulderHalf 0.20) so 28 is exactly neutral. Linear interp between
// the nearest two anchors; clamped to [12,85].
// ============================================================
const AGE_ANCHORS = [
// age, eyeY, shoulder½, leanDeg, torsoScaleY, limbScale, headFrac, palette key
{ age: 12, eyeY: 1.40, shoulderHalf: 0.145, leanDeg: 0, torsoY: 0.86, limb: 0.84, neckUp: 0.030, pal: 'tween' },
{ age: 16, eyeY: 1.55, shoulderHalf: 0.175, leanDeg: 0, torsoY: 0.94, limb: 0.93, neckUp: 0.012, pal: 'teen' },
{ age: 21, eyeY: 1.60, shoulderHalf: 0.195, leanDeg: 0, torsoY: 0.99, limb: 0.98, neckUp: 0.004, pal: 'young' },
{ age: 28, eyeY: 1.62, shoulderHalf: 0.200, leanDeg: 0, torsoY: 1.00, limb: 1.00, neckUp: 0.000, pal: 'base' }, // IDENTITY anchor
{ age: 35, eyeY: 1.62, shoulderHalf: 0.205, leanDeg: 0, torsoY: 1.00, limb: 1.00, neckUp: 0.000, pal: 'merino' },
{ age: 45, eyeY: 1.61, shoulderHalf: 0.205, leanDeg: 1, torsoY: 1.00, limb: 1.00, neckUp: 0.000, pal: 'cashmere'},
{ age: 55, eyeY: 1.60, shoulderHalf: 0.200, leanDeg: 2, torsoY: 0.99, limb: 0.99, neckUp: 0.002, pal: 'quarter' },
{ age: 65, eyeY: 1.58, shoulderHalf: 0.195, leanDeg: 4, torsoY: 0.98, limb: 0.98, neckUp: 0.004, pal: 'cardigan'},
{ age: 75, eyeY: 1.54, shoulderHalf: 0.185, leanDeg: 8, torsoY: 0.96, limb: 0.96, neckUp: 0.010, pal: 'vest' },
{ age: 85, eyeY: 1.48, shoulderHalf: 0.175, leanDeg: 12, torsoY: 0.93, limb: 0.94, neckUp: 0.018, pal: 'oat' }
];
// Clothing palettes per anchor — material COLOURS only (swap, not new meshes).
// 'base' = today's exact rig colours (regression identity).
const AGE_PALETTES = {
base: { sweater: 0x3d424d, jeans: 0x2b3a4d, skin: 0xc99a78, shoe: 0x1b1b20, skinRough: 0.70 },
tween: { sweater: 0x1f8a7a, jeans: 0x4a6aa0, skin: 0xd6a883, shoe: 0xf0f0f0, skinRough: 0.62 }, // bright teal hoodie / lighter jeans / white sneaker
teen: { sweater: 0x2a2c33, jeans: 0x232a36, skin: 0xceA07a, shoe: 0x141418, skinRough: 0.66 }, // charcoal graphic crew / slim dark denim
young: { sweater: 0x5e6b4a, jeans: 0x35506e, skin: 0xc99a78, shoe: 0x3a2a1e, skinRough: 0.68 }, // olive casual knit / mid denim / low boot
merino: { sweater: 0x4a505c, jeans: 0x222a36, skin: 0xc59370, shoe: 0x241a12, skinRough: 0.72 }, // slate merino / dark trouser-denim / derby
cashmere: { sweater: 0xb09472, jeans: 0x33373f, skin: 0xc18e6c, shoe: 0x5a4632, skinRough: 0.74 }, // camel cashmere / charcoal wool trouser / suede loafer
quarter: { sweater: 0x7d7f86, jeans: 0x2e3138, skin: 0xbe8a68, shoe: 0x2a2622, skinRough: 0.76 }, // heather quarter-zip / tailored trouser
cardigan: { sweater: 0x2a3550, jeans: 0x3a3d44, skin: 0xb98662, shoe: 0x33302c, skinRough: 0.80 }, // navy soft cardigan / relaxed trouser / cooler skin
vest: { sweater: 0x8a8378, jeans: 0x4a463f, skin: 0xb38260, shoe: 0x3d3a35, skinRough: 0.84 }, // warm-grey knit vest / easy trouser / elder skin
oat: { sweater: 0xc9bca4, jeans: 0x55514a, skin: 0xae7e5d, shoe: 0x45413b, skinRough: 0.88 } // oat thick cardigan / soft trouser / cool matte elder skin
};
function lerpHexColor(a, b, t) {
const ar=(a>>16)&255, ag=(a>>8)&255, ab=a&255;
const br=(b>>16)&255, bg=(b>>8)&255, bb=b&255;
return ((Math.round(ar+(br-ar)*t)<<16) | (Math.round(ag+(bg-ag)*t)<<8) | Math.round(ab+(bb-ab)*t));
}
function ageRigParams(age) {
age = Math.max(12, Math.min(85, age));
// find bracketing anchors
let lo = AGE_ANCHORS[0], hi = AGE_ANCHORS[AGE_ANCHORS.length - 1];
for (let i = 0; i < AGE_ANCHORS.length - 1; i++) {
if (age >= AGE_ANCHORS[i].age && age <= AGE_ANCHORS[i + 1].age) { lo = AGE_ANCHORS[i]; hi = AGE_ANCHORS[i + 1]; break; }
}
const span = hi.age - lo.age;
const t = span === 0 ? 0 : (age - lo.age) / span;
const L = (k) => lo[k] + (hi[k] - lo[k]) * t;
const palLo = AGE_PALETTES[lo.pal], palHi = AGE_PALETTES[hi.pal];
return {
age,
eyeY: L('eyeY'),
shoulderHalf:L('shoulderHalf'),
leanRad: L('leanDeg') * Math.PI / 180,
torsoY: L('torsoY'),
limb: L('limb'),
neckUp: L('neckUp'),
colors: {
sweater: lerpHexColor(palLo.sweater, palHi.sweater, t),
jeans: lerpHexColor(palLo.jeans, palHi.jeans, t),
skin: lerpHexColor(palLo.skin, palHi.skin, t),
shoe: lerpHexColor(palLo.shoe, palHi.shoe, t),
skinRough: palLo.skinRough + (palHi.skinRough - palLo.skinRough) * t
}
};
}
let avatarAge = 28; // current avatar age; 28 == today's rig
let avatarUpperRig = null; // named sub-group (torso+neck+shoulders+arms) — the stoop pivot
function buildAvatar(age) {
// Clean rebuild safety (rebuilds shouldn't stack bodies)
if (avatarRig) { scene.remove(avatarRig); avatarRig = null; }
// Zero-arg (or 28) == today's rig byte-for-byte. Age View passes an explicit age.
avatarAge = (typeof age === 'number') ? Math.max(12, Math.min(85, age)) : 28;
const P = ageRigParams(avatarAge);
const isBaseline = (avatarAge === 28); // exact-identity fast path / safety
const rig = new THREE.Group();
rig.name = 'avatarRig';
// The rig's ORIGIN is the floor directly under the eye; the body is built in rig-local
// coordinates with y=0 at the floor and the neck stub ending just under the eye, so
// looking straight down the line of sight lands on the chest/hands, not a clipped neck.
if (lockedSpinEye) rig.position.set(lockedSpinEye.x, 0, lockedSpinEye.z);
else rig.position.set(CONFIG.camera.startPos.x, 0, CONFIG.camera.startPos.z);
// ---- Materials: simple, high-end, NOT cartoon ----
// Colours come from the age palette; at age 28 (base) these are the exact original
// hex values, so the baseline rig is byte-identical. Roughness/metalness unchanged
// except skin roughness, which the palette nudges older (cooler/mattier) past 65.
const sweater = new THREE.MeshStandardMaterial({ color: P.colors.sweater, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.2 }); // charcoal knit (one shade lighter so torso/arms read against the dark upper scene on look-down — /contrarian)
const jeans = new THREE.MeshStandardMaterial({ color: P.colors.jeans, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.15 }); // indigo denim
const skin = new THREE.MeshStandardMaterial({ color: P.colors.skin, roughness: P.colors.skinRough, metalness: 0.0, envMapIntensity: 0.2 }); // hands
const shoe = new THREE.MeshStandardMaterial({ color: P.colors.shoe, roughness: 0.55, metalness: 0.05, envMapIntensity: 0.3 }); // dark leather
// Proportions (metres), tuned so the eye sits ~1.62m and feet at 0. At age 28 the
// P.* multipliers are all identity (1.0 / 0), so these collapse to the original
// constants. Younger → shorter torso + limbs + lower hip; older → slight shrink.
const hipY = 0.95 * (isBaseline ? 1 : (0.55 + 0.45 * P.eyeY / 1.62)); // floor→waist scales gently with stature
const chestTopY = isBaseline ? 1.42 : (hipY + 0.47 * P.torsoY); // top of torso (shoulder line)
const torsoH = chestTopY - hipY; // 0.47 at baseline
const shoulderHalf = P.shoulderHalf; // half shoulder width (arms hang outside this)
// BODY_BACK: shift the core mass (torso/neck/shoulders/hips/legs) BEHIND the eye in
// rig-local +z (Steve faces -z, so +z is behind him). In a real first-person view the
// eye sits at the FRONT of the head, ahead of the chest — without this the camera would
// be buried inside the torso, clipping it across the whole lower frame. With the chest
// pushed back ~0.16m the camera looks out from just in front of the sternum: looking
// straight ahead the torso is below + slightly behind the sight line (clean forward
// view); pitch down and the chest/lap fill the lower frame as expected. Arms/hands are
// NOT shifted back — they reach forward into the low-front field so a glance down sees them.
const BODY_BACK = 0.16;
// ---- upperRig — a NAMED sub-group holding torso + neck + shoulders + arms so the
// kyphotic forward-lean (stoop) at 75/85 is ONE coherent rotation about the hip line,
// not a per-mesh hack. At age 28 upperRig sits at origin with rotation 0, so the rig
// is byte-identical to today's (upperRig is a transparent passthrough group). The
// pivot is the waist (hipY): we translate the group's pivot there, rotate, translate
// back, so the upper body hinges forward from the hips like a real stoop. ----
const upperRig = new THREE.Group();
upperRig.name = 'upperRig';
rig.add(upperRig);
// ---- TORSO (charcoal sweater) — a soft tapered capsule, slightly narrower at the
// waist. Sits LOW/centre so it doesn't crowd the forward view; you mainly see the
// chest when you pitch down. ----
const torso = capsuleMesh(0.165, torsoH - 0.10, sweater, 16);
torso.position.set(0, hipY + torsoH / 2, BODY_BACK);
torso.scale.set(1.15, 1.0, 0.78); // broaden across, flatten front-to-back (human chest)
upperRig.add(torso);
// Short neck stub (sweater collar) — ends just below the eye so there's no clipped
// head; the camera looks out from just above + ahead of it. neckUp lifts the collar
// a touch on kids/elders to keep the head-to-body fraction reading right.
const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.075, 0.12, 12), sweater);
neck.position.set(0, chestTopY + 0.04 + P.neckUp, BODY_BACK);
neck.castShadow = true; upperRig.add(neck);
// ---- SHOULDERS — round caps so the sweater reads as a real shoulder line, set wide
// and low (peripheral), not blocking the centre forward view. ----
[-1, 1].forEach(s => {
const sh = new THREE.Mesh(new THREE.SphereGeometry(0.11, 14, 12), sweater);
sh.position.set(s * shoulderHalf, chestTopY - 0.04, BODY_BACK);
sh.scale.set(1.1, 0.85, 0.9);
sh.castShadow = true; upperRig.add(sh);
});
// ---- ARMS — upper arm + forearm + hand. FORWARD is -z (Steve faces -z), so the arms
// bend forward by REDUCING z. They rest with the hands clasped low-front at ~waist height,
// sitting in the LOWER-FRONT field: below the sight line when you look straight ahead (so
// the forward wing-bank view stays clean), but clearly in frame the moment you glance/pitch
// down. Raised to ~waist height (not dangling at the knees) so a natural 40-50° look-down
// catches the hands + sleeves, like resting your hands in front of you.
[-1, 1].forEach(s => {
const arm = new THREE.Group();
arm.position.set(s * shoulderHalf, chestTopY - 0.06, BODY_BACK);
if (!isBaseline) arm.scale.setScalar(P.limb); // shorter limbs on kids, slight on elders
// Upper arm — drops from the shoulder, angled out + slightly forward (toward -z).
const upper = capsuleMesh(0.062, 0.24, sweater, 12);
upper.position.set(s * 0.05, -0.16, -0.03);
upper.rotation.z = s * 0.22; // splay slightly outward
upper.rotation.x = 0.22; // tilt the top back so the elbow comes FORWARD (-z)
arm.add(upper);
// Forearm (sweater sleeve to wrist), bringing the hands forward + slightly inward so
// they meet low-front (relaxed resting clasp).
const fore = capsuleMesh(0.052, 0.22, sweater, 12);
fore.position.set(s * 0.085, -0.36, -0.20);
fore.rotation.z = -s * 0.22; // bring wrists inward toward centre
fore.rotation.x = 1.05; // forearm swings strongly forward + down to the lap-front
arm.add(fore);
// Hand — soft skin block at the end of the forearm, low-front + centred (clasped).
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.058, 12, 10), skin);
hand.scale.set(1.15, 0.72, 1.3);
hand.position.set(s * 0.05, -0.46, -0.40);
hand.castShadow = true;
arm.add(hand);
arm.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
upperRig.add(arm);
});
// ---- LEGS — two indigo-denim capsules from the hip to the ankle, set at hip width,
// standing straight. Feet (dark shoes) flat on the floor, toes FORWARD (-z). ----
const legLen = hipY - 0.10; // hip down to ankle
[-1, 1].forEach(s => {
const leg = capsuleMesh(0.085, legLen - 0.17, jeans, 12);
// Legs descend from the (back-shifted) hips, angling slightly forward (toward -z) to the
// feet so the stance reads natural and the feet land in the low-front field on look-down.
leg.position.set(s * 0.105, (hipY - 0.05) / 2 + 0.05, BODY_BACK - 0.06);
leg.scale.set(1.0, 1.0, 0.95);
leg.rotation.x = -0.06; // slight forward lean toward the feet (-z)
rig.add(leg);
// Foot — a low rounded box shoe, toe pointing forward (-z, the way Steve faces).
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.075, 0.27), shoe);
foot.position.set(s * 0.105, 0.038, BODY_BACK - 0.22);
foot.castShadow = true; foot.receiveShadow = true;
rig.add(foot);
});
// Hip / waistband joining the torso to the legs (denim).
const hips = new THREE.Mesh(new THREE.CylinderGeometry(0.155, 0.135, 0.18, 16), jeans);
hips.position.set(0, hipY - 0.02, BODY_BACK);
hips.scale.set(1.1, 1.0, 0.8);
hips.castShadow = true; rig.add(hips);
// ---- KYPHOTIC STOOP — hinge the whole upperRig forward (-z) about the waist line.
// At age 28 leanRad == 0 so upperRig stays at identity (byte-identical baseline).
// Pivot at the hip: rotate about x at y=hipY,z=BODY_BACK by lifting the group's
// pivot there. Because every upper-body mesh is built in rig-local coords, we set the
// group's rotation about its own origin then compensate position so the hinge sits at
// the waist rather than the floor. Forward stoop = negative x-rotation (top tips -z).
if (!isBaseline && P.leanRad > 0) {
upperRig.position.set(0, hipY, BODY_BACK);
upperRig.rotation.x = -P.leanRad;
// children were built in absolute rig-local coords; shift them back by the pivot
// so the rotation hinges at the waist instead of double-translating.
upperRig.children.forEach(c => { c.position.y -= hipY; c.position.z -= BODY_BACK; });
}
// Soft contact shadow under the feet so the figure reads grounded on the floor.
addContactShadow(rig.position.x, rig.position.z, 0.55, 0.42, 0.7);
rig.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
scene.add(rig);
avatarRig = rig;
avatarRig.visible = avatarWanted; // honour the current view's visibility across age rebuilds
avatarUpperRig = upperRig;
updateAvatarRig(); // seat yaw + position immediately
requestShadowUpdate(2);
}
// Per-frame: keep the body's rig at the eye's floor-projected position and yawed to
// spinYaw — POSITION + YAW ONLY, never pitch. (Pitch lives on the camera alone; the
// body must stay upright so looking down reveals a stationary torso/hands/feet.)
function updateAvatarRig() {
if (!avatarRig) return;
const eye = lockedSpinEye || camera.position;
avatarRig.position.x = eye.x;
avatarRig.position.z = eye.z;
avatarRig.rotation.y = spinYaw; // yaw with the look; ignore spinPitch entirely
}
// ============================================================
// PHASE 3 — SEATED THIRD-PERSON AVATAR ("At the Table" view)
// ------------------------------------------------------------
// The first-person rig above (buildAvatar) is HEADLESS-forward — you look OUT of it.
// The table view is THIRD-PERSON (camera over the seated figure's shoulder toward the
// boards), so it needs a SEATED figure WITH a visible head/face. This reuses the same
// primitive vocabulary (capsuleMesh limbs, charcoal sweater + indigo jeans + skin +
// shoe materials) but hinges the hips + knees ~90° so the figure sits on a chair,
// torso upright, hands resting near the table edge, head/face looking at the boards.
//
// Gated to the 'table' view mode only: seatedAvatarWanted (separate from avatarWanted,
// which gates the first-person body). setTableSeatVisible() flips the whole set (figure +
// furniture); buildTableSeat() lazy-builds it the first time the table mode is entered.
// ============================================================
let seatedAvatarRig = null; // the whole seated figure group
let seatedAvatarWanted = false; // visible only in the 'table' mode
function buildSeatedAvatar(sx, sz, faceYaw) {
// Clean rebuild safety.
if (seatedAvatarRig) { scene.remove(seatedAvatarRig); seatedAvatarRig = null; }
const P = ageRigParams(28); // a clean adult figure (identity anchor)
const rig = new THREE.Group();
rig.name = 'seatedAvatarRig';
// Origin = the point on the floor directly under the seated hips. faceYaw aims the
// figure toward the boards (typically -z ≈ Math.PI when seated in front of them).
rig.position.set(sx, 0, sz);
rig.rotation.y = (typeof faceYaw === 'number') ? faceYaw : Math.PI;
// Materials — same look as the first-person rig (charcoal knit / indigo denim / skin / leather).
const sweater = new THREE.MeshStandardMaterial({ color: P.colors.sweater, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.2 });
const jeans = new THREE.MeshStandardMaterial({ color: P.colors.jeans, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.15 });
const skin = new THREE.MeshStandardMaterial({ color: P.colors.skin, roughness: P.colors.skinRough, metalness: 0.0, envMapIntensity: 0.2 });
const shoe = new THREE.MeshStandardMaterial({ color: P.colors.shoe, roughness: 0.55, metalness: 0.05, envMapIntensity: 0.3 });
const hair = new THREE.MeshStandardMaterial({ color: 0x2a231c, roughness: 0.85, metalness: 0.0 });
// A chair seat sits ~0.44m off the floor (matches placeChair's seat height); the
// seated figure's hips rest just above it. Local +z is FORWARD (toward the boards
// once yawed), so the thighs extend +z and the shins drop down from the knees.
const SEAT_Y = 0.46; // hip pivot height (on the chair cushion)
const THIGH_L = 0.42; // hip → knee (runs forward, roughly level)
const SHIN_L = 0.44; // knee → ankle (drops to the floor)
const shoulderHalf = P.shoulderHalf;
// ---- PELVIS / HIPS (denim) ----
const hips = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.15, 0.20, 16), jeans);
hips.position.set(0, SEAT_Y, 0);
hips.scale.set(1.15, 1.0, 0.9);
hips.castShadow = true; rig.add(hips);
// ---- TORSO (charcoal sweater) — upright from the hips to the shoulder line ----
const torsoH = 0.50;
const chestTopY = SEAT_Y + 0.10 + torsoH; // shoulder line
const torso = capsuleMesh(0.17, torsoH - 0.10, sweater, 16);
torso.position.set(0, SEAT_Y + 0.12 + torsoH / 2, -0.02);
torso.scale.set(1.15, 1.0, 0.80); // broad across, flat front-to-back
torso.castShadow = true; rig.add(torso);
// ---- NECK + HEAD (this figure HAS a face — it's third-person) ----
const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.065, 0.08, 12), skin);
neck.position.set(0, chestTopY + 0.02, -0.01);
neck.castShadow = true; rig.add(neck);
const headG = new THREE.Group();
headG.position.set(0, chestTopY + 0.16, 0.0);
// Head — a soft ovoid, slightly forward-tilted looking at the boards.
const head = new THREE.Mesh(new THREE.SphereGeometry(0.105, 20, 18), skin);
head.scale.set(0.92, 1.06, 0.98);
head.castShadow = true; headG.add(head);
// Hair cap — hemisphere over the crown + back.
const hairCap = new THREE.Mesh(new THREE.SphereGeometry(0.112, 18, 14, 0, Math.PI * 2, 0, Math.PI * 0.62), hair);
hairCap.position.set(0, 0.012, -0.006);
hairCap.scale.set(0.95, 1.05, 1.0);
hairCap.castShadow = true; headG.add(hairCap);
// Nose nub — a tiny cue of a face, pointing FORWARD (+z, toward the boards).
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 8), skin);
nose.position.set(0, -0.01, 0.10);
headG.add(nose);
headG.rotation.x = 0.06; // slight downward gaze toward the table/boards
rig.add(headG);
// ---- SHOULDERS ----
[-1, 1].forEach(s => {
const sh = new THREE.Mesh(new THREE.SphereGeometry(0.10, 14, 12), sweater);
sh.position.set(s * shoulderHalf, chestTopY - 0.02, -0.02);
sh.scale.set(1.1, 0.85, 0.9);
sh.castShadow = true; rig.add(sh);
});
// ---- ARMS — upper arm drops from the shoulder, forearm swings FORWARD (+z) so the
// hands rest near the table edge (a seated consultation pose). ----
[-1, 1].forEach(s => {
const arm = new THREE.Group();
arm.position.set(s * (shoulderHalf + 0.02), chestTopY - 0.05, -0.02);
const upper = capsuleMesh(0.06, 0.22, sweater, 12);
upper.position.set(s * 0.02, -0.14, 0.02);
upper.rotation.z = s * 0.14;
upper.rotation.x = 0.32; // elbow forward + down
arm.add(upper);
const fore = capsuleMesh(0.05, 0.22, sweater, 12);
fore.position.set(s * 0.03, -0.30, 0.20);
fore.rotation.z = -s * 0.10;
fore.rotation.x = 1.15; // forearm runs forward toward the table
arm.add(fore);
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.052, 12, 10), skin);
hand.scale.set(1.1, 0.7, 1.25);
hand.position.set(s * 0.02, -0.35, 0.36); // resting near the table edge
hand.castShadow = true; arm.add(hand);
arm.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
rig.add(arm);
});
// ---- LEGS — SEATED: thigh runs forward (+z) roughly level from the hip, shin drops
// straight down from the knee to a foot flat on the floor. ~90° at hip and knee. ----
[-1, 1].forEach(s => {
const kneeX = s * 0.12, kneeZ = THIGH_L; // knee is forward of the hips
// Thigh: from the hip forward to the knee (nearly horizontal).
const thigh = capsuleMesh(0.085, THIGH_L - 0.14, jeans, 12);
thigh.position.set(kneeX * 0.5, SEAT_Y - 0.01, THIGH_L / 2);
thigh.rotation.x = Math.PI / 2 - 0.12; // ~horizontal, tipped slightly down toward the knee
thigh.castShadow = true; rig.add(thigh);
// Shin: from the knee down to the ankle.
const shin = capsuleMesh(0.075, SHIN_L - 0.14, jeans, 12);
shin.position.set(kneeX, SEAT_Y - SHIN_L / 2 + 0.02, kneeZ - 0.02);
shin.rotation.x = 0.06;
shin.castShadow = true; rig.add(shin);
// Foot — flat on the floor, toe forward (+z).
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.07, 0.26), shoe);
foot.position.set(kneeX, 0.04, kneeZ + 0.06);
foot.castShadow = true; foot.receiveShadow = true; rig.add(foot);
});
rig.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
// Soft contact shadow under the seated figure.
addContactShadow(sx, sz + 0.2, 0.9, 0.9, 0.55);
scene.add(rig);
seatedAvatarRig = rig;
rig.visible = seatedAvatarWanted;
requestShadowUpdate(2);
return rig;
}
// (Removed: buildConsultationNook — the always-on round-table + two-bouclé-chairs nook.
// Replaced by the swappable FURNITURE_STYLES registry above; window._qhNookGroup is now
// the active furniture root so the "At the Table" view still hides the side set.)
// ============================================================
// PHASE 3 — "AT THE TABLE" consultation set. A dedicated round table + a single chair
// with the SEATED avatar on it, positioned CENTRE-FRONT of the board wall (distinct
// from the always-on right-side nook). Lazy-built the first time the table view is
// entered, then just toggled visible/hidden. The seated figure faces the boards (-z),
// so the camera parks behind its shoulder looking past the head at the wall of designs.
// ============================================================
let tableSeatGroup = null; // the table-view furniture (table + chair) — toggled with the view
function buildTableSeat() {
if (tableSeatGroup) return tableSeatGroup; // build once
const backZ = -CONFIG.room.depth / 2 + 0.2; // board-wall plane
// Place the table a comfortable distance IN FRONT of the boards, centred on x.
const tx = 0, tz = backZ + 1.9; // ~1.9m off the wall
const g = new THREE.Group();
g.name = 'tableSeatGroup';
const woodTop = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: 0.42, metalness: 0.0, envMapIntensity: 0.3 });
const woodDark = new THREE.MeshStandardMaterial({ color: 0x6e573a, roughness: 0.5, metalness: 0.0 });
const boucle = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.2 });
const TABLE_R = 0.58, TABLE_H = 0.74;
const top = new THREE.Mesh(new THREE.CylinderGeometry(TABLE_R, TABLE_R, 0.05, 44), woodTop);
top.position.set(tx, TABLE_H, tz); top.castShadow = true; top.receiveShadow = true; g.add(top);
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.10, TABLE_H - 0.05, 16), woodDark);
col.position.set(tx, (TABLE_H - 0.05) / 2, tz); col.castShadow = true; g.add(col);
const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.36, 0.05, 24), woodDark);
foot.position.set(tx, 0.03, tz); foot.castShadow = true; foot.receiveShadow = true; g.add(foot);
// A single bouclé chair on the near (camera) side of the table, facing the boards (-z).
const chZ = tz + 0.92;
const chair = new THREE.Group(); chair.position.set(tx, 0, chZ); chair.rotation.y = 0; // faces -z (toward boards)
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.16, 0.52), boucle);
seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; chair.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.52, 0.14), boucle);
back.position.set(0, 0.72, 0.21); back.castShadow = true; chair.add(back); // backrest behind the sitter (+z, toward camera)
[-0.27, 0.27].forEach(ax => {
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.48), boucle);
arm.position.set(ax, 0.56, 0.0); arm.castShadow = true; chair.add(arm);
});
[[-0.21,0.21],[0.21,0.21],[-0.21,-0.21],[0.21,-0.21]].forEach(([lx,lz]) => {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.015, 0.36, 8), woodDark);
leg.position.set(lx, 0.18, lz); leg.castShadow = true; chair.add(leg);
});
g.add(chair);
scene.add(g);
addContactShadow(tx, tz, 1.0, 1.0, 0.6);
addContactShadow(tx, chZ, 0.7, 0.7, 0.55);
tableSeatGroup = g;
// Seat the avatar on the chair, facing the boards (-z). The chair seat is at ~0.44m;
// buildSeatedAvatar hips sit at ~0.46m, so the figure rests naturally on the cushion.
buildSeatedAvatar(tx, chZ, Math.PI); // faceYaw = π → local +z (forward/hands/thighs) points to -z world (the boards)
g.visible = false; // hidden until the table view turns it on
requestShadowUpdate(4);
return g;
}
// Show/hide the whole "At the Table" set (furniture + seated figure) together.
function setTableSeatVisible(v) {
seatedAvatarWanted = !!v;
if (v) buildTableSeat();
if (tableSeatGroup) tableSeatGroup.visible = !!v;
if (seatedAvatarRig) seatedAvatarRig.visible = !!v;
if (typeof requestShadowUpdate === 'function') requestShadowUpdate(6);
}
// Where the camera should park to frame the seated figure over-the-shoulder toward the
// boards. Returns {pos, target} in world space, derived from the built table/chair.
function tableViewCamera() {
const backZ = -CONFIG.room.depth / 2 + 0.2;
const tz = backZ + 1.9; // table centre z (mirror of buildTableSeat)
const chZ = tz + 0.92; // chair/sitter z
const boardCY = CONFIG.wing.height / 2 + 0.04;
// Behind + above the seated figure's head, looking past it at the board wall.
const pos = new THREE.Vector3(0.55, 1.85, chZ + 1.15);
const target = new THREE.Vector3(0.0, boardCY, backZ);
return { pos, target };
}
// A single olive/fig in a square honed-stone planter — soft layered canopy (not a
// hard sphere), slim trunk, restrained. Built from a handful of low-poly meshes.
function buildOliveTree(px, py, pz) {
const g = new THREE.Group();
g.position.set(px, py, pz);
// Square honed-stone planter (warm stone, matte)
const stoneMat = new THREE.MeshStandardMaterial({ color: 0xcfc7b8, roughness: 0.85, metalness: 0.0, envMapIntensity: 0.3 });
const planter = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.34, 0.34), stoneMat);
planter.position.set(0, 0.17, 0); planter.castShadow = true; planter.receiveShadow = true; g.add(planter);
// Soil cap
const soil = new THREE.Mesh(new THREE.BoxGeometry(0.30, 0.02, 0.30), new THREE.MeshStandardMaterial({ color: 0x2e261c, roughness: 1.0, metalness: 0.0 }));
soil.position.set(0, 0.345, 0); g.add(soil);
// Slim trunk
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6e5c45, roughness: 0.9, metalness: 0.0 });
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.03, 0.5, 7), trunkMat);
trunk.position.set(0, 0.6, 0); trunk.castShadow = true; g.add(trunk);
// Soft olive canopy — several low-poly tufts at varied scale = a layered, organic
// silhouette rather than one cartoon ball. Muted grey-green (olive foliage).
const leafMat = new THREE.MeshStandardMaterial({ color: 0x6f7a5a, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.25 });
const tufts = [
[0.00, 0.92, 0.00, 0.20],
[0.11, 0.99, 0.04, 0.13],
[-0.10, 0.97, -0.05, 0.12],
[0.04, 1.08, -0.06, 0.11],
[-0.05, 1.05, 0.08, 0.10]
];
tufts.forEach(([tx, ty, tz, r]) => {
const tuft = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 0), leafMat);
tuft.position.set(tx, ty, tz);
tuft.rotation.set(Math.random(), Math.random(), Math.random());
tuft.castShadow = true; g.add(tuft);
});
scene.add(g);
}
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/vendors');
vendors = data.vendors || [];
} catch (e) { console.warn('Vendors load error:', e.message); vendors = []; }
await fetchWindow(0);
updateLoadStatus('Building wing wall...', 80);
buildWingWall();
updateWindowLabel();
// CENTER BOOK default (Steve 2026-06-29): hide the 50-board arc and present ONE open
// mirrored sample book centred on the spawn's forward axis. The avatar spawns dead-centre,
// stationary, facing it — NO auto-fly. [ ] / N-P page the catalog.
if (BOOK_MODE) {
// Steve 2026-07-02: KEEP the straight back-wall wing bank visible on load — all the
// other wings hang as ~2" colour slivers across the back wall — AND keep the centred
// open book in front (the "open boards on both sides" he likes). Was hidden before.
if (currentWallGroup) currentWallGroup.visible = true;
buildCenterBook();
setCenterBook(Math.floor((products.slice(0, windowSize).length) / 2));
document.body.classList.add('book-mode');
}
updateLoadStatus('Ready!', 100);
setTimeout(() => {
document.getElementById('loading-screen').classList.add('fade-out');
// GUIDED MODE default (DTD verdict A): unless the user has switched to Explore,
// fly to the middle board open so the big Previous/Next/Auto-Tour buttons drive
// everything from the first second — no keyboard, no walking.
setTimeout(() => { if (!exploreMode) enterGuidedDefault(); }, 700);
// RE-ASSERT the Slice-1 resting default AFTER the view-mode boot fully settles. The
// view-mode engine (viewmodes.js boot) fires setMode('hero') at load + a delayed
// savedMode restore at +1200ms, both of which focusOnWing the middle board (and now
// clad + show the panel via Slice-2). Without this re-assert the boot can land
// mid-focus → a stray selection/panel/clad. This makes "resting middle, no focus, no
// panel, neutral walls" the authoritative final boot state every time (no race).
setTimeout(() => { if (!_pendingBootClick && !exploreMode && !peruseActive) enterGuidedDefault(); }, 1500);
// BOOT RACE GUARD — fire the queued boot click LAST. The version engine (versions.js)
// owns the final boot scene: it applies V1 (→ enterFixedCentre) at ~+1800ms, which would
// wipe an earlier-replayed selection. So replay the user's queued click at +2600ms, AFTER
// the V1 build settles, making THEIR click the authoritative final action — board
// conveyors to front, walls clad, detail shown. No dead click; the click wins, durably.
setTimeout(() => { if (_pendingBootClick && !exploreMode && !peruseActive) drainBootClick(); }, 2600);
setTimeout(() => {
const tip = document.getElementById('welcome-tip');
if (tip) tip.classList.add('fade-out');
}, 9000);
}, 400);
populateVendorSidebar();
startTVSlideshow();
}
// Fetch one 50-wing window of the catalog (sorted/brand-filtered server-side).
async function fetchWindow(offset) {
const url = `/api/showroom/products?offset=${offset}&limit=${windowSize}&sort=${encodeURIComponent(currentSort)}&brand=${encodeURIComponent(currentBrand)}`;
try {
const data = await xhrGet(url);
products = data.products || [];
windowTotal = data.total || products.length;
windowOffset = data.offset != null ? data.offset : offset;
} catch (e) { console.warn('Products load error:', e.message); products = []; }
}
// Tear down the current wall group (geometry/material/texture) before a rebuild.
// Guard the SHARED resources — pool materials/maps, the shared contact/light/frame
// textures + MAT.* — so a rebuild doesn't dispose them out from under the next build
// (which would force a full GPU re-upload and stall the rebuild frame).
function isSharedMaterial(m) {
if (!m) return true;
for (const k in MAT) { if (MAT[k] === m) return true; }
for (let i = 0; i < TEXTURE_POOL.length; i++) { if (TEXTURE_POOL[i].material === m) return true; }
return false;
}
function disposeGroup(group) {
if (!group) return;
const sharedGeo = _boardGeo ? [_boardGeo.face, _boardGeo.frameH, _boardGeo.frameV, _boardGeo.contact, _placardGeo, _overlayGeo]
: [_placardGeo, _overlayGeo];
group.traverse(obj => {
if (obj.geometry && sharedGeo.indexOf(obj.geometry) === -1) obj.geometry.dispose(); // keep shared geos
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => {
if (isSharedMaterial(m)) return; // never dispose shared pool/MAT materials
if (m.map && m.map.dispose && !m.map.__shared) m.map.dispose();
if (m.dispose) m.dispose();
});
}
});
scene.remove(group);
}
// Rebuild the wing wall against the current window (after paging/sort/brand change).
function rebuildWingWall() {
if (focusedWing) unfocusWing(true);
if (fanWings.length) closeFanCascade(true);
animatingWings.length = 0;
disposeGroup(currentWallGroup);
currentWallGroup = null;
wingBoards = [];
wingRacks = [];
buildWingWall();
// Keep external/diagnostic handles pointing at the live arrays after a rebuild
window._wingBoards = wingBoards;
window._wingRacks = wingRacks;
// Refresh sidebar to reflect the new window
const list = document.getElementById('vendor-list');
if (list) list.innerHTML = '';
populateVendorSidebar();
updateWindowLabel();
}
// Page the window forward/back; wraps for the endless peruse loop. Returns true if it actually paged.
async function advanceWindow(dir) {
if (windowTotal <= windowSize) return false;
let next = windowOffset + dir * windowSize;
if (next >= windowTotal) next = 0; // wrap to start (endless)
if (next < 0) next = Math.max(0, Math.floor((windowTotal - 1) / windowSize) * windowSize);
if (next === windowOffset) return false;
await fetchWindow(next);
rebuildWingWall();
if (BOOK_MODE) { setCenterBook(0); updateWindowLabel(); } // refresh the centered book to the new window's first design
return true;
}
function updateWindowLabel() {
const el = document.getElementById('window-label');
if (!el) return;
const start = windowTotal === 0 ? 0 : windowOffset + 1;
const end = Math.min(windowOffset + products.length, windowTotal);
el.textContent = `${start}–${end} of ${windowTotal}`;
const prev = document.getElementById('btn-prev-window');
const next = document.getElementById('btn-next-window');
if (prev) prev.disabled = windowTotal <= windowSize;
if (next) next.disabled = windowTotal <= windowSize;
}
function buildWingWall() {
const W = CONFIG.room.width, D = CONFIG.room.depth;
const WC = CONFIG.wing;
// Server returns the window already sorted/brand-filtered — keep its order.
let allProds = products.slice(0, windowSize);
// Fallback only if the data layer is entirely unavailable: synthetic pool wings
if (allProds.length === 0) {
for (let i = 0; i < WC.totalCount; i++) {
const poolIdx = i % TEXTURE_POOL.length;
allProds.push({
pattern_name: TEXTURE_POOL[poolIdx].pattern, color_name: TEXTURE_POOL[poolIdx].color,
sku: `QH-SYN-${1000 + i}`, vendor: 'Showroom Collection', width: 27,
material: 'Wallcovering', _poolIdx: poolIdx
});
}
}
const totalWings = allProds.length;
// Brand sections from consecutive runs (China Seas / Grasscloth)
const vendorSections = [];
allProds.forEach((p, i) => {
const v = p.vendor || 'Quadrille House';
const last = vendorSections[vendorSections.length - 1];
if (last && last.vendor === v) last.endIdx = i;
else vendorSections.push({ vendor: v, startIdx: i, endIdx: i });
});
// ============================================================
// PJ WINGBOARD ARC (Image #6) — boards hang on a CURVED brass rail wrapping the
// room corner/bay. Each board is a SINGLE full-width face hinged on its LEFT
// vertical edge; QHRack places the hinge on the arc + stores the closed-rake +
// open-flat yaw targets. The whole rack lives in WORLD space (the arc centre is
// a room coordinate), so wallGroup stays at the origin.
// ============================================================
const wallGroup = new THREE.Group();
wallGroup.position.set(0, 0, 0);
const wingW = WC.boardWidthM; // full 30" face when open
const H = WC.height;
const yC = H / 2 + 0.04; // board vertical centre (placard clears the floor)
// ---- Curved brass rail following the arc (segmented thin boxes ≈ a torus arc) ----
const railY = H + 0.10;
const railSegs = Math.max(28, totalWings * 2);
const railA0 = QHRack.arcAngleFor(0, totalWings, WC);
const railA1 = QHRack.arcAngleFor(totalWings - 1, totalWings, WC);
for (let s = 0; s < railSegs; s++) {
const a0 = railA0 + (s / railSegs) * (railA1 - railA0);
const a1 = railA0 + ((s + 1) / railSegs) * (railA1 - railA0);
const p0 = QHRack.railPointFor(a0, WC), p1 = QHRack.railPointFor(a1, WC);
const segLen = Math.hypot(p1.x - p0.x, p1.z - p0.z) + 0.004;
const seg = new THREE.Mesh(new THREE.BoxGeometry(segLen, 0.02, 0.02), MAT.chrome);
seg.position.set((p0.x + p1.x) / 2, railY, (p0.z + p1.z) / 2);
seg.rotation.y = -Math.atan2(p1.z - p0.z, p1.x - p0.x);
seg.castShadow = true; seg.receiveShadow = true;
wallGroup.add(seg);
}
// Build each wing — SINGLE-HINGE board on the arc
for (let i = 0; i < totalWings; i++) {
const product = allProds[i];
// The pivot IS the hinge (left vertical edge). QHRack.placeBoard sets its arc
// position + closed/open yaw; the board MESH is offset +wingW/2 in local X so the
// hinge sits at the board's left edge.
const pivot = new THREE.Group();
const poolIdx = product._poolIdx !== undefined ? product._poolIdx : (i % TEXTURE_POOL.length);
const poolMat = TEXTURE_POOL[poolIdx].material;
const THICK = 0.014; // physical board thickness → real edge + cast shadow
const faceOffX = wingW / 2; // board centre sits +halfWidth from the hinge
const BG = boardGeo(wingW, H, THICK); // SHARED geometry singletons (per fixed board size)
// ONE full-width board face. (faceL/faceR both alias this single plane so the
// raycast + loadWingBook plumbing keeps working with no per-leaf split.)
const face = new THREE.Mesh(BG.face, poolMat);
face.position.set(faceOffX, yC, 0);
// Closed packed boards DON'T cast into the shadow map — their contact-shadow sprite
// grounds them, and casting 18 tightly-packed 6-ft boards doubles every shadow rebake
// (key+pic lights) for almost no visible gain. Only the OPEN hero casts (toggled on
// focus in isolateBoard). Big nav-FPS win; the rack still reads grounded.
face.castShadow = false; face.receiveShadow = true;
face.userData = { isWingFace: true, parentPivot: pivot, leaf: 'F' };
pivot.add(face);
// Per-board light gradient + sheen overlay, tracking the single face.
const lit = makeBoardLightOverlay(wingW, H, yC); lit.position.x = faceOffX;
pivot.add(lit);
const planeL = face, planeR = face;
const leafL = pivot, leafR = pivot;
// Thin dark-bronze / near-black surround framing the 30" board face (matches the
// PJ photo's slim dark frames). Offset to the face centre so it tracks the board.
// Four bars share two geometries (horizontal + vertical) — no per-board allocation.
const frameZ = THICK / 2 + 0.002;
const bar = (geo, x, y) => {
const b = new THREE.Mesh(geo, MAT.frame || MAT.chrome);
b.position.set(faceOffX + x, yC + y, frameZ); b.castShadow = false; b.receiveShadow = true;
pivot.add(b);
};
bar(BG.frameH, 0, BG.fh / 2 - BG.frameT / 2); // top
bar(BG.frameH, 0, -BG.fh / 2 + BG.frameT / 2); // bottom
bar(BG.frameV, -BG.fw / 2 + BG.frameT / 2, 0); // left
bar(BG.frameV, BG.fw / 2 - BG.frameT / 2, 0); // right
// Gallery placard — engraved-look brass-on-dark plate under the board. The texture
// is built once per pattern|color key + CACHED (shared across rebuilds/windows) so a
// window-page rebuild doesn't synchronously bake 18 fresh 512×128 canvases + GPU
// uploads in one frame (the prior FPS-9 paging stall). Geometry is shared too.
const pName = (product.pattern_name || product.name || 'Pattern').toString();
const pColor = (product.color_name || product.color || '').toString();
const plTex = placardTexture(pName, pColor);
const placard = new THREE.Mesh(
placardGeo(wingW),
new THREE.MeshBasicMaterial({ map: plTex, transparent: true })
);
placard.position.set(faceOffX, 0.115, THICK / 2 + 0.012);
pivot.add(placard);
const wingImg = product.tile || product.image;
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' && product.width) {
widthInches = product.width;
}
let repeatInches = null;
if (typeof product.repeat === 'string') {
const m = product.repeat.match(/([\d.]+)/);
if (m) repeatInches = parseFloat(m[1]);
} else if (typeof product.repeat === 'number' && product.repeat) {
repeatInches = product.repeat;
}
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,
repeat_inches: repeatInches,
match_type: product.match_type || '',
finish: product.finish || '',
// specs_known ⇒ width/repeat are real line specs, so tile at true scale
rawWidthKnown: !!product.specs_known,
material: product.material || 'Wallcovering',
name: product.pattern_name || product.name || 'Pattern',
image: product.tile || product.image || null, // prefer a generated seamless tile if present
rawImage: product.image || null,
isSeamlessTile: !!product.tile, // gen-asset tiles are known-seamless
collection: product.collection || '',
store_url: product.store_url || '',
cta_mode: product.cta_mode || 'quote',
room: product.room || null,
hue: typeof product.hue === 'number' ? product.hue : null,
color_bucket: product.color_bucket || null,
isFullDesign: null // resolved lazily by the FullDesign gate on texture load
};
pivot.userData = {
product: normalizedProduct, index: i,
isOpen: false, targetAngle: 0, currentAngle: 0,
boardWidth: wingW, wallGroup, wingDirection: 1,
leafL, leafR, faceL: planeL, faceR: planeR, face,
pendingImage: wingImg || null, imageLoaded: false,
bookOpen: 0, bookTarget: 0, // legacy butterfly state (unused in arc layout)
flip: 0 // 0 = raked/closed, 1 = open/flat (rack.js drives)
};
// Place this board's hinge on the arc + set its closed-rake / open-flat yaw.
QHRack.placeBoard(pivot, i, totalWings, WC, REVEAL);
wallGroup.add(pivot);
wingBoards.push(pivot);
// CONTACT GROUNDING — a soft radial contact shadow on the floor under the board's
// OPEN footprint. Parented to the pivot at the face-centre offset so it rotates with
// the board's hinge swing and always lands beneath the board face. Lifted to world Y=0
// (the pivot sits at floor level) so it reads as a ground contact, not a floating slab.
const shadeMat = new THREE.MeshBasicMaterial({
map: contactShadowTexture(), transparent: true, opacity: 0.78,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
});
const shade = new THREE.Mesh(BG.contact, shadeMat); // shared contact geometry
shade.rotation.x = -Math.PI / 2;
shade.position.set(faceOffX, 0.006, 0.0); // 6 mm above floor, under the board face
shade.renderOrder = 1;
pivot.add(shade);
// (legacy flat-wall drop-shadow disabled — the arc rack has no flat back wall behind
// each board, so a wall-cast sprite would float. Contact + cast shadows ground them.)
if (false) { // eslint-disable-line no-constant-condition
const dropMat = new THREE.MeshBasicMaterial({
map: wallDropShadowTexture(), transparent: true, opacity: 0.34,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
});
const drop = new THREE.Mesh(new THREE.PlaneGeometry(wingW * 1.12, H * 1.04), dropMat);
drop.position.set(0, yC - 0.05, -0.19);
drop.renderOrder = 0;
drop.userData.isWallDrop = true;
wallGroup.add(drop);
pivot.userData.wallDrop = drop;
} // end legacy drop-shadow (disabled)
}
// Vendor section labels disabled in the arc layout — the flat-wall slotW/availW
// positioning model no longer applies to a curved rail. (Brand wordmark lives on
// the feature wall instead.) Kept behind a constant-false guard for reference.
// eslint-disable-next-line no-constant-condition
if (false) {
const vendorColors = {};
vendors.forEach(v => { vendorColors[v.name] = v.color; });
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 — HIGH-RES engraved-brass gallery plaque (crisp, clean, PJ-style).
const lc = document.createElement('canvas'); lc.width = 4096; lc.height = 320;
const lx = lc.getContext('2d');
lx.clearRect(0, 0, 4096, 320); // transparent ground (no cheap colour bar)
lx.font = '600 168px Georgia, "Times New Roman", serif';
lx.textAlign = 'center'; lx.textBaseline = 'middle';
// letter-spaced uppercase for an engraved-plaque feel
const txt = section.vendor.toUpperCase().split('').join(' ');
lx.fillStyle = 'rgba(26,18,6,0.32)'; lx.fillText(txt, 2050, 166); // faint engraved shadow
lx.fillStyle = '#c2a052'; lx.fillText(txt, 2048, 160); // soft brass face
const lt = new THREE.CanvasTexture(lc);
if (renderer.capabilities) lt.anisotropy = renderer.capabilities.getMaxAnisotropy();
lt.minFilter = THREE.LinearMipmapLinearFilter; lt.generateMipmaps = true;
const labelW = Math.max(sectionW * 0.92, 0.4);
const label = new THREE.Mesh(new THREE.PlaneGeometry(labelW, labelW * 320 / 4096),
new THREE.MeshBasicMaterial({ map: lt, transparent: true }));
label.position.set(centerX, WC.height + 0.2, 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);
}
});
} // end vendor-section labels (disabled in arc layout)
currentWallGroup = wallGroup;
scene.add(wallGroup);
wingRacks.push({ group: wallGroup, vendor: 'All Vendors', products: allProds });
// BOOT RACE GUARD: the bank + its raycaster targets (faceL/faceR per board) now exist.
// Flag READY only when the full 50-board bank is wired (the Slice-1 invariant count) so a
// partial/synthetic build can't claim ready. From here the click listener stops queuing and
// routes clicks straight to the raycaster. A QUEUED boot click (if any) is NOT replayed
// here — it's replayed by loadProducts() AFTER the boot-settle window (drainBootClick) so it
// is the authoritative FINAL boot action and the resting-middle re-asserts cannot stomp it.
if (wingBoards.length >= 50) wingWallReady = true;
}
// Replay a click the user made while the collection was still loading. Called by loadProducts
// as the LAST boot step (after the resting-middle re-asserts) so the user's choice WINS: it
// runs the exact onCanvasClick path → raycasts the now-built bank → selects + conveyors-to-
// front + clads the board they pointed at. No dead click; their intent is honoured, durably.
function drainBootClick() {
if (!_pendingBootClick || !wingWallReady) return;
const q = _pendingBootClick; _pendingBootClick = null;
_bootClickConsumed = true; // also blocks any later resting-default re-assert (belt + braces)
onCanvasClick(q);
// Boot window is over once the replayed click's carousel + open has settled. After that, a
// deliberate V1 click should reset to canonical-rest normally, so stop yielding enterFixedCentre.
setTimeout(() => { _bootFixedCentreYield = false; }, 3500);
}
// Shared texture loader + image cache
const texLoader = new THREE.TextureLoader();
const imageTexCache = {};
// buildWingRack removed — replaced by buildWingWall() above
// ---- FullDesign tileability gate (JS port of skills/fulldesign test_fulldesign.py) ----
const _fdCanvas = document.createElement('canvas');
function detectFullDesign(img) {
// Small detection canvas (200×400) — the L/R seam comparison needs only the leading
// and trailing 2px columns, so scaling the full Shopify JPEG down to 200×400 instead
// of 400×800 quarters the main-thread drawImage cost (the per-flip texture stall).
const STRIP = 2, THRESH = 30;
const w = Math.min(200, img.naturalWidth || 200);
const h = Math.min(400, img.naturalHeight || 400);
if (w < STRIP * 2) return { isFullDesign: false, lrScore: 999 };
_fdCanvas.width = w; _fdCanvas.height = h;
const ctx = _fdCanvas.getContext('2d', { willReadFrequently: true });
let left, right;
try {
ctx.drawImage(img, 0, 0, w, h);
left = ctx.getImageData(0, 0, STRIP, h).data;
right = ctx.getImageData(w - STRIP, 0, STRIP, h).data;
} catch (e) { return { isFullDesign: false, lrScore: 999 }; }
let sum = 0, n = 0;
for (let i = 0; i < left.length; i += 4) {
sum += Math.abs(left[i] - right[i]) + Math.abs(left[i+1] - right[i+1]) + Math.abs(left[i+2] - right[i+2]);
n += 3;
}
const lrScore = n ? sum / n : 999;
return { isFullDesign: lrScore < THRESH, lrScore };
}
// Repeat counts for a tile face. China Seas has no width/repeat, so keep motifs
// roughly square on the thin slat (rx=1, ry from image aspect, clamped 2..8).
function computeRepeat(wingW, product, img) {
const faceH = CONFIG.wing.height;
if (product && product.width_inches && product.repeat_inches && product.rawWidthKnown) {
const tileW = product.width_inches * 0.0254, tileH = product.repeat_inches * 0.0254;
return { rx: Math.max(1, wingW / tileW), ry: Math.max(1, faceH / tileH) };
}
const imgAspect = (img.naturalWidth || 1) / (img.naturalHeight || 1);
let ry = Math.round(imgAspect * (faceH / wingW));
ry = Math.max(2, Math.min(8, ry || 4));
return { rx: 1, ry };
}
// Thin red "general wall image" flag bar atop a non-seamless wing.
function setWingBanner(pivot, on) {
if (!pivot) return;
const ud = pivot.userData;
if (on && !ud.banner) {
const wingW = ud.boardWidth || 0.12;
const bar = new THREE.Mesh(
new THREE.PlaneGeometry(wingW, 0.05),
new THREE.MeshBasicMaterial({ color: 0xb41e1e, transparent: true, opacity: 0.88 })
);
bar.position.set(wingW / 2, CONFIG.wing.height - 0.05, 0.003);
bar.userData = { isBanner: true };
pivot.add(bar);
ud.banner = bar;
} else if (!on && ud.banner) {
pivot.remove(ud.banner);
if (ud.banner.geometry) ud.banner.geometry.dispose();
if (ud.banner.material) ud.banner.material.dispose();
ud.banner = null;
}
}
function loadWingTexture(faceMesh, imageUrl, onDone) {
if (!imageUrl) { if (onDone) onDone(); return; }
const pivot = faceMesh.userData.parentPivot;
const product = pivot ? pivot.userData.product : null;
const wingW = (pivot && pivot.userData.boardWidth) || 0.12;
const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
const apply = (cached) => {
faceMesh.material = new THREE.MeshLambertMaterial({ map: cached.texture });
if (product) product.isFullDesign = cached.isFullDesign;
setWingBanner(pivot, cached.isFullDesign === false);
if (onDone) onDone();
};
if (imageTexCache[imageUrl]) { apply(imageTexCache[imageUrl]); return; }
const img = new Image();
img.onload = () => {
let isFD;
if (product && product.isSeamlessTile) isFD = true; // gen-asset tiles are known-seamless
else isFD = detectFullDesign(img).isFullDesign;
const texture = new THREE.Texture(img);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
if (isFD) {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const { rx, ry } = computeRepeat(wingW, product, img);
texture.repeat.set(rx, ry);
} else {
texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping;
}
texture.needsUpdate = true;
const cached = { texture, isFullDesign: isFD };
imageTexCache[imageUrl] = cached;
apply(cached);
};
img.onerror = () => { if (onDone) onDone(); };
img.src = src;
}
// Load a wing's pattern across BOTH leaves as ONE CONTINUOUS design (NOT a
// book-match mirror): the open 2-leaf board presents the WHOLE real wallcovering.
// Each leaf is half the board width, so the left leaf shows U 0..0.5 of the full
// board span and the right leaf shows U 0.5..1, with the same vertical tiling.
// computeRepeat gives the full-board horizontal repeats (rx) and vertical (ry);
// each leaf draws HALF the horizontal span. Cached per-URL (shares one image).
// left leaf: repeat.x = rx/2, offset.x = 0
// right leaf: repeat.x = rx/2, offset.x = rx/2
// SINGLE-HINGE board → load the ONE continuous design onto the single full-width face.
// (No L/R leaf split — the whole wallcovering reads across one board, no mirror, no seam.)
function loadWingBook(pivot, onDone) {
const ud = pivot.userData;
const imageUrl = ud.pendingImage;
const face = ud.face || ud.faceL;
if (!imageUrl || !face) { 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;
// roughness ~0.6 + a hint of envMap so the focused board catches the grazing picture-light
// as a physical satin surface, while staying crisp + true-colour (not greyed).
const faceMat = (tex) => new THREE.MeshStandardMaterial({ map: tex, roughness: 0.6, metalness: 0.0, envMapIntensity: 0.18 });
const apply = (cached) => {
face.material = faceMat(cached.texture);
if (product) product.isFullDesign = cached.isFullDesign;
setWingBanner(pivot, cached.isFullDesign === false);
if (onDone) onDone();
};
if (imageTexCache[imageUrl] && imageTexCache[imageUrl].texture) { apply(imageTexCache[imageUrl]); return; }
// Build the texture from a decoded source. The seam check (detectFullDesign) + repeat
// fit are the same either way; only the decode path differs.
const buildFrom = (source, fdImg) => {
const isFD = (product && product.isSeamlessTile) ? true : (fdImg ? detectFullDesign(fdImg).isFullDesign : true);
const t = (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap)
? new THREE.CanvasTexture(source) : new THREE.Texture(source);
t.image = source;
t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
t.anisotropy = maxAniso; srgb(t);
if (isFD) {
const { rx, ry } = computeRepeat(wingW, product, source);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(Math.max(1, rx), ry);
} else {
t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
t.repeat.set(1, 1);
}
t.needsUpdate = true;
const cached = { texture: t, isFullDesign: isFD };
imageTexCache[imageUrl] = cached;
apply(cached);
};
// FAST PATH: fetch + createImageBitmap decodes the JPEG OFF the main thread, so the
// texture upload no longer stalls the flip frame (the rapid-nav FPS dip). The seam
// check still needs an HTMLImageElement, so we keep a lightweight <img> in parallel.
if (typeof createImageBitmap === 'function' && src.charAt(0) === '/') {
let fdImg = null;
if (!(product && product.isSeamlessTile)) { fdImg = new Image(); fdImg.src = src; }
fetch(src).then(r => r.blob()).then(b => createImageBitmap(b))
.then(bmp => buildFrom(bmp, (fdImg && fdImg.complete) ? fdImg : null))
.catch(() => { // fall back to plain <img> decode
const img = new Image(); img.decoding = 'async';
img.onload = () => buildFrom(img, img); img.onerror = () => { if (onDone) onDone(); };
img.src = src;
});
return;
}
const img = new Image();
img.decoding = 'async';
img.onload = () => buildFrom(img, img);
img.onerror = () => { if (onDone) onDone(); };
img.src = src;
}
// ============================================================
// CENTER BOOK — single open mirrored sample book (Steve 2026-06-29 default view)
// ------------------------------------------------------------
// ONE board centred on the spawn's forward axis (-Z), opened like a sample book with
// TWO leaves hinged at the spine. The RIGHT leaf shows the design normally; the LEFT leaf
// shows a horizontal MIRROR of it (repeat.x negative) so the pattern reads symmetrically
// meeting at the centre spine. Paged with [ ] / N-P. No arc, no auto-fly.
// ============================================================
const BOOK_SPINE_Z = -2.4; // spine sits ~8ft ahead of the centred spawn
// Per-leaf splay (degrees off flat), opening FORWARD toward the viewer. Steve's ask: START
// at 20°. Live-driven by the Open° slider, persisted to localStorage('qh_book_splay').
let BOOK_SPLAY_DEG = (function () {
const v = parseFloat(localStorage.getItem('qh_book_splay'));
return (v >= 0 && v <= 90) ? v : 20;
})();
let BOOK_LEAF_SPLAY = BOOK_SPLAY_DEG * Math.PI / 180; // radians each leaf opens off flat
function buildCenterBook() {
if (centerBook) return centerBook;
const WC = CONFIG.wing;
const H = WC.height;
const leafW = WC.boardWidthM; // each leaf is a full 30" face → ~60" open spread
const yC = H / 2 + 0.04;
const THICK = 0.014;
const group = new THREE.Group();
group.position.set(0, 0, BOOK_SPINE_Z); // hinge/spine on the room centre-line, ahead of the viewer
// NO group flip: a PlaneGeometry's textured front (+Z normal) already faces the +Z spawn
// (viewer at z=0 looking -Z), so the leaves present their pattern straight at the avatar.
const faceGeo = new THREE.PlaneGeometry(leafW, H);
// RIGHT leaf — hinged at the spine (local x=0), face centred at +x. A NEGATIVE +y rotation
// swings its OUTER (+x) edge FORWARD (+Z, toward the viewer) so the open book faces the
// avatar — spine farthest, pages opening toward you (Steve: "facing forward not backwards").
// The textured front rotates to (-sin, 0, +cos): still a +Z component → pattern faces the spawn.
const leafR = new THREE.Group();
leafR.rotation.y = -BOOK_LEAF_SPLAY;
const faceR = new THREE.Mesh(faceGeo, new THREE.MeshStandardMaterial({ color: 0xece4d2, roughness: 0.7 }));
faceR.position.set(leafW / 2, yC, 0);
faceR.receiveShadow = true; faceR.castShadow = true;
faceR.userData = { isWingFace: true, parentPivot: centerBookPivotProxy(), leaf: 'R' };
leafR.add(faceR);
// LEFT leaf — mirror twin: face centred at -x, splayed by +y so its outer (-x) edge swings
// FORWARD (+Z) symmetrically, opening toward the viewer. Texture horizontally mirrored (setCenterBook).
const leafL = new THREE.Group();
leafL.rotation.y = BOOK_LEAF_SPLAY;
const faceL = new THREE.Mesh(faceGeo.clone(), new THREE.MeshStandardMaterial({ color: 0xece4d2, roughness: 0.7 }));
faceL.position.set(-leafW / 2, yC, 0);
faceL.receiveShadow = true; faceL.castShadow = true;
faceL.userData = { isWingFace: true, parentPivot: null, leaf: 'L' };
leafL.add(faceL);
// Slim dark-bronze spine post + a thin frame around the open spread.
const spine = new THREE.Mesh(new THREE.BoxGeometry(0.02, H + 0.04, 0.05), MAT.frame || MAT.dark);
spine.position.set(0, yC, 0.0); spine.castShadow = true;
group.add(spine);
// EDGE STROKE — every edge of BOTH panels gets the same grey (MAT.frame, 0x32323a) as a
// border, like the wing-board frames. Built as 4 thin bars per leaf, parented to the leaf
// so they swing with it, sitting just in front (+z) of the pattern face so they read as a
// crisp outline on all four sides (top/bottom/left/right). BORDER_W is the "2" stroke width.
const BORDER_W = 0.014; // grey stroke width (≈2px at showroom distance) — bump to thicken
const BORDER_D = 0.022; // bar depth
const BORDER_Z = 0.009; // sit just in front of the face plane
function strokeLeaf(leaf, faceCx) {
const hGeo = new THREE.BoxGeometry(leafW, BORDER_W, BORDER_D); // top / bottom
const vGeo = new THREE.BoxGeometry(BORDER_W, H, BORDER_D); // left / right (full height → covers corners)
const mk = (geo, x, y) => {
const m = new THREE.Mesh(geo, MAT.frame || MAT.dark);
m.position.set(x, y, BORDER_Z); m.castShadow = true; leaf.add(m);
};
mk(hGeo, faceCx, yC + H / 2 - BORDER_W / 2); // top
mk(hGeo.clone(), faceCx, yC - H / 2 + BORDER_W / 2); // bottom
mk(vGeo, faceCx - leafW / 2 + BORDER_W / 2, yC); // left
mk(vGeo.clone(), faceCx + leafW / 2 - BORDER_W / 2, yC); // right
}
strokeLeaf(leafR, leafW / 2);
strokeLeaf(leafL, -leafW / 2);
group.add(leafL);
group.add(leafR);
scene.add(group);
centerBook = { group, leafL, leafR, faceL, faceR, leafW, H, yC };
if (window._requestShadowUpdate) window._requestShadowUpdate(8);
return centerBook;
}
// Lightweight pivot proxy so loadWingBook (which reads ud.face / ud.product / ud.boardWidth
// off a parentPivot) can paint the RIGHT leaf via the existing loader path.
let _bookPivotProxy = null;
function centerBookPivotProxy() {
if (!_bookPivotProxy) _bookPivotProxy = { userData: {} };
return _bookPivotProxy;
}
// Load the catalog design at bookIndex onto both leaves: the right leaf normal, the left
// leaf a horizontal mirror (repeat.x flipped) so the two meet symmetrically at the spine.
function setCenterBook(index) {
if (!centerBook || !products.length) return;
const list = products.slice(0, windowSize);
if (!list.length) return;
bookIndex = ((index % list.length) + list.length) % list.length;
const raw = list[bookIndex];
const product = normalizeProduct(raw);
centerBook.product = product;
const imageUrl = product.image || raw.tile || raw.image;
updateWingCaption(product); // minimal fixed caption bar (Steve's pick)
if (!imageUrl) return;
// Steve hard rule (2026-06-29): every Next/Prev book-page renders the NEW design onto
// ALL FOUR walls. setCenterBook is the single funnel for book paging + initial load, so
// cladding here keeps the room in lockstep with the open book. Gated on wallsAutoClad so
// the "Walls: neutral" toggle still wins; cladWalls' monotonic token cancels stale paints.
if (wallsAutoClad) cladWalls(imageUrl, product.isSeamlessTile);
const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
const maxAniso = (renderer.capabilities && renderer.capabilities.getMaxAnisotropy) ? renderer.capabilities.getMaxAnisotropy() : 1;
const wingW = centerBook.leafW;
const paint = (source, fdImg) => {
const isFD = product.isSeamlessTile ? true : (fdImg ? detectFullDesign(fdImg).isFullDesign : true);
const { rx, ry } = isFD ? computeRepeat(wingW, product, source) : { rx: 1, ry: 1 };
const mkTex = (mirror) => {
const t = (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap)
? new THREE.CanvasTexture(source) : new THREE.Texture(source);
t.image = source;
t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
t.anisotropy = maxAniso; srgb(t);
if (isFD) {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
// RIGHT leaf: repeat.x = +rx. LEFT leaf: repeat.x = -rx flips the map horizontally
// (offset.x = rx keeps it positioned), so the left leaf is a true mirror of the right.
t.repeat.set(mirror ? -Math.max(1, rx) : Math.max(1, rx), ry);
if (mirror) t.offset.x = Math.max(1, rx);
} else {
t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
t.repeat.set(mirror ? -1 : 1, 1);
if (mirror) t.offset.x = 1;
}
t.needsUpdate = true;
return t;
};
centerBook.faceR.material = new THREE.MeshStandardMaterial({ map: mkTex(false), roughness: 0.6, metalness: 0.0, envMapIntensity: 0.18 });
centerBook.faceL.material = new THREE.MeshStandardMaterial({ map: mkTex(true), roughness: 0.6, metalness: 0.0, envMapIntensity: 0.18 });
if (window._requestShadowUpdate) window._requestShadowUpdate(6);
};
if (typeof createImageBitmap === 'function' && src.charAt(0) === '/') {
let fdImg = null;
if (!product.isSeamlessTile) { fdImg = new Image(); fdImg.src = src; }
fetch(src).then(r => r.blob()).then(b => createImageBitmap(b))
.then(bmp => paint(bmp, (fdImg && fdImg.complete) ? fdImg : null))
.catch(() => { const im = new Image(); im.onload = () => paint(im, im); im.src = src; });
return;
}
const im = new Image(); im.onload = () => paint(im, im); im.src = src;
}
// Page the centered book to the next/previous PATTERN across the FULL 883-design catalog
// (not just the current 50-window). delta = +1 next, -1 previous. When stepping past the
// edge of the loaded 50-window, fetch the adjacent window first, then show the right design.
// Endless: wraps around the whole catalog. Guards against overlapping fetches mid-step.
let _bookPaging = false;
async function pageCenterBook(delta) {
if (!centerBook || _bookPaging) return;
const total = windowTotal || products.length || 0;
if (total <= 0) return;
_bookPaging = true;
try {
// Global index across all 883 designs, endless wrap.
const global = (((windowOffset + bookIndex + delta) % total) + total) % total;
const targetWindow = Math.floor(global / windowSize) * windowSize;
if (targetWindow !== windowOffset) {
await fetchWindow(targetWindow); // load the adjacent 50-window into products[]
updateWindowLabel();
}
setCenterBook(global - windowOffset); // local index within the (possibly new) window
} finally {
_bookPaging = false;
}
}
// Live splay control — drag the Open° slider to fan the open book between 0° (flat) and 90°.
// Both leaves swing FORWARD symmetrically (R = -deg, L = +deg). Persisted to localStorage.
function setBookSplay(deg) {
BOOK_SPLAY_DEG = Math.max(0, Math.min(90, deg));
BOOK_LEAF_SPLAY = BOOK_SPLAY_DEG * Math.PI / 180;
if (centerBook) {
centerBook.leafR.rotation.y = -BOOK_LEAF_SPLAY;
centerBook.leafL.rotation.y = BOOK_LEAF_SPLAY;
if (window._requestShadowUpdate) window._requestShadowUpdate(6);
}
localStorage.setItem('qh_book_splay', BOOK_SPLAY_DEG);
}
// Normalize a raw product row into the display shape used by the book + caption.
// (Mirrors the per-board normalization in buildWingWall, kept small + shared.)
function normalizeProduct(p) {
let widthInches = 27;
if (typeof p.width === 'string') { const m = p.width.match(/([\d.]+)/); if (m) widthInches = parseFloat(m[1]); }
else if (typeof p.width === 'number' && p.width) widthInches = p.width;
let repeatInches = null;
if (typeof p.repeat === 'string') { const m = p.repeat.match(/([\d.]+)/); if (m) repeatInches = parseFloat(m[1]); }
else if (typeof p.repeat === 'number' && p.repeat) repeatInches = p.repeat;
return {
pattern_name: p.pattern_name || p.name || 'Pattern',
color: p.color_name || p.color || '',
sku: p.sku || p.mfr_sku || '',
vendor: p.vendor || 'Quadrille House',
width_inches: widthInches, repeat_inches: repeatInches,
material: p.material || 'Wallcovering',
name: p.pattern_name || p.name || 'Pattern',
image: p.tile || p.image || null,
isSeamlessTile: !!p.tile,
collection: p.collection || '',
isFullDesign: null
};
}
// ============================================================
// CLOSED-SLIVER REAL THUMBNAILS (Image #6 gap closer)
// ------------------------------------------------------------
// In the PJ Dallas photo you see a thin vertical strip of EVERY real design packed
// in the arc. Previously the closed boards all shared one synthetic pastel pool
// pattern; only the open hero showed the real China Seas wallcovering. This loads a
// REAL but LOW-RES thumbnail onto every closed board's face so the slivers read as
// the actual catalogue — within the texture budget:
// • LOW-RES: remote Shopify URLs get `?width=THUMB_PX` (server-side downscale →
// ~7 KB vs ~125 KB; 17× lighter). Local gen-tiles already small → used as-is.
// • CAPPED CONCURRENCY: at most SLIVER_MAX in flight; a stagger interval keeps the
// initial burst from tanking FPS. Decode is OFF-thread (createImageBitmap).
// • CACHED per-URL in sliverTexCache (separate from the full-res imageTexCache so a
// later focus still upgrades the SAME board to full-res via loadWingBook).
// • The thumbnail keeps the board's tiling (repeat) so the sliver shows real motif,
// not a stretched single image.
// ============================================================
const sliverTexCache = {};
let _sliverInFlight = 0;
const SLIVER_MAX = 3; // concurrent thumbnail fetches (budget-safe)
const SLIVER_THUMB_PX = 160; // downscaled width — 160 keeps a steeply-raked sliver legible (Contrarian: 120 squeezed to ~3px = mush)
let _sliverPumpAt = 0;
const SLIVER_PUMP_MS = 90; // min gap between kicking off new thumbnail loads
// Build the LOW-RES source URL for a closed board's sliver. Routes through the server's
// /api/thumb downscaler (sharp, disk-cached) so the texture budget stays tiny:
// • local gen-tiles (which are 2000²+ 12 MB PNGs!) → downscale by SKU, NEVER served raw
// • remote heroes → downscale the whitelisted URL
// Falls back to the raw proxy only if neither a SKU nor a remote URL is available.
function sliverThumbSrc(product, imageUrl) {
const sku = product && (product.sku || product.mfr_sku);
// Prefer the SKU path: it downscales the crisp local gen-tile (and dodges a 12 MB PNG).
if (sku && imageUrl && imageUrl.charAt(0) === '/' && imageUrl.indexOf('/gen-tiles/') !== -1) {
return '/api/thumb?sku=' + encodeURIComponent(sku) + '&w=' + SLIVER_THUMB_PX;
}
if (!imageUrl) return null;
if (imageUrl.charAt(0) === '/') {
// Local non-tile asset (already small) — serve as-is.
return imageUrl;
}
// Remote: downscale via /api/thumb (sharp) rather than shipping the full 125 KB hero.
return '/api/thumb?url=' + encodeURIComponent(imageUrl) + '&w=' + SLIVER_THUMB_PX;
}
// Apply a (real, low-res) thumbnail texture to a CLOSED board's face. Uses a cheap
// Lambert material (closed slivers don't need PBR sheen — the open hero gets that).
function loadSliverThumb(pivot, onDone) {
const ud = pivot.userData;
const face = ud.face || ud.faceL;
const imageUrl = ud.pendingImage;
// Skip if no image, already showing the real thumb, or already upgraded to full-res hero.
if (!face || !imageUrl || ud.sliverLoaded || ud.imageLoaded) { if (onDone) onDone(); return; }
const product = ud.product;
const wingW = ud.boardWidth || 0.12;
const maxAniso = (renderer.capabilities && renderer.capabilities.getMaxAnisotropy) ? renderer.capabilities.getMaxAnisotropy() : 1;
const apply = (cached) => {
// Only paint the thumbnail if the board hasn't been opened to full-res in the meantime.
if (!ud.imageLoaded) {
face.material = new THREE.MeshLambertMaterial({ map: cached.texture });
}
ud.sliverLoaded = true;
if (onDone) onDone();
};
if (sliverTexCache[imageUrl] && sliverTexCache[imageUrl].texture) { apply(sliverTexCache[imageUrl]); return; }
const buildFrom = (source) => {
const isFD = (product && product.isSeamlessTile) ? true : true; // closed sliver always tiles for real motif
const t = (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap)
? new THREE.CanvasTexture(source) : new THREE.Texture(source);
t.image = source;
t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
t.anisotropy = Math.min(4, maxAniso); srgb(t); // capped aniso — these are tiny + many
const { rx, ry } = computeRepeat(wingW, product, source);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(Math.max(1, rx), ry);
t.needsUpdate = true;
const cached = { texture: t };
sliverTexCache[imageUrl] = cached;
apply(cached);
};
const src = sliverThumbSrc(product, imageUrl);
if (!src) { if (onDone) onDone(); return; }
if (typeof createImageBitmap === 'function' && src.charAt(0) === '/') {
fetch(src).then(r => r.blob()).then(b => createImageBitmap(b))
.then(bmp => buildFrom(bmp))
.catch(() => {
const img = new Image(); img.decoding = 'async';
img.onload = () => buildFrom(img); img.onerror = () => { if (onDone) onDone(); };
img.src = src;
});
return;
}
const img = new Image(); img.decoding = 'async';
img.onload = () => buildFrom(img); img.onerror = () => { if (onDone) onDone(); };
img.src = src;
}
// Pump the closed-sliver thumbnails: nearest-first, capped concurrency, staggered so
// the initial wall of real designs streams in without an FPS cliff. Called from animate.
const _sliverWP = new THREE.Vector3();
function pumpSliverThumbs() {
if (_sliverInFlight >= SLIVER_MAX) return;
const now = performance.now();
if (now - _sliverPumpAt < SLIVER_PUMP_MS) return;
// Pick the nearest not-yet-thumbed board to the camera so the front of the arc fills first.
let target = null, best = Infinity;
const cx = camera.position.x, cz = camera.position.z;
for (let i = 0; i < wingBoards.length; i++) {
const ud = wingBoards[i].userData;
if (ud.sliverLoaded || ud.imageLoaded || !ud.pendingImage) continue;
wingBoards[i].getWorldPosition(_sliverWP);
const d = (_sliverWP.x - cx) * (_sliverWP.x - cx) + (_sliverWP.z - cz) * (_sliverWP.z - cz);
if (d < best) { best = d; target = wingBoards[i]; }
}
if (!target) return;
_sliverPumpAt = now;
_sliverInFlight++;
loadSliverThumb(target, () => { _sliverInFlight--; });
}
// ============================================================
// WING INTERACTION — Two-step: click to move close, buttons to open
// ============================================================
function onCanvasClick(event) {
// BOOT RACE GUARD: if the wing bank hasn't finished loading, the raycaster has nothing to
// hit — a click here used to silently no-op ("it's broken" to a senior). Instead, QUEUE
// the click coordinates and show a gentle "loading the collection…" cue; buildWingWall()
// replays this exact click the instant the bank is ready, so the board the user pointed at
// gets selected. Honour the click; never drop it. (Keep only the latest queued click.)
if (!wingWallReady) {
_pendingBootClick = { clientX: event.clientX, clientY: event.clientY };
_bootFixedCentreYield = true; // from now (until the drain settles) V1's boot apply yields to this click
updateLoadStatus('Loading the collection — your selection is queued…', 90);
return;
}
// A drag that turned the body is NOT a board selection — swallow the trailing click.
if (_dragTurned) { _dragTurned = false; return; }
// A click during the auto-tour stops it and keeps the current wing open
if (peruseActive) { stopPeruse(true); return; }
// 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 => { if (p.userData.faceL) faces.push(p.userData.faceL); if (p.userData.faceR) faces.push(p.userData.faceR); });
const hits = raycaster.intersectObjects(faces, false);
window._qhLastClickHits = hits.length; // diagnostics: lets the proof bar see if a click raycast found a face
if (hits.length > 0) {
focusOnWing(hits[0].object.userData.parentPivot);
}
}
// STEP 1: Click on a wing/sliver — CAROUSEL it to the dead-front centre slot (the whole
// rack conveyor-shifts like a dry-cleaner / tie-rack), THEN fan it open. The CAMERA STAYS
// FIXED-CENTRE — only the rack moves. focusOnWing now starts the shift; _openCenteredBoard
// (fired on settle by the per-frame carousel ease) delivers the open + detail + 4-wall clad.
function focusOnWing(pivot) {
// The user has interacted — boot settle is over; from here a Hero-mode re-entry may
// legitimately auto-focus. (Before this, the boot stays at the resting-open default.)
window._qhBootSettled = true;
// A real selection supersedes the resting-open boot visual.
if (restingOpenWing && restingOpenWing !== pivot) restingOpenWing.userData.panelClosed = true;
restingOpenWing = null;
// Close any active fan cascade
if (fanWings.length > 0) closeFanCascade(true);
// If re-clicking the SAME centred+open wing, unfocus (carousel stays put).
if (focusedWing === pivot && !carouselActive) { unfocusWing(); return; }
// Mark this board the selection IMMEDIATELY (index tracking for Prev/Next), but keep its
// panel CLOSED so updateBooks/flipUpdate does NOT fan it open until it reaches centre.
if (focusedWing && focusedWing !== pivot) focusedWing.userData.panelClosed = true;
focusedWing = pivot;
pivot.userData.panelClosed = true; // stays raked-closed while it conveyors in
isolateBoard(pivot); // hero will cast its shadow; slivers stay visible
// The camera never leaves the fixed centre — reset the spin pose so the dead-front slot
// (where this board is sliding to) is framed head-on. No camera fly, no lock.
if (controlsLocked) unlockControls();
if (!exploreMode) { spinYaw = 0; spinPitch = BOOT_PITCH; if (lockedSpinEye) camera.position.copy(lockedSpinEye); applySpinPose(); }
if (camera.fov !== FOV_DEFAULT) { targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix(); }
// Conveyor the rack so this board's effective index lands on the centre slot, THEN open.
carouselTo(pivot.userData.index, () => _openCenteredBoard(pivot));
}
// Drive the rack's continuous index-offset toward the value that centres targetIndex.
// The per-frame ease in animate()/updateCarousel re-places every board along the rail
// until the offset settles, then fires onSettle. Re-clicking a DIFFERENT board just
// re-points carouselTarget so the rack re-shifts to bring THAT board to centre.
function carouselTo(targetIndex, onSettle) {
if (!window.QHRack || !wingBoards.length) { if (onSettle) onSettle(); return; }
carouselTarget = QHRack.centerOffsetFor(targetIndex, wingBoards.length);
_carouselPending = (typeof onSettle === 'function') ? { fn: onSettle } : null;
carouselActive = true;
// If we're already (essentially) centred on this board, settle now — no shift needed.
if (Math.abs(QHRack.getIndexOffset() - carouselTarget) < 0.002) {
QHRack.setIndexOffset(carouselTarget);
QHRack.relayout(wingBoards, CONFIG.wing, REVEAL);
carouselActive = false;
const p = _carouselPending; _carouselPending = null;
if (p) p.fn();
}
}
// Per-frame carousel ease — same lerp pattern as flipUpdate. Slides the rack's
// index-offset toward carouselTarget, re-placing every board at its shifted arc-angle so
// they glide along the rail as one conveyor, then fires the pending open on settle.
function updateCarousel(dt) {
if (!carouselActive || !window.QHRack || !wingBoards.length) return;
const cur = QHRack.getIndexOffset();
const k = Math.min(1, dt * 6); // ease rate (matches flipUpdate's feel)
let next = cur + (carouselTarget - cur) * k;
if (Math.abs(carouselTarget - next) < 0.0015) next = carouselTarget;
QHRack.setIndexOffset(next);
// Re-place every board's hinge on the (shifted) rail. relayout preserves each board's
// flip state, so an opening hero keeps opening while the rest re-rake along the rail.
QHRack.relayout(wingBoards, CONFIG.wing, REVEAL);
if (window._requestShadowUpdate) window._requestShadowUpdate(1);
if (next === carouselTarget) {
carouselActive = false;
const p = _carouselPending; _carouselPending = null;
if (p) p.fn();
}
}
// Deliver the focus payload to the board now sitting at the dead-front centre slot:
// fan it open (panel un-closed → flipUpdate eases it flat), load its design, show the
// (collapsed) detail card, and clad ALL FOUR walls. The camera is already fixed-centre
// and head-on to this slot, so NO camera move happens.
function _openCenteredBoard(pivot) {
if (!pivot || pivot !== focusedWing) return; // a newer click superseded this one
const product = pivot.userData.product;
pivot.userData.panelClosed = false; // now fan open (flipUpdate → flat hero)
// Lazy-load this wing's book-matched pattern now that it's the centre hero.
if (pivot.userData.pendingImage && !pivot.userData.imageLoaded) {
pivot.userData.imageLoaded = true;
loadWingBook(pivot);
}
// Show detail panel (collapsed-by-default) with product info.
showWingDetail(product);
// Clad ALL FOUR room walls in this pattern on EVERY selection (Steve hard rule:
// the click IS the selection, and selecting fills the whole room so a yaw to any
// side wall shows the chosen pattern, not bare cream). The immersive room-preview
// is the definitive default — clad unconditionally, not gated on wallsAutoClad.
cladWalls(product.image, product.isSeamlessTile);
updateOpenBtnState(false);
document.getElementById('info-text').textContent = `${product.pattern_name || product.name} · ${product.vendor} | ◀ Back / Next ▶ to carousel through`;
}
// Isolate the focused board — the PJ way: the hero swings OPEN and presents its design,
// while the rest of the arc STAYS VISIBLE as the packed sliver backdrop (NOT hidden) —
// exactly the reference photo (open boards amid the fanned slivers). The hero is the
// only board that casts into the shadow map (toggled here), so it reads as a real
// lifted-forward panel without the cost of 18 cast-shadow boards.
function isolateBoard(pivot) {
if (window._requestShadowUpdate) window._requestShadowUpdate(6);
wingBoards.forEach(p => {
p.visible = true; // keep the sliver arc — do NOT hide
if (p.userData.face) p.userData.face.castShadow = (p === pivot); // only the hero casts
if (p.userData.wallDrop) p.userData.wallDrop.visible = false;
});
}
function restoreBoards() {
wingBoards.forEach(p => {
p.visible = true;
if (p.userData.face) p.userData.face.castShadow = false; // back to no-cast slivers
if (p.userData.wallDrop) p.userData.wallDrop.visible = false; // arc has no flat wall behind
});
if (window._requestShadowUpdate) window._requestShadowUpdate(6);
}
// Next / Back — flip to the next/previous wingboard (pages window edges, endless).
async function gotoBoardOffset(delta) {
// CENTER BOOK default: Next/Prev page the open book through the catalog.
if (BOOK_MODE) { pageCenterBook(delta > 0 ? 1 : -1); return; }
if (!wingBoards.length) return;
let idx = (focusedWing ? focusedWing.userData.index : 0) + delta;
if (idx >= wingBoards.length) { if (await advanceWindow(1)) { restoreBoards(); focusOnWing(wingBoards[0]); reframeView(); return; } idx = wingBoards.length - 1; }
else if (idx < 0) { if (await advanceWindow(-1)) { restoreBoards(); focusOnWing(wingBoards[wingBoards.length - 1]); reframeView(); return; } idx = 0; }
focusOnWing(wingBoards[idx]);
reframeView();
}
// If a board-presentation view-mode (Macro/Angled/Room) is active, re-apply its
// framing to the newly-focused board after the guided Next/Back flip settles.
function reframeView() {
if (window._viewmode && typeof window._viewmode.reframe === 'function') {
setTimeout(() => { try { window._viewmode.reframe(); } catch (e) {} }, 60);
}
}
// "Fan out" — open the FOCUSED board's two panels (left & right) to present its
// single design. The leaf hinge is animated by updateBooks for the focused board;
// this just toggles the open/closed override + button state. (No more multi-board
// cascade — one board, opened to show one design.)
function fanCascadeOpen(direction) {
if (!focusedWing) return;
const ud = focusedWing.userData;
// Re-pressing the same side closes it.
if (!ud.panelClosed && fanWings.length && ud.wingDirection === direction) { closeFanCascade(); return; }
ud.panelClosed = false;
ud.wingDirection = direction;
fanWings = [focusedWing];
fanCenterIndex = ud.index;
updateOpenBtnState(direction);
}
function closeFanCascade(skipBtnUpdate) {
if (focusedWing) focusedWing.userData.panelClosed = true;
fanWings = [];
fanCenterIndex = -1;
if (!skipBtnUpdate) updateOpenBtnState(0);
}
// Back button — close fan, recentre the carousel on the middle, stay fixed-centre.
function unfocusWing(skipPanel) {
if (fanWings.length > 0) closeFanCascade();
restoreBoards(); // bring the other wingboards back
focusedWing = null;
// Cancel any pending carousel open and conveyor the rack back to its boot layout
// (middle board centred, index-offset → 0) so Back returns the resting-open middle.
_carouselPending = null;
if (window.QHRack && wingBoards.length && !skipPanel) carouselTo(Math.floor(wingBoards.length / 2), null);
// Revert walls only on a true exit — peruse's internal wing-to-wing close passes
// skipPanel=true and the next cladWalls overwrites, so no neutral flicker.
if (!skipPanel) revertWalls();
if (!skipPanel) document.getElementById('wing-detail').classList.add('hidden');
if (!skipPanel) hideNowViewing(); // tuck the navigator strip away on a true exit
if (!skipPanel) hideGuidedTitle(); // and the big top-centre title
// Unlock first so smoothCameraTo can move
unlockControls();
// Returning from a focus lands back at the fixed-centre spin pose facing the bank
// (NO fly-back walk in guided mode — the eye never left centre).
if (!exploreMode && lockedSpinEye) {
spinYaw = 0; spinPitch = BOOT_PITCH;
smoothCameraTo(lockedSpinEye.clone(), (function () {
const cp = lockedSpinEye, cy = Math.cos(spinPitch);
return new THREE.Vector3(cp.x + Math.sin(spinYaw) * cy * TARGET_DIST,
cp.y + Math.sin(spinPitch) * TARGET_DIST,
cp.z - Math.cos(spinYaw) * cy * TARGET_DIST);
})(), () => applySpinPose(), FOV_DEFAULT);
previousCamPos = null; previousCamTarget = null;
} else if (previousCamPos && previousCamTarget) {
smoothCameraTo(previousCamPos, previousCamTarget, null, FOV_DEFAULT);
previousCamPos = null;
previousCamTarget = null;
} else if (camera.fov !== FOV_DEFAULT) {
targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix();
}
document.getElementById('info-text').textContent = 'WASD to walk | Click a wing board to view pattern | M for minimap';
}
// Clean a verbose China Seas title ("Aga Apple on Almost White Screen Printed - Off-White")
// into a display pattern + a derived colorway, without losing the product identity.
function splitPatternColor(name) {
if (!name) return { pattern: 'Pattern', colorway: '' };
let pattern = name, colorway = '';
// colorway often trails after " - "
const dash = name.split(/\s+-\s+/);
if (dash.length > 1) { colorway = dash[dash.length - 1].trim(); }
// strip the process descriptor for the headline
pattern = dash[0].replace(/\s+screen\s*print(ed)?\b.*$/i, '').trim() || dash[0].trim();
return { pattern, colorway };
}
function specRow(label, value) {
if (!value && value !== 0) return '';
return `<div class="wd-spec"><dt>${label}</dt><dd>${value}</dd></div>`;
}
// Detail popup starts COLLAPSED on EVERY fresh selection (Steve HARD rule) — shows just
// brand + pattern name; the user taps the chevron to expand the full spec sheet for THAT
// selection only. The expand is a per-session, per-selection toggle — it is NOT persisted
// as a sticky default, because a stored "expanded" flag would silently defeat
// collapsed-by-default on the next click / reload (the HOLE-3 footgun). So:
// • detailCollapsed is the LIVE state of the currently-shown card (starts true);
// • setDetailCollapsed() drives the live state + DOM but never writes localStorage;
// • every showWingDetail() hard-resets the card to COLLAPSED regardless of prior toggle.
let detailCollapsed = true;
function setDetailCollapsed(c) {
detailCollapsed = c;
const panel = document.getElementById('wing-detail');
if (panel) panel.classList.toggle('collapsed', c);
const btn = document.getElementById('detail-collapse');
if (btn) { btn.innerHTML = c ? '▸' : '▾'; btn.title = c ? 'Show details' : 'Hide details'; }
}
// MINIMAL FIXED CAPTION BAR (Steve's pick 2026-06-29) — a small static strip pinned to
// the bottom of the screen showing `PatternName · Colorway · SKU · Vendor` for the
// currently-faced / centred design. It NEVER pops, slides, or animates — it just updates
// its text. Replaces the popping wing-detail card.
function updateWingCaption(product) {
const bar = document.getElementById('wing-caption');
if (!bar) return;
window._activeProduct = product;
if (!product) { bar.textContent = ''; return; }
const { pattern, colorway } = splitPatternColor(product.pattern_name || product.name);
const color = product.color || colorway || '';
const parts = [pattern || 'Pattern'];
if (color) parts.push(color);
if (product.sku) parts.push(product.sku);
if (product.vendor) parts.push(product.vendor);
bar.textContent = parts.join(' · ');
}
function showWingDetail(product) {
window._activeProduct = product;
const panel = document.getElementById('wing-detail');
panel.classList.remove('hidden');
setDetailCollapsed(true); // HARD reset: every fresh selection starts COLLAPSED (HOLE-3 fix)
const { pattern, colorway } = splitPatternColor(product.pattern_name || product.name);
document.getElementById('detail-pattern').textContent = pattern || 'Pattern';
document.getElementById('detail-color').textContent = product.color || colorway || '';
document.getElementById('detail-vendor').textContent = product.vendor || '';
document.getElementById('detail-sku').textContent = product.sku || '';
// Spec sheet — real China Seas dimensional specs
const fmtIn = (n) => (n || n === 0) ? `${(Math.round(n * 10) / 10)}″` : '';
const w = product.width_inches, r = product.repeat_inches;
const rows = [
specRow('Width', fmtIn(w)),
specRow('Repeat', r ? `${fmtIn(r)} V` : (w ? 'Free / no repeat' : '')),
specRow('Match', product.match_type || ''),
specRow('Material', product.material || product.finish || 'Wallcovering'),
specRow('Collection', product.collection || ''),
].join('');
const grid = document.getElementById('detail-spec-grid');
if (grid) grid.innerHTML = rows;
// Tileability flag chip
const flag = document.getElementById('detail-flag');
if (flag) {
if (product.isFullDesign === false) { flag.textContent = 'General wall image — not a seamless tile'; flag.className = 'wd-flag warn'; }
else if (product.isFullDesign === true) { flag.textContent = 'Tiles seamlessly'; flag.className = 'wd-flag ok'; }
else { flag.textContent = ''; flag.className = 'wd-flag'; }
}
// Hero image — distinct room shot or generated room backdrop; fall back to swatch
const roomImg = document.getElementById('detail-room');
const hero = product.room || product.image || product.rawImage;
if (roomImg) {
if (hero) {
roomImg.src = hero.charAt(0) === '/' ? hero : ('/api/proxy/image?url=' + encodeURIComponent(hero));
roomImg.parentElement.style.display = 'block';
} else { roomImg.parentElement.style.display = 'none'; roomImg.removeAttribute('src'); }
}
// CTA label by mode
const cta = document.getElementById('btn-view-dw');
if (cta) cta.textContent = product.cta_mode === 'live' ? 'View on Designer Wallcoverings' : 'Request a Memo Sample';
// Feed the NOW VIEWING media-player strip in lock-step with the detail card.
updateNowViewing(product);
// GUIDED MODE: large readable title + N-of-N up top, for senior legibility.
updateGuidedTitle(product);
}
// NOW VIEWING navigator — populate the museum media-player strip from the focused
// board: swatch thumb + serif pattern name + tracked colorway + 27×27 spec + the
// global N-of-N progress (across the whole window, not just the visible boards).
function updateNowViewing(product) {
const nv = document.getElementById('now-viewing');
if (!nv) return;
nv.classList.remove('hidden');
const { pattern, colorway } = splitPatternColor(product.pattern_name || product.name);
const nm = document.getElementById('nv-name');
if (nm) nm.textContent = pattern || 'Pattern';
// Colorway: real colorway > derived from title > collection name (never the literal
// placeholder word). Hide the separator dot if there's genuinely nothing to show.
const col = document.getElementById('nv-color');
const dot = document.querySelector('#now-viewing .nv-dot');
const cw = product.color || colorway || product.collection || '';
if (col) col.textContent = cw;
if (dot) dot.style.display = cw ? '' : 'none';
// Spec: width × repeat (China Seas straight-match = 27″ × 27″). Falls back gracefully.
const fmtIn = (n) => (n || n === 0) ? `${(Math.round(n * 10) / 10)}″` : '';
const w = product.width_inches, r = product.repeat_inches;
const specEl = document.getElementById('nv-spec');
if (specEl) specEl.textContent = (w && r) ? `${fmtIn(w)} × ${fmtIn(r)}` : (w ? fmtIn(w) : '27″ × 27″');
// Swatch thumb — the actual wallcovering image (not the room shot).
const thumb = document.getElementById('nv-thumb');
const src = product.image || product.rawImage || product.room;
if (thumb && src) thumb.src = src.charAt(0) === '/' ? src : ('/api/proxy/image?url=' + encodeURIComponent(src));
// N of N — global position across the active window/collection.
const idx = (typeof windowOffset === 'number' ? windowOffset : 0) +
(focusedWing && focusedWing.userData ? focusedWing.userData.index : 0) + 1;
const total = windowTotal || products.length || 0;
const iEl = document.getElementById('nv-index'); if (iEl) iEl.textContent = idx;
const tEl = document.getElementById('nv-total'); if (tEl) tEl.textContent = total || '—';
const fill = document.getElementById('nv-fill');
if (fill) fill.style.width = (total ? Math.max(2, Math.min(100, (idx / total) * 100)) : 2) + '%';
syncNowViewingPlay();
}
// Keep the strip's play/pause glyph in sync with the Peruse tour state.
function syncNowViewingPlay() {
const pb = document.getElementById('nv-play');
if (!pb) return;
pb.classList.toggle('playing', !!peruseActive);
pb.innerHTML = peruseActive ? '❚❚' : '▷';
pb.title = peruseActive ? 'Pause the tour' : 'Peruse (auto-tour)';
}
function hideNowViewing() {
const nv = document.getElementById('now-viewing');
if (nv) nv.classList.add('hidden');
}
function updateOpenBtnState() {
const btnL = document.getElementById('btn-fan-left');
const btnR = document.getElementById('btn-fan-right');
if (btnL) { btnL.classList.remove('active'); btnL.innerHTML = '\u25C4 Back'; }
if (btnR) { btnR.classList.remove('active'); btnR.innerHTML = 'Next \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 => { 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';
}
// ============================================================
// WASD WALKING — first-person navigation
// ============================================================
// ============================================================
// YAW-SPIN-ONLY camera (Slice-1 canonical). The camera is FIXED at room centre and
// NEVER moves position. Arrow Left/Right (and A/D) rotate the look IN PLACE by spinning
// the orbit target around the fixed camera position; Up/Down (W/S) apply a small pitch.
// Yaw is CLAMPED to a forward arc so the user pans left wall → wings → right wall → down
// to the table, but never faces behind the wings.
// ============================================================
const SPIN_YAW_SPEED = 1.6; // rad/s of yaw under held arrow
const SPIN_PITCH_SPEED = 0.9; // rad/s of pitch under held arrow
const YAW_CLAMP = 72 * Math.PI / 180; // ±72° off wing-facing (-z) — the look-vector z stays negative so the leftmost/rightmost boards + side wall stay in frame; never an empty room, never behind the wings
// Chunk K: deepened the downward clamp from -0.62 (~36°) to -1.05 (~60°) so the user can
// actually pitch down far enough to look AT their own first-person body — the torso, lap,
// hands and feet fill the lower frame, proving "BE Steve". At -36° the sight line shot well
// past the body onto the floor 2m ahead (the body sat below the frame). -60° still keeps the
// never-behind yaw clamp + lands on the consultation table when yawed to the nook.
const PITCH_MIN = -1.05; // look down ~60° (to your own body / lap / the sample table)
const PITCH_MAX = 0.30; // look up ~17°
const TARGET_DIST = 2.0; // distance the look-target sits ahead of the fixed camera
// Spin pose: yaw=0 faces -z (the wing bank); +yaw turns toward +x (right wall).
let spinYaw = 0, spinPitch = -0.08;
let lockedSpinEye = null; // the immutable fixed-centre eye point (set at init)
// Rebuild controls.target from the current fixed camera position + spin yaw/pitch.
function applySpinPose() {
if (!camera || !controls) return;
const cp = camera.position;
// yaw=0 → direction -z; +yaw rotates toward +x.
const cy = Math.cos(spinPitch);
const dir = new THREE.Vector3(
Math.sin(spinYaw) * cy,
Math.sin(spinPitch),
-Math.cos(spinYaw) * cy
);
controls.target.set(cp.x + dir.x * TARGET_DIST, cp.y + dir.y * TARGET_DIST, cp.z + dir.z * TARGET_DIST);
}
// GROUNDED WALKABLE BOX (Steve 2026-06-30: "views always grounded… no behind the closet").
// The avatar roams a safe central rectangle that excludes every furniture keep-out so it can
// never clip into or walk behind the closet/sample-rack, the consultation nook, or the olive
// planter — and, because the eye height is never touched, never goes aerial. Room spans x/z
// ±3.05. Front limit z=-1.0 keeps the body BEHIND the nook (z=-1.1) + olive (z=-1.3) and well
// clear of the open book / rack (z ≤ -2.4); the box stays inside the side walls (±1.7) and
// stops short of the back wall (+2.3). Within this box the user wanders freely and always
// faces the bank from a comfortable distance.
const WALK_MIN_X = -1.7, WALK_MAX_X = 1.7;
const WALK_MIN_Z = -1.0, WALK_MAX_Z = 2.3;
function clampWalk(nx, nz) {
return [
Math.max(WALK_MIN_X, Math.min(WALK_MAX_X, nx)),
Math.max(WALK_MIN_Z, Math.min(WALK_MAX_Z, nz))
];
}
// Move the avatar eye by `step` metres along its current facing (XZ floor projection),
// clamped to the grounded walkable box. Shared by arrow-walk + scroll-walk. Eye height is
// NEVER changed here — that is what keeps every view grounded (no aerial).
function walkForward(step) {
if (!lockedSpinEye) return;
const fx = Math.sin(spinYaw), fz = -Math.cos(spinYaw); // yaw=0 faces -Z
const [nx, nz] = clampWalk(lockedSpinEye.x + fx * step, lockedSpinEye.z + fz * step);
lockedSpinEye.x = nx; lockedSpinEye.z = nz;
if (camera) { camera.position.x = nx; camera.position.z = nz; }
applySpinPose(); // re-aim the look-target from the new eye position
}
// ============================================================
// MOUSE NAVIGATION — scroll = walk, drag = turn (grounded, never orbits)
// ============================================================
const WHEEL_STEP = 0.22; // metres the eye walks per wheel notch (gentle)
const DRAG_YAW_PER_PX = 0.005; // radians of body-turn per pixel of horizontal drag
const DRAG_THRESH = 6; // px of movement before a press is a drag (turn) not a click
// Scroll wheel walks the avatar forward/back along its facing — the mouse-only twin of ↑/↓.
// Disabled while focused/animating/recentering. Never dollies the camera (no orbit zoom),
// so a scroll can never get "too close" past the walkable box.
function onCanvasWheel(e) {
if (!exploreMode || controlsLocked || cameraAnim || recenterTween) return;
e.preventDefault();
walkForward((e.deltaY < 0 ? 1 : -1) * WHEEL_STEP); // scroll up = forward
if (window._requestShadowUpdate) window._requestShadowUpdate(4);
}
// Drag-to-turn: horizontal drag yaws the body (the mouse twin of A/D). VERTICAL drag is
// ignored on purpose — that is what keeps the mouse "always grounded" (no drag-to-aerial).
// A press that never crosses DRAG_THRESH falls through to onCanvasClick as a normal select.
let _ptr = null, _dragTurned = false;
function onCanvasPointerDown(e) {
if (!exploreMode || controlsLocked) { _ptr = null; _dragTurned = false; return; }
_ptr = { x0: e.clientX, y0: e.clientY, yaw0: spinYaw, moved: 0 };
_dragTurned = false;
}
function onCanvasPointerMove(e) {
if (!_ptr) return;
const dx = e.clientX - _ptr.x0;
_ptr.moved = Math.max(_ptr.moved, Math.abs(dx) + Math.abs(e.clientY - _ptr.y0));
if (_ptr.moved > DRAG_THRESH) {
_dragTurned = true;
spinYaw = _ptr.yaw0 + dx * DRAG_YAW_PER_PX; // unclamped: free to look around the room (still grounded)
applySpinPose();
}
}
function onCanvasPointerUp() { _ptr = null; }
// ============================================================
// RETURN TO CENTRE — always-available reset to the boot pose
// ============================================================
// Glides the avatar back to the middle of the room, facing the bank, at eye level. Wired to
// the ⊕ Center button, the 'C'/Home keys, and used as the deterministic boot pose.
let recenterTween = null;
function recenterView(animated) {
const sp = CONFIG.camera.startPos;
const toEye = new THREE.Vector3(sp.x, sp.y, sp.z);
if (!animated || !lockedSpinEye || !camera) {
lockedSpinEye = toEye.clone();
spinYaw = 0; spinPitch = BOOT_PITCH;
if (camera) camera.position.copy(lockedSpinEye);
applySpinPose();
if (typeof updateAvatarRig === 'function') updateAvatarRig();
recenterTween = null;
return;
}
recenterTween = {
fromEye: lockedSpinEye.clone(), toEye,
fromYaw: spinYaw, toYaw: 0,
fromPitch: spinPitch, toPitch: BOOT_PITCH,
t: 0, dur: 0.5
};
if (window._requestShadowUpdate) window._requestShadowUpdate(40);
}
// Public entry the button + keys call: if the user is focused on a wing, step out first
// (that fly-back already re-centres the look); otherwise glide home.
function requestRecenter() {
if (controlsLocked) { try { unfocusWing(); } catch (e) { console.warn('unfocusWing failed:', e); } return; }
if (cameraAnim) return;
recenterView(true);
}
// Advance the recenter glide one frame; returns true while it owns the camera so animate()
// skips updateWASD for that frame.
function tickRecenter(dt) {
if (!recenterTween) return false;
recenterTween.t += dt / recenterTween.dur;
const e = recenterTween.t >= 1 ? 1 : (1 - Math.pow(1 - recenterTween.t, 3)); // cubic ease-out
lockedSpinEye.lerpVectors(recenterTween.fromEye, recenterTween.toEye, e);
spinYaw = recenterTween.fromYaw + (recenterTween.toYaw - recenterTween.fromYaw) * e;
spinPitch = recenterTween.fromPitch + (recenterTween.toPitch - recenterTween.fromPitch) * e;
if (camera) camera.position.copy(lockedSpinEye);
applySpinPose();
if (recenterTween.t >= 1) recenterTween = null;
return true;
}
function updateWASD(dt) {
if (controlsLocked || cameraAnim) return; // no input when focused on a wing or animating
// ── EXPLORE / TAKEOVER (default): you ARE the avatar. ↑/↓ = walk forward/back along the
// facing direction; A/D = TURN the body (yaw). ←/→ ARROWS page Prev/Next design (handled in
// keydown) — NOT turn — so browsing the wings and steering the body don't collide.
if (exploreMode) {
let moveIn = 0, turnIn = 0;
if (keysDown['ArrowUp'] || keysDown['KeyW']) moveIn += 1; // walk forward
if (keysDown['ArrowDown'] || keysDown['KeyS']) moveIn -= 1; // walk back
if (keysDown['KeyA']) turnIn -= 1; // turn body left (A — arrows page patterns)
if (keysDown['KeyD']) turnIn += 1; // turn body right (D)
// Turn the body first so a held forward walks along the NEW facing this frame.
if (turnIn !== 0) { spinYaw += turnIn * SPIN_YAW_SPEED * dt; applySpinPose(); }
if (moveIn !== 0) walkForward(moveIn * WALK_SPEED * dt); // grounded, box-clamped
return;
}
// ── GUIDED (Explore off): the legacy fixed-centre yaw-spin. ←/→ spin the look,
// ↑/↓ pitch it; the eye never moves.
let yawIn = 0, pitchIn = 0;
if (keysDown['KeyA']) yawIn -= 1; // A/D yaw (←/→ arrows page patterns in book mode)
if (keysDown['KeyD']) yawIn += 1;
if (keysDown['ArrowUp'] || keysDown['KeyW']) pitchIn += 1;
if (keysDown['ArrowDown'] || keysDown['KeyS']) pitchIn -= 1;
if (yawIn === 0 && pitchIn === 0) return;
spinYaw = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, spinYaw + yawIn * SPIN_YAW_SPEED * dt));
spinPitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, spinPitch + pitchIn * SPIN_PITCH_SPEED * dt));
applySpinPose(); // camera position is NEVER touched — only the look-target rotates
}
// ============================================================
// 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 => {
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();
// ARC-RACK FLIP — single-vertical-hinge swing. The focused board eases from its packed
// closed-rake to open/flat (facing the room centre, the hero); every other board eases
// back to closed-rake. QHRack.flipUpdate does the math + applies pivot.rotation.y; we
// just resolve which board is the open hero + lazy-load its design + keep shadows fresh.
function updateBooks(dt) {
if (!wingBoards.length) return;
// In explore mode, a walked-up neighbour (within ~4 ft) opens like the focused board.
let nearest = null;
const interactive = exploreMode && proximityEnabled && !controlsLocked && !peruseActive && !cameraAnim;
if (interactive) {
const camX = camera.position.x, camZ = camera.position.z;
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]; }
}
}
// The single OPEN board = the focused one (guided/peruse), else the walked-up neighbour,
// else the RESTING-OPEN middle board (Slice-1 boot visual: middle open-at-angle, no focus,
// no detail panel, nothing "selected"). restingOpenWing is cleared the moment a real
// focus/selection happens so it never fights an actual click.
const openPivot = (focusedWing && !focusedWing.userData.panelClosed) ? focusedWing
: (nearest || (restingOpenWing && !restingOpenWing.userData.panelClosed ? restingOpenWing : null));
// Lazy-load the open board's full design the moment it begins to swing open.
if (openPivot && !openPivot.userData.imageLoaded) { openPivot.userData.imageLoaded = true; loadWingBook(openPivot); }
// A real focus opens fully flat (hero); the resting-open middle eases to the
// slider-controlled OPEN_ANGLE_DEG (default 45°) so it reads as "open at an angle",
// not a flat selected hero. flip = openAngle / rake (both in radians), so the board's
// actual swing off closed equals OPEN_ANGLE_DEG; clamp to [0,1].
const isRestingOnly = (openPivot && openPivot === restingOpenWing && openPivot !== focusedWing);
let restFlip = 1;
if (isRestingOnly) {
const rake = openPivot.userData.rake || (73 * Math.PI / 180);
restFlip = Math.max(0, Math.min(1, (OPEN_ANGLE_DEG * Math.PI / 180) / rake));
} else if (openPivot && openPivot === focusedWing) {
// Wing° slider (VIEW_OPEN, radians) rakes the FOCUSED open board off flat:
// 0°=flat/dead-on (flip 1, the hero), higher = raked back toward closed.
// Previously VIEW_OPEN was set by the slider but never read → slider did nothing.
const rake = openPivot.userData.rake || (73 * Math.PI / 180);
restFlip = Math.max(0, Math.min(1, (rake - VIEW_OPEN) / rake));
}
const moved = QHRack.flipUpdate(wingBoards, openPivot, dt, restFlip);
// SHADOW REBAKE BUDGET: don't rebake the (2 lights × 238-mesh) shadow maps EVERY
// moving frame — that's the dominant nav cost. Rebake once when a flip STARTS (catch
// the first frame of motion) and once when it SETTLES (motion→still), not in between.
// The packed closed boards barely shift their footprint mid-swing, so the visual cost
// of skipping the interim frames is negligible while the FPS win is large.
if (moved && !_flipWasMoving) { if (window._requestShadowUpdate) window._requestShadowUpdate(2); }
if (!moved && _flipWasMoving) { if (window._requestShadowUpdate) window._requestShadowUpdate(4); } // settle
_flipWasMoving = moved;
}
let _flipWasMoving = false;
// ============================================================
// ANIMATION LOOP
// ============================================================
// ON-DEMAND SHADOWS — keep the shadow map fresh for N frames after any change so
// animated board opens/closes + camera settles bake correctly, then go idle (saving
// the per-frame shadow re-render). Call requestShadowUpdate() on every state change.
let _shadowFrames = 0;
function requestShadowUpdate(frames) { _shadowFrames = Math.max(_shadowFrames, frames || 12); }
window._requestShadowUpdate = requestShadowUpdate;
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
// On-demand shadow refresh: only re-render the shadow maps while something is moving.
if (_shadowFrames > 0) { renderer.shadowMap.needsUpdate = true; _shadowFrames--; }
// Also refresh while a board leaf is mid-animation or the camera is flying.
if (cameraAnim || animatingWings.length) renderer.shadowMap.needsUpdate = true;
// 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);
if (cameraAnim.endFov !== undefined && Math.abs(camera.fov - cameraAnim.endFov) > 0.01) {
camera.fov = cameraAnim.endFov; camera.updateProjectionMatrix();
}
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);
if (cameraAnim.endFov !== undefined && cameraAnim.startFov !== cameraAnim.endFov) {
camera.fov = cameraAnim.startFov + (cameraAnim.endFov - cameraAnim.startFov) * e;
camera.updateProjectionMatrix();
}
}
}
// 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) {
// FIRST-PERSON: the arrow keys drive everything. updateWASD walks the eye (explore)
// or spins the look-target (guided); either way we then aim the camera directly at the
// resolved look-target. We deliberately do NOT call controls.update() — OrbitControls
// would re-derive the camera position around the target and fight the walk/spin pose.
// A return-to-centre glide, when active, owns the pose this frame (skip live input).
if (!tickRecenter(dt)) updateWASD(dt);
const fixedEye = lockedSpinEye || camera.position;
camera.position.copy(fixedEye); // hard-pin the eye to the walked/centred point
camera.lookAt(controls.target);
}
// Chunk K — keep Steve's first-person body yawed with the look + pinned under the eye
// (POSITION + YAW only; pitch is the camera's alone so a look-down reveals the body).
updateAvatarRig();
// 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;
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;
}
}
// Carousel conveyor (Chunk I) — slide the rack so the selected board reaches the
// dead-front centre slot, then fire its open. Runs before updateBooks so the board is
// re-placed on the rail BEFORE flipUpdate eases its swing this frame.
updateCarousel(dt);
// Proximity book-match — nearest wing opens its two leaves when you're within 4 ft
updateBooks(dt);
// Closed-sliver REAL thumbnails — stream a low-res real design onto every closed
// board so the packed arc reads as the actual catalogue (PJ photo), not one synthetic
// pattern. Capped concurrency + own stagger inside; cheap to poll each frame.
// This SUPERSEDES the old lazyLoadNearbyTextures() full-res preload of closed boards
// (which uploaded 125 KB heroes onto slivers you only see 4" of — wasteful, and it
// raced the thumb loader by pre-setting imageLoaded). Full-res now loads ONLY when a
// board actually opens (focusOnWing / updateBooks). Net: lighter + the slivers are real.
pumpSliverThumbs();
// Update minimap every 10 frames (cheap but no need every frame)
if (frameCount % 10 === 0) updateMinimap();
// FPS / ms / draw-calls HUD readout (Slice-3 / chunk F: measurable on demand).
// FPS + frame-ms (1000/fps) + this frame's GPU draw-call count, so a nav sweep's
// cost is visible without external tooling. window._perf carries the last sample
// for the headless proof harness to read.
frameCount++;
const now = performance.now();
if (now - lastFpsTime > 1000) {
const fps = Math.round(frameCount * 1000 / (now - lastFpsTime));
const ms = +(1000 / Math.max(1, fps)).toFixed(1);
const draws = renderer.info ? renderer.info.render.calls : 0;
window._perf = { fps, ms, draws, pixelRatio: renderer.getPixelRatio() };
const el = document.getElementById('fps-counter');
if (el) {
el.textContent = fps + ' FPS · ' + ms + ' ms · ' + draws + ' draws';
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;
}
// View-mode engine per-frame hook (orbit auto-spin, carousel turntable, macro drift,
// perf readout). Installed by public/js/viewmodes.js; null when no module loaded.
if (window._qh && typeof window._qh.frameHook === 'function') {
try { window._qh.frameHook(dt); } catch (e) { /* never let a mode break the loop */ }
}
// Version overlay per-frame hook (numbered-pin reprojection). Installed by
// public/js/versions.js; null when the overlay is off / module not loaded.
if (window._qh && typeof window._qh.overlayHook === 'function') {
try { window._qh.overlayHook(dt); } catch (e) { /* never let the overlay break the loop */ }
}
renderer.render(scene, camera);
}
// ============================================================
// PERUSE — endless auto-tour through the whole collection
// ============================================================
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;
setPeruseBtn(true);
peruseIndex = focusedWing ? focusedWing.userData.index : 0;
document.getElementById('info-text').textContent = 'Perusing the collection… (P, click, or move to stop)';
peruseStep();
}
function peruseStep() {
if (!peruseActive) return;
if (peruseIndex >= wingBoards.length) peruseIndex = 0;
const pivot = wingBoards[peruseIndex];
if (!pivot) { stopPeruse(); return; }
// Fly to 4 ft back, centered; the board fans its panels open (updateBooks opens
// the focused board) to present its single design, dwell PERUSE_DWELL, then advance.
focusOnWing(pivot);
peruseTimer = setTimeout(async () => {
if (!peruseActive) return;
peruseIndex++;
if (peruseIndex >= wingBoards.length) {
// End of this 50-wing window → page forward (wraps last→first) = truly endless
await advanceWindow(1);
peruseIndex = 0;
}
if (peruseActive) peruseStep();
}, PERUSE_DWELL);
}
function stopPeruse(keepFocus) {
peruseActive = false;
if (peruseTimer) { clearTimeout(peruseTimer); peruseTimer = null; }
setPeruseBtn(false);
if (!keepFocus) {
if (fanWings.length) closeFanCascade();
if (focusedWing) unfocusWing();
document.getElementById('info-text').textContent = 'WASD to walk · Click a wing to inspect · P to Peruse';
} else {
document.getElementById('info-text').textContent = 'Paused. Esc to step back · P to resume the tour';
}
}
function setPeruseBtn(on) {
const b = document.getElementById('btn-peruse');
if (b) {
b.classList.toggle('active', on);
b.innerHTML = on ? '■ Stop Perusing' : '▷ Peruse Collection';
}
syncNowViewingPlay(); // keep the navigator strip's play glyph in lock-step
syncGuidedTourBtn(); // keep the big Auto-Tour button in lock-step
}
// ============================================================
// GUIDED MODE — senior-first navigation (DTD verdict A, 2026-06-27)
// ============================================================
// Apply the Explore on/off state everywhere: orbit controls, the toggle button
// label/look, and the bottom instruction. OFF (default) = guided, no keyboard.
function applyExploreMode() {
if (controls && !controlsLocked) {
// Single-ownership navigation (Steve 2026-06-30): the rig owns all movement in BOTH
// modes, so OrbitControls' self-updating rotate/pan/zoom stay OFF — no free orbit means
// no "too close" dolly, no aerial tilt, no swinging behind the furniture.
controls.enableRotate = false;
controls.enablePan = false;
controls.enableZoom = false;
}
const btn = document.getElementById('btn-explore');
if (btn) {
btn.classList.toggle('active', exploreMode);
btn.textContent = exploreMode ? 'Explore: On' : 'Explore: Off';
btn.title = exploreMode
? 'Walking is ON — scroll or ↑↓ to walk, drag or A/D to turn, ⊕ Center to return. Tap to turn off.'
: 'Turn on walking — scroll or arrows to move around the room. Off by default.';
}
const bm = document.getElementById('btn-bookmatch');
if (bm) bm.style.display = exploreMode ? '' : 'none'; // walk-up book-match is an explore-only affordance
// Body class drives which nav surface shows: guided (big bar + title) vs explore
// (the compact Now-Viewing strip + bottom HUD). They share screen real estate, so
// only one set is visible at a time. The big detail card stays in both.
document.body.classList.toggle('explore-mode', exploreMode);
document.body.classList.toggle('guided-mode', !exploreMode);
updateGuidedInstruction();
}
// Let the view-mode engine (Walk / Carousel) flip native Explore on/off without
// looping back through the panel. Sets the state + applies it; no localStorage write
// so a view-mode choice doesn't permanently change the user's Explore preference.
window._toggleExploreFromViewmode = function (on) {
exploreMode = !!on;
applyExploreMode();
};
function toggleExplore() {
exploreMode = !exploreMode;
localStorage.setItem('qh_explore', exploreMode ? '1' : '0');
applyExploreMode();
if (exploreMode) {
// Stepping into Explore: release any guided focus so the user can walk freely.
if (peruseActive) stopPeruse(true);
if (focusedWing) unfocusWing();
const t = document.getElementById('info-text');
if (t) t.textContent = 'Scroll or ↑↓ to walk · drag or A/D to turn · ⊕ Center to return · Click a board to view it.';
} else {
// Back to guided: refly to a board so the big buttons have something to drive.
enterGuidedDefault();
}
}
function updateGuidedInstruction() {
const el = document.getElementById('guided-instruction');
if (!el) return;
if (peruseActive) {
el.innerHTML = 'Watching all designs… tap <strong>Pause</strong> to stop, or <strong>Next</strong> to step ahead.';
} else if (exploreMode) {
el.innerHTML = '<strong>Scroll</strong> or <strong>↑↓</strong> to walk · <strong>drag</strong> to turn · <strong>⊕ Center</strong> to return.';
} else {
el.innerHTML = 'Tap <strong>Next</strong> to see the next design — or <strong>Auto Tour</strong> to watch them all.';
}
}
// Large readable pattern title (top-centre) + N-of-N position. Driven from the
// focused board so it stays in lock-step with Now Viewing + the detail card.
function updateGuidedTitle(product) {
const wrap = document.getElementById('guided-title');
if (!wrap) return;
if (!product) { wrap.classList.add('hidden'); return; }
wrap.classList.remove('hidden');
const { pattern, colorway } = splitPatternColor(product.pattern_name || product.name);
const nm = document.getElementById('gt-name');
if (nm) nm.textContent = pattern || 'Pattern';
const cw = product.color || colorway || product.collection || '';
const colEl = document.getElementById('gt-color');
const dot = document.getElementById('gt-dot');
if (colEl) colEl.textContent = cw;
const idx = (typeof windowOffset === 'number' ? windowOffset : 0) +
(focusedWing && focusedWing.userData ? focusedWing.userData.index : 0) + 1;
const total = windowTotal || products.length || 0;
const posEl = document.getElementById('gt-pos');
if (posEl) posEl.textContent = total ? ('Design ' + idx + ' of ' + total) : '';
if (dot) dot.style.display = (cw && total) ? '' : 'none';
}
function hideGuidedTitle() {
const wrap = document.getElementById('guided-title');
if (wrap) wrap.classList.add('hidden');
}
// Keep the big Auto-Tour button glyph/label + state in sync with Peruse.
function syncGuidedTourBtn() {
const b = document.getElementById('g-tour');
if (!b) return;
b.classList.toggle('playing', !!peruseActive);
b.innerHTML = peruseActive ? '❚❚ Pause' : '▷ Auto Tour';
b.title = peruseActive ? 'Pause the tour' : 'Watch every design hands-free';
updateGuidedInstruction();
}
// On load (and when leaving Explore), fly to the MIDDLE board, open it, and present
// its design — so the big Previous/Next/Auto-Tour buttons immediately have a hero.
function enterGuidedDefault() {
// CENTER BOOK is the default presentation — there is no resting-open arc board to set.
if (BOOK_MODE) { guidedReady = true; return; }
if (!wingBoards.length) return;
// BOOT RACE GUARD: if the user clicked a board while the collection was still loading, that
// queued click has already been replayed into a real selection — do NOT overwrite their
// choice with the resting-middle default. Their click wins (the whole point of the guard).
if (_bootClickConsumed) return;
// Slice-1 boot: the MIDDLE board opens AT AN ANGLE as the resting visual — NO camera
// lock (stays fixed-centre spin pose), NO detail panel, and NOTHING marked "selected".
// (Selecting/clicking → focusOnWing is the later slice.) Other boards keep their slivers.
const mid = Math.floor(wingBoards.length / 2);
restoreBoards();
focusedWing = null; // not a selection
// NEUTRAL walls + NO detail panel at rest/home — a transient boot-time Hero focus
// (viewmodes setMode 'hero') now clads AND shows the detail card (Slice-2 always-clad
// + collapse-on-load), so the resting/home pose must explicitly strip both back.
// Boot = no selection, no panel, neutral walls (Slice-1 invariant).
revertWalls();
const _wd = document.getElementById('wing-detail'); if (_wd) _wd.classList.add('hidden');
hideNowViewing();
hideGuidedTitle();
restingOpenWing = wingBoards[mid];
restingOpenWing.userData.panelClosed = false; // ease open via flipUpdate (partial angle)
// Eagerly load the middle board's design so the open hero shows its pattern immediately.
if (restingOpenWing.userData.pendingImage && !restingOpenWing.userData.imageLoaded) {
restingOpenWing.userData.imageLoaded = true;
loadWingBook(restingOpenWing);
}
guidedReady = true;
}
// Slice-1 canonical rest: snap the camera to the FIXED-CENTRE spin pose facing the
// wing bank, unlock, hide the detail panel + guided title, drop any selection, and set
// the resting-open middle board. This is the home pose for the build — used at boot and
// when V1 is (re)applied. NO camera lock, NO detail panel, NOTHING "selected".
function enterFixedCentre() {
// CENTER BOOK is the default presentation; the arc fixed-centre rest pose does not apply.
// (V1's boot apply still calls this — keep it a no-op so it can't disturb the book/eye.)
if (BOOK_MODE) return;
// BOOT RACE GUARD: during the boot window, if the user has a queued/just-consumed boot click,
// the version engine's boot-time V1 apply (versions.js → enterFixedCentre) must NOT wipe
// their selection. Their click is the authoritative final boot action (replayed at +2600ms).
// This yields ONLY during boot (_bootFixedCentreYield); a deliberate V1 click later resets
// normally. Without this, a slow-load V1 build that lands after the drain would clobber it.
if (_bootFixedCentreYield && (_pendingBootClick || _bootClickConsumed)) return;
if (focusedWing) focusedWing.userData.panelClosed = true;
focusedWing = null;
// Hard-snap the carousel home (middle board centred, no in-flight shift) so a V1
// (re)apply / canonical-rest always boots with the rack at its natural layout.
carouselActive = false; _carouselPending = null;
if (window.QHRack && wingBoards.length) { QHRack.setIndexOffset(0); QHRack.relayout(wingBoards, CONFIG.wing, REVEAL); carouselTarget = 0; }
if (controlsLocked) unlockControls();
previousCamPos = null; previousCamTarget = null;
const wd = document.getElementById('wing-detail'); if (wd) wd.classList.add('hidden');
hideGuidedTitle();
// TAKEOVER: in explore/walk mode the eye belongs to the user — never snatch it back to
// centre on a V1 (re)apply, just make sure it exists. In guided mode, pin the eye at
// room centre facing the bank (the legacy fixed-centre rest pose).
if (!lockedSpinEye) {
camera.position.set(CONFIG.camera.startPos.x, CONFIG.camera.startPos.y, CONFIG.camera.startPos.z);
lockedSpinEye = camera.position.clone();
}
if (!exploreMode) {
camera.position.copy(lockedSpinEye);
spinYaw = 0; spinPitch = BOOT_PITCH;
}
applySpinPose();
if (camera.fov !== FOV_DEFAULT) { targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix(); }
camera.lookAt(controls.target);
enterGuidedDefault(); // sets the resting-open middle (no focus, no panel)
}
// ALL DESIGNS — big-thumbnail grid overlay. Builds from the live window products;
// tapping a card closes the overlay and flies to that board. Familiar web-grid
// browse path for users who find spatial navigation hard.
function buildAllDesignsGrid() {
const host = document.getElementById('grid-items');
if (!host) return;
host.innerHTML = '';
const list = products.slice(0, windowSize);
list.forEach((p, i) => {
const card = document.createElement('div');
card.className = 'go-card';
const img = document.createElement('img');
img.className = 'go-img';
img.loading = 'lazy';
const src = p.tile || p.image || p.room;
if (src) img.src = src.charAt(0) === '/' ? src : ('/api/proxy/image?url=' + encodeURIComponent(src));
img.alt = (p.pattern_name || p.name || 'Design');
const cap = document.createElement('div'); cap.className = 'go-cap';
const { pattern, colorway } = splitPatternColor(p.pattern_name || p.name);
const pat = document.createElement('div'); pat.className = 'go-pat'; pat.textContent = pattern || 'Pattern';
const col = document.createElement('div'); col.className = 'go-col';
col.textContent = p.color_name || p.color || colorway || '';
cap.appendChild(pat); cap.appendChild(col);
card.appendChild(img); card.appendChild(cap);
card.addEventListener('click', () => {
closeAllDesignsGrid();
if (peruseActive) stopPeruse(true);
restoreBoards();
if (wingBoards[i]) focusOnWing(wingBoards[i]);
});
host.appendChild(card);
});
}
function openAllDesignsGrid() {
if (peruseActive) stopPeruse(true);
buildAllDesignsGrid();
const ov = document.getElementById('grid-overlay');
if (ov) ov.classList.add('open');
}
function closeAllDesignsGrid() {
const ov = document.getElementById('grid-overlay');
if (ov) ov.classList.remove('open');
}
// ============================================================
// 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());
const dCol = document.getElementById('detail-collapse');
if (dCol) dCol.addEventListener('click', () => setDetailCollapsed(!detailCollapsed));
document.getElementById('btn-fan-left').addEventListener('click', () => gotoBoardOffset(-1)); // Back
document.getElementById('btn-fan-right').addEventListener('click', () => gotoBoardOffset(1)); // Next
// NOW VIEWING navigator transport — reuses the same board-flip + Peruse engine.
const nvBack = document.getElementById('nv-back');
const nvNext = document.getElementById('nv-next');
const nvPlay = document.getElementById('nv-play');
if (nvBack) nvBack.addEventListener('click', () => { stopPeruse(true); gotoBoardOffset(-1); });
if (nvNext) nvNext.addEventListener('click', () => { stopPeruse(true); gotoBoardOffset(1); });
if (nvPlay) nvPlay.addEventListener('click', () => togglePeruse());
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(); }
}
});
// ---- Quadrille House additions ----
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.classList.toggle('active', wallsAutoClad); // sync button to default (ON — Slice-2)
if (wb) wb.addEventListener('click', () => {
wallsAutoClad = !wallsAutoClad;
wb.classList.toggle('active', wallsAutoClad);
if (wallsAutoClad && focusedWing) cladWalls(focusedWing.userData.product.image, focusedWing.userData.product.isSeamlessTile);
else revertWalls();
document.getElementById('info-text').textContent = wallsAutoClad ? 'Walls: wallpapered on focus' : 'Walls: neutral';
});
const vd = document.getElementById('btn-view-dw');
if (vd) vd.addEventListener('click', () => {
const p = window._activeProduct;
if (p && p.store_url) window.open(p.store_url, '_blank', 'noopener');
});
const prevB = document.getElementById('btn-prev-window');
const nextB = document.getElementById('btn-next-window');
if (prevB) prevB.addEventListener('click', () => { stopPeruse(); advanceWindow(-1); });
if (nextB) nextB.addEventListener('click', () => { stopPeruse(); advanceWindow(1); });
const sortSel = document.getElementById('sort-select');
if (sortSel) {
sortSel.value = currentSort; // reflect the persisted choice into the control on boot
sortSel.addEventListener('change', async () => {
stopPeruse(); currentSort = sortSel.value;
localStorage.setItem('qh_sort', currentSort); // standing rule: sort persists across reloads
await fetchWindow(0); rebuildWingWall();
});
}
// Wing-angle slider — live, persisted. Controls the open-board VIEW angle.
const ang = document.getElementById('angle-range');
const angVal = document.getElementById('angle-val');
if (ang) {
ang.value = Math.round(VIEW_OPEN * 180 / Math.PI);
if (angVal) angVal.textContent = ang.value + '°';
ang.addEventListener('input', () => {
const deg = parseInt(ang.value) || 0;
VIEW_OPEN = deg * Math.PI / 180; // updateBooks rakes the focused open board to it live
localStorage.setItem('qh_view_deg', deg);
if (angVal) angVal.textContent = deg + '°';
// Nudge the focused board out of its settled state so flipUpdate re-eases to
// the new Wing° target this frame (flipUpdate only moves boards whose flip != target).
if (focusedWing && window._requestShadowUpdate) window._requestShadowUpdate(6);
});
}
const dens = document.getElementById('density-range');
if (dens) {
dens.value = windowSize; // reflect the persisted density into the slider on boot
dens.addEventListener('change', async () => {
stopPeruse(); windowSize = parseInt(dens.value) || 50;
localStorage.setItem('qh_density', windowSize); // standing rule: density persists across reloads
await fetchWindow(0); rebuildWingWall();
});
}
// Optional visitor AGE on the Sample Tray — a plain data point that rides along with the
// sample request. No server submit exists yet, so it persists to localStorage and is read
// back via window._qh.visitorAge when a request flow is added. (Replaces the removed
// in-showroom Age View: age is captured on the form, it no longer drives the visuals.)
const ageInput = document.getElementById('tray-age-input');
if (ageInput) {
const saved = localStorage.getItem('qh_visitor_age');
if (saved !== null && saved !== '') { const n = parseInt(saved, 10); ageInput.value = saved; visitorAge = Number.isFinite(n) ? n : null; }
ageInput.addEventListener('input', () => {
const v = ageInput.value.trim();
const n = v === '' ? null : Math.max(0, Math.min(120, parseInt(v, 10) || 0));
visitorAge = n;
if (n === null) localStorage.removeItem('qh_visitor_age');
else localStorage.setItem('qh_visitor_age', String(n));
});
}
// Sample-request SUBMIT — POST the tray + age + contact to the server. On success the tray
// clears; on failure everything is kept so the visitor never loses their input.
const submitBtn = document.getElementById('tray-submit');
if (submitBtn) {
const statusEl = document.getElementById('tray-status');
const setStatus = (msg, cls) => { if (statusEl) { statusEl.textContent = msg; statusEl.className = cls || ''; } };
submitBtn.addEventListener('click', async () => {
if (!sampleTray.length || submitBtn.disabled) return;
const val = id => (document.getElementById(id)?.value || '').trim();
const payload = {
samples: sampleTray.map(p => ({ sku: p.sku, pattern_name: p.pattern_name, color: p.color })),
age: visitorAge,
contact: { name: val('tray-name'), email: val('tray-email'), phone: val('tray-phone') },
notes: val('tray-notes'),
};
submitBtn.disabled = true; setStatus('Sending…', '');
try {
const res = await fetch('/api/sample-request', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status));
// Success — clear tray + form + persisted age, then confirm.
sampleTray.length = 0;
['tray-name', 'tray-email', 'tray-phone', 'tray-notes', 'tray-age-input'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
visitorAge = null; localStorage.removeItem('qh_visitor_age');
updateSampleTray(); // clears chips + disables button + stows the empty tray
// Un-stow so the confirmation is actually visible, then auto-tidy if still empty.
const trayEl = document.getElementById('sample-tray');
if (trayEl) { trayEl.classList.remove('qh-stowed'); trayEl.classList.add('visible'); }
setStatus('Request sent — thank you!', 'ok');
setTimeout(() => {
if (!sampleTray.length && trayEl) { trayEl.classList.remove('visible'); trayEl.classList.add('qh-stowed'); setStatus('', ''); }
}, 4500);
} catch (e) {
submitBtn.disabled = false; // keep everything; let them retry
setStatus('Could not send — please try again.', 'err');
console.warn('[sample-request] submit failed:', e.message);
}
});
}
// REVEAL slider — how much of each closed board's pattern shows in the packed arc.
// Live: re-rakes every board's closed yaw via QHRack.relayout (no full rebuild), so
// the open hero stays open while the slivers widen/narrow underneath. Persisted.
const rev = document.getElementById('reveal-range');
const revVal = document.getElementById('reveal-val');
if (rev) {
rev.value = REVEAL;
if (revVal) revVal.textContent = String(Math.round(REVEAL));
rev.addEventListener('input', () => {
REVEAL = parseInt(rev.value);
if (revVal) revVal.textContent = rev.value;
localStorage.setItem('qh_reveal', REVEAL);
if (window.QHRack && wingBoards.length) {
QHRack.relayout(wingBoards, CONFIG.wing, REVEAL);
if (window._requestShadowUpdate) window._requestShadowUpdate(6);
}
});
}
// OPEN-ANGLE slider (Slice-1) — the resting MIDDLE board's open angle on boot.
// Live: updateBooks re-derives the resting board's flip from OPEN_ANGLE_DEG every
// frame, so dragging this re-rakes the open hero in real time. Persisted to
// localStorage('qh_open_angle'). Range 20°–90°, default 45°.
const opn = document.getElementById('open-range');
const opnVal = document.getElementById('open-val');
if (opn) {
// In BOOK_MODE this slider drives the centered open book's forward splay (default 65°,
// Steve's ask); otherwise it drives the legacy arc's resting-board open angle.
opn.value = Math.round(BOOK_MODE ? BOOK_SPLAY_DEG : OPEN_ANGLE_DEG);
if (opnVal) opnVal.textContent = opn.value + '°';
opn.addEventListener('input', () => {
if (BOOK_MODE) {
setBookSplay(parseInt(opn.value) || 20);
if (opnVal) opnVal.textContent = Math.round(BOOK_SPLAY_DEG) + '°';
return;
}
OPEN_ANGLE_DEG = parseInt(opn.value) || 45;
if (opnVal) opnVal.textContent = OPEN_ANGLE_DEG + '°';
localStorage.setItem('qh_open_angle', OPEN_ANGLE_DEG);
// Nudge the resting board out of its settled state so flipUpdate re-eases to the
// new target this frame (flipUpdate only moves boards whose flip != target).
if (restingOpenWing && window._requestShadowUpdate) window._requestShadowUpdate(6);
});
}
// ---- ADJUSTABLE ROOM sliders (Phase 2) — W / D / H. resizeRoom tears down + rebuilds
// the shell + wing arc + active room-type furniture. Fires on 'change' (drag-release)
// so a rebuild isn't triggered every tick. Persisted to qh_room_w / _d / _h.
const rw = document.getElementById('room-w-range'), rwv = document.getElementById('room-w-val');
const rd = document.getElementById('room-d-range'), rdv = document.getElementById('room-d-val');
const rh = document.getElementById('room-h-range'), rhv = document.getElementById('room-h-val');
const _lsNum = (k, d) => { const v = parseFloat(localStorage.getItem(k)); return (v > 0 ? v : d); };
if (rw && rd && rh) {
// Restore persisted dims onto the sliders (do NOT resize on boot — the room-type
// engine builds into the current CONFIG dims; a boot resize would double-build).
rw.value = _lsNum('qh_room_w', CONFIG.room.width);
rd.value = _lsNum('qh_room_d', CONFIG.room.depth);
rh.value = _lsNum('qh_room_h', CONFIG.room.height);
if (rwv) rwv.textContent = (+rw.value).toFixed(1) + 'm';
if (rdv) rdv.textContent = (+rd.value).toFixed(1) + 'm';
if (rhv) rhv.textContent = (+rh.value).toFixed(1) + 'm';
const liveLabel = () => { if (rwv) rwv.textContent = (+rw.value).toFixed(1) + 'm'; if (rdv) rdv.textContent = (+rd.value).toFixed(1) + 'm'; if (rhv) rhv.textContent = (+rh.value).toFixed(1) + 'm'; };
rw.addEventListener('input', liveLabel); rd.addEventListener('input', liveLabel); rh.addEventListener('input', liveLabel);
const doResize = () => { try { resizeRoom(parseFloat(rw.value), parseFloat(rd.value), parseFloat(rh.value)); } catch (e) { console.warn('resizeRoom failed', e); } };
rw.addEventListener('change', doResize); rd.addEventListener('change', doResize); rh.addEventListener('change', doResize);
// If persisted dims differ from the current CONFIG, apply once after boot settles
// (after products + room type are built), so a returning user's saved size restores.
const wantW = _lsNum('qh_room_w', CONFIG.room.width), wantD = _lsNum('qh_room_d', CONFIG.room.depth), wantH = _lsNum('qh_room_h', CONFIG.room.height);
if (Math.abs(wantW - CONFIG.room.width) > 0.05 || Math.abs(wantD - CONFIG.room.depth) > 0.05 || Math.abs(wantH - CONFIG.room.height) > 0.05) {
setTimeout(() => { try { resizeRoom(wantW, wantD, wantH); } catch (e) {} }, 1600);
}
}
// ---- GUIDED MODE wiring (senior-first big buttons) ----
const gPrev = document.getElementById('g-prev');
const gNext = document.getElementById('g-next');
const gTour = document.getElementById('g-tour');
const gGrid = document.getElementById('g-grid');
if (gPrev) gPrev.addEventListener('click', () => { stopPeruse(true); gotoBoardOffset(-1); });
if (gNext) gNext.addEventListener('click', () => { stopPeruse(true); gotoBoardOffset(1); });
if (gTour) gTour.addEventListener('click', () => togglePeruse());
if (gGrid) gGrid.addEventListener('click', () => openAllDesignsGrid());
const gCenter = document.getElementById('g-center');
if (gCenter) gCenter.addEventListener('click', () => { stopPeruse(true); requestRecenter(); });
const goClose = document.getElementById('go-close');
if (goClose) goClose.addEventListener('click', () => closeAllDesignsGrid());
const exp = document.getElementById('btn-explore');
if (exp) exp.addEventListener('click', () => toggleExplore());
// Reflect the persisted Explore state on the controls + toggle button now.
applyExploreMode();
// Mobile d-pad → synthesized WASD
document.querySelectorAll('#dpad button').forEach(btn => {
const code = { f:'KeyW', b:'KeyS', l:'KeyA', r:'KeyD' }[btn.dataset.dir];
const press = (v) => { keysDown[code] = v; if (v && peruseActive) stopPeruse(); };
btn.addEventListener('touchstart', e => { e.preventDefault(); press(true); }, { passive:false });
btn.addEventListener('touchend', e => { e.preventDefault(); press(false); }, { passive:false });
btn.addEventListener('mousedown', () => press(true));
btn.addEventListener('mouseup', () => press(false));
btn.addEventListener('mouseleave', () => press(false));
});
}
function navigateToSection(section) {
if (peruseActive) stopPeruse();
// 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: [[0,1.6,0],[1.25,0.74,-0.55]] }[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 = { 'Showroom Collection':'#888888' };
vendors.forEach(v => { vc[v.name] = v.color; });
// 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 (peruseActive) stopPeruse();
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);
});
}
// Narrow-FOV HERO framing. A wide FOV (55°) up close makes the open board flare in
// perspective — the leaves splay and the top edges bow outward. Backing the camera
// off and narrowing the FOV compresses perspective so the wallcovering reads as a
// flat rectangular plane, like a PJ hero shot. Restored to FOV_DEFAULT on unfocus.
const FOV_DEFAULT = CONFIG.camera.fov; // 55
const FOV_HERO = 30; // tight telephoto-ish framing when focused
let targetFov = FOV_DEFAULT;
function smoothCameraTo(pos, target, onComplete, fov) {
// Clock-based animation — runs inside main animate() loop, not a competing rAF chain
if (fov !== undefined) targetFov = fov;
cameraAnim = {
startPos: camera.position.clone(),
endPos: pos.clone(),
startTarget: controls.target.clone(),
endTarget: target.clone(),
startFov: camera.fov,
endFov: targetFov,
progress: 0,
duration: 0.8, // seconds
onComplete: onComplete || null
};
}
// Boot pose: STAND IN THE DOORWAY at the back (entrance) end of the room and STOP
// (Steve 2026-07-01). Re-asserted after the BOOK_MODE boot chain settles (which would
// otherwise re-centre the camera on the book), so the first-person user starts in the
// doorway looking in toward the boards. No dolly — stationary until WASD.
function _qhStandInDoorway() {
cameraAnim = null; // cancel any tween that would move us
// Explore must be ON, else the per-frame spin-pose re-pins the camera to lockedSpinEye
// (~centre-book z=0.7) every frame and the doorway pose never sticks.
if (!exploreMode && window._toggleExploreFromViewmode) window._toggleExploreFromViewmode(true);
if (controlsLocked) unlockControls();
const z = CONFIG.room.depth / 2 - 0.25; // in the doorway threshold, centred
camera.position.set(0, 1.6, z);
controls.target.set(0, 1.4, -CONFIG.room.depth / 2 + 0.2); // look toward the board wall
if (lockedSpinEye) lockedSpinEye.copy(camera.position); // keep any spin-pose in sync with the doorway
camera.lookAt(controls.target);
camera.updateProjectionMatrix();
if (controls.update) controls.update();
}
window._qhStandInDoorway = _qhStandInDoorway;
window._qhStartCenterWalk = _qhStandInDoorway; // legacy alias (auto-walk retired)
function updateSampleTray() {
const tray = document.getElementById('sample-tray'), items = document.getElementById('tray-items');
document.getElementById('tray-count').textContent = sampleTray.length;
const submitBtn = document.getElementById('tray-submit');
if (submitBtn) submitBtn.disabled = sampleTray.length === 0;
if (sampleTray.length > 0) {
// The dock stows the tray on boot with .qh-stowed (display:none!important); clear it so
// the tray can actually surface when a sample is added (its .visible display:block can't
// beat !important on its own). Without this the Sample Tray — and its age field — never show.
tray.classList.remove('qh-stowed');
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'); tray.classList.add('qh-stowed'); }
}
function applyLightingMode(mode) {
scene.children.filter(c => c.isLight).forEach(l => {
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.05 : mode==='Gallery' ? 0.022 : 0.018;
}
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' };
};
// ============================================================
// ROOM TYPES (Phase 2) — 5 furnished room presets, parallel to THEMES/MODES.
// ------------------------------------------------------------
// Each record is a data descriptor (geometry, clad-vs-painted walls, floor,
// ceiling fixture, furniture layout, lighting rig, camera, palette hook) per
// docs/ROOM-TYPES-SPEC.md. buildRoomType(key) tears down the previous type's
// furniture + fixtures + fixture-lights and rebuilds the chosen one, adjusting
// floor/paint/lights to match. The wing-arc showroom shell stays; a room type
// dresses the SAME box (or, via resizeRoom, a rebuilt box) with real furniture.
//
// HYBRID furniture (DTD verdict C): every piece is FIRST a crafted THREE
// primitive (ships fully offline). GLTFLoader then LAZY-loads CC0/public-domain
// hero GLBs for the active room only and swaps them over the matching primitive;
// a missing/failed model keeps the primitive (never ship broken/unlicensed).
// ============================================================
const PI = Math.PI;
// Color-bucket → accent-tone map for the palette hook. Soft goods (rug / duvet /
// pillows / sofa upholstery) chase the wallcovering's warm/cool bias; wood / stone
// / leather anchors never move (60-30-10, pattern = the 60). Falls back to neutral
// when the product carries no color_bucket/hue (the live China Seas feed does not).
function accentTone(bucket, hue) {
// Prefer an explicit bucket; else derive a coarse warm/cool from hue degrees.
let b = (bucket || '').toLowerCase();
if (!b && typeof hue === 'number') {
b = (hue < 75 || hue >= 330) ? 'warm' : (hue >= 75 && hue < 165) ? 'cool-green' : 'cool';
}
if (/red|orange|gold|brown|terracotta|warm|ochre|rust/.test(b)) return { soft: 0xd8b48c, throw: 0xb0663c, rug: 0xc2a578 };
if (/green|sage|olive|cool-green/.test(b)) return { soft: 0xb9c2ab, throw: 0x6f7a5a, rug: 0xa7ac96 };
if (/blue|indigo|teal|purple|violet|cool|slate/.test(b)) return { soft: 0xa9b4c4, throw: 0x4a5a72, rug: 0x9aa2ac };
return { soft: 0xcfc4b0, throw: 0xb9a88c, rug: 0xb9a88c }; // neutral cream default
}
// Gallery spot-temperature hook: cool patterns → shift spots toward neutral so they
// don't go muddy; warm patterns keep the warm 0xffe6bd. True = use the cool spot.
function _galleryCoolSpot() {
let p = null;
if (focusedWing && focusedWing.userData && focusedWing.userData.product) p = focusedWing.userData.product;
else if (centerBook && centerBook.product) p = centerBook.product;
const b = (p && p.color_bucket || '').toLowerCase();
const hue = p && typeof p.hue === 'number' ? p.hue : null;
if (/blue|indigo|teal|purple|violet|cool|slate|green|sage/.test(b)) return true;
if (hue != null && hue >= 75 && hue < 300) return true;
return false;
}
// Read the active room's product color context (from the focused wing / center book).
function activePaletteCtx() {
let p = null;
if (focusedWing && focusedWing.userData && focusedWing.userData.product) p = focusedWing.userData.product;
else if (centerBook && centerBook.product) p = centerBook.product;
return accentTone(p && p.color_bucket, p && p.hue);
}
// ---- ROOM_TYPES registry -------------------------------------------------
const ROOM_TYPES = {
bedroom: {
label: 'Bedroom',
room: { width: 4.6, depth: 5.0, height: 2.7 },
clad: ['back'],
paint: 0xece4d6,
floor: 'oak',
ceiling: 0xf2eee5,
fixture: 'pendant',
mood: 'warm-dim',
lights: { ambient: [0xfff0dd, 0.30], hemi: [0xfff1e0, 0xe4d8c4, 0.28],
key: { color: 0xfff2e0, intensity: 0.75, radius: 4 }, fill: [0xdfe6ff, 0.18], pic: 0.30 },
camera: { pos: [0, 1.55, 'D/2-1.4'], look: [0, 1.4, '-D/2+0.4'], fov: 55 },
furniture: 'bedroom'
},
office: {
label: 'Office',
room: { width: 4.8, depth: 5.2, height: 2.8 },
clad: ['back'],
paint: 0xf1f1ee,
floor: 'concrete', // no texture → flat MeshStandard fallback
ceiling: 0xf4f4f0,
fixture: 'track-recessed',
mood: 'neutral-bright',
lights: { ambient: [0xffffff, 0.50], hemi: [0xf4f6ff, 0xdedad2, 0.45],
key: { color: 0xfff8f2, intensity: 1.0, radius: 2.5 }, fill: [0xeef2ff, 0.35], pic: 0.25 },
camera: { pos: [0, 1.6, 'D/2-1.0'], look: [0, 1.35, '-D/2+0.3'], fov: 58 },
furniture: 'office'
},
lobby: {
label: 'Hotel Lobby',
room: { width: 8.0, depth: 8.0, height: 4.2 },
clad: ['back'],
paint: 0xe6dccb,
floor: 'stone',
ceiling: 0xece6da,
fixture: 'chandelier',
mood: 'dramatic',
lights: { ambient: [0x2a2620, 0.30], hemi: [0x4a4436, 0x1a1712, 0.25],
key: { color: 0xfff1da, intensity: 1.15, radius: 4 }, fill: [0xffd9a8, 0.20],
rim: [0xffe6bd, 0.30], pic: 0.60 },
camera: { pos: [0.8, 1.6, 'D/2-1.5'], look: [0, 1.8, '-D/2+0.9'], fov: 65 },
furniture: 'lobby'
},
living: {
label: 'Living Room',
room: { width: 6.10, depth: 6.10, height: 2.9 },
clad: ['back'],
paint: null, // keep MAT.wall limewash
floor: 'oak',
ceiling: 0xf2eee5,
fixture: 'flush-mount',
mood: 'warm',
lights: { ambient: [0xffe8cf, 0.30], hemi: [0xfff3e0, 0x3a2f26, 0.40],
key: { color: 0xfff1da, intensity: 1.0, radius: 3.5 }, fill: [0xdfe6ff, 0.25], pic: 0.55 },
camera: { pos: [0, 1.5, 'D/2-1.6'], look: [0, 1.3, '-D/2+0.6'], fov: 58 },
furniture: 'living'
},
gallery: {
label: 'Art Gallery',
room: { width: 7.0, depth: 7.0, height: 3.8 },
clad: ['back'],
paint: 0x161a22,
floor: 'dark-concrete',
ceiling: 0x14161c,
fixture: 'track-spots',
mood: 'high-contrast',
lights: { ambient: [0x161a22, 0.42], hemi: [0x161a22, 0x0a0c10, 0.05],
key: { color: 0xffe6bd, intensity: 0.55, radius: 3 }, fill: [0x6f7d96, 0.22],
rim: [0xffd9a0, 0.30], pic: 0.20 },
camera: { pos: [0, 1.55, 'D/2-1.8'], look: [0, 1.6, '-D/2+0.05'], fov: 52 },
furniture: 'gallery'
}
};
const ROOM_TYPE_ORDER = ['bedroom', 'office', 'living', 'lobby', 'gallery'];
// Active room-type state.
let currentRoomType = null;
let roomTypeGroup = null; // holds furniture + fixture meshes for the active type
let roomTypeLights = []; // fixture-owned lights (point/spot) — removed on teardown
const heroModelCache = {}; // url → THREE.Group (parsed GLB), reused across rebuilds
// ---- FLOOR materials -----------------------------------------------------
// Only floor-oak + floor-stone textures exist on disk (honest per spec). concrete /
// dark-concrete are flat/procedural MeshStandard fallbacks.
const _floorMatCache = {};
function floorMaterial(kind) {
if (_floorMatCache[kind]) return _floorMatCache[kind];
let m;
if (kind === 'oak') {
m = new THREE.MeshStandardMaterial({
map: loadTex('floor-oak', 2.4, 2.4),
normalMap: loadDataTex('floor-oak-normal', 2.4, 2.4),
roughnessMap: loadDataTex('floor-oak-rough', 2.4, 2.4),
normalScale: new THREE.Vector2(0.55, 0.55), roughness: 1.0, metalness: 0.0, envMapIntensity: 0.35
});
} else if (kind === 'stone') {
const t = srgb(loadTex('floor-stone', 2.4, 2.4));
if (renderer && renderer.capabilities) t.anisotropy = renderer.capabilities.getMaxAnisotropy();
m = new THREE.MeshStandardMaterial({ map: t, roughness: 0.3, metalness: 0.05, envMapIntensity: 0.7 });
} else if (kind === 'concrete') {
m = new THREE.MeshStandardMaterial({ color: 0x9a9a98, roughness: 0.35, metalness: 0.05, envMapIntensity: 0.4 });
} else { // dark-concrete (gallery)
m = new THREE.MeshStandardMaterial({ color: 0x2a2a2c, roughness: 0.4, metalness: 0.05, envMapIntensity: 0.35 });
}
_floorMatCache[kind] = m;
return m;
}
// The floor mesh built by buildRoom — retagged so a room type can swap its material.
function setFloorMaterial(kind) {
if (window._floorMesh) window._floorMesh.material = floorMaterial(kind);
}
// ---- shared soft-good / anchor materials (created lazily) -----------------
function stdMat(color, rough, metal) { return new THREE.MeshStandardMaterial({ color, roughness: rough == null ? 0.8 : rough, metalness: metal || 0.0, envMapIntensity: 0.4 }); }
function emissiveMat(color, intensity) { return new THREE.MeshStandardMaterial({ color: 0xfff4e0, emissive: color, emissiveIntensity: intensity == null ? 0.5 : intensity, roughness: 1.0 }); }
// ---- FURNITURE PRIMITIVES (upgraded, ported from proto/v9) ----------------
// Every builder appends to `g` (the roomTypeGroup) so teardown is a single removal.
// Positions are already in the room-center frame (viewer faces -z, back = -D/2).
function craftSofa(g, x, z, rotY, tone) {
const grp = new THREE.Group();
const body = stdMat(tone.soft, 0.95, 0.0);
const seatMat = stdMat(0xe7ddc8, 1.0, 0.0);
const base = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.45, 0.95), body); base.position.y = 0.32; base.castShadow = base.receiveShadow = true; grp.add(base);
const back = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.7, 0.22), body); back.position.set(0, 0.62, -0.36); back.castShadow = true; grp.add(back);
const seat = new THREE.Mesh(new THREE.BoxGeometry(2.05, 0.18, 0.78), seatMat); seat.position.set(0, 0.58, 0.04); seat.castShadow = true; grp.add(seat);
[-1.0, 1.0].forEach(ax => { const a = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.6, 0.95), body); a.position.set(ax, 0.45, 0); a.castShadow = true; grp.add(a); });
// Two accent throw pillows (the 10 of 60-30-10).
[-0.55, 0.55].forEach(px => { const pil = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.30, 0.14), stdMat(tone.throw, 0.9, 0.0)); pil.position.set(px, 0.74, -0.22); pil.rotation.z = px < 0 ? 0.2 : -0.2; pil.castShadow = true; grp.add(pil); });
grp.position.set(x, 0, z); grp.rotation.y = rotY; g.add(grp);
addContactShadow(x, z, 2.4, 1.1, 0.6);
}
function craftBed(g, x, z, tone) {
const grp = new THREE.Group();
const wood = MAT.wood, duvet = stdMat(tone.soft, 0.95, 0.0);
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.95, 0.28, 2.15), wood); frame.position.set(0, 0.14, 0); frame.castShadow = frame.receiveShadow = true; grp.add(frame);
const mattress = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.22, 2.05), stdMat(0xf0ece2, 1.0, 0.0)); mattress.position.set(0, 0.39, 0.02); mattress.castShadow = true; grp.add(mattress);
const duv = new THREE.Mesh(new THREE.BoxGeometry(1.86, 0.14, 1.5), duvet); duv.position.set(0, 0.52, 0.28); duv.castShadow = true; grp.add(duv);
// Two pillows against the headboard side.
[-0.42, 0.42].forEach(px => { const pw = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.16, 0.34), stdMat(0xfaf6ee, 1.0, 0.0)); pw.position.set(px, 0.52, -0.72); pw.castShadow = true; grp.add(pw); });
// A folded accent throw at the foot (chases the hue).
const thr = new THREE.Mesh(new THREE.BoxGeometry(1.86, 0.06, 0.5), stdMat(tone.throw, 0.9, 0.0)); thr.position.set(0, 0.6, 0.78); thr.castShadow = true; grp.add(thr);
grp.position.set(x, 0, z); g.add(grp);
addContactShadow(x, z, 2.1, 2.3, 0.6);
}
function craftHeadboard(g, x, z) {
const hb = new THREE.Mesh(new THREE.BoxGeometry(1.9, 1.0, 0.12), MAT.wood); hb.position.set(x, 0.9, z); hb.castShadow = true; g.add(hb);
}
function craftNightstand(g, x, z) {
const ns = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.4), MAT.wood); ns.position.set(x, 0.25, z); ns.castShadow = ns.receiveShadow = true; g.add(ns);
// Small emissive lamp glow on top.
const lamp = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.11, 0.16, 16), emissiveMat(0xffe6c0, 0.55)); lamp.position.set(x, 0.6, z); g.add(lamp);
addContactShadow(x, z, 0.6, 0.5, 0.5);
}
function craftDresser(g, x, z, rotY) {
const grp = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.85, 0.5), MAT.wood); body.position.y = 0.42; body.castShadow = body.receiveShadow = true; grp.add(body);
// Drawer seams.
for (let r = 0; r < 3; r++) { const d = new THREE.Mesh(new THREE.BoxGeometry(1.3, 0.24, 0.02), stdMat(0x59422e, 0.6, 0.0)); d.position.set(0, 0.16 + r * 0.26, 0.26); grp.add(d); }
grp.position.set(x, 0, z); grp.rotation.y = rotY; g.add(grp);
}
function craftRug(g, x, z, w, d, color, round, r) {
let mesh;
if (round) mesh = new THREE.Mesh(new THREE.CircleGeometry(r || 1.7, 48), stdMat(color, 1.0, 0.0));
else mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, d), stdMat(color, 1.0, 0.0));
mesh.rotation.x = -PI / 2; mesh.position.set(x, 0.012, z); mesh.receiveShadow = true; g.add(mesh);
}
function craftDesk(g, x, z, rotY) {
const grp = new THREE.Group();
const top = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.05, 0.75), MAT.wood); top.position.set(0, 0.74, 0); top.castShadow = top.receiveShadow = true; grp.add(top);
[-0.72, 0.72].forEach(lx => { const leg = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.72, 0.68), MAT.wood); leg.position.set(lx, 0.37, 0); leg.castShadow = true; grp.add(leg); });
// Emissive monitor.
const mon = new THREE.Mesh(new THREE.PlaneGeometry(0.55, 0.34), new THREE.MeshBasicMaterial({ color: 0x1a2430 })); mon.position.set(0, 1.02, -0.18); grp.add(mon);
const stand = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.14, 0.06), MAT.dark); stand.position.set(0, 0.85, -0.18); grp.add(stand);
// Two swatch/paper boxes.
[[-0.5, 0.06], [0.55, 0.02]].forEach(([sx, sy]) => { const pb = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.03, 0.32), stdMat(0xe8e0cf, 0.9, 0.0)); pb.position.set(sx, 0.77 + sy, 0.05); pb.rotation.y = sx < 0 ? 0.2 : -0.15; grp.add(pb); });
grp.position.set(x, 0, z); grp.rotation.y = rotY; g.add(grp);
addContactShadow(x, z, 1.7, 0.9, 0.55);
}
function craftOfficeChair(g, x, z, rotY, tone) {
const grp = new THREE.Group();
const uph = stdMat(tone.soft, 0.75, 0.05);
// 5-star base.
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.05, 12), MAT.dark); hub.position.y = 0.04; grp.add(hub);
for (let i = 0; i < 5; i++) { const a = (i / 5) * PI * 2; const arm = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.03, 0.3), MAT.dark); arm.position.set(Math.cos(a) * 0.15, 0.03, Math.sin(a) * 0.15); arm.rotation.y = -a; grp.add(arm); const cw = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.05, 8), MAT.dark); cw.rotation.z = PI / 2; cw.position.set(Math.cos(a) * 0.3, 0.03, Math.sin(a) * 0.3); grp.add(cw); }
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.42, 10), MAT.chrome); col.position.y = 0.28; grp.add(col);
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.1, 0.5), uph); seat.position.y = 0.52; seat.castShadow = true; grp.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.1), uph); back.position.set(0, 0.85, -0.22); back.castShadow = true; grp.add(back);
grp.position.set(x, 0, z); grp.rotation.y = rotY; g.add(grp);
addContactShadow(x, z, 0.7, 0.7, 0.5);
}
function craftArmchair(g, x, z, rotY) {
const grp = new THREE.Group();
const boucle = stdMat(0xe8e0d0, 0.95, 0.0), wood = stdMat(0x6e573a, 0.5, 0.0);
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.18, 0.56), boucle); seat.position.set(0, 0.44, 0); seat.castShadow = true; grp.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.55, 0.16), boucle); back.position.set(0, 0.72, -0.22); back.castShadow = true; grp.add(back);
[-0.3, 0.3].forEach(ax => { const arm = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.24, 0.5), boucle); arm.position.set(ax, 0.56, 0); arm.castShadow = true; grp.add(arm); });
[[-0.22, 0.22], [0.22, 0.22], [-0.22, -0.22], [0.22, -0.22]].forEach(([lx, lz]) => { const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.016, 0.4, 8), wood); leg.position.set(lx, 0.2, lz); grp.add(leg); });
grp.position.set(x, 0, z); grp.rotation.y = rotY; g.add(grp);
addContactShadow(x, z, 0.8, 0.8, 0.5);
}
function craftCoffeeTable(g, x, z, r, wood) {
const mat = wood ? MAT.wood : new THREE.MeshStandardMaterial({ color: 0xcfc7b8, roughness: 0.4, metalness: 0.1, envMapIntensity: 0.6 });
const top = new THREE.Mesh(new THREE.CylinderGeometry(r || 0.5, r || 0.5, 0.08, 36), mat); top.position.set(x, 0.4, z); top.castShadow = top.receiveShadow = true; g.add(top);
const drum = new THREE.Mesh(new THREE.CylinderGeometry((r || 0.5) * 0.55, (r || 0.5) * 0.6, 0.36, 24), MAT.chrome); drum.position.set(x, 0.2, z); drum.castShadow = true; g.add(drum);
addContactShadow(x, z, (r || 0.5) * 2.2, (r || 0.5) * 2.2, 0.55);
}
function craftReception(g, x, z) {
const grp = new THREE.Group();
const front = new THREE.Mesh(new THREE.BoxGeometry(3.0, 1.05, 0.7), MAT.wood); front.position.set(0, 0.525, 0); front.castShadow = front.receiveShadow = true; grp.add(front);
const cap = new THREE.Mesh(new THREE.BoxGeometry(3.15, 0.1, 0.85), new THREE.MeshStandardMaterial({ color: 0xcfc7b8, roughness: 0.35, metalness: 0.1, envMapIntensity: 0.6 })); cap.position.set(0, 1.1, 0); cap.castShadow = true; grp.add(cap);
grp.position.set(x, 0, z); g.add(grp);
addContactShadow(x, z, 3.2, 0.9, 0.6);
}
function craftBench(g, x, z) {
const grp = new THREE.Group();
const slab = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.1, 0.45), stdMat(0x2f2c2a, 0.5, 0.05)); slab.position.set(0, 0.42, 0); slab.castShadow = slab.receiveShadow = true; grp.add(slab);
[-0.6, 0.6].forEach(lx => { const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.42, 0.4), stdMat(0x25221f, 0.5, 0.05)); leg.position.set(lx, 0.21, 0); leg.castShadow = true; grp.add(leg); });
grp.position.set(x, 0, z); g.add(grp);
addContactShadow(x, z, 1.7, 0.5, 0.55);
}
function craftPedestal(g, x, z) {
const p = new THREE.Mesh(new THREE.BoxGeometry(0.4, 1.1, 0.4), stdMat(0xe8e4dc, 0.7, 0.0)); p.position.set(x, 0.55, z); p.castShadow = p.receiveShadow = true; g.add(p);
// A small turned sculptural object on top.
const obj = new THREE.Mesh(new THREE.IcosahedronGeometry(0.13, 0), stdMat(0xb9a88c, 0.6, 0.1)); obj.position.set(x, 1.22, z); obj.castShadow = true; g.add(obj);
addContactShadow(x, z, 0.5, 0.5, 0.5);
}
function craftFramedArt(g, x, y, z, rotY, imageUrl) {
const grp = new THREE.Group();
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.72, 0.92, 0.04), MAT.frame); frame.castShadow = true; grp.add(frame);
const plane = new THREE.Mesh(new THREE.PlaneGeometry(0.62, 0.82), new THREE.MeshBasicMaterial({ color: 0xd8cfc0 }));
plane.position.z = 0.025; grp.add(plane);
if (imageUrl) {
const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
new THREE.TextureLoader().load(src, t => { srgb(t); plane.material = new THREE.MeshBasicMaterial({ map: t }); plane.material.needsUpdate = true; }, undefined, () => {});
}
grp.position.set(x, y, z); grp.rotation.y = rotY; g.add(grp);
}
function craftWallLabel(g, x, y, z, text) {
const c = document.createElement('canvas'); c.width = 256; c.height = 96;
const ctx = c.getContext('2d'); ctx.fillStyle = '#f4efe6'; ctx.fillRect(0, 0, 256, 96);
ctx.fillStyle = '#1a1a1a'; ctx.font = 'bold 22px Georgia, serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText((text || 'Pattern').slice(0, 22), 16, 20);
ctx.font = '15px Georgia, serif'; ctx.fillStyle = '#666'; ctx.fillText('Quadrille · China Seas', 16, 54);
const tex = new THREE.CanvasTexture(c);
const m = new THREE.Mesh(new THREE.PlaneGeometry(0.34, 0.13), new THREE.MeshBasicMaterial({ map: tex })); m.position.set(x, y, z); g.add(m);
}
// ---- CEILING FIXTURES (emissive primitive + optional co-located light) ----
function fxPendant(g, H, D, tone) {
const x = 0, z = -D / 2 + 1.4, y = H - 0.55;
const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.5, 8), MAT.chrome); stem.position.set(x, H - 0.28, z); g.add(stem);
const shade = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.22, 24), emissiveMat(0xffe6c0, 0.5)); shade.position.set(x, y, z); g.add(shade);
const pl = new THREE.PointLight(0xffcaa0, 0.6, 5); pl.position.set(x, y - 0.1, z); g.add(pl); roomTypeLights.push(pl);
}
function fxTrackRecessed(g, W, H, D) {
const glowTex = stripGlowTexture();
[-W * 0.22, W * 0.22].forEach(sx => {
const strip = new THREE.Mesh(new THREE.PlaneGeometry(0.1, D * 0.7), new THREE.MeshBasicMaterial({ color: 0xf6f8ff })); strip.rotation.x = PI / 2; strip.position.set(sx, H - 0.012, 0); g.add(strip);
const glow = new THREE.Mesh(new THREE.PlaneGeometry(0.5, D * 0.78), new THREE.MeshBasicMaterial({ map: glowTex, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false })); glow.rotation.x = PI / 2; glow.position.set(sx, H - 0.02, 0); glow.renderOrder = 3; g.add(glow);
});
// 3 emissive downlight discs over the desk.
[-0.5, 0, 0.5].forEach(dx => { const disc = new THREE.Mesh(new THREE.CircleGeometry(0.09, 20), new THREE.MeshBasicMaterial({ color: 0xf6f8ff })); disc.rotation.x = PI / 2; disc.position.set(dx, H - 0.014, -D / 2 + 1.1); g.add(disc); });
const pl = new THREE.PointLight(0xf6f8ff, 0.25, 6); pl.position.set(0, H - 0.4, -D / 2 + 1.1); g.add(pl); roomTypeLights.push(pl);
}
function fxChandelier(g, H, D) {
const cx = 0, cz = 0.2, cy = H - 1.1;
const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 1.0, 10), MAT.chrome); stem.position.set(cx, H - 0.5, cz); g.add(stem);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.55, 0.03, 8, 32), MAT.chrome); ring.rotation.x = PI / 2; ring.position.set(cx, cy, cz); g.add(ring);
const ring2 = new THREE.Mesh(new THREE.TorusGeometry(0.32, 0.025, 8, 28), MAT.chrome); ring2.rotation.x = PI / 2; ring2.position.set(cx, cy - 0.28, cz); g.add(ring2);
const bulbMat = emissiveMat(0xffe6bd, 0.9);
for (let i = 0; i < 12; i++) { const a = (i / 12) * PI * 2; const r = i % 2 ? 0.32 : 0.55, yo = i % 2 ? -0.28 : 0; const b = new THREE.Mesh(new THREE.SphereGeometry(0.05, 12, 10), bulbMat); b.position.set(cx + Math.cos(a) * r, cy + yo - 0.06, cz + Math.sin(a) * r); g.add(b); }
const pl = new THREE.PointLight(0xffe6bd, 0.9, 10); pl.position.set(cx, cy - 0.2, cz); g.add(pl); roomTypeLights.push(pl);
}
function fxFlushMount(g, H) {
const dome = new THREE.Mesh(new THREE.SphereGeometry(0.22, 24, 12, 0, PI * 2, PI * 0.5, PI * 0.5), emissiveMat(0xffe6c0, 0.5)); dome.position.set(0, H - 0.18, 0); g.add(dome);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.23, 0.015, 6, 24), MAT.chrome); ring.rotation.x = PI / 2; ring.position.set(0, H - 0.16, 0); g.add(ring);
}
function fxTrackSpots(g, W, H, D, tone, spotColor) {
const railZ = -D / 2 + 1.4, railY = H - 0.12;
const rail = new THREE.Mesh(new THREE.BoxGeometry(W * 0.7, 0.05, 0.06), MAT.dark); rail.position.set(0, railY, railZ); g.add(rail);
const nHeads = 4;
for (let i = 0; i < nHeads; i++) {
const hx = (-(nHeads - 1) / 2 + i) * (W * 0.7 / nHeads);
const head = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.07, 0.16, 12), MAT.dark); head.position.set(hx, railY - 0.1, railZ); head.rotation.x = 0.5; g.add(head);
const spot = new THREE.SpotLight(spotColor || 0xffe6bd, 1.4, 9, PI / 7.5, 0.45, 1.2);
spot.position.set(hx, railY - 0.1, railZ);
spot.target.position.set(hx * 0.8, 1.5, -D / 2 + 0.05);
if (i === Math.floor(nHeads / 2)) { spot.castShadow = true; spot.shadow.mapSize.set(1024, 1024); spot.shadow.camera.near = 0.5; spot.shadow.camera.far = D; }
g.add(spot); g.add(spot.target); roomTypeLights.push(spot);
}
}
// ---- FURNITURE SETS ------------------------------------------------------
// Each returns nothing; appends its pieces to roomTypeGroup (g). Positions in
// the room-center frame for the type's own W/D. Also records hero-model swap
// slots via `heroSlots` for GLTFLoader to fill.
let heroSlots = []; // { key, url, pos:[x,y,z], rot, scale, primitiveNodes:[Object3D] }
function furnitureBedroom(g, W, D, H, tone) {
const backZ = -D / 2;
craftHeadboard(g, 0, backZ + 0.35);
const bedZ = backZ + 1.35;
const bedPieces = [];
const before = g.children.length;
craftBed(g, 0, bedZ, tone);
for (let i = before; i < g.children.length; i++) bedPieces.push(g.children[i]);
heroSlots.push({ key: 'bed', url: MODEL_URLS.bed, pos: [0, 0, bedZ], rot: 0, scale: null, nodes: bedPieces });
craftNightstand(g, -1.15, backZ + 0.55);
craftNightstand(g, 1.15, backZ + 0.55);
craftDresser(g, -W / 2 + 0.3, 0.4, PI / 2);
craftRug(g, 0, backZ + 2.6, 2.6, 1.8, tone.rug, false);
}
function furnitureOffice(g, W, D, H, tone) {
const backZ = -D / 2;
const deskPieces = []; const before = g.children.length;
craftDesk(g, 0, backZ + 1.1, 0);
for (let i = before; i < g.children.length; i++) deskPieces.push(g.children[i]);
heroSlots.push({ key: 'desk', url: MODEL_URLS.desk, pos: [0, 0, backZ + 1.1], rot: 0, scale: null, nodes: deskPieces });
const chairPieces = []; const cb = g.children.length;
craftOfficeChair(g, 0, backZ + 0.55, PI, tone);
for (let i = cb; i < g.children.length; i++) chairPieces.push(g.children[i]);
heroSlots.push({ key: 'office-chair', url: MODEL_URLS['office-chair'], pos: [0, 0, backZ + 0.55], rot: PI, scale: null, nodes: chairPieces });
buildBookShelfInto(g, W / 2 - 0.2, 0, -PI / 2, 2.4);
craftArmchair(g, W / 2 - 0.9, D / 2 - 1.2, 2.6);
}
function furnitureLobby(g, W, D, H, tone) {
const backZ = -D / 2;
craftReception(g, 0, backZ + 0.9);
const sofaAPieces = []; let before = g.children.length;
craftSofa(g, 0, 1.2, PI, tone);
for (let i = before; i < g.children.length; i++) sofaAPieces.push(g.children[i]);
heroSlots.push({ key: 'sofa', url: MODEL_URLS.sofa, pos: [0, 0, 1.2], rot: PI, scale: null, nodes: sofaAPieces });
const sofaBPieces = []; before = g.children.length;
craftSofa(g, 0, -1.2, 0, tone);
for (let i = before; i < g.children.length; i++) sofaBPieces.push(g.children[i]);
heroSlots.push({ key: 'sofa', url: MODEL_URLS.sofa, pos: [0, 0, -1.2], rot: 0, scale: null, nodes: sofaBPieces });
const ctPieces = []; before = g.children.length;
craftCoffeeTable(g, 0, 0, 0.6, false);
for (let i = before; i < g.children.length; i++) ctPieces.push(g.children[i]);
heroSlots.push({ key: 'coffee-table', url: MODEL_URLS['coffee-table'], pos: [0, 0, 0], rot: 0, scale: null, nodes: ctPieces });
buildOliveInto(g, -2.4, 0, backZ + 0.8);
buildOliveInto(g, 2.4, 0, backZ + 0.8);
craftRug(g, 0, 0, 4.0, 3.0, tone.rug, false);
}
function furnitureLiving(g, W, D, H, tone) {
const backZ = -D / 2;
const sofaPieces = []; let before = g.children.length;
craftSofa(g, 0, backZ + 0.78, 0, tone);
for (let i = before; i < g.children.length; i++) sofaPieces.push(g.children[i]);
heroSlots.push({ key: 'sofa', url: MODEL_URLS.sofa, pos: [0, 0, backZ + 0.78], rot: 0, scale: null, nodes: sofaPieces });
const ctPieces = []; before = g.children.length;
craftCoffeeTable(g, 0, backZ + 1.9, 0.5, true);
for (let i = before; i < g.children.length; i++) ctPieces.push(g.children[i]);
heroSlots.push({ key: 'coffee-table', url: MODEL_URLS['coffee-table'], pos: [0, 0, backZ + 1.9], rot: 0, scale: null, nodes: ctPieces });
craftRug(g, 0, backZ + 1.7, 0, 0, tone.rug, true, 1.7);
// Floor lamp (proto).
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 1.5, 12), stdMat(0x3a3026, 0.6, 0.1)); pole.position.set(W * 0.32, 0.75, -D * 0.3); g.add(pole);
const shade = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.28, 20), emissiveMat(0xffe6c0, 0.6)); shade.position.set(W * 0.32, 1.5, -D * 0.3); g.add(shade);
const pl = new THREE.PointLight(0xffcaa0, 0.55, 9); pl.position.set(W * 0.32, 1.5, -D * 0.3); g.add(pl); roomTypeLights.push(pl);
craftArmchair(g, -W * 0.3, D * 0.2, 2.4);
buildOliveInto(g, -2.2, 0, backZ + 0.4);
}
function furnitureGallery(g, W, D, H, tone) {
const backZ = -D / 2;
craftBench(g, 0, 1.2); craftBench(g, 0, 2.2);
const p1 = []; let before = g.children.length;
craftPedestal(g, -2.0, -0.5);
for (let i = before; i < g.children.length; i++) p1.push(g.children[i]);
heroSlots.push({ key: 'pedestal', url: MODEL_URLS.pedestal, pos: [-2.0, 0, -0.5], rot: 0, scale: null, nodes: p1 });
craftPedestal(g, 2.0, -0.5);
// Two framed side pieces on the LEFT wall showing the pattern (gallery-of-collection).
const img = activeProductImage();
craftFramedArt(g, -W / 2 + 0.05, 1.5, -1.0, PI / 2, img);
craftFramedArt(g, -W / 2 + 0.05, 1.5, 1.0, PI / 2, img);
const pn = activeProductName();
craftWallLabel(g, 1.2, 1.4, backZ + 0.03, pn);
}
// buildBookShelf / buildOliveTree add straight to `scene`; these wrappers reparent
// their created meshes into the room-type group so teardown removes them cleanly.
function buildBookShelfInto(g, x, z, rot, unitW) {
const before = scene.children.length;
buildBookShelf(x, z, rot, unitW);
for (let i = scene.children.length - 1; i >= before; i--) { const c = scene.children[i]; scene.remove(c); g.add(c); }
}
function buildOliveInto(g, x, y, z) {
const before = scene.children.length;
buildOliveTree(x, y, z);
for (let i = scene.children.length - 1; i >= before; i--) { const c = scene.children[i]; scene.remove(c); g.add(c); }
addContactShadow(x, z, 0.6, 0.6, 0.8);
}
function activeProductImage() {
if (focusedWing && focusedWing.userData && focusedWing.userData.product) return focusedWing.userData.product.image;
if (centerBook && centerBook.product) return centerBook.product.image;
return null;
}
function activeProductName() {
if (focusedWing && focusedWing.userData && focusedWing.userData.product) return focusedWing.userData.product.pattern_name;
if (centerBook && centerBook.product) return centerBook.product.pattern_name;
return 'China Seas';
}
// ---- ROOM-TYPE APPLY / TEARDOWN ------------------------------------------
function teardownRoomType() {
if (roomTypeGroup) {
roomTypeGroup.traverse(o => {
if (o.geometry && o.geometry.dispose) o.geometry.dispose();
if (o.material) { const mm = Array.isArray(o.material) ? o.material : [o.material]; mm.forEach(m => { if (m.map && m.map.dispose) m.map.dispose(); if (m.dispose) m.dispose(); }); }
});
scene.remove(roomTypeGroup);
}
roomTypeLights.forEach(l => { if (l.target) scene.remove(l.target); scene.remove(l); });
roomTypeLights = [];
roomTypeGroup = null;
heroSlots = [];
}
// Apply the light rig of a room type onto the existing lights (never creates new
// ambient/hemi/key — retunes the ones buildLighting made, like THEMES does).
function applyRoomTypeLights(rec) {
const L = rec.lights; if (!L) return;
const amb = scene.children.find(o => o.isAmbientLight);
const hemi = scene.children.find(o => o.isHemisphereLight);
const key = window._keyLight, pic = window._picLight;
// fill = a non-shadow directional; rim = the other non-shadow directional.
const dirs = scene.children.filter(o => o.isDirectionalLight && !o.castShadow);
if (amb && L.ambient) { amb.color.setHex(L.ambient[0]); amb.intensity = L.ambient[1]; }
if (hemi && L.hemi) { hemi.color.setHex(L.hemi[0]); if (hemi.groundColor) hemi.groundColor.setHex(L.hemi[1]); hemi.intensity = L.hemi[2]; }
if (key && L.key) { key.color.setHex(L.key.color); key.intensity = L.key.intensity; if ('radius' in key.shadow && L.key.radius) key.shadow.radius = L.key.radius; }
if (dirs[0] && L.fill) { dirs[0].color.setHex(L.fill[0]); dirs[0].intensity = L.fill[1]; }
if (dirs[1]) { if (L.rim) { dirs[1].color.setHex(L.rim[0]); dirs[1].intensity = L.rim[1]; } else dirs[1].intensity = 0.0; }
if (pic && typeof L.pic === 'number') pic.intensity = L.pic;
requestShadowUpdate(8);
}
// Set which of the room's walls are clad vs painted for this type. Painted walls
// get a flat paint material (or keep MAT.wall limewash when paint===null).
function applyRoomTypePaint(rec) {
const cladSet = new Set(rec.clad || ['back']);
const paintMat = (rec.paint == null) ? MAT.wall : stdMat(rec.paint, 0.95, 0.0);
roomWalls.forEach(w => {
if (cladSet.has(w.side)) {
// Clad walls keep their live clad material if a pattern is currently applied;
// else reset to the base wall mat now (so a wall that was painted near-black in
// the gallery reverts to plaster here) — the pattern re-clads on focus.
w.origMat = MAT.wall;
w.mesh.material = (w.cladMat && w.cladMat.map) ? w.cladMat : w.origMat;
} else {
w.origMat = paintMat;
w.mesh.material = paintMat;
}
});
}
// The main entry: swap the showroom into the chosen room type.
function buildRoomType(key, opts) {
const rec = ROOM_TYPES[key]; if (!rec) return false;
opts = opts || {};
currentRoomType = key;
teardownRoomType();
roomTypeGroup = new THREE.Group(); roomTypeGroup.name = 'roomType:' + key;
scene.add(roomTypeGroup);
const W = CONFIG.room.width, D = CONFIG.room.depth, H = CONFIG.room.height;
const tone = activePaletteCtx();
// Floor material (respect a user floor override if the picker set one).
const floorKind = opts.floor || _userFloorOverride || rec.floor;
setFloorMaterial(floorKind);
// Paint / clad selection.
applyRoomTypePaint(rec);
// Ceiling color.
if (window._ceilingMesh && rec.ceiling != null && window._ceilingMesh.material.color) window._ceilingMesh.material.color.setHex(rec.ceiling);
// Snapshot scene children so we can sweep any scene-direct additions (contact
// shadows via addContactShadow) into the room-type group → teardown disposes them.
const _sceneSnap = scene.children.length;
// Ceiling fixture.
switch (rec.fixture) {
case 'pendant': fxPendant(roomTypeGroup, H, D, tone); break;
case 'track-recessed': fxTrackRecessed(roomTypeGroup, W, H, D); break;
case 'chandelier': fxChandelier(roomTypeGroup, H, D); break;
case 'flush-mount': fxFlushMount(roomTypeGroup, H); break;
case 'track-spots': fxTrackSpots(roomTypeGroup, W, H, D, tone, _galleryCoolSpot() ? 0xfff4ec : 0xffe6bd); break;
}
// Furniture set.
switch (rec.furniture) {
case 'bedroom': furnitureBedroom(roomTypeGroup, W, D, H, tone); break;
case 'office': furnitureOffice(roomTypeGroup, W, D, H, tone); break;
case 'lobby': furnitureLobby(roomTypeGroup, W, D, H, tone); break;
case 'living': furnitureLiving(roomTypeGroup, W, D, H, tone); break;
case 'gallery': furnitureGallery(roomTypeGroup, W, D, H, tone); break;
}
// Sweep any scene-direct meshes added during the furniture build (contact shadows)
// into the room-type group, EXCEPT the group itself, so teardown removes them.
for (let i = scene.children.length - 1; i >= _sceneSnap; i--) {
const c = scene.children[i];
if (c === roomTypeGroup) continue;
if (c.isLight) continue; // fixture lights are tracked in roomTypeLights
scene.remove(c); roomTypeGroup.add(c);
}
// Lighting rig.
applyRoomTypeLights(rec);
// Static-shadow flags for the new furniture.
roomTypeGroup.traverse(o => { if (o.isMesh && o.geometry && o.geometry.type !== 'PlaneGeometry' && o.geometry.type !== 'CircleGeometry') { o.castShadow = true; o.receiveShadow = true; } });
requestShadowUpdate(20);
// Persist.
try { localStorage.setItem('qh_room_type', key); } catch (e) {}
window._activeRoomType = key;
// Attempt hero-GLB swaps for the active room (lazy; keeps primitive on failure).
loadHeroModelsForActive();
return true;
}
// Frame the type's default camera (used by the picker + on explicit request).
function frameRoomType(key) {
const rec = ROOM_TYPES[key]; if (!rec || !rec.camera) return;
const D = CONFIG.room.depth;
const resolve = v => (typeof v === 'string') ? eval(v.replace(/D/g, D)) : v; // only 'D/2±n' expressions
const c = rec.camera;
const pos = new THREE.Vector3(resolve(c.pos[0]), c.pos[1], resolve(c.pos[2]));
const look = new THREE.Vector3(resolve(c.look[0]), c.look[1], resolve(c.look[2]));
if (typeof smoothCameraTo === 'function') { smoothCameraTo(pos, look, () => { if (typeof lockControls === 'function') lockControls(pos.clone(), look.clone()); }, c.fov); }
}
// ---- GLTFLoader (hero-model swap) ----------------------------------------
// CC0 / public-domain GLB URLs. See public/models/CREDITS.md. Local copies live
// under /models/; if a local copy is absent the loader simply fails and the
// crafted primitive stays — NEVER ship a broken/unlicensed model.
const MODEL_URLS = {
// null = no licensed GLB yet → keep the crafted primitive (no request, no 404).
// Drop a CC0/CC-BY .glb into /models/ and set its URL here to auto-swap it in.
'bed': null,
'sofa': '/models/sofa.glb', // Khronos SheenWoodLeatherSofa (CC-BY/CC0)
'desk': null,
'office-chair': '/models/office-chair.glb', // Khronos SheenChair (CC0)
'coffee-table': null,
'pedestal': null
};
let _gltfLoader = null;
function getGLTFLoader() {
if (_gltfLoader) return _gltfLoader;
if (!THREE.GLTFLoader) return null;
_gltfLoader = new THREE.GLTFLoader();
return _gltfLoader;
}
// Fit a loaded model group to a target bounding size (so CC0 models at arbitrary
// scale sit correctly), center it on the primitive's footprint, and drop it on floor.
function fitModel(obj, targetW, targetD) {
const box = new THREE.Box3().setFromObject(obj);
const size = new THREE.Vector3(); box.getSize(size);
if (size.x < 1e-4 || size.z < 1e-4) return;
const s = Math.min(targetW / size.x, targetD / size.z);
obj.scale.setScalar(s);
const box2 = new THREE.Box3().setFromObject(obj);
const c = new THREE.Vector3(); box2.getCenter(c);
obj.position.x -= c.x; obj.position.z -= c.z;
obj.position.y -= box2.min.y; // sit on floor
obj.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
}
const HERO_TARGET = { bed: [1.9, 2.1], sofa: [2.3, 1.0], desk: [1.7, 0.8], 'office-chair': [0.7, 0.7], 'coffee-table': [1.1, 1.1], pedestal: [0.45, 0.45] };
function loadHeroModelsForActive() {
const loader = getGLTFLoader(); if (!loader) return; // GLTFLoader not present → all primitives
heroSlots.forEach(slot => {
if (!slot.url) return;
const done = (gltf) => {
try {
const model = (heroModelCache[slot.url] || gltf.scene).clone(true);
heroModelCache[slot.url] = gltf.scene;
const tw = (HERO_TARGET[slot.key] || [1, 1]);
fitModel(model, tw[0], tw[1]);
const wrap = new THREE.Group();
wrap.position.set(slot.pos[0], slot.pos[1], slot.pos[2]);
wrap.rotation.y = slot.rot || 0;
wrap.add(model);
// Hide the primitive nodes only once the real model is in.
slot.nodes.forEach(n => { n.visible = false; });
if (roomTypeGroup) roomTypeGroup.add(wrap);
window._heroModelsLoaded = (window._heroModelsLoaded || 0) + 1;
requestShadowUpdate(8);
} catch (e) { /* keep primitive */ }
};
if (heroModelCache[slot.url]) { done({ scene: heroModelCache[slot.url] }); return; }
try {
loader.load(slot.url, done, undefined, () => { /* load failed → primitive stays */ });
} catch (e) { /* primitive stays */ }
});
}
// User floor override (flooring picker). null = use the room type's default.
let _userFloorOverride = (function () { const v = localStorage.getItem('qh_floor'); return (v === 'oak' || v === 'stone') ? v : null; })();
function setFloorOverride(kind) {
_userFloorOverride = (kind === 'default') ? null : kind;
try { if (_userFloorOverride) localStorage.setItem('qh_floor', _userFloorOverride); else localStorage.removeItem('qh_floor'); } catch (e) {}
const use = _userFloorOverride || (ROOM_TYPES[currentRoomType] && ROOM_TYPES[currentRoomType].floor) || 'oak';
setFloorMaterial(use);
}
// ---- resizeRoom(w,d,h) — tear down + rebuild the room shell + wing arc -----
// Disposes the old wall/floor/ceiling/furniture geometry+materials, rebuilds
// buildRoom(), re-fits wing.arcRadius + camera.startPos.z to the new footprint,
// rebuilds the wing wall + the active room type, and re-frames. Persists w/h.
let _roomShellRefs = null; // captured on first resize so we can dispose prior shell
function resizeRoom(w, d, h) {
w = Math.max(3.0, Math.min(14.0, w || CONFIG.room.width));
d = Math.max(3.0, Math.min(14.0, d || CONFIG.room.depth));
h = Math.max(2.2, Math.min(6.0, h || CONFIG.room.height));
// Tear down the room-type dressing first.
teardownRoomType();
// Remove + dispose the prior shell meshes (floor/ceiling/walls/baseboards/crown/
// strips/sign) — everything buildRoom() created. We identify them by the tagged
// refs (floorMesh/ceilingMesh) + roomWalls[], plus a sweep of the plane/box décor
// that buildRoom adds without refs. Simplest robust approach: snapshot scene
// children before/after a rebuild is not possible retroactively, so we remove the
// known shell nodes and any leftover décor tagged _shell.
disposeAndRemove(window._floorMesh); window._floorMesh = null;
disposeAndRemove(window._ceilingMesh); window._ceilingMesh = null;
roomWalls.forEach(rw => { disposeAndRemove(rw.mesh); if (rw.cladMat) { if (rw.cladMat.map) rw.cladMat.map.dispose(); rw.cladMat.dispose(); } });
roomWalls = [];
(window._shellDecor || []).forEach(disposeAndRemove);
window._shellDecor = [];
// Commit new geometry.
CONFIG.room.width = w; CONFIG.room.depth = d; CONFIG.room.height = h;
// Re-fit the wing arc + camera pull-back to the new footprint (spec: bigger room →
// larger arc radius; keep the eye a comfortable distance off the back wall).
CONFIG.wing.arcRadius = Math.max(2.6, Math.min(4.6, d * 0.52));
CONFIG.camera.startPos.z = Math.max(0.4, Math.min(2.0, d / 2 - 2.4));
// Rebuild the shell.
buildRoom();
// Re-fit the eye + walk box.
if (lockedSpinEye) { lockedSpinEye.set(0, 1.6, CONFIG.camera.startPos.z); if (camera) camera.position.copy(lockedSpinEye); applySpinPose(); }
// Rebuild the wing wall so board positions re-fit the new arc (if products loaded).
// rebuildWingWall() disposes the prior arc group first (no dup), then in BOOK_MODE
// we re-seat the center book so its spine re-fits the new room depth.
if (products && products.length) {
try {
rebuildWingWall();
if (BOOK_MODE) setCenterBook(typeof bookIndex === 'number' ? bookIndex : 0);
} catch (e) { /* wing rebuild best-effort */ }
}
enableStaticShadows();
// Re-dress ONLY if the user had actively chosen a room type — the boot default is a
// clean empty room (no sofa/lamps/rug), so a resize must NOT silently re-introduce the
// dressing. currentRoomType is null until the user clicks a room-type chip.
if (currentRoomType) buildRoomType(currentRoomType);
// Persist.
try { localStorage.setItem('qh_room_w', String(w)); localStorage.setItem('qh_room_h', String(h)); localStorage.setItem('qh_room_d', String(d)); } catch (e) {}
requestShadowUpdate(24);
window._roomDims = { w, d, h };
return { w, d, h, arcRadius: CONFIG.wing.arcRadius, camZ: CONFIG.camera.startPos.z };
}
function disposeAndRemove(o) {
if (!o) return;
if (o.geometry && o.geometry.dispose) o.geometry.dispose();
if (o.material) { const mm = Array.isArray(o.material) ? o.material : [o.material]; mm.forEach(m => { if (m && m.map && m.map.dispose) m.map.dispose(); if (m && m.dispose) m.dispose(); }); }
scene.remove(o);
}
// ============================================================
// VIEW-MODE / THEME API — stable surface for public/js/viewmodes.js.
// Keeps the view-mode + theme engine in its own module without re-plumbing
// the whole showroom. Everything here is a thin wrapper over existing internals.
// ============================================================
window._qh = {
THREE,
get scene() { return scene; },
get camera() { return camera; },
get controls() { return controls; },
get renderer() { return renderer; },
get wingBoards() { return wingBoards; },
get focusedWing(){ return focusedWing; },
get restingOpenWing(){ return restingOpenWing; },
get peruseActive(){ return peruseActive; },
get exploreMode(){ return exploreMode; },
// NAVIGATION (Steve 2026-06-30) verification surface — recentre + the grounded walk box.
recenter(animated){ return recenterView(!!animated); },
walkForward(step){ return walkForward(step); },
get walkBox(){ return { minX: WALK_MIN_X, maxX: WALK_MAX_X, minZ: WALK_MIN_Z, maxZ: WALK_MAX_Z }; },
get recentering(){ return !!recenterTween; },
// CENTER BOOK (Steve 2026-06-29) verification surface — proves the single mirrored book
// is present, which catalog design it shows, and lets a test page through it.
get bookMode() { return BOOK_MODE; },
get bookPresent(){ return !!centerBook; },
get bookIndex() { return bookIndex; },
bookState() {
if (!centerBook) return null;
const fl = centerBook.faceL, fr = centerBook.faceR;
return {
present: true, index: bookIndex,
product: centerBook.product ? { pattern: centerBook.product.pattern_name, sku: centerBook.product.sku, vendor: centerBook.product.vendor } : null,
leftMirrored: !!(fl && fl.material && fl.material.map && fl.material.map.repeat.x < 0),
rightNormal: !!(fr && fr.material && fr.material.map && fr.material.map.repeat.x > 0),
spineZ: centerBook.group.position.z
};
},
pageCenterBook,
CONFIG,
// Camera + focus
smoothCameraTo,
navigateToSection,
focusOnWing,
unfocusWing,
enterGuidedDefault,
enterFixedCentre,
isolateBoard,
restoreBoards,
lockControls,
unlockControls,
get controlsLocked() { return controlsLocked; },
setControlsLocked(v) { controlsLocked = v; },
gotoBoardOffset,
stopPeruse,
startPeruse,
// Carousel (Chunk I) — verification surface. carouselTo(K) conveyors board K to the
// dead-front centre slot; indexOffset/centerOffsetFor/carouselActive let the proof bar
// assert the rack (not the camera) moved and the clicked board reached centre.
carouselTo,
get carouselActive() { return carouselActive; },
get carouselTarget() { return carouselTarget; },
get indexOffset() { return window.QHRack ? window.QHRack.getIndexOffset() : 0; },
centerSlotIndex() { return (wingBoards.length - 1) / 2; },
// Boot-race guard (contrarian polish) — verification surface. wingWallReady flips true the
// instant the full bank is wired into the raycaster; a pre-ready click is queued in
// _pendingBootClick and replayed on ready. onCanvasClick is exposed so the proof bar can
// fire a real-coordinate click at t<ready and assert it queues, then selects on ready.
get wingWallReady() { return wingWallReady; },
get pendingBootClick() { return _pendingBootClick; },
get bootClickConsumed() { return _bootClickConsumed; },
onCanvasClick,
// Lighting
get keyLight() { return window._keyLight; },
get picLight() { return window._picLight; },
// Walls
cladWalls,
revertWalls,
get cladSides() { return (window._cladSides || []).slice(); },
get wallSides() { return (window._wallSides || []).slice(); },
// Yaw-clamp surface (verification): the ±72° spin clamp + a tester that drives the
// spin past the clamp and reports the capped yaw, proving Slice-1's clamp survives.
get YAW_CLAMP() { return YAW_CLAMP; },
get spinYaw() { return spinYaw; },
// Chunk K — first-person avatar verification surface. avatarPresent proves the body is
// built; avatarState exposes the rig's yaw + position + the camera's pitch so the proof
// bar can assert YAW-ONLY parenting (rig.rotation.y tracks spinYaw, rig.rotation.x stays
// 0 even when the camera pitches down) and that the body is pinned under the fixed eye.
get avatarPresent() { return !!avatarRig; },
// Show/hide the first-person body. Called by viewmodes: visible only in Walk-Through
// (true first-person), hidden in every framed view so the camera doesn't render the
// avatar's back across the whole frame.
setAvatarVisible(v) { avatarWanted = !!v; if (avatarRig) avatarRig.visible = avatarWanted; },
get avatarVisible() { return avatarWanted; },
// Optional visitor age captured on the Sample Tray form — a plain data point for a future
// request submit to include; null if the visitor left it blank.
get visitorAge() { return visitorAge; },
// ---- PHASE 3 "At the Table" surface — the SEATED third-person figure + consultation
// set, and the over-the-shoulder camera pose. Lazy-built on first show. Gated to the
// 'table' view mode only (separate from the first-person avatarWanted).
setTableSeatVisible(v) { setTableSeatVisible(v); },
get seatedAvatarPresent() { return !!seatedAvatarRig; },
get tableSeatVisible() { return !!(tableSeatGroup && tableSeatGroup.visible); },
tableViewCamera() { return tableViewCamera(); },
buildTableSeat() { return buildTableSeat(); },
get spinPitch() { return spinPitch; },
avatarState() {
if (!avatarRig) return null;
return {
present: true,
rigYaw: avatarRig.rotation.y,
rigPitch: avatarRig.rotation.x, // must stay 0 (body never tilts with the look)
rigPos: { x: avatarRig.position.x, y: avatarRig.position.y, z: avatarRig.position.z },
spinYaw, spinPitch,
childCount: avatarRig.children.length,
eye: lockedSpinEye ? { x: lockedSpinEye.x, y: lockedSpinEye.y, z: lockedSpinEye.z } : null
};
},
// Drive the spin pose for headless verification (no key events in screenshot harness).
setSpin(yaw, pitch) {
if (typeof yaw === 'number') spinYaw = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, yaw));
if (typeof pitch === 'number') spinPitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, pitch));
applySpinPose(); updateAvatarRig();
},
testYawClamp() {
const prev = spinYaw;
spinYaw = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, 99)); // try to drive far right
const right = spinYaw;
spinYaw = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, -99)); // and far left
const left = spinYaw;
spinYaw = prev; applySpinPose();
return { right, left, clamp: YAW_CLAMP };
},
// FOV constants
FOV_DEFAULT, FOV_HERO,
setTargetFov(f) { targetFov = f; },
requestShadowUpdate,
// ---- FURNITURE STYLE REGISTRY (Option A) — the style-selector UI drives these.
FURNITURE_STYLE_ORDER,
swapFurniture(key, opts) { return swapFurniture(key, opts); },
get furnitureStyle() { return furnitureStyleKey; },
get furnitureExtras() { return furnitureExtras; },
get furniturePresent() { return !!furnitureRoot; },
get furnitureChildCount() { return furnitureRoot ? furnitureRoot.children.length : 0; },
furnitureStyleLabel(key) { return (FURNITURE_STYLES[key] || {}).label || key; },
// World-position helper for the numbered-element overlay: returns the world-space
// centre of wing board i (or the rail centre if out of range).
boardWorldPos(i) {
const b = wingBoards[i];
const v = new THREE.Vector3();
if (b) { b.getWorldPosition(v); v.y = CONFIG.wing.height / 2 + 0.04; }
else { v.set(0, CONFIG.wing.height / 2 + 0.04, -CONFIG.room.depth / 2 + 0.2); }
return v;
},
// Project a THREE.Vector3 world point to {x,y,onScreen} CSS pixels for HUD pins.
projectToScreen(vec3) {
const v = vec3.clone().project(camera);
const onScreen = v.z < 1 && v.x >= -1.2 && v.x <= 1.2 && v.y >= -1.2 && v.y <= 1.2;
return {
x: (v.x * 0.5 + 0.5) * window.innerWidth,
y: (-v.y * 0.5 + 0.5) * window.innerHeight,
onScreen
};
},
// Convenience: a few stable named scene anchors versions.js can pin numbers to.
namedAnchor(name) {
const w = CONFIG.wing.height, R = CONFIG.room;
switch (name) {
case 'railCenter': return new THREE.Vector3(0, w + 0.10, -R.depth / 2 + 0.25);
case 'signWall': return new THREE.Vector3(-R.width / 2 + 0.4, 1.55, -R.depth / 2 + 0.6);
case 'nook': return new THREE.Vector3(R.width / 2 - 1.5, 0.62, R.depth / 2 - 1.7);
case 'floor': return new THREE.Vector3(0, 0.02, R.depth / 2 - 2.2);
case 'leftWall': return new THREE.Vector3(-R.width / 2 + 0.2, 1.5, 0);
case 'rightWall': return new THREE.Vector3(R.width / 2 - 0.2, 1.5, 0);
case 'ceiling': return new THREE.Vector3(0, R.height - 0.15, 0);
default: return new THREE.Vector3(0, 1.2, 0);
}
},
// ---- ROOM TYPES (Phase 2) — 5 furnished presets + flooring + adjustable room.
ROOM_TYPES, ROOM_TYPE_ORDER,
buildRoomType,
frameRoomType,
get currentRoomType() { return currentRoomType; },
get roomTypeFurnitureCount() { return roomTypeGroup ? roomTypeGroup.children.length : 0; },
get heroModelsLoaded() { return window._heroModelsLoaded || 0; },
setFloorOverride,
get floorOverride() { return _userFloorOverride; },
resizeRoom,
get roomDims() { return { w: CONFIG.room.width, d: CONFIG.room.depth, h: CONFIG.room.height, arcRadius: CONFIG.wing.arcRadius }; },
// A per-frame hook the view-mode engine installs (orbit/carousel/macro drift etc.)
frameHook: null,
// A SECOND per-frame hook for the version overlay (pin reprojection). Kept separate
// from frameHook so the view-mode engine and version overlay don't clobber each other.
overlayHook: null
};
})();