← back to Quadrille Showroom
auto-save: 2026-07-02T08:46:35 (30 files) — data/thumb-cache/099160b17f710e570c5aa9ac.jpg data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg data/thumb-cache/44fbcf466a13d44c920bf876.jpg
aec52fe2b669415f639267da85789cafbb5b804d · 2026-07-02 08:46:38 -0700 · Steve Abrams
Files touched
D data/thumb-cache/099160b17f710e570c5aa9ac.jpgD data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpgD data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpgD data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpgD data/thumb-cache/44fbcf466a13d44c920bf876.jpgD data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpgD data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpgD data/thumb-cache/6de50aa4eecf76ec8b46608f.jpgD data/thumb-cache/79e9655d90d2707d0f3cc98c.jpgD data/thumb-cache/7ee3f79366d62966a5295da4.jpgD data/thumb-cache/83bf2a3012a67ceae0264aa9.jpgD data/thumb-cache/8e080de9798485fa3badb5f6.jpgD data/thumb-cache/90b97a1dae394bf1bd6d4902.jpgD data/thumb-cache/946174e77373830644fdcce4.jpgD data/thumb-cache/962c93a220c69af4f237020e.jpgD data/thumb-cache/a575f374084aef6f484e37e0.jpgD data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpgD data/thumb-cache/b77ee76df67378944ef41b72.jpgD data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpgD data/thumb-cache/c8f0693350b1df2a1926bec0.jpgD data/thumb-cache/c987c8b51e74d135d6c0670b.jpgD data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpgD data/thumb-cache/d9462876e1e7c57a8d51e235.jpgD data/thumb-cache/ded5027936e45e8f614fe792.jpgD data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpgD data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpgA public/js/furniture-styles.jsM public/js/showroom.jsM public/showroom.htmlM server.js
Diff
commit aec52fe2b669415f639267da85789cafbb5b804d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 2 08:46:38 2026 -0700
auto-save: 2026-07-02T08:46:35 (30 files) — data/thumb-cache/099160b17f710e570c5aa9ac.jpg data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg data/thumb-cache/44fbcf466a13d44c920bf876.jpg
---
data/thumb-cache/099160b17f710e570c5aa9ac.jpg | Bin 7955 -> 0 bytes
data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg | Bin 5346 -> 0 bytes
data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg | Bin 5457 -> 0 bytes
data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg | Bin 6804 -> 0 bytes
data/thumb-cache/44fbcf466a13d44c920bf876.jpg | Bin 5683 -> 0 bytes
data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg | Bin 5606 -> 0 bytes
data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg | Bin 7271 -> 0 bytes
data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg | Bin 8106 -> 0 bytes
data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg | Bin 6036 -> 0 bytes
data/thumb-cache/7ee3f79366d62966a5295da4.jpg | Bin 7253 -> 0 bytes
data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg | Bin 7125 -> 0 bytes
data/thumb-cache/8e080de9798485fa3badb5f6.jpg | Bin 6499 -> 0 bytes
data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg | Bin 5544 -> 0 bytes
data/thumb-cache/946174e77373830644fdcce4.jpg | Bin 5357 -> 0 bytes
data/thumb-cache/962c93a220c69af4f237020e.jpg | Bin 6684 -> 0 bytes
data/thumb-cache/a575f374084aef6f484e37e0.jpg | Bin 7643 -> 0 bytes
data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg | Bin 7221 -> 0 bytes
data/thumb-cache/b77ee76df67378944ef41b72.jpg | Bin 6854 -> 0 bytes
data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg | Bin 6446 -> 0 bytes
data/thumb-cache/c8f0693350b1df2a1926bec0.jpg | Bin 7369 -> 0 bytes
data/thumb-cache/c987c8b51e74d135d6c0670b.jpg | Bin 5939 -> 0 bytes
data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg | Bin 6048 -> 0 bytes
data/thumb-cache/d9462876e1e7c57a8d51e235.jpg | Bin 7492 -> 0 bytes
data/thumb-cache/ded5027936e45e8f614fe792.jpg | Bin 7760 -> 0 bytes
data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg | Bin 6359 -> 0 bytes
data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg | Bin 4056 -> 0 bytes
public/js/furniture-styles.js | 116 ++++++++
public/js/showroom.js | 392 ++++++++++++++++++++------
public/showroom.html | 2 +
server.js | 38 ++-
30 files changed, 454 insertions(+), 94 deletions(-)
diff --git a/data/thumb-cache/099160b17f710e570c5aa9ac.jpg b/data/thumb-cache/099160b17f710e570c5aa9ac.jpg
deleted file mode 100644
index c6a5012..0000000
Binary files a/data/thumb-cache/099160b17f710e570c5aa9ac.jpg and /dev/null differ
diff --git a/data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg b/data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg
deleted file mode 100644
index 4eeb7e9..0000000
Binary files a/data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg and /dev/null differ
diff --git a/data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg b/data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg
deleted file mode 100644
index 77052ce..0000000
Binary files a/data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg and /dev/null differ
diff --git a/data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg b/data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg
deleted file mode 100644
index dedc65b..0000000
Binary files a/data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg and /dev/null differ
diff --git a/data/thumb-cache/44fbcf466a13d44c920bf876.jpg b/data/thumb-cache/44fbcf466a13d44c920bf876.jpg
deleted file mode 100644
index b4b347f..0000000
Binary files a/data/thumb-cache/44fbcf466a13d44c920bf876.jpg and /dev/null differ
diff --git a/data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg b/data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg
deleted file mode 100644
index 291b046..0000000
Binary files a/data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg and /dev/null differ
diff --git a/data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg b/data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg
deleted file mode 100644
index f593e54..0000000
Binary files a/data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg and /dev/null differ
diff --git a/data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg b/data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg
deleted file mode 100644
index 665bbd9..0000000
Binary files a/data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg and /dev/null differ
diff --git a/data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg b/data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg
deleted file mode 100644
index f6243d7..0000000
Binary files a/data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg and /dev/null differ
diff --git a/data/thumb-cache/7ee3f79366d62966a5295da4.jpg b/data/thumb-cache/7ee3f79366d62966a5295da4.jpg
deleted file mode 100644
index 5dcd4d0..0000000
Binary files a/data/thumb-cache/7ee3f79366d62966a5295da4.jpg and /dev/null differ
diff --git a/data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg b/data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg
deleted file mode 100644
index a7071d2..0000000
Binary files a/data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg and /dev/null differ
diff --git a/data/thumb-cache/8e080de9798485fa3badb5f6.jpg b/data/thumb-cache/8e080de9798485fa3badb5f6.jpg
deleted file mode 100644
index 07e170f..0000000
Binary files a/data/thumb-cache/8e080de9798485fa3badb5f6.jpg and /dev/null differ
diff --git a/data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg b/data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg
deleted file mode 100644
index 796cf60..0000000
Binary files a/data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg and /dev/null differ
diff --git a/data/thumb-cache/946174e77373830644fdcce4.jpg b/data/thumb-cache/946174e77373830644fdcce4.jpg
deleted file mode 100644
index 2287228..0000000
Binary files a/data/thumb-cache/946174e77373830644fdcce4.jpg and /dev/null differ
diff --git a/data/thumb-cache/962c93a220c69af4f237020e.jpg b/data/thumb-cache/962c93a220c69af4f237020e.jpg
deleted file mode 100644
index 5914d57..0000000
Binary files a/data/thumb-cache/962c93a220c69af4f237020e.jpg and /dev/null differ
diff --git a/data/thumb-cache/a575f374084aef6f484e37e0.jpg b/data/thumb-cache/a575f374084aef6f484e37e0.jpg
deleted file mode 100644
index c22c066..0000000
Binary files a/data/thumb-cache/a575f374084aef6f484e37e0.jpg and /dev/null differ
diff --git a/data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg b/data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg
deleted file mode 100644
index 139cdf2..0000000
Binary files a/data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg and /dev/null differ
diff --git a/data/thumb-cache/b77ee76df67378944ef41b72.jpg b/data/thumb-cache/b77ee76df67378944ef41b72.jpg
deleted file mode 100644
index a8267c7..0000000
Binary files a/data/thumb-cache/b77ee76df67378944ef41b72.jpg and /dev/null differ
diff --git a/data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg b/data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg
deleted file mode 100644
index b2b0d9d..0000000
Binary files a/data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg and /dev/null differ
diff --git a/data/thumb-cache/c8f0693350b1df2a1926bec0.jpg b/data/thumb-cache/c8f0693350b1df2a1926bec0.jpg
deleted file mode 100644
index 0a0b6f2..0000000
Binary files a/data/thumb-cache/c8f0693350b1df2a1926bec0.jpg and /dev/null differ
diff --git a/data/thumb-cache/c987c8b51e74d135d6c0670b.jpg b/data/thumb-cache/c987c8b51e74d135d6c0670b.jpg
deleted file mode 100644
index 5b299fb..0000000
Binary files a/data/thumb-cache/c987c8b51e74d135d6c0670b.jpg and /dev/null differ
diff --git a/data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg b/data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg
deleted file mode 100644
index 6b9fa43..0000000
Binary files a/data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg and /dev/null differ
diff --git a/data/thumb-cache/d9462876e1e7c57a8d51e235.jpg b/data/thumb-cache/d9462876e1e7c57a8d51e235.jpg
deleted file mode 100644
index 6e4401d..0000000
Binary files a/data/thumb-cache/d9462876e1e7c57a8d51e235.jpg and /dev/null differ
diff --git a/data/thumb-cache/ded5027936e45e8f614fe792.jpg b/data/thumb-cache/ded5027936e45e8f614fe792.jpg
deleted file mode 100644
index 2352cf3..0000000
Binary files a/data/thumb-cache/ded5027936e45e8f614fe792.jpg and /dev/null differ
diff --git a/data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg b/data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg
deleted file mode 100644
index a4072b2..0000000
Binary files a/data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg and /dev/null differ
diff --git a/data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg b/data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg
deleted file mode 100644
index c759c53..0000000
Binary files a/data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg and /dev/null differ
diff --git a/public/js/furniture-styles.js b/public/js/furniture-styles.js
new file mode 100644
index 0000000..58a6889
--- /dev/null
+++ b/public/js/furniture-styles.js
@@ -0,0 +1,116 @@
+/* ============================================================================
+ * furniture-styles.js — The Quadrille House: FURNITURE STYLE SELECTOR.
+ *
+ * A bottom-left chip-tray with the four procedural furniture styles
+ * (Contemporary / Traditional / Transitional / Brutalist). Clicking a chip calls
+ * window._qh.swapFurniture(styleKey, { includeExtras }). A "+ Decor" toggle opts
+ * the set into its optional decor extras (onload stays minimal = table + chair).
+ * The chosen style persists in localStorage (via swapFurniture) so it survives a
+ * reload; the tray reflects the active style read back from window._qh.
+ *
+ * Matches the hamburger-dock visual language (gold #c9a96e on smoked glass,
+ * Georgia eyebrow). Its own #qh-style-tray id is NOT in dock.js's stow lists, so
+ * it stays permanently visible like the guided pager.
+ * ========================================================================== */
+(function () {
+ 'use strict';
+
+ var LS_EXTRAS = 'qh_furniture_extras_ui'; // remembers ONLY the toggle's UI state
+ var extrasOn = false;
+
+ function injectStyles() {
+ if (document.getElementById('qh-style-styles')) return;
+ var st = document.createElement('style');
+ st.id = 'qh-style-styles';
+ st.textContent = [
+ '#qh-style-tray{position:fixed;left:16px;bottom:16px;z-index:8800;',
+ 'display:flex;flex-direction:column;gap:7px;padding:10px 11px;',
+ 'background:rgba(14,14,20,0.90);border:1px solid rgba(201,169,110,0.30);border-radius:14px;',
+ 'backdrop-filter:blur(12px);box-shadow:0 18px 50px -12px rgba(0,0,0,0.8);',
+ 'font-family:Georgia,serif;max-width:250px;}',
+ '.qh-style-eyebrow{font-size:10px;letter-spacing:1.5px;text-transform:uppercase;',
+ 'color:#8c8678;font-weight:700;padding:0 2px 1px;}',
+ '.qh-style-chips{display:flex;flex-wrap:wrap;gap:6px;}',
+ '.qh-style-chip{cursor:pointer;padding:7px 12px;border-radius:9px;',
+ 'background:rgba(255,255,255,0.04);border:1px solid rgba(201,169,110,0.20);',
+ 'color:#e8e2d4;font-size:12.5px;font-weight:600;letter-spacing:.2px;',
+ 'transition:background .12s,border-color .12s,color .12s;white-space:nowrap;}',
+ '.qh-style-chip:hover{background:rgba(201,169,110,0.18);border-color:#c9a96e;}',
+ '.qh-style-chip.active{background:rgba(201,169,110,0.28);border-color:#c9a96e;color:#fbf4e3;}',
+ '.qh-style-decor{display:flex;align-items:center;justify-content:space-between;gap:8px;',
+ 'margin-top:1px;padding:6px 10px;border-radius:9px;cursor:pointer;',
+ 'background:rgba(255,255,255,0.03);border:1px solid rgba(201,169,110,0.16);',
+ 'color:#cfc7b4;font-size:11.5px;font-weight:600;letter-spacing:.3px;',
+ 'transition:background .12s,border-color .12s,color .12s;}',
+ '.qh-style-decor:hover{border-color:#c9a96e;color:#f0e6cf;}',
+ '.qh-style-decor.on{background:rgba(201,169,110,0.22);border-color:#c9a96e;color:#fbf4e3;}',
+ '.qh-style-decor .qh-decor-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;',
+ 'background:rgba(201,169,110,0.35);transition:background .12s;}',
+ '.qh-style-decor.on .qh-decor-dot{background:#c9a96e;}'
+ ].join('\n');
+ document.head.appendChild(st);
+ }
+
+ var tray;
+
+ function reflect() {
+ if (!tray || !window._qh) return;
+ var active = window._qh.furnitureStyle;
+ tray.querySelectorAll('.qh-style-chip').forEach(function (c) {
+ c.classList.toggle('active', c.getAttribute('data-style') === active);
+ });
+ var decor = tray.querySelector('.qh-style-decor');
+ if (decor) decor.classList.toggle('on', extrasOn);
+ }
+
+ function build() {
+ var order = (window._qh && window._qh.FURNITURE_STYLE_ORDER) || [];
+ injectStyles();
+ tray = document.createElement('div');
+ tray.id = 'qh-style-tray';
+
+ var html = '<div class="qh-style-eyebrow">Furniture Style</div><div class="qh-style-chips">';
+ order.forEach(function (key) {
+ var label = window._qh.furnitureStyleLabel ? window._qh.furnitureStyleLabel(key) : key;
+ html += '<button class="qh-style-chip" data-style="' + key + '">' + label + '</button>';
+ });
+ html += '</div>' +
+ '<div class="qh-style-decor" role="button" tabindex="0">' +
+ '<span>Add Decor</span><span class="qh-decor-dot"></span></div>';
+ tray.innerHTML = html;
+ document.body.appendChild(tray);
+
+ tray.querySelector('.qh-style-chips').addEventListener('click', function (e) {
+ var chip = e.target.closest('.qh-style-chip'); if (!chip) return;
+ window._qh.swapFurniture(chip.getAttribute('data-style'), { includeExtras: extrasOn });
+ reflect();
+ });
+
+ var decor = tray.querySelector('.qh-style-decor');
+ function toggleDecor() {
+ extrasOn = !extrasOn;
+ try { localStorage.setItem(LS_EXTRAS, extrasOn ? '1' : '0'); } catch (e) {}
+ window._qh.swapFurniture(window._qh.furnitureStyle, { includeExtras: extrasOn });
+ reflect();
+ }
+ decor.addEventListener('click', toggleDecor);
+ decor.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDecor(); }
+ });
+
+ reflect();
+ }
+
+ function boot(tries) {
+ tries = tries || 0;
+ // Wait until showroom.js has booted its furniture API + built the first set.
+ if (!window._qh || !window._qh.FURNITURE_STYLE_ORDER || !window._qh.furniturePresent) {
+ if (tries > 200) return; // give up after ~12s
+ return setTimeout(function () { boot(tries + 1); }, 60);
+ }
+ build();
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { boot(0); });
+ else boot(0);
+})();
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 7e1494b..ef3b152 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -965,35 +965,307 @@ function enableStaticShadows() {
// FURNITURE — simplified
// ============================================================
function buildFurniture() {
- const W = CONFIG.room.width, D = CONFIG.room.depth;
+ // 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 });
+}
- // ============================================================
- // CONSULTATION NOOK (PJ Dallas reference) — a round warm-wood pedestal table, TWO
- // cream bouclé armchairs, a potted plant, swatch cards on the table. Sits in the
- // FOREGROUND of the arc bay so the establishing shot reads: rack behind, nook in front.
- // In the 20'×20' room the viewer is fixed at centre facing the arc (toward -z). The
- // consultation nook is tucked to the RIGHT side, pulled FORWARD toward the wings (more
- // negative z) and low, so it sits INSIDE the tightened ±72° yaw clamp at ~yaw +66°
- // (atan2(1.78, 0.78) ≈ 66.3°): a spin-right + look-down reveals the sample table without
- // walking, and the 72° clamp still reaches it. Relocated proportionally for the 20'×20'
- // room (Chunk A′): old (1.25,-0.55) × 6.10/4.27 ≈ (1.78,-0.78) — same yaw angle, farther out.
- // ============================================================
- // Pulled a touch left + forward (was 1.78,-0.78) so the table + bouclé chairs read as
- // part of the furnished 20'×20' scene — a gentle right-turn from the spawn lands on them,
- // and they stay clear of the centred open book (x=0, z≈-2.4). Steve 2026-06-29.
- const nookX = 1.55, nookZ = -1.1;
- buildConsultationNook(nookX, nookZ);
+// ============================================================
+// 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 });
+}
+
+// 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;
+}
- // (Removed: boxy TV + bezel + slideshow screen — the wallcoverings are the only
- // loud thing in the room per the gallery art-direction.) The TV-slideshow helpers
- // null-guard on window._tvScreen, so leaving it unset makes them no-op.
+// 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);
+}
+
+const FURNITURE_STYLES = {
+ // CONTEMPORARY — clean, low, rectilinear; pale oak top on minimal charcoal blade legs.
+ contemporary: {
+ label: 'Contemporary',
+ buildTable(g) {
+ const oak = fmat(0xc2ad86, 0.5, 0.0), blade = fmat(0x35352f, 0.7, 0.05);
+ const top = new THREE.Mesh(new THREE.BoxGeometry(1.12, 0.05, 0.70), oak);
+ 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(0xcfcabf, 0.9, 0.0), blade = fmat(0x35352f, 0.7, 0.05);
+ 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);
+ }
+ },
- // ONE restrained olive in a honed-stone planter — quiet, expensive, beside the nook
- // (like the PJ photo's potted plant tucked by the arc). Off to the left of the table.
- buildOliveTree(nookX - 0.7, 0, nookZ - 0.2);
- addContactShadow(nookX - 0.7, nookZ - 0.2, 0.6, 0.6, 0.85);
+ // TRADITIONAL — warm walnut, lathe-turned legs, curved-back upholstered chair.
+ traditional: {
+ label: 'Traditional',
+ buildTable(g) {
+ const walnut = fmat(0x5a3a24, 0.45, 0.0);
+ 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), fmat(0x4d3120, 0.5, 0.0));
+ 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) {
+ const walnut = fmat(0x5a3a24, 0.5, 0.0), damask = fmat(0xe6d8bd, 0.85, 0.0);
+ const c = new THREE.Group(); c.position.set(0, 0, 0.70);
+ const seat = new THREE.Mesh(new THREE.BoxGeometry(0.50, 0.11, 0.48), damask);
+ seat.position.set(0, 0.46, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
+ // Curved, upholstered back — a partial cylinder shell wrapping the sitter's back.
+ const back = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.30, 0.30, 0.52, 20, 1, true, Math.PI * 0.72, Math.PI * 0.56), damask);
+ back.position.set(0, 0.76, -0.14); back.castShadow = true; c.add(back);
+ // crown rail + turned back posts for the traditional silhouette
+ const crown = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.44, 12), walnut);
+ crown.rotation.z = Math.PI / 2; crown.position.set(0, 1.02, -0.14); crown.castShadow = true; c.add(crown);
+ [-1, 1].forEach(s => buildTurnedLeg(c, s * 0.20, -0.20, walnut, 0.46)); // front-facing turned legs read fine
+ [-1, 1].forEach(s => buildTurnedLeg(c, s * 0.20, 0.20, walnut, 0.46));
+ g.add(c);
+ furnitureShadow(g, 0, 0.70, 0.70, 0.70, 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);
+ }
+ },
- // (Removed: framed abstract "art" on the right wall — the sample boards are the art.)
+ // TRANSITIONAL — a blend: simple frame + softened edges, mid-tone wood, greige seat.
+ transitional: {
+ label: 'Transitional',
+ buildTable(g) {
+ const wood = fmat(0x9a7a52, 0.5, 0.0);
+ 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), fmat(0x876a46, 0.55, 0.0));
+ 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) {
+ const wood = fmat(0x9a7a52, 0.55, 0.0), greige = fmat(0xd8cfc0, 0.88, 0.0);
+ const c = new THREE.Group(); c.position.set(0, 0, 0.69);
+ const seat = new THREE.Mesh(new THREE.BoxGeometry(0.49, 0.10, 0.47), greige);
+ seat.position.set(0, 0.45, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
+ const back = new THREE.Mesh(new THREE.BoxGeometry(0.49, 0.42, 0.06), greige);
+ back.position.set(0, 0.68, -0.20); back.castShadow = true; c.add(back);
+ // softened top rail — a rounded cap along the backrest top
+ const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.49, 14), wood);
+ cap.rotation.z = Math.PI / 2; cap.position.set(0, 0.89, -0.20); cap.castShadow = true; c.add(cap);
+ [[-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.68, 0.68, 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) {
+ const slab = fmat(0x8f8d88, 0.95, 0.0), mass = fmat(0x807e79, 0.96, 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);
+ furnitureShadow(g, 0, 0, 1.35, 1.0, 0.68);
+ },
+ buildChair(g) {
+ const seatMat = fmat(0x7f7d78, 0.95, 0.0), backMat = fmat(0x6f6d68, 0.96, 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);
+ // 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(0x9a9892, 0.95, 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(0x76746f, 0.96, 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;
}
// ============================================================
@@ -1445,65 +1717,9 @@ function buildSeatedAvatar(sx, sz, faceYaw) {
return rig;
}
-// Round warm-wood pedestal table + two cream bouclé armchairs + swatch cards — the
-// foreground consultation nook from the PJ Dallas showroom photo.
-function buildConsultationNook(cx, cz) {
- 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 });
-
- // Wrap the whole nook in a group so the table view can hide it (Steve 2026-07-01) —
- // otherwise the side nook + the centre table both show in "At the Table" (clutter).
- const nk = new THREE.Group();
- scene.add(nk);
- window._qhNookGroup = nk;
-
- const TABLE_R = 0.52, TABLE_H = 0.74;
- // Round top
- const top = new THREE.Mesh(new THREE.CylinderGeometry(TABLE_R, TABLE_R, 0.05, 40), woodTop);
- top.position.set(cx, TABLE_H, cz); top.castShadow = true; top.receiveShadow = true; nk.add(top);
- // Pedestal column
- const col = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, TABLE_H - 0.05, 16), woodDark);
- col.position.set(cx, (TABLE_H - 0.05) / 2, cz); col.castShadow = true; nk.add(col);
- // Splayed pedestal foot
- const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.34, 0.05, 24), woodDark);
- foot.position.set(cx, 0.03, cz); foot.castShadow = true; foot.receiveShadow = true; nk.add(foot);
- addContactShadow(cx, cz, 0.85, 0.85, 0.7);
-
- // A few swatch cards fanned on the table (small thin tilted planes, warm tones).
- const cardTones = [0xd8c7a8, 0xc6d2c0, 0xcdbcae];
- cardTones.forEach((c, k) => {
- const card = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.004, 0.22),
- new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, metalness: 0.0 }));
- card.position.set(cx - 0.12 + k * 0.12, TABLE_H + 0.03, cz - 0.05);
- card.rotation.y = (k - 1) * 0.35; card.castShadow = true; nk.add(card);
- });
-
- // TWO cream bouclé armchairs — one near (toward camera), one far (toward the arc),
- // both pulled up to the round table. Built from soft rounded boxes.
- const placeChair = (chx, chz, faceYaw) => {
- const g = new THREE.Group(); g.position.set(chx, 0, chz); g.rotation.y = faceYaw;
- const seat = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.16, 0.50), boucle);
- seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; g.add(seat);
- const back = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.50, 0.14), boucle);
- back.position.set(0, 0.70, -0.20); back.castShadow = true; g.add(back);
- // rounded arms
- [-0.26, 0.26].forEach(ax => {
- const arm = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.46), boucle);
- arm.position.set(ax, 0.56, 0.0); arm.castShadow = true; g.add(arm);
- });
- // short tapered legs
- [[-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.CylinderGeometry(0.02, 0.015, 0.36, 8), woodDark);
- leg.position.set(lx, 0.18, lz); leg.castShadow = true; g.add(leg);
- });
- nk.add(g);
- addContactShadow(chx, chz, 0.7, 0.7, 0.6);
- };
- // Chair toward the camera (faces the arc, back to viewer) + chair on the far side.
- placeChair(cx + 0.08, cz + 0.78, Math.PI); // near chair, faces -z (toward arc)
- placeChair(cx - 0.05, cz - 0.78, 0); // far chair, faces +z (toward viewer)
-}
+// (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
@@ -5236,6 +5452,14 @@ window._qh = {
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) {
diff --git a/public/showroom.html b/public/showroom.html
index eefb87e..3fafdbb 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -454,5 +454,7 @@
<!-- Hamburger dock: stows every panel on load, re-launches each as a movable modal -->
<script src="/js/modal-rig.js"></script>
<script src="/js/dock.js"></script>
+ <!-- Furniture style selector: swaps the procedural table+chair set (Option A) -->
+ <script src="/js/furniture-styles.js"></script>
</body>
</html>
diff --git a/server.js b/server.js
index dec3305..f0b2348 100644
--- a/server.js
+++ b/server.js
@@ -261,6 +261,20 @@ try { sharp = require('sharp'); } catch (e) { console.warn('[thumb] sharp not av
const THUMB_CACHE = path.join(DATA_DIR, 'thumb-cache');
try { fs.mkdirSync(THUMB_CACHE, { recursive: true }); } catch (e) {}
+// Cap concurrent UPSTREAM image fetches. A cold-cache page load fires ~24 sliver thumbs at
+// once; hitting the CDN with 24 simultaneous connections trips transient rate-limit 502s.
+// The disk cache makes every repeat load free, so throttling only the first cold burst
+// costs a beat, not UX. Root-cause fix for the 5x-caught cold-burst 502s.
+const UPSTREAM_MAX = 6;
+let _upstreamActive = 0; const _upstreamQ = [];
+function acquireUpstream() {
+ return new Promise(resolve => {
+ const run = () => { if (_upstreamActive < UPSTREAM_MAX) { _upstreamActive++; resolve(); } else _upstreamQ.push(run); };
+ run();
+ });
+}
+function releaseUpstream() { _upstreamActive--; if (_upstreamQ.length) _upstreamQ.shift()(); }
+
function sendThumb(res, buf, key) {
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=604800');
@@ -312,18 +326,22 @@ app.get('/api/thumb', async (req, res) => {
req.on('error', reject);
req.setTimeout(8000, () => req.destroy(new Error('upstream timeout')));
});
- // One retry w/ small backoff — a cold-cache burst of ~24 sliver thumbs can trip a
- // TRANSIENT upstream hiccup (5xx/timeout) that shouldn't surface as a hard 502.
- // Never retry a real 4xx (dead URL) — only transient network errors / 5xx.
+ // Retry w/ exponential backoff behind a concurrency cap — a cold-cache burst of ~24
+ // sliver thumbs can trip a TRANSIENT upstream hiccup (5xx/timeout) that shouldn't
+ // surface as a hard 502. The cap (acquireUpstream) prevents the burst; retries mop up
+ // any straggler. Never retry a real 4xx (dead URL) — only transient network errors/5xx.
+ await acquireUpstream();
let buf;
- for (let attempt = 0; ; attempt++) {
- try { buf = await fetchBuf(url); break; }
- catch (e) {
- const transient = !e.status || e.status >= 500;
- if (attempt >= 1 || !transient) throw e;
- await new Promise(r => setTimeout(r, 250));
+ try {
+ for (let attempt = 0; ; attempt++) {
+ try { buf = await fetchBuf(url); break; }
+ catch (e) {
+ const transient = !e.status || e.status >= 500;
+ if (attempt >= 2 || !transient) throw e;
+ await new Promise(r => setTimeout(r, 250 * (attempt + 1)));
+ }
}
- }
+ } finally { releaseUpstream(); }
return sendThumb(res, await downscale(buf));
}
return res.status(400).send('Missing source');
← d9cf3b2 5x sweep 1: retry transient upstream in /api/thumb (cold-cac
·
back to Quadrille Showroom
·
5x sweeps 2-5: upstream concurrency cap + placeholder fail-s b6f8daf →