← 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&hellip; tap <strong>Pause</strong> to stop, or <strong>Next</strong> to step ahead.';
  } else if (exploreMode) {
    el.innerHTML = '<strong>Scroll</strong> or <strong>&uarr;&darr;</strong> to walk &middot; <strong>drag</strong> to turn &middot; <strong>&#8853; Center</strong> to return.';
  } else {
    el.innerHTML = 'Tap <strong>Next</strong> to see the next design &mdash; 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 ? '&#9646;&#9646;' : '&#9654;';
    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
};
})();