[object Object]

← back to Quadrille Showroom

Open-face clean: hero board pushes forward + re-centres so the open 30x6 face reads full-bleed edge-to-edge with NO neighbour slat-bars/frames crossing the pattern

0e9e5640ec3240cb93dab284653e09bde0b355db · 2026-06-28 18:04:13 -0700 · steve@designerwallcoverings.com

Root cause: an open hero stayed in the rail plane at the same depth as its raked
neighbours, so their tilted sliver faces + dark frame bars projected IN FRONT of
the open face (the gray/colored vertical bars crossing the pattern). The left-hinge
face-offset also pushed the open face halfWidth to the right of dead-front.

Fix (rack.js flipUpdate): the opening hero now (1) slides forward along the inward
normal toward the room centre (OPEN_FORWARD_M=0.42m, flip-scaled) so it sits clearly
in front of every raked neighbour — normal depth-testing then occludes them, nothing
crosses the pattern; (2) shifts left by faceHalf*flip so the 30in face is centred
dead-front (same pattern left & right of the open middle). placeBoard records the rail
base pos + inward normal + faceHalf; relayout re-reads the clean base each carousel frame.

Proof (verify-openface.mjs occlusion raycast): 25/25 grid points over the open face
hit the hero FIRST, 0 offenders, real texture full-bleed, face-centre world-X ~0,
errorCount 0. Carousel/clad/fixed-centre invariants intact (verify-carousel green,
now measuring the face centre not the left-hinge pivot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 0e9e5640ec3240cb93dab284653e09bde0b355db
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sun Jun 28 18:04:13 2026 -0700

    Open-face clean: hero board pushes forward + re-centres so the open 30x6 face reads full-bleed edge-to-edge with NO neighbour slat-bars/frames crossing the pattern
    
    Root cause: an open hero stayed in the rail plane at the same depth as its raked
    neighbours, so their tilted sliver faces + dark frame bars projected IN FRONT of
    the open face (the gray/colored vertical bars crossing the pattern). The left-hinge
    face-offset also pushed the open face halfWidth to the right of dead-front.
    
    Fix (rack.js flipUpdate): the opening hero now (1) slides forward along the inward
    normal toward the room centre (OPEN_FORWARD_M=0.42m, flip-scaled) so it sits clearly
    in front of every raked neighbour — normal depth-testing then occludes them, nothing
    crosses the pattern; (2) shifts left by faceHalf*flip so the 30in face is centred
    dead-front (same pattern left & right of the open middle). placeBoard records the rail
    base pos + inward normal + faceHalf; relayout re-reads the clean base each carousel frame.
    
    Proof (verify-openface.mjs occlusion raycast): 25/25 grid points over the open face
    hit the hero FIRST, 0 offenders, real texture full-bleed, face-centre world-X ~0,
    errorCount 0. Carousel/clad/fixed-centre invariants intact (verify-carousel green,
    now measuring the face centre not the left-hinge pivot).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/js/rack.js           | 53 +++++++++++++++++++++++---
 scripts/verify-carousel.mjs | 11 ++++--
 scripts/verify-openface.mjs | 91 +++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 146 insertions(+), 9 deletions(-)

diff --git a/public/js/rack.js b/public/js/rack.js
index 1724376..6782ec1 100644
--- a/public/js/rack.js
+++ b/public/js/rack.js
@@ -97,6 +97,21 @@
     const openYaw = faceCentreYaw(angle, cfg);
     const rake = revealToRake(reveal);
 
+    // BASE rail position (no open-forward offset). flipUpdate adds the forward push for
+    // the open hero ON TOP of this base, so the next relayout (carousel shift) re-reads a
+    // clean rail point rather than compounding the offset.
+    ud.baseX = rp.x; ud.baseZ = rp.z;
+    // Inward unit normal toward the arc centre (cx,cz) — the direction a board pushes when
+    // it opens so its FACE comes forward toward the room centre / viewer, clearing the
+    // raked neighbour slivers + frames out from in front of its pattern.
+    const cx = cfg.arcCenter.x, cz = cfg.arcCenter.z;
+    let inx = cx - rp.x, inz = cz - rp.z;
+    const ilen = Math.hypot(inx, inz) || 1;
+    ud.inX = inx / ilen; ud.inZ = inz / ilen;
+    // Half the board face width (face sits +halfWidth from the left hinge) — used by
+    // flipUpdate to re-centre the open hero's face on the slot centre.
+    ud.faceHalf = (cfg && cfg.boardWidthM ? cfg.boardWidthM : 0.762) / 2;
+
     pivot.position.set(rp.x, 0, rp.z);
     ud.arcAngle = angle;
     ud.openYaw = openYaw;
@@ -117,6 +132,14 @@
   // all others target flip=0 (raked/closed). Returns true if anything moved.
   // 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".
+  // How far (metres, toward the arc centre / viewer) a FULLY-open hero is pushed forward
+  // out of the rail plane so its 30"×6' face reads as ONE clean full-bleed pattern with
+  // nothing crossing it — the raked neighbour slivers + their dark frame bars stay BEHIND.
+  // 0.42m brings the hero clear of the steeply-raked (~73°) 0.762m neighbours — even their
+  // wide-swinging top/bottom corners stay behind — so the 30"×6' face reads full and clean
+  // edge-to-edge with only its own thin dark frame, nothing crossing the pattern. (Verified
+  // by the screen-grid occlusion raycast: every sample over the face hits the hero first.)
+  const OPEN_FORWARD_M = 0.42;
   function flipUpdate(boards, focusedPivot, dt, openFlip) {
     if (!boards.length) return false;
     const k = Math.min(1, dt * 7);     // ease rate
@@ -124,15 +147,35 @@
     let moved = false;
     for (let i = 0; i < boards.length; i++) {
       const p = boards[i], ud = p.userData;
-      const target = (p === focusedPivot && !ud.panelClosed) ? of : 0;
+      const isHero = (p === focusedPivot && !ud.panelClosed);
+      const target = isHero ? of : 0;
+      let flipMoved = false;
       if (Math.abs(ud.flip - target) > 0.0008) {
         ud.flip += (target - ud.flip) * k;
-        moved = true;
+        flipMoved = true;
       } else if (ud.flip !== target) {
         ud.flip = target;
-        moved = true;
-      } else continue;
-      p.rotation.y = ud.closedYaw + (ud.openYaw - ud.closedYaw) * ud.flip;
+        flipMoved = true;
+      }
+      // Forward push + lateral re-centre: ONLY the opening hero leaves the rail plane,
+      // scaled by its flip. (1) Forward along the inward normal toward the room centre so
+      // the face clears the raked neighbour faces/frames out from in front of its pattern.
+      // (2) The board face sits +halfWidth from its LEFT hinge, so a flat-open board's face
+      // centre lands halfWidth to the RIGHT of the hinge/slot centre — shift the hero LEFT
+      // by faceOffX×flip (along its open-flat right-axis) so the 30" face is centred dead-front
+      // ("same pattern left & right of the open middle"). Neighbours stay on the rail base.
+      if (typeof ud.baseX === 'number') {
+        const fwd = isHero ? OPEN_FORWARD_M * ud.flip : 0;
+        // Open-flat the board faces the centre; its local +X (face-offset axis) maps to the
+        // world right-axis perpendicular to the inward normal: rightX=+inZ, rightZ=-inX.
+        const half = isHero ? (ud.faceHalf || 0.381) * ud.flip : 0;
+        const nx = ud.baseX + (ud.inX || 0) * fwd - (ud.inZ || 0) * half;
+        const nz = ud.baseZ + (ud.inZ || 0) * fwd + (ud.inX || 0) * half;
+        if (Math.abs(p.position.x - nx) > 1e-5 || Math.abs(p.position.z - nz) > 1e-5) {
+          p.position.x = nx; p.position.z = nz; flipMoved = true;
+        }
+      }
+      if (flipMoved) { moved = true; p.rotation.y = ud.closedYaw + (ud.openYaw - ud.closedYaw) * ud.flip; }
     }
     return moved;
   }
diff --git a/scripts/verify-carousel.mjs b/scripts/verify-carousel.mjs
index 7fcef1b..ee55189 100644
--- a/scripts/verify-carousel.mjs
+++ b/scripts/verify-carousel.mjs
@@ -80,11 +80,13 @@ await page.waitForTimeout(3200);
 
 const leftPivot = await pivotWorldX(leftIdx);
 const centerPivotX = await page.evaluate(() => {
-  // The board that NOW occupies the centre slot should be the clicked one; its pivot
-  // world-X should be ~0 (dead-front). Read the focused board's world X.
+  // The clicked board now occupies the centre slot. Its open hero is pushed forward + the
+  // pivot is offset left by halfWidth so the FACE (not the left-hinge pivot) lands dead-front;
+  // measure the face-centre world-X — THAT is what should read ~0 (the centred 30" pattern).
   const THREE = window._qh.THREE;
   const v = new THREE.Vector3();
-  window._qh.focusedWing.getWorldPosition(v);
+  const f = window._qh.focusedWing.userData.face || window._qh.focusedWing;
+  f.getWorldPosition(v);
   return +v.x.toFixed(4);
 });
 const afterLeft = await page.evaluate(() => {
@@ -135,7 +137,8 @@ await page.waitForTimeout(3200);
 const centerPivotX2 = await page.evaluate(() => {
   const THREE = window._qh.THREE;
   const v = new THREE.Vector3();
-  window._qh.focusedWing.getWorldPosition(v);
+  const f = window._qh.focusedWing.userData.face || window._qh.focusedWing;
+  f.getWorldPosition(v);
   return +v.x.toFixed(4);
 });
 const afterRight = await page.evaluate(() => {
diff --git a/scripts/verify-openface.mjs b/scripts/verify-openface.mjs
new file mode 100644
index 0000000..6776741
--- /dev/null
+++ b/scripts/verify-openface.mjs
@@ -0,0 +1,91 @@
+// Open-face proof — captures the OPEN hero as ONE clean full 30"x6' pattern face with
+// NO neighbour slat-bars / frames crossing it, plus a mid-swivel "flip-through" frame.
+// Also computes a screen-space occlusion check: raycast a grid of points over the open
+// hero's face rect and assert the FIRST hit mesh is the hero's own face (nothing in front).
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+const { chromium } = pw;
+import { mkdirSync } from 'fs';
+
+const OUT = '/Users/stevestudio2/Projects/quadrille-showroom/recordings/openface';
+mkdirSync(OUT, { recursive: true });
+const result = {}, errors = [];
+
+const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] });
+const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
+page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+await page.goto('http://localhost:7690/', { waitUntil: 'networkidle' });
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0, { timeout: 20000 });
+await page.waitForTimeout(2500);
+
+// Click a left-of-centre board -> it conveyors to centre + opens. (Same interaction as
+// the proven verify-carousel: pick floor(n/2)-8 then sample frames so the page stays hot.)
+const picked = await page.evaluate(() => {
+  const n = window._qh.wingBoards.length;
+  const idx = Math.max(0, Math.floor(n / 2) - 8);
+  window._qh.focusOnWing(window._qh.wingBoards[idx]);
+  return idx;
+});
+// Sample across animation frames (keeps rAF active) + grab the mid-swivel flip-through.
+for (let i = 0; i < 4; i++) {
+  await page.waitForTimeout(70);
+  await page.evaluate(() => window._qh.indexOffset);
+}
+await page.screenshot({ path: OUT + '/flip-through.png' });
+
+// Let the shift settle + the centred board fan fully open + walls clad.
+await page.waitForTimeout(3800);
+
+// OCCLUSION CHECK — grid of screen points over the open hero's face; raycast and confirm
+// the first hit is the hero's own face mesh (i.e. nothing crosses in front of the pattern).
+const occ = await page.evaluate(() => {
+  const qh = window._qh, THREE = qh.THREE;
+  const hero = qh.focusedWing; if (!hero) return { ok: false, reason: 'no focus' };
+  const face = hero.userData.face;
+  // Project the face's 4 corners to screen to get its on-screen rect.
+  const cam = window._cam, rend = window._renderer || qh.renderer;
+  const w = window.innerWidth, h = window.innerHeight;
+  const geo = face.geometry; geo.computeBoundingBox();
+  const bb = geo.boundingBox;
+  const corners = [
+    new THREE.Vector3(bb.min.x, bb.min.y, bb.max.z),
+    new THREE.Vector3(bb.max.x, bb.min.y, bb.max.z),
+    new THREE.Vector3(bb.min.x, bb.max.y, bb.max.z),
+    new THREE.Vector3(bb.max.x, bb.max.y, bb.max.z),
+  ].map(v => { face.localToWorld(v); v.project(cam); return { x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h }; });
+  const xs = corners.map(c => c.x), ys = corners.map(c => c.y);
+  const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
+  // Sample an inset grid (avoid the very edge where the frame lives).
+  const rc = new THREE.Raycaster();
+  let total = 0, heroFirst = 0; const offenders = {};
+  // collect raycastable face meshes of all boards
+  const faces = qh.wingBoards.map(b => b.userData.face).filter(Boolean);
+  for (let gx = 0.12; gx <= 0.88; gx += 0.19) {
+    for (let gy = 0.10; gy <= 0.90; gy += 0.16) {
+      const sx = x0 + (x1 - x0) * gx, sy = y0 + (y1 - y0) * gy;
+      const ndc = new THREE.Vector2((sx / w) * 2 - 1, -(sy / h) * 2 + 1);
+      rc.setFromCamera(ndc, cam);
+      const hits = rc.intersectObjects(faces, false);
+      if (!hits.length) continue;
+      total++;
+      if (hits[0].object === face) heroFirst++;
+      else { const idx = hits[0].object.userData.parentPivot ? hits[0].object.userData.parentPivot.userData.index : '?'; offenders[idx] = (offenders[idx] || 0) + 1; }
+    }
+  }
+  return {
+    ok: true, total, heroFirst, allHeroFirst: total > 0 && heroFirst === total,
+    offenders, faceScreenRect: { x0: Math.round(x0), y0: Math.round(y0), x1: Math.round(x1), y1: Math.round(y1), wpx: Math.round(x1 - x0), hpx: Math.round(y1 - y0) },
+    flip: +hero.userData.flip.toFixed(3),
+    heroIdx: hero.userData.index,
+    realTexture: !!(face.material && face.material.map),
+  };
+});
+result.pickedIdx = picked;
+result.occlusion = occ;
+await page.screenshot({ path: OUT + '/open-clean.png' });
+
+result.errorCount = errors.length;
+result.errors = errors;
+console.log(JSON.stringify(result, null, 2));
+await browser.close();

← f0eacf1 auto-save: 2026-06-28T17:45:58 (1 files) — SHOWROOM-REFERENC  ·  back to Quadrille Showroom  ·  auto-save: 2026-06-28T18:16:07 (2 files) — public/js/showroo 34871bc →