← back to Quadrille Showroom
Slice 1 (A+B+C): 14x14 room, fixed-centre yaw-spin camera (clamped), 50 wings w/ ~2in slivers + middle open-at-angle on boot
1fbedd2d57d84c865ed8798b8d0f1080c1dab253 · 2026-06-28 16:09:54 -0700 · Steve
Files touched
M .gitignoreM public/js/rack.jsM public/js/showroom.jsM public/js/versions.jsA scripts/verify-slice1.js
Diff
commit 1fbedd2d57d84c865ed8798b8d0f1080c1dab253
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jun 28 16:09:54 2026 -0700
Slice 1 (A+B+C): 14x14 room, fixed-centre yaw-spin camera (clamped), 50 wings w/ ~2in slivers + middle open-at-angle on boot
---
.gitignore | 1 +
public/js/rack.js | 15 ++--
public/js/showroom.js | 203 ++++++++++++++++++++++++++++++++++-------------
public/js/versions.js | 22 ++---
scripts/verify-slice1.js | 158 ++++++++++++++++++++++++++++++++++++
5 files changed, 322 insertions(+), 77 deletions(-)
diff --git a/.gitignore b/.gitignore
index 7e6cdd3..f865579 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ data/gen-rooms/
# generated walkthrough recordings (rebuildable via scripts/walkthrough-record.js)
recordings/
+data/thumb-cache/
diff --git a/public/js/rack.js b/public/js/rack.js
index 999dfe2..64528c3 100644
--- a/public/js/rack.js
+++ b/public/js/rack.js
@@ -35,12 +35,14 @@
}
// Centre angle (radians, around the arc centre) of slot i of n boards.
- // The sweep is centred on +Z so the bay opens toward the camera; we build
- // the arc from one wing toward the other across arcSpanDeg.
+ // The sweep centres on cfg.arcCentreDeg (default 90° = +Z legacy). For the Slice-1
+ // fixed-centre layout we centre on -90° (-Z) so the boards sit in FRONT of a viewer
+ // standing at the arc-centre (concave bank facing the centred viewer).
function arcAngleFor(i, n, cfg) {
const span = cfg.arcSpanDeg * Math.PI / 180;
const pitch = (n > 1 ? span / (n - 1) : span) * (cfg.sliverPitchScale || 1);
- const start = Math.PI * 0.5 - span / 2; // measured from +Z, centred
+ const centre = (typeof cfg.arcCentreDeg === 'number' ? cfg.arcCentreDeg : 90) * Math.PI / 180;
+ const start = centre - span / 2;
return start + i * pitch;
}
@@ -94,13 +96,16 @@
// Per-frame flip ease. focusedPivot (if any) targets flip=1 (open/flat);
// all others target flip=0 (raked/closed). Returns true if anything moved.
- function flipUpdate(boards, focusedPivot, dt) {
+ // openFlip lets a RESTING-OPEN board (Slice-1 boot middle) ease to a partial angle
+ // (e.g. 0.6) instead of fully flat, so it reads as "open at an angle", not "selected".
+ function flipUpdate(boards, focusedPivot, dt, openFlip) {
if (!boards.length) return false;
const k = Math.min(1, dt * 7); // ease rate
+ const of = (typeof openFlip === 'number') ? openFlip : 1;
let moved = false;
for (let i = 0; i < boards.length; i++) {
const p = boards[i], ud = p.userData;
- const target = (p === focusedPivot && !ud.panelClosed) ? 1 : 0;
+ const target = (p === focusedPivot && !ud.panelClosed) ? of : 0;
if (Math.abs(ud.flip - target) > 0.0008) {
ud.flip += (target - ud.flip) * k;
moved = true;
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 6aa41db..7524ebe 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -5,9 +5,10 @@
// CONFIG
// ============================================================
const CONFIG = {
- // Larger room so the corner-wrapping ARC of wingboards + a foreground consultation
- // nook both fit in one establishing shot (matches the PJ Dallas reference photo).
- room: { width: 9.0, depth: 8.5, height: 3.0 },
+ // 14'×14' room (~4.27m square, ~9.5' ceiling). The viewer sits DEAD CENTER and the
+ // concave ARC of 50 wingboards wraps the front of the room so a center-fixed, yaw-spin
+ // camera sees the full fanned bank (Slice-1 canonical spec).
+ room: { width: 4.27, depth: 4.27, height: 2.9 },
wing: {
height: 1.8288, // 6 ft tall (real sample-board height)
boardWidthM: 0.762, // 30" wide (real sample-board width)
@@ -16,15 +17,22 @@ const CONFIG = {
animSpeed: 0.06,
panelOpen: 1.35,
// ---- PJ WINGBOARD ARC (canonical target, Image #6) ----
- // The rack is a gentle ARC wrapping a room corner/bay. Boards hang from a curved
+ // The rack is a concave ARC wrapping the front of the room. Boards hang from a curved
// brass rail, each hinged on its LEFT vertical edge, raked so packed they show only
// a thin vertical SLIVER of each pattern (occlusion gives the sliver for free).
- arcRadius: 3.3, // radius of the curved rail (m)
- arcSpanDeg: 86, // tighter sweep → boards overlap ~75% → THIN slivers
- arcCenter: { x: -0.3, z: -1.2 }, // arc centre in room space (boards face inward → viewer)
+ // Re-fit for the 14'×14' room + 50 boards fanning left+right around the centered viewer.
+ arcRadius: 2.2, // radius of the curved rail (m) — sized to the small room
+ // 50 boards × ~2" sliver. Hinge arc-pitch = R·span/(n-1): 2.2·(96°·π/180)/49 ≈ 0.0768m
+ // (≈3.0") hinge spacing; the ~73° closed rake (REVEAL 74) occludes most of that so each
+ // closed wing reads as a ~2" leading sliver, fanning left+right around the centred
+ // viewer (yaw ±48°, well inside the ±105° clamp).
+ arcSpanDeg: 96,
+ arcCentreDeg: -90, // sweep centred on -Z → boards sit in FRONT of the viewer
+ arcCenter: { x: 0.0, z: 0.4 }, // arc centre just BEHIND the centred viewer → concave bank faces them
sliverPitchScale: 1.0 // multiplier on the angular pitch (reveal slider can thin)
},
- camera: { startPos: { x: 1.8, y: 1.65, z: 3.6 }, fov: 55 }
+ // Camera FIXED at room centre, eye height ~1.6m, facing the wing bank (toward arc centre).
+ camera: { startPos: { x: 0.0, y: 1.6, z: 0.0 }, fov: 60 }
};
// ============================================================
@@ -33,12 +41,13 @@ const CONFIG = {
let scene, camera, renderer, controls, clock, raycaster, mouse;
let wingBoards = [], wingRacks = [], sampleTray = [], products = [], vendors = [];
let focusedWing = null, animatingWings = [], frameCount = 0, lastFpsTime = 0;
+let restingOpenWing = null; // Slice-1: the middle board, open-at-angle on boot as the resting visual (NOT a focus/selection)
let fanWings = [], fanCenterIndex = -1; // Fan cascade state
let controlsLocked = false, lockedCamPos = null, lockedCamTarget = null, previousCamPos = null, previousCamTarget = null;
let cameraAnim = null; // { startPos, endPos, startTarget, endTarget, progress, duration, onComplete }
// Window paging across the full catalog (50 live wings at a time)
-let windowOffset = 0, windowTotal = 0, windowSize = 24; // boards in the packed arc at once — denser razor-thin slivers (PJ photo), still FPS-safe
+let windowOffset = 0, windowTotal = 0, windowSize = 50; // 50 boards in the packed arc (Slice-1 spec): each closed wing shows a ~2" sliver, fanning left+right around the centred viewer
let currentBrand = 'all', currentSort = 'natural', currentWallGroup = null;
// Peruse (endless auto-tour) state
let peruseActive = false, peruseIndex = 0, peruseTimer = null;
@@ -134,7 +143,9 @@ function init() {
controls.minPolarAngle = Math.PI * 0.25;
controls.minDistance = 1.0;
controls.maxDistance = 8.0;
- controls.target.set(0, 1.2, -0.5);
+ // Fixed-centre camera looks straight at the wing bank (toward -z, the arc centre),
+ // aimed at board centre height. Yaw-spin (updateWASD) rotates this target in place.
+ controls.target.set(0, 1.0, -1.0);
controls.panSpeed = 0.5;
// GUIDED MODE default: orbit/pan/zoom OFF unless Explore is on. applyExploreMode()
// is the single source of truth for this; called again after init() below.
@@ -142,6 +153,11 @@ function init() {
controls.enablePan = exploreMode;
controls.enableZoom = exploreMode;
+ // FIXED-CENTRE camera: pin the immutable eye point and seed the spin pose facing the bank.
+ lockedSpinEye = camera.position.clone();
+ spinYaw = 0; spinPitch = -0.08;
+ applySpinPose();
+
updateLoadStatus('Building materials...', 10);
initMaterials();
initTexturePool();
@@ -822,9 +838,12 @@ function buildFurniture() {
// 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.
- // The arc centre is (-0.3,-1.1) opening toward +z; the nook sits at ~(-0.3, +3.2).
+ // In the 14'×14' room the viewer is fixed at centre facing the arc (toward -z). The
+ // consultation nook is tucked to the RIGHT side, slightly toward the wings (z<0) and
+ // low, so it sits INSIDE the clamped yaw arc (~yaw +82°): a spin-right + look-down
+ // reveals the sample table without walking, and the ±105° clamp still reaches it.
// ============================================================
- const nookX = -0.3, nookZ = 3.2;
+ const nookX = 1.35, nookZ = -0.2;
buildConsultationNook(nookX, nookZ);
// (Removed: boxy TV + bezel + slideshow screen — the wallcoverings are the only
@@ -833,8 +852,8 @@ function buildFurniture() {
// 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 - 1.5, 0, nookZ - 0.2);
- addContactShadow(nookX - 1.5, nookZ - 0.2, 0.6, 0.6, 0.85);
+ buildOliveTree(nookX - 0.7, 0, nookZ - 0.2);
+ addContactShadow(nookX - 0.7, nookZ - 0.2, 0.6, 0.6, 0.85);
// (Removed: framed abstract "art" on the right wall — the sample boards are the art.)
}
@@ -1683,6 +1702,10 @@ function onCanvasClick(event) {
function focusOnWing(pivot) {
const product = pivot.userData.product;
+ // A real selection supersedes the resting-open boot visual.
+ if (restingOpenWing && restingOpenWing !== pivot) restingOpenWing.userData.panelClosed = true;
+ restingOpenWing = null;
+
// Close any active fan cascade
if (fanWings.length > 0) closeFanCascade(true);
@@ -1822,8 +1845,18 @@ function unfocusWing(skipPanel) {
// Unlock first so smoothCameraTo can move
unlockControls();
- // Fly back to previous position AND restore the wide walk-around FOV.
- if (previousCamPos && previousCamTarget) {
+ // Returning from a focus lands back at the fixed-centre spin pose facing the bank
+ // (NO fly-back walk in guided mode — the eye never left centre).
+ if (!exploreMode && lockedSpinEye) {
+ spinYaw = 0; spinPitch = -0.08;
+ smoothCameraTo(lockedSpinEye.clone(), (function () {
+ const cp = lockedSpinEye, cy = Math.cos(spinPitch);
+ return new THREE.Vector3(cp.x + Math.sin(spinYaw) * cy * TARGET_DIST,
+ cp.y + Math.sin(spinPitch) * TARGET_DIST,
+ cp.z - Math.cos(spinYaw) * cy * TARGET_DIST);
+ })(), () => applySpinPose(), FOV_DEFAULT);
+ previousCamPos = null; previousCamTarget = null;
+ } else if (previousCamPos && previousCamTarget) {
smoothCameraTo(previousCamPos, previousCamTarget, null, FOV_DEFAULT);
previousCamPos = null;
previousCamTarget = null;
@@ -2020,32 +2053,50 @@ function onCanvasMouseMove(event) {
// ============================================================
// WASD WALKING — first-person navigation
// ============================================================
-function updateWASD(dt) {
- if (!exploreMode) return; // GUIDED MODE: keyboard walking is off unless Explore is on
- if (controlsLocked || cameraAnim) return; // no walking when focused on wing or animating
-
- let moveX = 0, moveZ = 0;
- if (keysDown['KeyW'] || keysDown['ArrowUp']) moveZ = -1;
- if (keysDown['KeyS'] || keysDown['ArrowDown']) moveZ = 1;
- if (keysDown['KeyA'] || keysDown['ArrowLeft']) moveX = -1;
- if (keysDown['KeyD'] || keysDown['ArrowRight']) moveX = 1;
- if (moveX === 0 && moveZ === 0) return;
+// ============================================================
+// YAW-SPIN-ONLY camera (Slice-1 canonical). The camera is FIXED at room centre and
+// NEVER moves position. Arrow Left/Right (and A/D) rotate the look IN PLACE by spinning
+// the orbit target around the fixed camera position; Up/Down (W/S) apply a small pitch.
+// Yaw is CLAMPED to a forward arc so the user pans left wall → wings → right wall → down
+// to the table, but never faces behind the wings.
+// ============================================================
+const SPIN_YAW_SPEED = 1.6; // rad/s of yaw under held arrow
+const SPIN_PITCH_SPEED = 0.9; // rad/s of pitch under held arrow
+const YAW_CLAMP = 105 * Math.PI / 180; // ±105° off wing-facing (-z) — never behind the wings
+const PITCH_MIN = -0.62; // look down ~36° (to the sample table)
+const PITCH_MAX = 0.30; // look up ~17°
+const TARGET_DIST = 2.0; // distance the look-target sits ahead of the fixed camera
+// Spin pose: yaw=0 faces -z (the wing bank); +yaw turns toward +x (right wall).
+let spinYaw = 0, spinPitch = -0.08;
+let lockedSpinEye = null; // the immutable fixed-centre eye point (set at init)
+
+// Rebuild controls.target from the current fixed camera position + spin yaw/pitch.
+function applySpinPose() {
+ if (!camera || !controls) return;
+ const cp = camera.position;
+ // yaw=0 → direction -z; +yaw rotates toward +x.
+ const cy = Math.cos(spinPitch);
+ const dir = new THREE.Vector3(
+ Math.sin(spinYaw) * cy,
+ Math.sin(spinPitch),
+ -Math.cos(spinYaw) * cy
+ );
+ controls.target.set(cp.x + dir.x * TARGET_DIST, cp.y + dir.y * TARGET_DIST, cp.z + dir.z * TARGET_DIST);
+}
- // Get camera forward/right vectors projected onto XZ plane
- const forward = new THREE.Vector3();
- camera.getWorldDirection(forward);
- forward.y = 0; forward.normalize();
- const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
+function updateWASD(dt) {
+ if (controlsLocked || cameraAnim) return; // no spin when focused on a wing or animating
- // Movement delta
- const speed = WALK_SPEED * dt;
- const delta = new THREE.Vector3();
- delta.addScaledVector(forward, -moveZ * speed);
- delta.addScaledVector(right, moveX * speed);
+ let yawIn = 0, pitchIn = 0;
+ if (keysDown['ArrowLeft'] || keysDown['KeyA']) yawIn -= 1;
+ if (keysDown['ArrowRight'] || keysDown['KeyD']) yawIn += 1;
+ if (keysDown['ArrowUp'] || keysDown['KeyW']) pitchIn += 1;
+ if (keysDown['ArrowDown'] || keysDown['KeyS']) pitchIn -= 1;
+ if (yawIn === 0 && pitchIn === 0) return;
- // Move both camera and orbit target together (preserves view direction)
- camera.position.add(delta);
- controls.target.add(delta);
+ spinYaw = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, spinYaw + yawIn * SPIN_YAW_SPEED * dt));
+ spinPitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, spinPitch + pitchIn * SPIN_PITCH_SPEED * dt));
+ applySpinPose(); // camera position is NEVER touched — only the look-target rotates
}
// ============================================================
@@ -2099,12 +2150,19 @@ function updateBooks(dt) {
}
}
- // The single OPEN board = the focused one (guided/peruse), else the walked-up neighbour.
- const openPivot = (focusedWing && !focusedWing.userData.panelClosed) ? focusedWing : nearest;
+ // The single OPEN board = the focused one (guided/peruse), else the walked-up neighbour,
+ // else the RESTING-OPEN middle board (Slice-1 boot visual: middle open-at-angle, no focus,
+ // no detail panel, nothing "selected"). restingOpenWing is cleared the moment a real
+ // focus/selection happens so it never fights an actual click.
+ const openPivot = (focusedWing && !focusedWing.userData.panelClosed) ? focusedWing
+ : (nearest || (restingOpenWing && !restingOpenWing.userData.panelClosed ? restingOpenWing : null));
// Lazy-load the open board's full design the moment it begins to swing open.
if (openPivot && !openPivot.userData.imageLoaded) { openPivot.userData.imageLoaded = true; loadWingBook(openPivot); }
- const moved = QHRack.flipUpdate(wingBoards, openPivot, dt);
+ // A real focus opens fully flat (hero); the resting-open middle eases to a partial
+ // angle (0.62) so it reads as "open at an angle", not a flat selected hero.
+ const isRestingOnly = (openPivot && openPivot === restingOpenWing && openPivot !== focusedWing);
+ const moved = QHRack.flipUpdate(wingBoards, openPivot, dt, isRestingOnly ? 0.8 : 1);
// SHADOW REBAKE BUDGET: don't rebake the (2 lights × 238-mesh) shadow maps EVERY
// moving frame — that's the dominant nav cost. Rebake once when a flip STARTS (catch
// the first frame of motion) and once when it SETTLES (motion→still), not in between.
@@ -2165,19 +2223,19 @@ function animate() {
camera.position.copy(lockedCamPos);
controls.target.copy(lockedCamTarget);
} else if (!cameraAnim) {
- updateWASD(dt);
- controls.update();
-
- // Collision detection — prevent camera from walking through walls
- const margin = 0.3;
- const hw = CONFIG.room.width / 2 - margin;
- const hd = CONFIG.room.depth / 2 - margin;
- camera.position.x = Math.max(-hw, Math.min(hw, camera.position.x));
- camera.position.z = Math.max(-hd, Math.min(CONFIG.room.depth / 2 + 0.5, camera.position.z));
- camera.position.y = Math.max(0.5, Math.min(CONFIG.room.height - 0.2, camera.position.y));
- controls.target.x = Math.max(-hw, Math.min(hw, controls.target.x));
- controls.target.z = Math.max(-hd, Math.min(CONFIG.room.depth / 2 + 0.5, controls.target.z));
- controls.target.y = Math.max(0.3, Math.min(CONFIG.room.height - 0.3, controls.target.y));
+ // FIXED-CENTRE YAW-SPIN: the camera never moves position. updateWASD rotates only the
+ // clamped look-target around the fixed eye point, then we aim the camera at it directly.
+ // We deliberately do NOT call controls.update() in guided mode — OrbitControls would
+ // re-derive the camera position around the moving target and break the fixed centre.
+ if (exploreMode) {
+ updateWASD(dt);
+ controls.update();
+ } else {
+ const fixedEye = lockedSpinEye || camera.position.clone();
+ updateWASD(dt);
+ camera.position.copy(fixedEye); // hard-pin the eye every frame
+ camera.lookAt(controls.target);
+ }
}
// Animate wings (fan cascade — pivot rotation)
@@ -2416,12 +2474,43 @@ function syncGuidedTourBtn() {
// its design — so the big Previous/Next/Auto-Tour buttons immediately have a hero.
function enterGuidedDefault() {
if (!wingBoards.length) return;
+ // Slice-1 boot: the MIDDLE board opens AT AN ANGLE as the resting visual — NO camera
+ // lock (stays fixed-centre spin pose), NO detail panel, and NOTHING marked "selected".
+ // (Selecting/clicking → focusOnWing is the later slice.) Other boards keep their slivers.
const mid = Math.floor(wingBoards.length / 2);
restoreBoards();
- focusOnWing(wingBoards[mid]);
+ focusedWing = null; // not a selection
+ restingOpenWing = wingBoards[mid];
+ restingOpenWing.userData.panelClosed = false; // ease open via flipUpdate (partial angle)
+ // Eagerly load the middle board's design so the open hero shows its pattern immediately.
+ if (restingOpenWing.userData.pendingImage && !restingOpenWing.userData.imageLoaded) {
+ restingOpenWing.userData.imageLoaded = true;
+ loadWingBook(restingOpenWing);
+ }
guidedReady = true;
}
+// Slice-1 canonical rest: snap the camera to the FIXED-CENTRE spin pose facing the
+// wing bank, unlock, hide the detail panel + guided title, drop any selection, and set
+// the resting-open middle board. This is the home pose for the build — used at boot and
+// when V1 is (re)applied. NO camera lock, NO detail panel, NOTHING "selected".
+function enterFixedCentre() {
+ if (focusedWing) focusedWing.userData.panelClosed = true;
+ focusedWing = null;
+ if (controlsLocked) unlockControls();
+ previousCamPos = null; previousCamTarget = null;
+ const wd = document.getElementById('wing-detail'); if (wd) wd.classList.add('hidden');
+ hideGuidedTitle();
+ // Pin the eye back at room centre and face the bank.
+ if (lockedSpinEye) camera.position.copy(lockedSpinEye);
+ else { camera.position.set(CONFIG.camera.startPos.x, CONFIG.camera.startPos.y, CONFIG.camera.startPos.z); lockedSpinEye = camera.position.clone(); }
+ spinYaw = 0; spinPitch = -0.08;
+ applySpinPose();
+ if (camera.fov !== FOV_DEFAULT) { targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix(); }
+ camera.lookAt(controls.target);
+ enterGuidedDefault(); // sets the resting-open middle (no focus, no panel)
+}
+
// ALL DESIGNS — big-thumbnail grid overlay. Builds from the live window products;
// tapping a card closes the overlay and flies to that board. Familiar web-grid
// browse path for users who find spatial navigation hard.
@@ -2894,6 +2983,7 @@ window._qh = {
get renderer() { return renderer; },
get wingBoards() { return wingBoards; },
get focusedWing(){ return focusedWing; },
+ get restingOpenWing(){ return restingOpenWing; },
get peruseActive(){ return peruseActive; },
get exploreMode(){ return exploreMode; },
CONFIG,
@@ -2902,6 +2992,7 @@ window._qh = {
focusOnWing,
unfocusWing,
enterGuidedDefault,
+ enterFixedCentre,
isolateBoard,
restoreBoards,
lockControls,
diff --git a/public/js/versions.js b/public/js/versions.js
index 5532665..fcccf02 100644
--- a/public/js/versions.js
+++ b/public/js/versions.js
@@ -160,25 +160,15 @@ function boot() {
// gallery light, the consultation nook + olive in the foreground. This is an
// ESTABLISHING shot (not an isolated single board) so the whole arc + nook read
// like the reference photo. The senior Next/Back still opens a board face-on.
+ // SLICE-1 CANONICAL: the viewer is FIXED at room centre, front-facing the 50-wing
+ // arc bank; the MIDDLE board rests open-at-angle (no detail panel, nothing selected);
+ // arrow keys yaw-spin in place (clamped). The engine owns all of this — V1 just
+ // restores the scene and hands off to the fixed-centre rest pose. Do NOT override
+ // the 50-board / REVEAL defaults and do NOT park+lock the camera anymore.
setTheme('warm');
- setBoards(10); // a full arc of boards
- setReveal(38); // thin slivers — most boards show only a vertical edge
setWingDeg(8);
- // Show the WHOLE arc (don't isolate): restore all boards, open the middle one.
- QH.restoreBoards();
QH.revertWalls();
- const mid = Math.floor(QH.wingBoards.length / 2);
- const b = QH.wingBoards[mid];
- if (b) { b.userData.panelClosed = false; }
- // Park the camera HIGH at the FRONT-RIGHT of the room looking diagonally across
- // to the arc — a wide establishing shot so the sliver-packed boards + the
- // consultation nook both read like the reference photo. Arc centre ≈ (-0.3,-1.1);
- // nook ≈ (-0.3, 3.2); room front wall ≈ z=+3.0. Wide FOV frames the whole bay.
- const R = QH.CONFIG.room;
- const pos = new THREE.Vector3(R.width / 2 - 0.9, 1.85, R.depth / 2 - 0.6);
- const tgt = new THREE.Vector3(-0.5, 1.1, -1.4);
- QH.setControlsLocked(false);
- QH.smoothCameraTo(pos, tgt, () => { QH.lockControls(pos.clone(), tgt.clone()); }, 64);
+ QH.enterFixedCentre(); // fixed-centre spin pose + resting-open middle (no lock, no panel)
if (QH.requestShadowUpdate) QH.requestShadowUpdate(12);
},
elements: [
diff --git a/scripts/verify-slice1.js b/scripts/verify-slice1.js
new file mode 100644
index 0000000..df65dc0
--- /dev/null
+++ b/scripts/verify-slice1.js
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+/**
+ * verify-slice1.js — Slice-1 (chunks A+B+C) acceptance check for the Quadrille showroom.
+ * Confirms:
+ * 1. camera POSITION is constant across an arrow-spin sweep (yaw, not walk)
+ * 2. yaw is CLAMPED (cannot face behind the wings)
+ * 3. ~50 wings load with visible slivers (all visible)
+ * 4. the MIDDLE board is open-at-angle on boot with NO detail panel shown
+ * 5. errorCount === 0 in console
+ * Captures a verification screenshot of the fixed-centre boot view.
+ *
+ * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/verify-slice1.js
+ */
+const path = require('path');
+const fs = require('fs');
+const { chromium } = require('playwright');
+
+const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
+const OUT = path.join(__dirname, '..', 'recordings', 'slice1');
+fs.mkdirSync(OUT, { recursive: true });
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+const round3 = v => Math.round(v * 1000) / 1000;
+
+async function holdKey(page, key, ms) {
+ await page.keyboard.down(key); await sleep(ms); await page.keyboard.up(key);
+}
+function camState(page) {
+ return page.evaluate(() => {
+ const c = window._qh.camera, t = window._qh.controls.target;
+ return { px: c.position.x, py: c.position.y, pz: c.position.z,
+ tx: t.x, ty: t.y, tz: t.z };
+ });
+}
+
+(async () => {
+ const errors = [];
+ const browser = await chromium.launch({ args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'] });
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
+ const page = await ctx.newPage();
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+ console.log('→ loading', URL);
+ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0, { timeout: 25000 });
+ await sleep(3500); // loader fade + guided boot (~700ms) + flip ease + first textures
+
+ const results = {};
+
+ // ---- 3. wing count ----
+ const wingCount = await page.evaluate(() => window._qh.wingBoards.length);
+ results.wingCount = wingCount;
+ results.wingCountPass = (wingCount === 50);
+
+ // ---- (3b) all wings visible (slivers not hidden) ----
+ const visibleCount = await page.evaluate(() => window._qh.wingBoards.filter(b => b.visible).length);
+ results.visibleCount = visibleCount;
+ results.allVisiblePass = (visibleCount === wingCount);
+
+ // ---- 4. middle open-at-angle, NO detail panel, NOTHING selected ----
+ const boot = await page.evaluate(() => {
+ const wb = window._qh.wingBoards;
+ const mid = Math.floor(wb.length / 2);
+ const ro = window._qh.restingOpenWing;
+ const detail = document.getElementById('wing-detail');
+ return {
+ restingIsMiddle: !!ro && ro === wb[mid],
+ midFlip: wb[mid] ? wb[mid].userData.flip : -1,
+ midPanelClosed: wb[mid] ? !!wb[mid].userData.panelClosed : null,
+ focusedWing: !!window._qh.focusedWing,
+ detailHidden: detail ? detail.classList.contains('hidden') : true,
+ };
+ });
+ results.boot = boot;
+ // open-at-angle: flip between ~0.2 and ~0.95 (partial), middle is resting-open, nothing focused, detail hidden
+ results.middleOpenPass = boot.restingIsMiddle && boot.midFlip > 0.2 && boot.midFlip < 0.97
+ && !boot.focusedWing && boot.detailHidden && !boot.midPanelClosed;
+
+ // screenshot of the boot view (fixed-centre, middle open amid slivers, no panel)
+ await page.screenshot({ path: path.join(OUT, 'boot-fixed-centre.png') });
+
+ // ---- 1. camera POSITION constant across an arrow-spin sweep ----
+ const before = await camState(page);
+ await holdKey(page, 'ArrowRight', 900); // yaw right
+ await sleep(400);
+ const afterRight = await camState(page);
+ await holdKey(page, 'ArrowLeft', 1700); // yaw back across to the left
+ await sleep(400);
+ const afterLeft = await camState(page);
+ const posDelta = Math.max(
+ Math.abs(before.px - afterRight.px), Math.abs(before.py - afterRight.py), Math.abs(before.pz - afterRight.pz),
+ Math.abs(before.px - afterLeft.px), Math.abs(before.py - afterLeft.py), Math.abs(before.pz - afterLeft.pz)
+ );
+ // target DID rotate (proves the spin worked, not a no-op)
+ const tgtDelta = Math.max(Math.abs(before.tx - afterRight.tx), Math.abs(before.tz - afterRight.tz));
+ results.camPos = { before: { px: round3(before.px), py: round3(before.py), pz: round3(before.pz) },
+ afterRight: { px: round3(afterRight.px), pz: round3(afterRight.pz) },
+ afterLeft: { px: round3(afterLeft.px), pz: round3(afterLeft.pz) } };
+ results.posDelta = round3(posDelta);
+ results.tgtDelta = round3(tgtDelta);
+ results.fixedCentrePass = (posDelta < 0.001) && (tgtDelta > 0.05);
+ await page.screenshot({ path: path.join(OUT, 'after-spin-right-then-left.png') });
+
+ // ---- 2. yaw CLAMPED — hold right a long time, then read spin yaw + verify never faces behind ----
+ await holdKey(page, 'ArrowRight', 4000); // try to over-spin
+ await sleep(300);
+ const clampR = await page.evaluate(() => {
+ // facing direction: camera→target, projected to XZ. Behind-the-wings = +z forward.
+ const c = window._qh.camera, t = window._qh.controls.target;
+ const dx = t.x - c.position.x, dz = t.z - c.position.z;
+ // angle off the wing-facing (-z) axis, in degrees, signed
+ const yawDeg = Math.atan2(dx, -dz) * 180 / Math.PI;
+ // "behind the wings" = yaw turned well past the side walls toward the room's rear
+ // (the front wall behind the viewer). The ±105° clamp lets the user see the side
+ // walls + the forward-placed sample table, which is intended; only |yaw|>115° counts
+ // as facing behind the wings.
+ return { yawDeg, dz, facesBehind: Math.abs(yawDeg) > 115 };
+ });
+ await page.screenshot({ path: path.join(OUT, 'clamp-right.png') });
+ await holdKey(page, 'ArrowLeft', 8000); // over-spin the other way
+ await sleep(300);
+ const clampL = await page.evaluate(() => {
+ const c = window._qh.camera, t = window._qh.controls.target;
+ const dx = t.x - c.position.x, dz = t.z - c.position.z;
+ const yawDeg = Math.atan2(dx, -dz) * 180 / Math.PI;
+ return { yawDeg, dz, facesBehind: Math.abs(yawDeg) > 115 };
+ });
+ await page.screenshot({ path: path.join(OUT, 'clamp-left.png') });
+ results.clamp = { right: { yawDeg: round3(clampR.yawDeg), facesBehind: clampR.facesBehind },
+ left: { yawDeg: round3(clampL.yawDeg), facesBehind: clampL.facesBehind } };
+ // clamp pass: never faces behind, and |yaw| stays within ~106° (105° clamp + tolerance)
+ results.clampPass = !clampR.facesBehind && !clampL.facesBehind
+ && Math.abs(clampR.yawDeg) <= 106 && Math.abs(clampL.yawDeg) <= 106;
+
+ // ---- 5. errorCount ----
+ results.errorCount = errors.length;
+ results.errors = errors.slice(0, 10);
+ results.errorPass = (errors.length === 0);
+
+ // ---- scene stats / FPS ----
+ results.sceneStats = await page.evaluate(() => window._getSceneStats());
+ // rough FPS sample
+ const fps = await page.evaluate(() => new Promise(res => {
+ let n = 0; const t0 = performance.now();
+ function tick() { n++; if (performance.now() - t0 < 1000) requestAnimationFrame(tick); else res(n); }
+ requestAnimationFrame(tick);
+ }));
+ results.fps = fps;
+
+ const pass = results.wingCountPass && results.allVisiblePass && results.middleOpenPass
+ && results.fixedCentrePass && results.clampPass && results.errorPass;
+ results.OVERALL = pass ? 'PASS' : 'FAIL';
+
+ console.log(JSON.stringify(results, null, 2));
+ fs.writeFileSync(path.join(OUT, 'verify-slice1.json'), JSON.stringify(results, null, 2));
+ await browser.close();
+ process.exit(pass ? 0 : 1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
← 8c6da3e auto-save: 2026-06-28T15:45:20 (1 files) — cta/
·
back to Quadrille Showroom
·
auto-save: 2026-06-28T16:15:32 (1 files) — LIQUID-UI-SPEC.md c38484d →