← back to Quadrille Showroom
public/js/viewmodes.js
905 lines
/* ============================================================================
* viewmodes.js — The Quadrille House showroom: View-Mode + Theme engine.
*
* Self-contained module loaded AFTER showroom.js. Talks to the showroom only
* through the stable window._qh API (see bottom of showroom.js). Adds:
* • a labelled "View" panel of 10 switchable presentation modes (TASK 1)
* • a "Theme" panel of 6 lighting/palette moods (TASK 2)
* • a toggleable perf readout — FPS / frame-ms / drawCalls / triangles (TASK 3)
* Remembers the last View + Theme + perf-toggle in localStorage.
*
* Hard rule kept: Hero is the boot view; the senior guided Next/Back/Auto-Tour
* keeps working in every mode that makes sense. Nothing here regresses the
* locked features — it sits on top of focusOnWing / enterGuidedDefault / peruse.
* ========================================================================== */
(function () {
'use strict';
function boot() {
const QH = window._qh;
if (!QH || !QH.wingBoards || !QH.wingBoards.length) { return setTimeout(boot, 250); }
const THREE = QH.THREE;
const ROOM = QH.CONFIG.room;
const WING = QH.CONFIG.wing;
const backZ = -ROOM.depth / 2 + 0.2; // wing-wall plane
const boardCY = WING.height / 2 + 0.04; // vertical centre of a 6-ft board
const MACRO_DIST = 0.762; // macro camera distance = 2.5 ft (Steve 2026-06-30)
// ---- state ----
let currentMode = null; // active view-mode key
let currentTheme = null; // active theme key
let orbitSpin = 0; // accumulated turntable / orbit angle
let orbitAuto = false; // orbit mode: auto-spin until the user drags
let carouselT = 0; // carousel rotation phase
let _carouselWG = null; // cached wall group (avoid full scene.traverse per frame)
let _carouselShadowAccum = 0; // throttle carousel shadow rebakes
let macroPhase = 0; // macro slow drift phase
let compareB = -1; // compare: user-chosen SECOND board index (-1 = auto next)
let compareKeyHandler = null; // compare: keydown listener to cycle the 2nd design
let compareSetup = false; // compare mode: two boards staged
let savedFocusIndex = 0; // remember which board to return to
let tableSetUp = false; // table mode: seated set + overlay staged
let tableClickHandler = null; // table mode: capture-phase board-pick click listener
// The mode that the guided Next/Back/Auto-Tour buttons operate within.
// Hero / Angled / Macro / Compare / Room re-frame the SAME focused board, so
// guided nav stays meaningful. Orbit / Gallery / Top-down / Walk / Carousel are
// free-camera surveys; guided Next still advances the focused board underneath.
// ===========================================================================
// VIEW MODES — each: { label, hint, guided(bool), enter(), exit()?, frame(dt)? }
// enter() positions the camera / sets the presentation; frame() runs per-rAF.
// ===========================================================================
function curIdx() {
const f = QH.focusedWing; return f && f.userData ? f.userData.index : savedFocusIndex;
}
function boardWorld(i) {
const b = QH.wingBoards[i]; if (!b) return new THREE.Vector3(0, boardCY, backZ);
const v = new THREE.Vector3(); b.getWorldPosition(v); v.y = boardCY; return v;
}
// Lock the camera to a fixed pose (mode survey shots that shouldn't drift).
function park(pos, target, fov) {
QH.setControlsLocked(false);
QH.smoothCameraTo(pos, target, () => { QH.lockControls(pos.clone(), target.clone()); }, fov);
}
// Free the camera so a frame() hook (orbit/carousel) can drive it.
function freeCam() { QH.setControlsLocked(false); QH.unlockControls(); }
// Ensure a board is focused/open AND isolated so ONE design is presented
// (Hero / Angled / Macro / Room). If already focused on this board, re-isolate
// explicitly (focusOnWing only isolates on a focus *change*).
function ensureFocused(i) {
const idx = (i == null) ? curIdx() : i;
if (!QH.focusedWing || QH.focusedWing.userData.index !== idx) {
QH.restoreBoards();
if (QH.wingBoards[idx]) QH.focusOnWing(QH.wingBoards[idx]);
} else {
QH.isolateBoard(QH.focusedWing); // re-hide neighbours → single hero design
}
}
// Compare-mode render: show boards i + j open, frame the pair, name both in the banner.
function renderCompare(i, j) {
QH.revertWalls();
const boards = QH.wingBoards;
boards.forEach((b, k) => { b.visible = (k === i || k === j); if (b.userData.wallDrop) b.userData.wallDrop.visible = false; });
[i, j].forEach(k => {
const ud = boards[k] && boards[k].userData; if (!ud) return;
ud.panelClosed = false;
if (ud.leafL) ud.leafL.rotation.y = 0.10;
if (ud.leafR) ud.leafR.rotation.y = -0.10;
});
const a = boardWorld(i), b = boardWorld(j);
const midX = (a.x + b.x) / 2;
park(new THREE.Vector3(midX, boardCY, backZ + 2.9), new THREE.Vector3(midX, boardCY, backZ), 44);
const nameOf = (k) => { const p = boards[k] && boards[k].userData && boards[k].userData.product; return p ? (p.pattern_name || p.name || ('Board ' + (k + 1))) : ('Board ' + (k + 1)); };
setBanner('Comparing “' + nameOf(i) + '” vs “' + nameOf(j) + '” · ◀ ▶ change the second design');
}
const MODES = {
hero: {
label: 'Hero', hint: 'Flat, dead-on — the gallery hero panel.', guided: true,
enter() {
// BOOT GUARD (Slice-2): on the very first load, do NOT auto-focus the middle
// board — that would clad the walls + open the detail panel + mark a selection,
// violating the Slice-1 boot invariant (resting-open middle, no selection, no
// panel, neutral walls). The showroom's enterGuidedDefault owns the boot pose;
// hero only focuses once the user has actually interacted (a real click/nav).
if (window._qhBootSettled === false) { setWingDeg(8); return; }
ensureFocused();
// focusOnWing already gives the PJ flat hero framing; just make sure the
// open angle is near-flat for this mode.
setWingDeg(8);
}
},
gallery: {
label: 'Gallery', hint: 'Pull back to see the whole wall of boards.', guided: false,
enter() {
// Close + show every board on the rail; neutral walls; straight-on from the
// FRONT of the room so the whole 6-ft wall of boards reads at a glance.
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
QH.revertWalls();
setWingDeg(0);
const pos = new THREE.Vector3(0, 1.55, ROOM.depth / 2 - 0.2);
const tgt = new THREE.Vector3(0, boardCY, backZ);
park(pos, tgt, 62);
}
},
walk: {
label: 'Walk-Through', hint: 'First-person — W A S D to walk the room.', guided: false,
enter() {
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
QH.revertWalls();
setWingDeg(0);
freeCam();
const ctl = QH.controls;
ctl.enableRotate = true; ctl.enableZoom = true; ctl.enablePan = false;
// Hand off to the showroom's native Explore so WASD + proximity book-match work.
if (!QH.exploreMode && window._toggleExploreFromViewmode) window._toggleExploreFromViewmode(true);
// Stand IN THE DOORWAY at the back (entrance) end of the room, centred, eye-level,
// looking straight in toward the board wall (Steve 2026-07-01).
const pos = new THREE.Vector3(0, 1.6, ROOM.depth / 2 - 0.25);
QH.camera.position.copy(pos);
ctl.target.set(0, 1.4, backZ);
QH.setTargetFov(55); QH.camera.fov = 55; QH.camera.updateProjectionMatrix();
},
exit() {
// Leave Explore again so the other modes get a parked camera.
if (QH.exploreMode && window._toggleExploreFromViewmode) window._toggleExploreFromViewmode(false);
}
},
carousel: {
label: 'Carousel', hint: 'Boards rotate past like a turntable.', guided: false,
enter() {
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
QH.revertWalls();
setWingDeg(0);
freeCam();
carouselT = 0;
// Camera sits in the room centre looking at the wall; the WALL GROUP yaws.
const pos = new THREE.Vector3(0, 1.55, ROOM.depth / 2 - 1.4);
const tgt = new THREE.Vector3(0, boardCY, backZ);
park(pos, tgt, 50);
},
frame(dt) {
// Yaw the whole wing-wall group slowly so boards parade past — a lazy-susan.
// PERF: cache the wall group (full scene.traverse every frame was the FPS
// killer — dropped V2 to ~31fps) and throttle shadow rebakes to ~6/s instead
// of every frame (the rotation is slow; 60 rebakes/s is pure waste).
if (!_carouselWG) _carouselWG = findWallGroup();
const wg = _carouselWG;
if (wg) {
// CONTINUOUS parade (Steve 2026-06-30) — boards rotate steadily past like a
// real turntable, instead of the old Math.sin() rock (which just wobbled the
// arc back and forth ±0.5 rad and never actually paraded a new board to front).
wg.rotation.y += dt * 0.18;
if (wg.rotation.y > Math.PI * 2) wg.rotation.y -= Math.PI * 2;
_carouselShadowAccum += dt;
if (_carouselShadowAccum > 0.16) { _carouselShadowAccum = 0; if (QH.requestShadowUpdate) QH.requestShadowUpdate(1); }
}
},
exit() { if (_carouselWG) _carouselWG.rotation.y = 0; _carouselWG = null; _carouselShadowAccum = 0; }
},
macro: {
label: 'Macro', hint: 'Zoom close to read the texture detail.', guided: true,
enter() {
ensureFocused();
setWingDeg(2);
const c = boardWorld(curIdx());
// Zoom in to exactly 2.5 FEET from the board (Steve 2026-06-30) — 0.762 m —
// tight FOV to read the weave/print. Slight off-centre so grazing light catches
// the surface. MACRO_DIST is reused by the parallax drift so it stays at 2.5 ft.
const pos = new THREE.Vector3(c.x - 0.12, c.y + 0.15, c.z + MACRO_DIST);
const tgt = new THREE.Vector3(c.x - 0.05, c.y + 0.1, c.z);
park(pos, tgt, 30);
macroPhase = 0;
},
frame(dt) {
// Gentle parallax drift so the macro shot feels alive (purely visual).
macroPhase += dt * 0.4;
if (QH.controlsLocked) {
const c = boardWorld(curIdx());
const dx = Math.sin(macroPhase) * 0.05, dy = Math.cos(macroPhase * 0.7) * 0.03;
QH.camera.position.x = c.x - 0.12 + dx;
QH.camera.position.y = c.y + 0.15 + dy;
QH.camera.lookAt(c.x - 0.05, c.y + 0.1, c.z);
}
}
},
compare: {
label: 'Compare', hint: 'Two designs side-by-side — ◀ ▶ change the second one.', guided: false,
enter() {
// A = the current board; B = a user-CHOOSABLE second design (Steve 2026-06-30).
// Previously compare force-picked i+1 with no way to change it — confusing. Now
// ◀ ▶ (or [ ]) cycle the second design; the banner names both so you know what
// you're looking at.
const len = QH.wingBoards.length;
const i = curIdx();
if (compareB < 0 || compareB === i || compareB >= len) compareB = (i + 1) % len;
renderCompare(i, compareB);
compareKeyHandler = (e) => {
if (!compareSetup) return;
if (e.code === 'ArrowRight' || e.key === ']') { do { compareB = (compareB + 1) % len; } while (compareB === i); renderCompare(i, compareB); e.preventDefault(); }
else if (e.code === 'ArrowLeft' || e.key === '[') { do { compareB = (compareB - 1 + len) % len; } while (compareB === i); renderCompare(i, compareB); e.preventDefault(); }
};
window.addEventListener('keydown', compareKeyHandler);
compareSetup = true;
},
exit() {
compareSetup = false;
if (compareKeyHandler) { window.removeEventListener('keydown', compareKeyHandler); compareKeyHandler = null; }
QH.restoreBoards();
clearBanner();
}
},
room: {
label: 'Room Preview', hint: 'See the pattern clad on a full room wall.', guided: true,
enter() {
ensureFocused(); // focusing already clads the walls (wallsAutoClad)
setWingDeg(0);
const f = QH.focusedWing;
if (f && f.userData.product) {
QH.cladWalls(f.userData.product.image, f.userData.product.isSeamlessTile);
}
// Stand the camera back in the room so the CLAD WALL fills the frame, board small.
const c = boardWorld(curIdx());
const pos = new THREE.Vector3(c.x + 1.1, 1.55, ROOM.depth / 2 - 0.9);
const tgt = new THREE.Vector3(c.x - 0.3, 1.35, backZ);
park(pos, tgt, 56);
}
},
// PHASE 3 — "At the Table" (DTD verdict A). A SEATED third-person avatar at a
// consultation table in front of the boards. Clicking a board still centres that
// design (reuses focusOnWing → _openCenteredBoard → cladWalls). A CSS-3D sample
// overlay (ported from proto/sample-table.html) lets the user drop the currently-
// centred product's swatch onto the table + drag/remove it; the basket persists to
// localStorage('qh_sample_basket').
table: {
label: 'At the Table', hint: 'Sit at the consultation table — add swatches, drag to arrange.', guided: true,
enter() {
// FIRST entry (not a guided-reframe re-entry): reset to the full rail of boards on
// neutral walls so the seated user surveys every design, none pre-selected. On a
// reframe (tableSetUp already true) we skip this so we don't unfocus the board the
// user just navigated to.
if (!tableSetUp) {
QH.restoreBoards();
if (QH.focusedWing) QH.unfocusWing(true);
QH.revertWalls();
}
// Reveal the seated set + table (idempotent) and park the over-the-shoulder camera.
if (QH.setTableSeatVisible) QH.setTableSeatVisible(true);
if (window._qhNookGroup) window._qhNookGroup.visible = false; // hide the side nook so only the centre table shows
setWingDeg(0);
parkTableCamera();
// A table-mode click handler owns board selection: because parkTableCamera() locks
// the camera (so onCanvasClick early-returns and clicks don't snap the eye back to
// room-centre), we raycast boards ourselves, centre the design via the existing
// focusOnWing → _openCenteredBoard → cladWalls path, then re-park the table camera.
if (!tableClickHandler) {
tableClickHandler = (e) => {
if (currentMode !== 'table') return;
// Ignore clicks that land on the sample overlay / UI (they have their own handlers).
if (e.target && e.target.closest &&
e.target.closest('#vm-sample-stage, #vm-sample-add, #vm-panel, #vm-perf, #vm-banner, ' +
'#wing-detail, #window-bar, #dock, button, input, select, a')) return;
const idx = pickBoardAt(e.clientX, e.clientY);
if (idx < 0) return;
const b = QH.wingBoards[idx];
if (b) {
// We handled this click — stop it reaching showroom.onCanvasClick (which,
// during the ~0.8s before park() locks controls, would otherwise ALSO fire
// focusOnWing and snap the camera off the table pose). Then re-park.
e.stopPropagation();
QH.focusOnWing(b);
// focusOnWing synchronously snaps the eye to room-centre (its normal behaviour);
// immediately re-park so the camera glides straight back to the table pose
// instead of flashing through centre. A second re-park on next tick covers the
// async carousel settle that may re-touch the pose.
parkTableCamera();
setTimeout(parkTableCamera, 60);
}
};
window.addEventListener('click', tableClickHandler, true);
}
showSampleTable();
tableSetUp = true;
},
exit() {
tableSetUp = false;
hideSampleTable();
if (tableClickHandler) { window.removeEventListener('click', tableClickHandler, true); tableClickHandler = null; }
if (QH.setTableSeatVisible) QH.setTableSeatVisible(false);
if (window._qhNookGroup) window._qhNookGroup.visible = true; // restore the side nook when leaving table view
}
}
};
// Park the camera behind + above the seated figure, looking past it at the boards.
function parkTableCamera() {
const cam = (QH.tableViewCamera && QH.tableViewCamera()) ||
{ pos: new THREE.Vector3(0.55, 1.85, backZ + 3.2), target: new THREE.Vector3(0, boardCY, backZ) };
park(cam.pos.clone(), cam.target.clone(), 52);
}
// Raycast the board faces from the CURRENT (table) camera and return the hit board index,
// or -1. Mirrors showroom.onCanvasClick's raycast but works while the camera is locked.
let _tableRay = null, _tableMouse = null;
function pickBoardAt(clientX, clientY) {
if (!_tableRay) { _tableRay = new THREE.Raycaster(); _tableMouse = new THREE.Vector2(); }
_tableMouse.x = (clientX / window.innerWidth) * 2 - 1;
_tableMouse.y = -(clientY / window.innerHeight) * 2 + 1;
_tableRay.setFromCamera(_tableMouse, QH.camera);
const faces = [];
QH.wingBoards.forEach(p => { const ud = p.userData; if (ud.faceL) faces.push(ud.faceL); if (ud.faceR) faces.push(ud.faceR); });
const hits = _tableRay.intersectObjects(faces, false);
if (hits.length && hits[0].object.userData.parentPivot) {
return hits[0].object.userData.parentPivot.userData.index;
}
return -1;
}
const MODE_ORDER = ['walk','hero','gallery','carousel','macro','compare','room','table'];
// Helper to drive the showroom's open-board angle (the Wing° slider value).
function setWingDeg(deg) {
const ang = document.getElementById('angle-range');
if (ang) { ang.value = deg; ang.dispatchEvent(new Event('input')); }
}
function findWallGroup() {
let g = null;
QH.scene.traverse(o => { if (!g && o.isGroup && o.children && o.children.some(ch => ch.geometry && ch.geometry.type === 'BoxGeometry' && Math.abs(ch.position.y - (WING.height + 0.08)) < 0.05)) g = o; });
return g;
}
function setCeilingVisible(v) { if (window._ceilingMesh) window._ceilingMesh.visible = v; }
function setBanner(txt) { const el = document.getElementById('vm-banner'); if (el) { el.textContent = txt; el.style.display = 'block'; } }
function clearBanner() { const el = document.getElementById('vm-banner'); if (el) el.style.display = 'none'; }
// ===========================================================================
// PHASE 3 — "At the Table" CSS-3D SAMPLE OVERLAY.
// Ported from public/proto/sample-table.html (the proven CSS-3D interaction). A
// transparent overlay anchored over the lower-screen "table" plane: a "+ Add sample"
// affordance drops the currently-centred product's swatch as a small card lying flat
// in perspective; cards can be dragged to rearrange and removed with ×. The basket
// persists to localStorage('qh_sample_basket'). This coexists with the 3D scene (the
// seated figure + wood table live in Three.js; the draggable swatch cards are this
// lightweight CSS layer, exactly the low-risk path the spec preferred).
// ===========================================================================
const SAMPLE_COS = Math.cos(54 * Math.PI / 180); // matches the proto table rotateX
let _sampleSlotIndex = 0;
let _samplePlaced = []; // [{el, sku}]
function injectSampleTableStyles() {
if (document.getElementById('vm-sample-style')) return;
const s = document.createElement('style');
s.id = 'vm-sample-style';
s.textContent = [
'#vm-sample-stage{ position:fixed; inset:0; z-index:40; pointer-events:none;',
'perspective:1150px; perspective-origin:50% 40%; display:none; }',
'#vm-sample-stage.on{ display:block; }',
'#vm-sample-table{ position:absolute; left:50%; bottom:-6vh; width:170vw; height:78vh;',
'transform:translateX(-50%) rotateX(54deg); transform-style:preserve-3d; }',
'#vm-sample-surface{ position:absolute; inset:0; transform-style:preserve-3d; }',
'.vm-sample{ position:absolute; width:150px; height:150px; margin:-75px 0 0 -75px;',
'transform-style:preserve-3d; cursor:grab; will-change:transform,opacity; pointer-events:auto; }',
'.vm-sample .paper{ position:absolute; inset:0; border-radius:3px; background:#fbf7ee; padding:6px 6px 22px;',
'box-shadow:0 1px 0 rgba(255,255,255,.7) inset, 0 24px 30px -12px rgba(20,14,6,.55), 0 6px 12px -6px rgba(20,14,6,.5);',
'border:1px solid rgba(120,96,60,.35); }',
'.vm-sample .swatch{ position:absolute; left:6px; right:6px; top:6px; bottom:22px; border-radius:2px;',
'background-size:cover; background-position:center; box-shadow:inset 0 0 0 1px rgba(0,0,0,.06); }',
'.vm-sample .plate{ position:absolute; left:6px; right:6px; bottom:5px; height:14px; display:flex;',
'align-items:center; justify-content:space-between; gap:5px; font-family:"Cormorant Garamond",Georgia,serif; }',
'.vm-sample .pname{ font-size:10px; line-height:1; color:#2b2114; letter-spacing:.2px; white-space:nowrap;',
'overflow:hidden; text-overflow:ellipsis; max-width:74%; }',
'.vm-sample .pcol{ font-size:8px; letter-spacing:1px; text-transform:uppercase; color:#9a7a52; white-space:nowrap; }',
'.vm-sample .contact{ position:absolute; left:6%; right:6%; top:14%; bottom:-6%; border-radius:50%;',
'background:rgba(20,12,4,.34); filter:blur(10px); transform:translateZ(-2px); z-index:-1; }',
'.vm-sample .rm{ position:absolute; top:-12px; right:-12px; width:28px; height:28px; border-radius:50%;',
'border:1.5px solid #fff; background:#1c1c22; color:#fff; font:700 15px/1 -apple-system,Segoe UI,sans-serif;',
'cursor:pointer; display:none; align-items:center; justify-content:center;',
'box-shadow:0 6px 16px -4px rgba(0,0,0,.6); transform:translateZ(28px); }',
'.vm-sample:hover .rm{ display:flex; }',
'.vm-sample.lifted{ cursor:grabbing; }',
// "+ Add sample" affordance + running count, bottom-LEFT (clear of the centred
// #window-bar sort/density controls at bottom:54px).
'#vm-sample-add{ position:fixed; left:22px; bottom:22px; z-index:42;',
'display:none; align-items:center; gap:12px; }',
'#vm-sample-add.on{ display:flex; }',
'#vm-sample-addbtn{ min-height:44px; padding:0 22px; border-radius:11px; cursor:pointer;',
'border:1.5px solid #b9933f; color:#1a1206; letter-spacing:.4px;',
'font:700 16px/1 "Cormorant Garamond",Georgia,serif; background:linear-gradient(180deg,#e3cb95,#c19a44);',
'box-shadow:0 7px 18px -8px rgba(0,0,0,.55); transition:transform .1s,filter .1s; }',
'#vm-sample-addbtn:hover{ filter:brightness(1.05); transform:translateY(-1px); }',
'#vm-sample-addbtn:active{ transform:translateY(1px); }',
'#vm-sample-count{ font:600 12px/1 -apple-system,Segoe UI,sans-serif; letter-spacing:1.5px;',
'text-transform:uppercase; color:#cbb98e; background:rgba(16,13,9,.86); border:1px solid rgba(201,169,110,.4);',
'padding:10px 14px; border-radius:11px; backdrop-filter:blur(8px); }'
].join('\n');
document.head.appendChild(s);
}
function buildSampleTableDom() {
if (document.getElementById('vm-sample-stage')) return;
injectSampleTableStyles();
const stage = document.createElement('div');
stage.id = 'vm-sample-stage';
stage.innerHTML = '<div id="vm-sample-table"><div id="vm-sample-surface"></div></div>';
document.body.appendChild(stage);
const add = document.createElement('div');
add.id = 'vm-sample-add';
add.innerHTML = '<button id="vm-sample-addbtn">+ Add sample to table</button>' +
'<span id="vm-sample-count">0 samples</span>';
document.body.appendChild(add);
document.getElementById('vm-sample-addbtn').addEventListener('click', () => {
const p = currentCenteredProduct();
if (!p) { flashAddHint('Click a board to centre a design first'); return; }
addSampleToTable(p);
});
// Restore any previously-basketed skus as a count hint (cards aren't re-hydrated —
// the persisted basket is a request list, not a scene snapshot).
updateSampleCount();
}
function flashAddHint(msg) {
const c = document.getElementById('vm-sample-count');
if (!c) return;
c.textContent = msg;
clearTimeout(flashAddHint._t);
flashAddHint._t = setTimeout(() => { updateSampleCount(); }, 1800);
}
function currentCenteredProduct() {
const f = QH.focusedWing;
if (f && f.userData && f.userData.product) return f.userData.product;
// No board focused yet — fall back to the current/middle board so "Add sample"
// always has a swatch to drop (prevents a null-deref when nothing's been clicked).
const boards = QH.wingBoards || [];
const i = curIdx();
const b = boards[i] || boards[Math.floor(boards.length / 2)];
return (b && b.userData && b.userData.product) ? b.userData.product : null;
}
function imgUrl(u) {
if (!u) return '';
if (u.charAt(0) === '/') return u;
return '/api/proxy/image?url=' + encodeURIComponent(u);
}
function escHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, m =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
}
function sampleName(p) { return p.pattern_name || p.name || p.title || p.sku || 'Sample'; }
function sampleColor(p) { return p.color || p.color_name || p.collection || p.vendor || ''; }
function tableSize() {
const t = document.getElementById('vm-sample-table');
return t ? { w: t.clientWidth, h: t.clientHeight } : { w: window.innerWidth, h: window.innerHeight };
}
// Golden-angle spiral spread biased to the visible near band of the table (from proto).
function nextSampleSlot() {
const s = tableSize();
const cx = s.w * 0.5, cy = s.h * 0.42;
const i = _sampleSlotIndex++;
const ang = i * 2.399963; // golden angle
let rad = Math.min(s.w, s.h) * 0.072 * Math.sqrt(i + 0.6);
rad = Math.min(rad, Math.min(s.w, s.h) * 0.22);
const jx = ((Math.sin(i * 12.9898) * 43758.5453) % 1) * 70 - 35;
const jy = ((Math.sin(i * 78.233) * 12543.1234) % 1) * 40 - 20;
const x = cx + Math.cos(ang) * rad * 1.25 + jx;
const y = cy + Math.sin(ang) * rad * 0.55 + jy;
const rot = (((Math.sin(i * 3.1) * 1000)) % 1) * 36 - 18;
return { x, y, rot };
}
function persistBasket() {
try {
const skus = _samplePlaced.map(r => r.sku).filter(Boolean);
localStorage.setItem('qh_sample_basket', JSON.stringify(skus));
} catch (e) {}
}
function updateSampleCount() {
const el = document.getElementById('vm-sample-count');
if (!el) return;
const n = _samplePlaced.length;
el.textContent = n + (n === 1 ? ' sample' : ' samples');
}
function addSampleToTable(p) {
if (!p) { flashAddHint && flashAddHint('Click a design first'); return; }
const surface = document.getElementById('vm-sample-surface');
if (!surface) return;
// De-dupe: same SKU already on the table → don't stack, just nudge the count.
if (p.sku && _samplePlaced.some(r => r.sku === p.sku)) { flashAddHint('Already on the table'); return; }
const slot = nextSampleSlot();
const el = document.createElement('div');
el.className = 'vm-sample';
el.dataset.sku = p.sku || '';
el.innerHTML =
'<div class="contact"></div>' +
'<div class="paper">' +
'<div class="swatch" style="background-image:url(\'' + imgUrl(p.tile || p.image) + '\')"></div>' +
'<div class="plate">' +
'<span class="pname">' + escHtml(sampleName(p)) + '</span>' +
'<span class="pcol">' + escHtml(sampleColor(p)) + '</span>' +
'</div>' +
'</div>' +
'<button class="rm" title="Remove">×</button>';
const land = 'translate3d(' + slot.x + 'px,' + slot.y + 'px,0) rotateZ(' + slot.rot.toFixed(1) + 'deg)';
const lift = 'translate3d(' + slot.x + 'px,' + (slot.y - 60) + 'px,140px) rotateZ(' + (slot.rot - 20).toFixed(1) + 'deg) scale(1.06)';
el.style.transform = lift;
el.style.opacity = '0';
surface.appendChild(el);
// force reflow then animate to the landed transform
void el.offsetWidth;
el.style.transition = 'transform .6s cubic-bezier(.22,1.08,.36,1), opacity .3s ease-out';
el.style.opacity = '1';
el.style.transform = land;
el.dataset.x = slot.x; el.dataset.y = slot.y; el.dataset.rot = slot.rot;
el.querySelector('.rm').addEventListener('click', (ev) => {
ev.stopPropagation();
el.style.transition = 'transform .38s ease-in, opacity .38s ease-in';
el.style.transform = 'translate3d(' + slot.x + 'px,' + (slot.y - 80) + 'px,170px) scale(.9)';
el.style.opacity = '0';
setTimeout(() => { el.remove(); }, 380);
_samplePlaced = _samplePlaced.filter(r => r.el !== el);
updateSampleCount();
persistBasket();
});
enableSampleDrag(el);
_samplePlaced.push({ el, sku: p.sku });
updateSampleCount();
persistBasket();
}
// Perspective-correct drag (from proto): screen-Y delta un-foreshortened by cos(54°).
function enableSampleDrag(el) {
const surface = document.getElementById('vm-sample-surface');
let sx, sy, ox, oy, rot, active = false;
function down(e) {
if (e.target.classList.contains('rm')) return;
active = true; el.classList.add('lifted');
const pt = e.touches ? e.touches[0] : e;
sx = pt.clientX; sy = pt.clientY;
ox = parseFloat(el.dataset.x) || 0; oy = parseFloat(el.dataset.y) || 0;
rot = parseFloat(el.dataset.rot) || 0;
if (surface) surface.appendChild(el); // bring to front
el.style.transition = 'transform .12s ease-out';
el.style.transform = 'translate3d(' + ox + 'px,' + oy + 'px,40px) rotateZ(' + rot + 'deg) scale(1.04)';
e.preventDefault();
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', up);
}
function move(e) {
if (!active) return;
const pt = e.touches ? e.touches[0] : e;
const nx = ox + (pt.clientX - sx);
const ny = oy + (pt.clientY - sy) / SAMPLE_COS;
el.style.transition = 'none';
el.style.transform = 'translate3d(' + nx + 'px,' + ny + 'px,40px) rotateZ(' + rot + 'deg) scale(1.04)';
el.dataset.x = nx; el.dataset.y = ny;
e.preventDefault();
}
function up() {
if (!active) return; active = false; el.classList.remove('lifted');
const nx = parseFloat(el.dataset.x) || 0, ny = parseFloat(el.dataset.y) || 0;
el.style.transition = 'transform .2s cubic-bezier(.22,1,.36,1)';
el.style.transform = 'translate3d(' + nx + 'px,' + ny + 'px,0) rotateZ(' + rot + 'deg)';
window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up);
window.removeEventListener('touchmove', move); window.removeEventListener('touchend', up);
}
el.addEventListener('mousedown', down);
el.addEventListener('touchstart', down, { passive: false });
}
function showSampleTable() {
buildSampleTableDom();
const stage = document.getElementById('vm-sample-stage');
const add = document.getElementById('vm-sample-add');
if (stage) stage.classList.add('on');
if (add) add.classList.add('on');
}
function hideSampleTable() {
const stage = document.getElementById('vm-sample-stage');
const add = document.getElementById('vm-sample-add');
if (stage) stage.classList.remove('on');
if (add) add.classList.remove('on');
}
// ===========================================================================
// THEMES — lighting moods. Each tweaks exposure + light intensities + colour
// temperature + fog + a CSS vignette class, WITHOUT crushing swatch legibility.
// ===========================================================================
const THEMES = {
bright: { label: 'Bright Gallery', exposure: 1.16, ambient: 0.55, hemi: 0.50, key: 1.05, fill: 0.30, pic: 0.55, keyColor: 0xfff6ea, bg: 0x14141a, fog: 0.016, vignette: 0.06 },
warm: { label: 'Warm Atelier', exposure: 1.10, ambient: 0.42, hemi: 0.40, key: 1.15, fill: 0.22, pic: 0.70, keyColor: 0xffe9c8, bg: 0x16110a, fog: 0.020, vignette: 0.14 },
cool: { label: 'Cool Modern', exposure: 1.12, ambient: 0.50, hemi: 0.46, key: 1.00, fill: 0.40, pic: 0.45, keyColor: 0xeaf0ff, bg: 0x101218, fog: 0.016, vignette: 0.08 },
evening: { label: 'Evening', exposure: 1.02, ambient: 0.22, hemi: 0.18, key: 0.70, fill: 0.14, pic: 0.85, keyColor: 0xffdca8, bg: 0x0a0a10, fog: 0.034, vignette: 0.22 },
daylight: { label: 'Daylight', exposure: 1.22, ambient: 0.62, hemi: 0.58, key: 1.10, fill: 0.45, pic: 0.40, keyColor: 0xfffefb, bg: 0x191b1f, fog: 0.012, vignette: 0.04 },
boutique: { label: 'Boutique', exposure: 1.08, ambient: 0.34, hemi: 0.30, key: 1.20, fill: 0.20, pic: 0.95, keyColor: 0xffeccf, bg: 0x120e0a, fog: 0.024, vignette: 0.18 }
};
const THEME_ORDER = ['bright','warm','cool','evening','daylight','boutique'];
function applyTheme(key) {
const t = THEMES[key]; if (!t) return;
currentTheme = key;
const r = QH.renderer, s = QH.scene;
r.toneMappingExposure = t.exposure;
if (s.background && s.background.setHex) s.background.setHex(t.bg);
if (s.fog) s.fog.density = t.fog;
s.children.forEach(l => {
if (!l.isLight) return;
if (l.isAmbientLight) l.intensity = t.ambient;
else if (l.isHemisphereLight) l.intensity = t.hemi;
else if (l.isDirectionalLight && l.castShadow) { l.intensity = t.key; if (l.color && l.color.setHex) l.color.setHex(t.keyColor); }
else if (l.isDirectionalLight) l.intensity = t.fill;
});
if (QH.picLight) QH.picLight.intensity = t.pic;
if (QH.requestShadowUpdate) QH.requestShadowUpdate(8); // lights changed → rebake shadows
// CSS vignette strength
const pd = document.getElementById('photo-depth');
if (pd) pd.style.opacity = String(0.5 + t.vignette * 2); // subtle scale
document.querySelectorAll('#vm-theme .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.theme === key));
try { localStorage.setItem('qh_theme', key); } catch (e) {}
}
// ===========================================================================
// ROOM TYPES (Phase 2) — furnished preset chooser + flooring picker.
// ===========================================================================
let currentRoom = null;
function applyRoomType(key, opts) {
if (!QH.buildRoomType) return;
const ok = QH.buildRoomType(key, opts || {});
if (!ok) return;
currentRoom = key;
document.querySelectorAll('#vm-room .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.room === key));
try { localStorage.setItem('qh_room_type', key); } catch (e) {}
// Re-frame the room's default camera unless we're mid-walk (respect free explore).
if (!(opts && opts.noFrame) && QH.frameRoomType) { try { QH.frameRoomType(key); } catch (e) {} }
}
function setFloor(val) {
if (QH.setFloorOverride) QH.setFloorOverride(val);
document.querySelectorAll('#vm-floor .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.floor === val));
}
// Expose so the window-bar room-size sliders + tests can drive it.
window._roomType = { apply: applyRoomType, floor: setFloor, get current() { return currentRoom; } };
// ===========================================================================
// setMode — the single switcher. Instant + reversible.
// ===========================================================================
function setMode(key, fromInit) {
const m = MODES[key]; if (!m) return;
// Exit the previous mode cleanly.
if (currentMode && MODES[currentMode] && MODES[currentMode].exit) {
try { MODES[currentMode].exit(); } catch (e) {}
}
savedFocusIndex = curIdx();
currentMode = key;
QH.frameHook = m.frame ? ((dt) => { try { m.frame(dt); } catch (e) {} }) : perfFrameOnly;
// Wrap so the perf readout always updates regardless of the mode's own frame().
const modeFrame = m.frame || null;
QH.frameHook = (dt) => { if (modeFrame) { try { modeFrame(dt); } catch (e) {} } perfTick(dt); };
try { m.enter(); } catch (e) { console.warn('view-mode enter failed', key, e); }
// Avatar body is HIDDEN in every view (DTD verdict B, 3/3, 2026-06-29): the showroom's
// core action is walking up to a board to inspect a pattern, which is exactly when a
// visible body occludes the product / clips into the wall. Pure first-person in Walk-
// Through; never rendered in framed views either. The rig + setAvatarVisible API are
// kept so a future third-person "see yourself in the room" view can re-enable it.
if (QH.setAvatarVisible) QH.setAvatarVisible(false);
if (QH.requestShadowUpdate) QH.requestShadowUpdate(20); // mode changed geometry/visibility → rebake shadows
// Update panel UI
document.querySelectorAll('#vm-view .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.mode === key));
const hint = document.getElementById('vm-hint');
if (hint) hint.textContent = m.hint || '';
if (!fromInit) { try { localStorage.setItem('qh_viewmode', key); } catch (e) {} }
}
function perfFrameOnly(dt) { perfTick(dt); }
// ===========================================================================
// PERF READOUT (TASK 3) — FPS, frame-ms, drawCalls, triangles. Toggleable.
// ===========================================================================
let perfOn = false, perfAccum = 0, perfFrames = 0, perfLast = performance.now();
function perfTick() {
if (!perfOn) return;
perfFrames++;
const now = performance.now();
const elapsed = now - perfLast;
if (elapsed >= 500) {
const fps = Math.round(perfFrames * 1000 / elapsed);
const ms = (elapsed / perfFrames).toFixed(2);
const ri = QH.renderer.info.render;
const el = document.getElementById('vm-perf-body');
if (el) {
el.innerHTML =
'<span class="' + (fps >= 55 ? 'pf-good' : fps >= 40 ? 'pf-warn' : 'pf-bad') + '">' + fps + ' FPS</span>' +
'<span>' + ms + ' ms</span>' +
'<span>' + ri.calls + ' draws</span>' +
'<span>' + (ri.triangles >= 1000 ? (ri.triangles / 1000).toFixed(1) + 'k' : ri.triangles) + ' tris</span>';
}
perfFrames = 0; perfLast = now;
}
}
function togglePerf(on) {
perfOn = (on == null) ? !perfOn : on;
const box = document.getElementById('vm-perf');
if (box) box.classList.toggle('open', perfOn);
const btn = document.getElementById('vm-perf-btn');
if (btn) btn.classList.toggle('active', perfOn);
try { localStorage.setItem('qh_perf', perfOn ? '1' : '0'); } catch (e) {}
}
// ===========================================================================
// PANEL UI — vanilla CSS, ART-DIRECTION tokens (brass on museum glass).
// ===========================================================================
function buildPanel() {
const wrap = document.createElement('div');
wrap.id = 'vm-panel';
wrap.innerHTML =
'<div class="vm-section">' +
'<div class="vm-eyebrow">View</div>' +
'<div id="vm-view" class="vm-grid"></div>' +
'<div id="vm-hint" class="vm-hint"></div>' +
'</div>' +
'<div class="vm-section">' +
'<div class="vm-eyebrow">Theme</div>' +
'<div id="vm-theme" class="vm-grid vm-grid-theme"></div>' +
'</div>' +
'<div class="vm-section">' +
'<div class="vm-eyebrow">Room</div>' +
'<div id="vm-room" class="vm-grid vm-grid-theme"></div>' +
'<div class="vm-eyebrow" style="margin-top:8px">Flooring</div>' +
'<div id="vm-floor" class="vm-grid vm-grid-theme"></div>' +
'</div>' +
'<div class="vm-section vm-perfrow">' +
'<button id="vm-perf-btn" class="vm-perf-btn" title="Toggle performance readout">Perf</button>' +
'<button id="vm-collapse" class="vm-perf-btn" title="Collapse / expand the panel">View & Theme</button>' +
'</div>';
document.body.appendChild(wrap);
// perf readout box (separate, top-left)
const perf = document.createElement('div');
perf.id = 'vm-perf';
perf.innerHTML = '<div class="vm-perf-title">Render</div><div id="vm-perf-body"></div>';
document.body.appendChild(perf);
// compare-mode banner (under the title)
const banner = document.createElement('div');
banner.id = 'vm-banner';
document.body.appendChild(banner);
// populate view chips
const vg = document.getElementById('vm-view');
MODE_ORDER.forEach((k, n) => {
const b = document.createElement('button');
b.className = 'vm-chip'; b.dataset.mode = k;
b.innerHTML = '<span class="vm-num">' + (n + 1) + '</span>' + MODES[k].label;
b.title = MODES[k].hint;
b.addEventListener('click', () => setMode(k));
vg.appendChild(b);
});
// populate theme chips
const tg = document.getElementById('vm-theme');
THEME_ORDER.forEach(k => {
const b = document.createElement('button');
b.className = 'vm-chip'; b.dataset.theme = k; b.textContent = THEMES[k].label;
b.addEventListener('click', () => applyTheme(k));
tg.appendChild(b);
});
// populate ROOM-TYPE chips (Phase 2) — copy the theme-chip UI. Chooses a furnished
// room preset and re-frames its default camera. Persisted (qh_room_type).
const rg = document.getElementById('vm-room');
const rOrder = (QH.ROOM_TYPE_ORDER || []);
rOrder.forEach(k => {
const rec = QH.ROOM_TYPES[k];
const b = document.createElement('button');
b.className = 'vm-chip'; b.dataset.room = k; b.textContent = rec.label;
b.title = rec.label + ' — ' + (rec.mood || '');
b.addEventListener('click', () => applyRoomType(k));
rg.appendChild(b);
});
// populate FLOORING picker — oak / stone / (room default). Persisted (qh_floor).
const fg = document.getElementById('vm-floor');
[['default', 'Default'], ['oak', 'Oak'], ['stone', 'Stone']].forEach(([val, lbl]) => {
const b = document.createElement('button');
b.className = 'vm-chip'; b.dataset.floor = val; b.textContent = lbl;
b.addEventListener('click', () => setFloor(val));
fg.appendChild(b);
});
document.getElementById('vm-perf-btn').addEventListener('click', () => togglePerf());
document.getElementById('vm-collapse').addEventListener('click', () => {
wrap.classList.toggle('collapsed');
try { localStorage.setItem('qh_vm_collapsed', wrap.classList.contains('collapsed') ? '1' : '0'); } catch (e) {}
});
if (localStorage.getItem('qh_vm_collapsed') === '1') wrap.classList.add('collapsed');
}
// ===========================================================================
// INIT — build the panel, restore last choices, default to Hero on first load.
// ===========================================================================
buildPanel();
// Theme first (so lights are set before the view frames).
const savedTheme = localStorage.getItem('qh_theme');
applyTheme(THEMES[savedTheme] ? savedTheme : 'bright');
// Perf toggle restore.
if (localStorage.getItem('qh_perf') === '1') togglePerf(true); else togglePerf(false);
// Room-type dressing (sofa / lamps / wingchair / boards / rug / sculpture / plant) is
// OFF on boot (Steve 2026-07-02): "onload load nothing except the table and chair;
// remove all other elements." The room boots as a CLEAN empty room + the active style's
// table + chair only (swapFurniture owns those). A furnished room type is built ONLY when
// the user explicitly clicks a room-type chip (applyRoomType, wired at buildPanel). We do
// NOT auto-restore a saved room type either — a returning user also boots to the clean
// empty room and re-dresses by choosing a chip.
const savedFloor = QH.floorOverride || 'default';
document.querySelectorAll('#vm-floor .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.floor === savedFloor));
// View: Hero is ALWAYS the boot view for a first-time load (locked rule). But if
// the user previously picked a mode, restore it AFTER the guided default has
// settled — except never auto-restore the free-camera modes on boot (they'd land
// on an empty room for a returning senior). Restore only the board-presentation
// modes; otherwise fall back to Hero.
// Boot = WALK-THROUGH the CENTER of the showroom (Steve 2026-06-30): first-person
// walk down the middle of the room past the boards, with an automated slow dolly
// (Peruse) so it moves on its own until the user takes WASD control. A returning
// user's saved board-presentation mode still restores; free-cam modes never do.
const savedMode = localStorage.getItem('qh_viewmode');
const safeBootModes = { walk: 1, hero: 1, macro: 1, room: 1, table: 1 };
const bootMode = (savedMode && MODES[savedMode] && safeBootModes[savedMode]) ? savedMode : 'walk';
setMode(bootMode, true);
// Boot = the first-person user STANDS IN THE DOORWAY at the back (entrance) end of
// the room and STOPS (Steve 2026-07-01) — stationary, looking in toward the boards.
// The BOOK_MODE boot chain re-centres the camera on the book at +700/+1500ms, so we
// re-assert the doorway pose just after it settles. No dolly — stationary until WASD.
if (bootMode === 'walk') {
[1700, 2100].forEach(t => setTimeout(() => {
if (currentMode === 'walk' && window._qhStandInDoorway) window._qhStandInDoorway();
}, t));
}
// Expose for the test harness + for showroom guided buttons to re-assert the view.
window._viewmode = {
set: setMode,
theme: applyTheme,
perf: togglePerf,
room: applyRoomType,
floor: setFloor,
get current() { return currentMode; },
get theme_() { return currentTheme; },
get room_() { return currentRoom; },
// PHASE 3 "At the Table" hooks — verification/automation surface.
addSample: (p) => addSampleToTable(p || currentCenteredProduct()),
get sampleCount() { return _samplePlaced.length; },
get samplePlaced() { return _samplePlaced.slice(); },
MODES, THEMES, MODE_ORDER, THEME_ORDER
};
// Re-apply the current mode's framing when the guided Next/Back lands on a new
// board (so Macro/Angled/Room re-frame the new board, not just Hero).
window._viewmode.reframe = () => {
const m = MODES[currentMode];
if (m && m.guided && m.enter) { try { m.enter(); } catch (e) {} }
};
}
boot();
})();