[object Object]

← 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

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 →