← back to Quadrille Showroom
auto-save: 2026-06-30T21:43:31 (2 files) — public/js/showroom.js public/js/viewmodes.js
0fb3c6b9bc6d32ccbd2f6077af9a7d60947a528c · 2026-06-30 21:43:34 -0700 · Steve Abrams
Files touched
M public/js/showroom.jsM public/js/viewmodes.js
Diff
commit 0fb3c6b9bc6d32ccbd2f6077af9a7d60947a528c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 30 21:43:34 2026 -0700
auto-save: 2026-06-30T21:43:31 (2 files) — public/js/showroom.js public/js/viewmodes.js
---
public/js/showroom.js | 239 ++++++++++++++++++++++++++++++++++
public/js/viewmodes.js | 347 ++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 584 insertions(+), 2 deletions(-)
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 87227ed..ab23ebc 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -1295,6 +1295,155 @@ function updateAvatarRig() {
avatarRig.rotation.y = spinYaw; // yaw with the look; ignore spinPitch entirely
}
+// ============================================================
+// PHASE 3 — SEATED THIRD-PERSON AVATAR ("At the Table" view)
+// ------------------------------------------------------------
+// The first-person rig above (buildAvatar) is HEADLESS-forward — you look OUT of it.
+// The table view is THIRD-PERSON (camera over the seated figure's shoulder toward the
+// boards), so it needs a SEATED figure WITH a visible head/face. This reuses the same
+// primitive vocabulary (capsuleMesh limbs, charcoal sweater + indigo jeans + skin +
+// shoe materials) but hinges the hips + knees ~90° so the figure sits on a chair,
+// torso upright, hands resting near the table edge, head/face looking at the boards.
+//
+// Gated to the 'table' view mode only: seatedAvatarWanted (separate from avatarWanted,
+// which gates the first-person body). setTableSeatVisible() flips the whole set (figure +
+// furniture); buildTableSeat() lazy-builds it the first time the table mode is entered.
+// ============================================================
+let seatedAvatarRig = null; // the whole seated figure group
+let seatedAvatarWanted = false; // visible only in the 'table' mode
+
+function buildSeatedAvatar(sx, sz, faceYaw) {
+ // Clean rebuild safety.
+ if (seatedAvatarRig) { scene.remove(seatedAvatarRig); seatedAvatarRig = null; }
+
+ const P = ageRigParams(28); // a clean adult figure (identity anchor)
+ const rig = new THREE.Group();
+ rig.name = 'seatedAvatarRig';
+ // Origin = the point on the floor directly under the seated hips. faceYaw aims the
+ // figure toward the boards (typically -z ≈ Math.PI when seated in front of them).
+ rig.position.set(sx, 0, sz);
+ rig.rotation.y = (typeof faceYaw === 'number') ? faceYaw : Math.PI;
+
+ // Materials — same look as the first-person rig (charcoal knit / indigo denim / skin / leather).
+ const sweater = new THREE.MeshStandardMaterial({ color: P.colors.sweater, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.2 });
+ const jeans = new THREE.MeshStandardMaterial({ color: P.colors.jeans, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.15 });
+ const skin = new THREE.MeshStandardMaterial({ color: P.colors.skin, roughness: P.colors.skinRough, metalness: 0.0, envMapIntensity: 0.2 });
+ const shoe = new THREE.MeshStandardMaterial({ color: P.colors.shoe, roughness: 0.55, metalness: 0.05, envMapIntensity: 0.3 });
+ const hair = new THREE.MeshStandardMaterial({ color: 0x2a231c, roughness: 0.85, metalness: 0.0 });
+
+ // A chair seat sits ~0.44m off the floor (matches placeChair's seat height); the
+ // seated figure's hips rest just above it. Local +z is FORWARD (toward the boards
+ // once yawed), so the thighs extend +z and the shins drop down from the knees.
+ const SEAT_Y = 0.46; // hip pivot height (on the chair cushion)
+ const THIGH_L = 0.42; // hip → knee (runs forward, roughly level)
+ const SHIN_L = 0.44; // knee → ankle (drops to the floor)
+ const shoulderHalf = P.shoulderHalf;
+
+ // ---- PELVIS / HIPS (denim) ----
+ const hips = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.15, 0.20, 16), jeans);
+ hips.position.set(0, SEAT_Y, 0);
+ hips.scale.set(1.15, 1.0, 0.9);
+ hips.castShadow = true; rig.add(hips);
+
+ // ---- TORSO (charcoal sweater) — upright from the hips to the shoulder line ----
+ const torsoH = 0.50;
+ const chestTopY = SEAT_Y + 0.10 + torsoH; // shoulder line
+ const torso = capsuleMesh(0.17, torsoH - 0.10, sweater, 16);
+ torso.position.set(0, SEAT_Y + 0.12 + torsoH / 2, -0.02);
+ torso.scale.set(1.15, 1.0, 0.80); // broad across, flat front-to-back
+ torso.castShadow = true; rig.add(torso);
+
+ // ---- NECK + HEAD (this figure HAS a face — it's third-person) ----
+ const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.065, 0.08, 12), skin);
+ neck.position.set(0, chestTopY + 0.02, -0.01);
+ neck.castShadow = true; rig.add(neck);
+
+ const headG = new THREE.Group();
+ headG.position.set(0, chestTopY + 0.16, 0.0);
+ // Head — a soft ovoid, slightly forward-tilted looking at the boards.
+ const head = new THREE.Mesh(new THREE.SphereGeometry(0.105, 20, 18), skin);
+ head.scale.set(0.92, 1.06, 0.98);
+ head.castShadow = true; headG.add(head);
+ // Hair cap — hemisphere over the crown + back.
+ const hairCap = new THREE.Mesh(new THREE.SphereGeometry(0.112, 18, 14, 0, Math.PI * 2, 0, Math.PI * 0.62), hair);
+ hairCap.position.set(0, 0.012, -0.006);
+ hairCap.scale.set(0.95, 1.05, 1.0);
+ hairCap.castShadow = true; headG.add(hairCap);
+ // Nose nub — a tiny cue of a face, pointing FORWARD (+z, toward the boards).
+ const nose = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 8), skin);
+ nose.position.set(0, -0.01, 0.10);
+ headG.add(nose);
+ headG.rotation.x = 0.06; // slight downward gaze toward the table/boards
+ rig.add(headG);
+
+ // ---- SHOULDERS ----
+ [-1, 1].forEach(s => {
+ const sh = new THREE.Mesh(new THREE.SphereGeometry(0.10, 14, 12), sweater);
+ sh.position.set(s * shoulderHalf, chestTopY - 0.02, -0.02);
+ sh.scale.set(1.1, 0.85, 0.9);
+ sh.castShadow = true; rig.add(sh);
+ });
+
+ // ---- ARMS — upper arm drops from the shoulder, forearm swings FORWARD (+z) so the
+ // hands rest near the table edge (a seated consultation pose). ----
+ [-1, 1].forEach(s => {
+ const arm = new THREE.Group();
+ arm.position.set(s * (shoulderHalf + 0.02), chestTopY - 0.05, -0.02);
+
+ const upper = capsuleMesh(0.06, 0.22, sweater, 12);
+ upper.position.set(s * 0.02, -0.14, 0.02);
+ upper.rotation.z = s * 0.14;
+ upper.rotation.x = 0.32; // elbow forward + down
+ arm.add(upper);
+
+ const fore = capsuleMesh(0.05, 0.22, sweater, 12);
+ fore.position.set(s * 0.03, -0.30, 0.20);
+ fore.rotation.z = -s * 0.10;
+ fore.rotation.x = 1.15; // forearm runs forward toward the table
+ arm.add(fore);
+
+ const hand = new THREE.Mesh(new THREE.SphereGeometry(0.052, 12, 10), skin);
+ hand.scale.set(1.1, 0.7, 1.25);
+ hand.position.set(s * 0.02, -0.35, 0.36); // resting near the table edge
+ hand.castShadow = true; arm.add(hand);
+
+ arm.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+ rig.add(arm);
+ });
+
+ // ---- LEGS — SEATED: thigh runs forward (+z) roughly level from the hip, shin drops
+ // straight down from the knee to a foot flat on the floor. ~90° at hip and knee. ----
+ [-1, 1].forEach(s => {
+ const kneeX = s * 0.12, kneeZ = THIGH_L; // knee is forward of the hips
+ // Thigh: from the hip forward to the knee (nearly horizontal).
+ const thigh = capsuleMesh(0.085, THIGH_L - 0.14, jeans, 12);
+ thigh.position.set(kneeX * 0.5, SEAT_Y - 0.01, THIGH_L / 2);
+ thigh.rotation.x = Math.PI / 2 - 0.12; // ~horizontal, tipped slightly down toward the knee
+ thigh.castShadow = true; rig.add(thigh);
+
+ // Shin: from the knee down to the ankle.
+ const shin = capsuleMesh(0.075, SHIN_L - 0.14, jeans, 12);
+ shin.position.set(kneeX, SEAT_Y - SHIN_L / 2 + 0.02, kneeZ - 0.02);
+ shin.rotation.x = 0.06;
+ shin.castShadow = true; rig.add(shin);
+
+ // Foot — flat on the floor, toe forward (+z).
+ const foot = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.07, 0.26), shoe);
+ foot.position.set(kneeX, 0.04, kneeZ + 0.06);
+ foot.castShadow = true; foot.receiveShadow = true; rig.add(foot);
+ });
+
+ rig.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+ // Soft contact shadow under the seated figure.
+ addContactShadow(sx, sz + 0.2, 0.9, 0.9, 0.55);
+
+ scene.add(rig);
+ seatedAvatarRig = rig;
+ rig.visible = seatedAvatarWanted;
+ requestShadowUpdate(2);
+ return rig;
+}
+
// 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) {
@@ -1349,6 +1498,88 @@ function buildConsultationNook(cx, cz) {
placeChair(cx - 0.05, cz - 0.78, 0); // far chair, faces +z (toward viewer)
}
+// ============================================================
+// PHASE 3 — "AT THE TABLE" consultation set. A dedicated round table + a single chair
+// with the SEATED avatar on it, positioned CENTRE-FRONT of the board wall (distinct
+// from the always-on right-side nook). Lazy-built the first time the table view is
+// entered, then just toggled visible/hidden. The seated figure faces the boards (-z),
+// so the camera parks behind its shoulder looking past the head at the wall of designs.
+// ============================================================
+let tableSeatGroup = null; // the table-view furniture (table + chair) — toggled with the view
+
+function buildTableSeat() {
+ if (tableSeatGroup) return tableSeatGroup; // build once
+ const backZ = -CONFIG.room.depth / 2 + 0.2; // board-wall plane
+ // Place the table a comfortable distance IN FRONT of the boards, centred on x.
+ const tx = 0, tz = backZ + 1.9; // ~1.9m off the wall
+
+ const g = new THREE.Group();
+ g.name = 'tableSeatGroup';
+
+ const woodTop = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: 0.42, metalness: 0.0, envMapIntensity: 0.3 });
+ const woodDark = new THREE.MeshStandardMaterial({ color: 0x6e573a, roughness: 0.5, metalness: 0.0 });
+ const boucle = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.2 });
+
+ const TABLE_R = 0.58, TABLE_H = 0.74;
+ const top = new THREE.Mesh(new THREE.CylinderGeometry(TABLE_R, TABLE_R, 0.05, 44), woodTop);
+ top.position.set(tx, TABLE_H, tz); top.castShadow = true; top.receiveShadow = true; g.add(top);
+ const col = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.10, TABLE_H - 0.05, 16), woodDark);
+ col.position.set(tx, (TABLE_H - 0.05) / 2, tz); col.castShadow = true; g.add(col);
+ const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.36, 0.05, 24), woodDark);
+ foot.position.set(tx, 0.03, tz); foot.castShadow = true; foot.receiveShadow = true; g.add(foot);
+
+ // A single bouclé chair on the near (camera) side of the table, facing the boards (-z).
+ const chZ = tz + 0.92;
+ const chair = new THREE.Group(); chair.position.set(tx, 0, chZ); chair.rotation.y = 0; // faces -z (toward boards)
+ const seat = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.16, 0.52), boucle);
+ seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; chair.add(seat);
+ const back = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.52, 0.14), boucle);
+ back.position.set(0, 0.72, 0.21); back.castShadow = true; chair.add(back); // backrest behind the sitter (+z, toward camera)
+ [-0.27, 0.27].forEach(ax => {
+ const arm = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.48), boucle);
+ arm.position.set(ax, 0.56, 0.0); arm.castShadow = true; chair.add(arm);
+ });
+ [[-0.21,0.21],[0.21,0.21],[-0.21,-0.21],[0.21,-0.21]].forEach(([lx,lz]) => {
+ const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.015, 0.36, 8), woodDark);
+ leg.position.set(lx, 0.18, lz); leg.castShadow = true; chair.add(leg);
+ });
+ g.add(chair);
+
+ scene.add(g);
+ addContactShadow(tx, tz, 1.0, 1.0, 0.6);
+ addContactShadow(tx, chZ, 0.7, 0.7, 0.55);
+ tableSeatGroup = g;
+
+ // Seat the avatar on the chair, facing the boards (-z). The chair seat is at ~0.44m;
+ // buildSeatedAvatar hips sit at ~0.46m, so the figure rests naturally on the cushion.
+ buildSeatedAvatar(tx, chZ, Math.PI); // faceYaw = π → local +z (forward/hands/thighs) points to -z world (the boards)
+
+ g.visible = false; // hidden until the table view turns it on
+ requestShadowUpdate(4);
+ return g;
+}
+
+// Show/hide the whole "At the Table" set (furniture + seated figure) together.
+function setTableSeatVisible(v) {
+ seatedAvatarWanted = !!v;
+ if (v) buildTableSeat();
+ if (tableSeatGroup) tableSeatGroup.visible = !!v;
+ if (seatedAvatarRig) seatedAvatarRig.visible = !!v;
+ if (typeof requestShadowUpdate === 'function') requestShadowUpdate(6);
+}
+// Where the camera should park to frame the seated figure over-the-shoulder toward the
+// boards. Returns {pos, target} in world space, derived from the built table/chair.
+function tableViewCamera() {
+ const backZ = -CONFIG.room.depth / 2 + 0.2;
+ const tz = backZ + 1.9; // table centre z (mirror of buildTableSeat)
+ const chZ = tz + 0.92; // chair/sitter z
+ const boardCY = CONFIG.wing.height / 2 + 0.04;
+ // Behind + above the seated figure's head, looking past it at the board wall.
+ const pos = new THREE.Vector3(0.55, 1.85, chZ + 1.15);
+ const target = new THREE.Vector3(0.0, boardCY, backZ);
+ return { pos, target };
+}
+
// A single olive/fig in a square honed-stone planter — soft layered canopy (not a
// hard sphere), slim trunk, restrained. Built from a handful of low-poly meshes.
function buildOliveTree(px, py, pz) {
@@ -4881,6 +5112,14 @@ window._qh = {
// avatar's back across the whole frame. Persists across setAvatarAge() rebuilds.
setAvatarVisible(v) { avatarWanted = !!v; if (avatarRig) avatarRig.visible = avatarWanted; },
get avatarVisible() { return avatarWanted; },
+ // ---- PHASE 3 "At the Table" surface — the SEATED third-person figure + consultation
+ // set, and the over-the-shoulder camera pose. Lazy-built on first show. Gated to the
+ // 'table' view mode only (separate from the first-person avatarWanted).
+ setTableSeatVisible(v) { setTableSeatVisible(v); },
+ get seatedAvatarPresent() { return !!seatedAvatarRig; },
+ get tableSeatVisible() { return !!(tableSeatGroup && tableSeatGroup.visible); },
+ tableViewCamera() { return tableViewCamera(); },
+ buildTableSeat() { return buildTableSeat(); },
get spinPitch() { return spinPitch; },
avatarState() {
if (!avatarRig) return null;
diff --git a/public/js/viewmodes.js b/public/js/viewmodes.js
index f26d7c9..32c15cb 100644
--- a/public/js/viewmodes.js
+++ b/public/js/viewmodes.js
@@ -38,6 +38,8 @@ function boot() {
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
@@ -252,10 +254,96 @@ function boot() {
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);
+ 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);
+ }
}
};
- const MODE_ORDER = ['walk','hero','gallery','carousel','macro','compare','room'];
+ // 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) {
@@ -271,6 +359,257 @@ function boot() {
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;
+ return 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) {
+ 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.
@@ -514,7 +853,7 @@ function boot() {
// (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 };
+ const safeBootModes = { walk: 1, hero: 1, macro: 1, room: 1, table: 1 };
const bootMode = (savedMode && MODES[savedMode] && safeBootModes[savedMode]) ? savedMode : 'walk';
setMode(bootMode, true);
if (bootMode === 'walk') {
@@ -532,6 +871,10 @@ function boot() {
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
};
← 5388cf1 showroom P2: 5 room types (bedroom/office/living/lobby/galle
·
back to Quadrille Showroom
·
showroom P3: At-the-Table view — seated avatar + click-patte 8dfe072 →