← back to Quadrille Showroom
Add 10 switchable view modes + 6 lighting themes + toggleable perf readout (View/Theme panel)
52f212e8cf139a53802f29ae8a042f8f804cd79c · 2026-06-28 06:02:08 -0700 · Steve
Files touched
M public/css/showroom.cssM public/js/showroom.jsA public/js/viewmodes.jsM public/showroom.html
Diff
commit 52f212e8cf139a53802f29ae8a042f8f804cd79c
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jun 28 06:02:08 2026 -0700
Add 10 switchable view modes + 6 lighting themes + toggleable perf readout (View/Theme panel)
---
public/css/showroom.css | 84 +++++++++
public/js/showroom.js | 69 ++++++-
public/js/viewmodes.js | 483 ++++++++++++++++++++++++++++++++++++++++++++++++
public/showroom.html | 1 +
4 files changed, 635 insertions(+), 2 deletions(-)
diff --git a/public/css/showroom.css b/public/css/showroom.css
index 946bda1..3d6d692 100644
--- a/public/css/showroom.css
+++ b/public/css/showroom.css
@@ -297,3 +297,87 @@ body { overflow: hidden; background: #0a0a0f; font-family: 'Segoe UI', system-ui
#wing-detail.collapsed .detail-actions { display: none; }
#wing-detail.collapsed .wd-body { padding: 14px 40px 14px 20px; }
#wing-detail.collapsed .wd-title { margin-bottom: 0; }
+
+/* ============================================================
+ VIEW-MODE + THEME PANEL (viewmodes.js) — museum-glass chrome,
+ ART-DIRECTION tokens: brushed brass on dark, tracked eyebrows.
+ Top-LEFT so it never fights the bottom guided bar or right HUD.
+ ============================================================ */
+#vm-panel {
+ position: fixed; top: 70px; left: 16px; z-index: 42;
+ width: 248px; display: flex; flex-direction: column; gap: 12px;
+ background: rgba(12,12,17,0.90); border: 1.5px solid rgba(201,169,110,0.40);
+ border-radius: 16px; padding: 14px 14px 12px;
+ backdrop-filter: blur(14px); box-shadow: 0 18px 50px -14px rgba(0,0,0,0.75);
+ transition: transform .2s ease, opacity .2s ease;
+}
+#vm-panel.collapsed { transform: translateX(-228px); }
+#vm-panel.collapsed:hover { transform: translateX(0); }
+#vm-panel .vm-section { display: flex; flex-direction: column; gap: 8px; }
+#vm-panel .vm-eyebrow {
+ font-size: 11px; letter-spacing: 2.4px; text-transform: uppercase;
+ color: #c9a96e; font-weight: 700; opacity: .9;
+}
+#vm-panel .vm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+#vm-panel .vm-grid-theme { grid-template-columns: 1fr 1fr; }
+#vm-panel .vm-chip {
+ display: flex; align-items: center; gap: 6px;
+ min-height: 34px; padding: 6px 9px; cursor: pointer;
+ font-size: 12.5px; font-weight: 600; letter-spacing: .2px; line-height: 1.1;
+ border-radius: 9px; border: 1px solid rgba(201,169,110,0.30);
+ background: rgba(255,255,255,0.05); color: #ede4cf;
+ transition: background .12s, border-color .12s, color .12s;
+ text-align: left; -webkit-user-select: none; user-select: none;
+}
+#vm-panel .vm-chip:hover { background: rgba(201,169,110,0.18); border-color: #c9a96e; }
+#vm-panel .vm-chip.active {
+ background: linear-gradient(180deg, #d8bd84, #b9933f); color: #1a1206;
+ border-color: #b9933f; box-shadow: 0 2px 10px rgba(185,147,63,0.35);
+}
+#vm-panel .vm-chip .vm-num {
+ font-size: 10px; font-weight: 800; opacity: .65;
+ min-width: 13px; text-align: center; font-variant-numeric: tabular-nums;
+}
+#vm-panel .vm-chip.active .vm-num { opacity: .85; }
+#vm-panel .vm-hint {
+ font-size: 11.5px; line-height: 1.35; color: #b8b0a0; min-height: 30px;
+ padding-top: 2px; font-style: italic;
+}
+#vm-panel .vm-perfrow { flex-direction: row; gap: 8px; }
+#vm-panel .vm-perf-btn {
+ flex: 1; min-height: 30px; font-size: 11px; letter-spacing: 1px;
+ text-transform: uppercase; font-weight: 700; cursor: pointer;
+ border-radius: 8px; border: 1px solid rgba(201,169,110,0.30);
+ background: rgba(255,255,255,0.05); color: #c9a96e;
+}
+#vm-panel .vm-perf-btn:hover { background: rgba(201,169,110,0.16); }
+#vm-panel .vm-perf-btn.active { background: rgba(201,169,110,0.85); color: #1a1206; border-color: #c9a96e; }
+
+/* Perf readout — small fixed badge, top-centre-left, only when toggled on */
+#vm-perf {
+ position: fixed; top: 70px; left: 280px; z-index: 41; display: none;
+ background: rgba(10,10,14,0.86); border: 1px solid rgba(201,169,110,0.35);
+ border-radius: 10px; padding: 8px 12px; backdrop-filter: blur(8px);
+ font-variant-numeric: tabular-nums; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.7);
+}
+#vm-perf.open { display: block; }
+#vm-perf .vm-perf-title {
+ font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
+ color: #c9a96e; font-weight: 700; margin-bottom: 4px;
+}
+#vm-perf-body { display: flex; gap: 14px; font-size: 13px; color: #ded6c6; font-weight: 600; }
+#vm-perf-body .pf-good { color: #7ec97e; } #vm-perf-body .pf-warn { color: #d4c06a; } #vm-perf-body .pf-bad { color: #d07a6a; }
+
+/* Compare-mode banner under the title */
+#vm-banner {
+ position: fixed; left: 50%; top: 116px; transform: translateX(-50%);
+ z-index: 37; display: none; max-width: 80vw; text-align: center;
+ font-size: 13px; font-weight: 600; letter-spacing: .3px; color: #f5efe2;
+ background: rgba(12,12,17,0.74); border: 1px solid rgba(201,169,110,0.30);
+ padding: 7px 18px; border-radius: 18px; backdrop-filter: blur(8px);
+}
+
+@media (max-width: 720px) {
+ #vm-panel { top: 60px; left: 8px; width: 200px; }
+ #vm-perf { left: 216px; top: 60px; }
+}
diff --git a/public/js/showroom.js b/public/js/showroom.js
index cdd4b37..09b8a93 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -1496,9 +1496,17 @@ function restoreBoards() {
async function gotoBoardOffset(delta) {
if (!wingBoards.length) return;
let idx = (focusedWing ? focusedWing.userData.index : 0) + delta;
- if (idx >= wingBoards.length) { if (await advanceWindow(1)) { restoreBoards(); focusOnWing(wingBoards[0]); return; } idx = wingBoards.length - 1; }
- else if (idx < 0) { if (await advanceWindow(-1)) { restoreBoards(); focusOnWing(wingBoards[wingBoards.length - 1]); return; } idx = 0; }
+ 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
@@ -1926,6 +1934,12 @@ function animate() {
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 */ }
+ }
+
renderer.render(scene, camera);
}
@@ -2028,6 +2042,14 @@ function applyExploreMode() {
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');
@@ -2541,4 +2563,47 @@ window._getSceneStats = function() {
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' };
};
+
+// ============================================================
+// 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 peruseActive(){ return peruseActive; },
+ get exploreMode(){ return exploreMode; },
+ CONFIG,
+ // Camera + focus
+ smoothCameraTo,
+ focusOnWing,
+ unfocusWing,
+ enterGuidedDefault,
+ isolateBoard,
+ restoreBoards,
+ lockControls,
+ unlockControls,
+ get controlsLocked() { return controlsLocked; },
+ setControlsLocked(v) { controlsLocked = v; },
+ gotoBoardOffset,
+ stopPeruse,
+ startPeruse,
+ // Lighting
+ get keyLight() { return window._keyLight; },
+ get picLight() { return window._picLight; },
+ // Walls
+ cladWalls,
+ revertWalls,
+ // FOV constants
+ FOV_DEFAULT, FOV_HERO,
+ setTargetFov(f) { targetFov = f; },
+ // A per-frame hook the view-mode engine installs (orbit/carousel/macro drift etc.)
+ frameHook: null
+};
})();
diff --git a/public/js/viewmodes.js b/public/js/viewmodes.js
new file mode 100644
index 0000000..8a44a5e
--- /dev/null
+++ b/public/js/viewmodes.js
@@ -0,0 +1,483 @@
+/* ============================================================================
+ * 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
+
+ // ---- 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 macroPhase = 0; // macro slow drift phase
+ let compareSetup = false; // compare mode: two boards staged
+ let savedFocusIndex = 0; // remember which board to return to
+
+ // 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 so the design is presented (Hero/Angled/Macro/etc).
+ function ensureFocused(i) {
+ const idx = (i == null) ? curIdx() : i;
+ QH.restoreBoards();
+ if (!QH.focusedWing || QH.focusedWing.userData.index !== idx) {
+ if (QH.wingBoards[idx]) QH.focusOnWing(QH.wingBoards[idx]);
+ }
+ }
+
+ const MODES = {
+ hero: {
+ label: 'Hero', hint: 'Flat, dead-on — the gallery hero panel.', guided: true,
+ enter() {
+ ensureFocused();
+ // focusOnWing already gives the PJ flat hero framing; just make sure the
+ // open angle is near-flat for this mode.
+ setWingDeg(8);
+ }
+ },
+
+ angled: {
+ label: 'Angled', hint: 'Board opens to a ~65° V — the open-book look.', guided: true,
+ enter() {
+ ensureFocused();
+ setWingDeg(60);
+ // Step a touch closer + lower so the V reads dimensionally.
+ const c = boardWorld(curIdx());
+ const pos = new THREE.Vector3(c.x + 0.35, c.y - 0.05, c.z + 1.7);
+ const tgt = new THREE.Vector3(c.x, c.y, c.z);
+ park(pos, tgt, 38);
+ },
+ exit() { setWingDeg(8); }
+ },
+
+ orbit: {
+ label: 'Orbit', hint: 'Drag to orbit around the focused board.', guided: true,
+ enter() {
+ ensureFocused();
+ setWingDeg(20);
+ freeCam();
+ // Turn orbit controls ON for this mode regardless of Explore.
+ const ctl = QH.controls;
+ ctl.enableRotate = true; ctl.enableZoom = true; ctl.enablePan = false;
+ const c = boardWorld(curIdx());
+ ctl.target.copy(c);
+ ctl.minDistance = 1.2; ctl.maxDistance = 5.5;
+ QH.camera.position.set(c.x, c.y + 0.1, c.z + 2.6);
+ QH.setTargetFov(40); QH.camera.fov = 40; QH.camera.updateProjectionMatrix();
+ orbitAuto = true; orbitSpin = 0;
+ },
+ frame(dt) {
+ if (!orbitAuto) return; // user drag cancels auto-spin
+ const ctl = QH.controls, cam = QH.camera, c = ctl.target;
+ orbitSpin += dt * 0.32;
+ const r = 2.6, h = c.y + 0.12;
+ cam.position.set(c.x + Math.sin(orbitSpin) * r, h, c.z + Math.cos(orbitSpin) * r);
+ cam.lookAt(c);
+ },
+ exit() {
+ orbitAuto = false;
+ QH.controls.minDistance = 1.0; QH.controls.maxDistance = 8.0;
+ }
+ },
+
+ gallery: {
+ label: 'Gallery', hint: 'Pull back to see the whole wall of boards.', guided: false,
+ enter() {
+ // Show every board on the rail; no isolation.
+ QH.restoreBoards();
+ if (QH.focusedWing) QH.unfocusWing(true);
+ setWingDeg(0);
+ const pos = new THREE.Vector3(0, 1.62, ROOM.depth / 2 - 0.7);
+ const tgt = new THREE.Vector3(0, boardCY, backZ);
+ park(pos, tgt, 58);
+ }
+ },
+
+ topdown: {
+ label: 'Top-Down', hint: 'Overhead plan view of the showroom.', guided: false,
+ enter() {
+ QH.restoreBoards();
+ if (QH.focusedWing) QH.unfocusWing(true);
+ setWingDeg(0);
+ const pos = new THREE.Vector3(0, ROOM.height + 3.4, 0.2);
+ const tgt = new THREE.Vector3(0, 0, backZ + 0.4);
+ park(pos, tgt, 60);
+ }
+ },
+
+ 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);
+ 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);
+ const pos = new THREE.Vector3(0, 1.6, ROOM.depth / 2 - 1.2);
+ 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);
+ 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.
+ const wg = findWallGroup();
+ if (wg) { carouselT += dt * 0.22; wg.rotation.y = Math.sin(carouselT) * 0.5; }
+ },
+ exit() { const wg = findWallGroup(); if (wg) wg.rotation.y = 0; }
+ },
+
+ macro: {
+ label: 'Macro', hint: 'Zoom close to read the texture detail.', guided: true,
+ enter() {
+ ensureFocused();
+ setWingDeg(2);
+ const c = boardWorld(curIdx());
+ // Very close, tight FOV — read the weave/print. Slight off-centre so the
+ // grazing light catches the surface.
+ const pos = new THREE.Vector3(c.x - 0.12, c.y + 0.15, c.z + 0.62);
+ const tgt = new THREE.Vector3(c.x - 0.05, c.y + 0.1, c.z);
+ park(pos, tgt, 26);
+ 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 boards side-by-side for A/B.', guided: false,
+ enter() {
+ // Stage two adjacent boards open + visible; frame both.
+ const i = curIdx();
+ const j = (i + 1) % QH.wingBoards.length;
+ // Show only these two.
+ QH.wingBoards.forEach((b, k) => { b.visible = (k === i || k === j); if (b.userData.wallDrop) b.userData.wallDrop.visible = false; });
+ // Open both leaves a touch so each presents its design.
+ [i, j].forEach(k => {
+ const ud = QH.wingBoards[k].userData;
+ ud.panelClosed = false;
+ // mark as a "compare-open" so the showroom's updateBooks won't fight it:
+ // we set the leaf rotation directly here each enter; small static splay.
+ if (ud.leafL) ud.leafL.rotation.y = 0.10;
+ if (ud.leafR) ud.leafR.rotation.y = -0.10;
+ });
+ QH.focusOnWing && (QH._noop = 0);
+ // Frame the pair: camera centred between the two boards, pulled back.
+ const a = boardWorld(i), b = boardWorld(j);
+ const midX = (a.x + b.x) / 2;
+ const pos = new THREE.Vector3(midX, boardCY, backZ + 2.9);
+ const tgt = new THREE.Vector3(midX, boardCY, backZ);
+ park(pos, tgt, 44);
+ compareSetup = true;
+ // Update the title to say what's being compared.
+ setBanner('Comparing two designs side-by-side — Next steps both forward.');
+ },
+ exit() {
+ compareSetup = false;
+ 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);
+ }
+ }
+ };
+
+ const MODE_ORDER = ['hero','angled','orbit','gallery','topdown','walk','carousel','macro','compare','room'];
+
+ // 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 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'; }
+
+ // ===========================================================================
+ // 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;
+ // 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) {}
+ }
+
+ // ===========================================================================
+ // 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); }
+ // 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 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);
+ });
+
+ 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);
+
+ // 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.
+ const savedMode = localStorage.getItem('qh_viewmode');
+ const safeBootModes = { hero: 1, angled: 1, macro: 1, room: 1 };
+ setMode('hero', true); // boot = Hero, guided focus already set the middle board
+ if (savedMode && MODES[savedMode] && safeBootModes[savedMode] && savedMode !== 'hero') {
+ setTimeout(() => setMode(savedMode, true), 1200);
+ }
+
+ // Expose for the test harness + for showroom guided buttons to re-assert the view.
+ window._viewmode = {
+ set: setMode,
+ theme: applyTheme,
+ perf: togglePerf,
+ get current() { return currentMode; },
+ get theme_() { return currentTheme; },
+ 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();
+})();
diff --git a/public/showroom.html b/public/showroom.html
index 60f4567..2214b0c 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -379,5 +379,6 @@
<script src="/js/three.min.js"></script>
<script src="/js/OrbitControls.js"></script>
<script src="/js/showroom.js"></script>
+ <script src="/js/viewmodes.js"></script>
</body>
</html>
← c1cf76b TEMP dev aid: auto-reload open tab every 30s (idle-guarded 6
·
back to Quadrille Showroom
·
auto-save: 2026-06-28T06:13:03 (3 files) — public/js/showroo afcaf8b →