← 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
M public/js/rack.jsM scripts/verify-carousel.mjsA scripts/verify-openface.mjs
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 →