← back to Quadrille Showroom
Chunk K (first-person Steve avatar, yaw-only rig) + Chunk A′ (20'×20' room)
09cf8ad2a9aec5cb9227d4ecf61be587d4938e36 · 2026-06-28 23:26:30 -0700 · Steve Abrams
K: first-person body built from THREE primitives (capsule limbs + boxes, $0, no
GLTF) — torso/shoulders/two arms+hands/legs/feet, charcoal sweater + indigo jeans +
dark shoes, NO head mesh (camera is the eyes). Parented to a YAW-ONLY rig
(avatarRig.rotation.y = spinYaw, position pinned under lockedSpinEye) so the body
turns with yaw but NEVER tilts with pitch — look down (PITCH_MIN deepened -0.62→-1.05,
~60°) and you see a stationary torso/lap/hands/legs/feet on the floor (BE Steve);
straight-ahead the body sits below the sight line so the wing bank reads clean.
updateAvatarRig() runs every frame after the spin pose; camera.position untouched.
A′: CONFIG.room 4.27→6.10 (20'×20'); arc re-fit arcRadius 2.2→3.2, arcCenter.z
0.4→0.5 so the 50-board bank still fans with ~2" slivers at a comfortable ~8.9'
distance; consultation nook relocated proportionally (1.25,-0.55)→(1.78,-0.78), same
+66° yaw inside the ±72° clamp. buildRoom/walls/baseboards/crown scale off CONFIG.
QH API: avatarPresent / avatarState() / setSpin() verification surface.
Proof bar (real-GPU Chrome, scripts/shot-chunkK-avatar.mjs): posDelta yaw 0, rigPitch
0 across all poses, 50 boards, clad 4 walls on click, errorCount 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/js/showroom.jsA scripts/shot-chunkK-avatar.mjs
Diff
commit 09cf8ad2a9aec5cb9227d4ecf61be587d4938e36
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jun 28 23:26:30 2026 -0700
Chunk K (first-person Steve avatar, yaw-only rig) + Chunk A′ (20'×20' room)
K: first-person body built from THREE primitives (capsule limbs + boxes, $0, no
GLTF) — torso/shoulders/two arms+hands/legs/feet, charcoal sweater + indigo jeans +
dark shoes, NO head mesh (camera is the eyes). Parented to a YAW-ONLY rig
(avatarRig.rotation.y = spinYaw, position pinned under lockedSpinEye) so the body
turns with yaw but NEVER tilts with pitch — look down (PITCH_MIN deepened -0.62→-1.05,
~60°) and you see a stationary torso/lap/hands/legs/feet on the floor (BE Steve);
straight-ahead the body sits below the sight line so the wing bank reads clean.
updateAvatarRig() runs every frame after the spin pose; camera.position untouched.
A′: CONFIG.room 4.27→6.10 (20'×20'); arc re-fit arcRadius 2.2→3.2, arcCenter.z
0.4→0.5 so the 50-board bank still fans with ~2" slivers at a comfortable ~8.9'
distance; consultation nook relocated proportionally (1.25,-0.55)→(1.78,-0.78), same
+66° yaw inside the ±72° clamp. buildRoom/walls/baseboards/crown scale off CONFIG.
QH API: avatarPresent / avatarState() / setSpin() verification surface.
Proof bar (real-GPU Chrome, scripts/shot-chunkK-avatar.mjs): posDelta yaw 0, rigPitch
0 across all poses, 50 boards, clad 4 walls on click, errorCount 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/js/showroom.js | 246 ++++++++++++++++++++++++++++++++++++++---
scripts/shot-chunkK-avatar.mjs | 91 +++++++++++++++
2 files changed, 323 insertions(+), 14 deletions(-)
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 97eccbd..2082c34 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -5,10 +5,12 @@
// CONFIG
// ============================================================
const CONFIG = {
- // 14'×14' room (~4.27m square, ~9.5' ceiling). The viewer sits DEAD CENTER and the
+ // 20'×20' room (~6.10m 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 },
+ // camera sees the full fanned bank (Slice-1 canonical spec). Room enlarged from 14'→20'
+ // (Chunk A′) so the first-person Steve avatar (Chunk K) has believable standing room and
+ // the bank sits a comfortable arm's-length distance from the centred viewer (not inches).
+ room: { width: 6.10, depth: 6.10, height: 2.9 },
wing: {
height: 1.8288, // 6 ft tall (real sample-board height)
boardWidthM: 0.762, // 30" wide (real sample-board width)
@@ -21,14 +23,16 @@ const CONFIG = {
// 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).
// 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 ±72° clamp).
+ arcRadius: 3.2, // radius of the curved rail (m) — re-fit for the 20'×20' room (Chunk A′)
+ // 50 boards × ~2" sliver. Hinge arc-pitch = R·span/(n-1): 3.2·(96°·π/180)/49 ≈ 0.1094m
+ // (≈4.3") raw hinge spacing; the ~73° closed rake (REVEAL 74) occludes most of that so each
+ // closed wing still reads as a ~2" leading sliver, fanning left+right around the centred
+ // viewer (yaw ±48°, well inside the ±72° clamp). With arcCenter.z=0.5 the dead-front centre
+ // board sits at z≈-2.7m (≈8.9') ahead of the eye — a comfortable showroom distance, NOT
+ // inches from the viewer; the edge boards swing to x≈±2.4m (room half-width 3.05m, fits).
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
+ arcCenter: { x: 0.0, z: 0.5 }, // arc centre just BEHIND the centred viewer → concave bank faces them
sliverPitchScale: 1.0 // multiplier on the angular pitch (reveal slider can thin)
},
// Camera FIXED at room centre, eye height ~1.6m, facing the wing bank (toward arc centre).
@@ -206,6 +210,7 @@ function init() {
buildRoom();
buildLighting();
buildFurniture();
+ buildAvatar(); // Chunk K — first-person Steve body (yaw-only rig under the eye)
enableStaticShadows();
updateLoadStatus('Loading products...', 60);
@@ -918,13 +923,14 @@ 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.
- // In the 14'×14' room the viewer is fixed at centre facing the arc (toward -z). The
+ // In the 20'×20' room the viewer is fixed at centre facing the arc (toward -z). The
// consultation nook is tucked to the RIGHT side, pulled FORWARD toward the wings (more
// negative z) and low, so it sits INSIDE the tightened ±72° yaw clamp at ~yaw +66°
- // (atan2(1.25, 0.55) ≈ 66.3°): a spin-right + look-down reveals the sample table without
- // walking, and the 72° clamp still reaches it. (Was +82°, which fell OUTSIDE the new clamp.)
+ // (atan2(1.78, 0.78) ≈ 66.3°): a spin-right + look-down reveals the sample table without
+ // walking, and the 72° clamp still reaches it. Relocated proportionally for the 20'×20'
+ // room (Chunk A′): old (1.25,-0.55) × 6.10/4.27 ≈ (1.78,-0.78) — same yaw angle, farther out.
// ============================================================
- const nookX = 1.25, nookZ = -0.55;
+ const nookX = 1.78, nookZ = -0.78;
buildConsultationNook(nookX, nookZ);
// (Removed: boxy TV + bezel + slideshow screen — the wallcoverings are the only
@@ -939,6 +945,185 @@ function buildFurniture() {
// (Removed: framed abstract "art" on the right wall — the sample boards are the art.)
}
+// ============================================================
+// CHUNK K — FIRST-PERSON STEVE AVATAR BODY ("BE Steve")
+// ------------------------------------------------------------
+// The camera IS Steve's eyes (~1.6m at room centre). We render his BODY from the
+// neck down — torso, shoulders, two arms with hands, legs, feet on the floor — but
+// NOT a head/face (you're inside it; a head mesh would clip the near plane).
+//
+// PARENTING (critical, per spec): the body hangs off a YAW-ONLY rig
+// (avatarRig.rotation.y = spinYaw, avatarRig.position = lockedSpinEye projected to
+// the floor). So:
+// • YAW (ArrowLeft/Right): the rig turns → the body turns WITH you (it's YOUR body).
+// • PITCH (ArrowUp/Down): the rig does NOT tilt → look down and you see a stationary
+// torso/hands/legs/feet below you (proving "BE Steve"); look up and they fall away.
+// We deliberately do NOT rigid-parent to the camera (that would tilt the whole body
+// when you pitch down — wrong). Position tracks lockedSpinEye so any future eye move
+// carries the body; pitch is read ONLY by the camera, never by the rig.
+//
+// $0 — built entirely from THREE primitives (capsule-ish stacked cylinders + boxes),
+// no GLTF, no model-gen. Tasteful, realistic proportions, simple casual Steve clothing
+// (charcoal sweater + indigo jeans + dark shoes). Shoulders/torso sit LOW and to the
+// sides so the forward wing-bank view stays clean; the body only fills the lower frame
+// when you glance/pitch down.
+// ============================================================
+let avatarRig = null; // yaw-only group the whole body hangs from
+const AVATAR_EYE_Y = 1.62; // eye height (matches camera y) — body origin is the floor under it
+
+function capsuleMesh(radius, length, mat, radialSegs) {
+ // THREE r-legacy has no CapsuleGeometry in the bundled build — compose a cylinder
+ // shaft + two hemisphere caps into a small group so limbs read soft & rounded, not
+ // like cut pipes. `length` = straight shaft length (caps add `radius` at each end).
+ const g = new THREE.Group();
+ const rs = radialSegs || 12;
+ const shaft = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, rs, 1, true), mat);
+ g.add(shaft);
+ const capGeo = new THREE.SphereGeometry(radius, rs, Math.max(6, rs / 2));
+ const top = new THREE.Mesh(capGeo, mat); top.position.y = length / 2; g.add(top);
+ const bot = new THREE.Mesh(capGeo, mat); bot.position.y = -length / 2; g.add(bot);
+ g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+ return g;
+}
+
+function buildAvatar() {
+ // Clean rebuild safety (rebuilds shouldn't stack bodies)
+ if (avatarRig) { scene.remove(avatarRig); avatarRig = null; }
+
+ const rig = new THREE.Group();
+ rig.name = 'avatarRig';
+ // The rig's ORIGIN is the floor directly under the eye; the body is built in rig-local
+ // coordinates with y=0 at the floor and the neck stub ending just under the eye, so
+ // looking straight down the line of sight lands on the chest/hands, not a clipped neck.
+ if (lockedSpinEye) rig.position.set(lockedSpinEye.x, 0, lockedSpinEye.z);
+ else rig.position.set(CONFIG.camera.startPos.x, 0, CONFIG.camera.startPos.z);
+
+ // ---- Materials: simple, high-end, NOT cartoon ----
+ const sweater = new THREE.MeshStandardMaterial({ color: 0x2e3138, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.2 }); // charcoal knit
+ const jeans = new THREE.MeshStandardMaterial({ color: 0x2b3a4d, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.15 }); // indigo denim
+ const skin = new THREE.MeshStandardMaterial({ color: 0xc99a78, roughness: 0.7, metalness: 0.0, envMapIntensity: 0.2 }); // hands
+ const shoe = new THREE.MeshStandardMaterial({ color: 0x1b1b20, roughness: 0.55, metalness: 0.05, envMapIntensity: 0.3 }); // dark leather
+
+ // Proportions (metres), tuned so the eye sits ~1.62m and feet at 0.
+ const hipY = 0.95; // top of legs / waist
+ const chestTopY = 1.42; // top of torso (shoulder line)
+ const torsoH = chestTopY - hipY; // 0.47
+ const shoulderHalf = 0.20; // half shoulder width (arms hang outside this)
+ // BODY_BACK: shift the core mass (torso/neck/shoulders/hips/legs) BEHIND the eye in
+ // rig-local +z (Steve faces -z, so +z is behind him). In a real first-person view the
+ // eye sits at the FRONT of the head, ahead of the chest — without this the camera would
+ // be buried inside the torso, clipping it across the whole lower frame. With the chest
+ // pushed back ~0.16m the camera looks out from just in front of the sternum: looking
+ // straight ahead the torso is below + slightly behind the sight line (clean forward
+ // view); pitch down and the chest/lap fill the lower frame as expected. Arms/hands are
+ // NOT shifted back — they reach forward into the low-front field so a glance down sees them.
+ const BODY_BACK = 0.16;
+
+ // ---- TORSO (charcoal sweater) — a soft tapered capsule, slightly narrower at the
+ // waist. Sits LOW/centre so it doesn't crowd the forward view; you mainly see the
+ // chest when you pitch down. ----
+ const torso = capsuleMesh(0.165, torsoH - 0.10, sweater, 16);
+ torso.position.set(0, hipY + torsoH / 2, BODY_BACK);
+ torso.scale.set(1.15, 1.0, 0.78); // broaden across, flatten front-to-back (human chest)
+ rig.add(torso);
+
+ // Short neck stub (sweater collar) — ends just below the eye so there's no clipped
+ // head; the camera looks out from just above + ahead of it.
+ const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.075, 0.12, 12), sweater);
+ neck.position.set(0, chestTopY + 0.04, BODY_BACK);
+ neck.castShadow = true; rig.add(neck);
+
+ // ---- SHOULDERS — round caps so the sweater reads as a real shoulder line, set wide
+ // and low (peripheral), not blocking the centre forward view. ----
+ [-1, 1].forEach(s => {
+ const sh = new THREE.Mesh(new THREE.SphereGeometry(0.11, 14, 12), sweater);
+ sh.position.set(s * shoulderHalf, chestTopY - 0.04, BODY_BACK);
+ sh.scale.set(1.1, 0.85, 0.9);
+ sh.castShadow = true; rig.add(sh);
+ });
+
+ // ---- ARMS — upper arm + forearm + hand. FORWARD is -z (Steve faces -z), so the arms
+ // bend forward by REDUCING z. They rest with the hands clasped low-front at ~waist height,
+ // sitting in the LOWER-FRONT field: below the sight line when you look straight ahead (so
+ // the forward wing-bank view stays clean), but clearly in frame the moment you glance/pitch
+ // down. Raised to ~waist height (not dangling at the knees) so a natural 40-50° look-down
+ // catches the hands + sleeves, like resting your hands in front of you.
+ [-1, 1].forEach(s => {
+ const arm = new THREE.Group();
+ arm.position.set(s * shoulderHalf, chestTopY - 0.06, BODY_BACK);
+
+ // Upper arm — drops from the shoulder, angled out + slightly forward (toward -z).
+ const upper = capsuleMesh(0.062, 0.24, sweater, 12);
+ upper.position.set(s * 0.05, -0.16, -0.03);
+ upper.rotation.z = s * 0.22; // splay slightly outward
+ upper.rotation.x = 0.22; // tilt the top back so the elbow comes FORWARD (-z)
+ arm.add(upper);
+
+ // Forearm (sweater sleeve to wrist), bringing the hands forward + slightly inward so
+ // they meet low-front (relaxed resting clasp).
+ const fore = capsuleMesh(0.052, 0.22, sweater, 12);
+ fore.position.set(s * 0.085, -0.36, -0.20);
+ fore.rotation.z = -s * 0.22; // bring wrists inward toward centre
+ fore.rotation.x = 1.05; // forearm swings strongly forward + down to the lap-front
+ arm.add(fore);
+
+ // Hand — soft skin block at the end of the forearm, low-front + centred (clasped).
+ const hand = new THREE.Mesh(new THREE.SphereGeometry(0.058, 12, 10), skin);
+ hand.scale.set(1.15, 0.72, 1.3);
+ hand.position.set(s * 0.05, -0.46, -0.40);
+ hand.castShadow = true;
+ arm.add(hand);
+
+ arm.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+ rig.add(arm);
+ });
+
+ // ---- LEGS — two indigo-denim capsules from the hip to the ankle, set at hip width,
+ // standing straight. Feet (dark shoes) flat on the floor, toes FORWARD (-z). ----
+ const legLen = hipY - 0.10; // hip down to ankle
+ [-1, 1].forEach(s => {
+ const leg = capsuleMesh(0.085, legLen - 0.17, jeans, 12);
+ // Legs descend from the (back-shifted) hips, angling slightly forward (toward -z) to the
+ // feet so the stance reads natural and the feet land in the low-front field on look-down.
+ leg.position.set(s * 0.105, (hipY - 0.05) / 2 + 0.05, BODY_BACK - 0.06);
+ leg.scale.set(1.0, 1.0, 0.95);
+ leg.rotation.x = -0.06; // slight forward lean toward the feet (-z)
+ rig.add(leg);
+
+ // Foot — a low rounded box shoe, toe pointing forward (-z, the way Steve faces).
+ const foot = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.075, 0.27), shoe);
+ foot.position.set(s * 0.105, 0.038, BODY_BACK - 0.22);
+ foot.castShadow = true; foot.receiveShadow = true;
+ rig.add(foot);
+ });
+
+ // Hip / waistband joining the torso to the legs (denim).
+ const hips = new THREE.Mesh(new THREE.CylinderGeometry(0.155, 0.135, 0.18, 16), jeans);
+ hips.position.set(0, hipY - 0.02, BODY_BACK);
+ hips.scale.set(1.1, 1.0, 0.8);
+ hips.castShadow = true; rig.add(hips);
+
+ // Soft contact shadow under the feet so the figure reads grounded on the floor.
+ addContactShadow(rig.position.x, rig.position.z, 0.55, 0.42, 0.7);
+
+ rig.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+ scene.add(rig);
+ avatarRig = rig;
+ updateAvatarRig(); // seat yaw + position immediately
+ requestShadowUpdate(2);
+}
+
+// Per-frame: keep the body's rig at the eye's floor-projected position and yawed to
+// spinYaw — POSITION + YAW ONLY, never pitch. (Pitch lives on the camera alone; the
+// body must stay upright so looking down reveals a stationary torso/hands/feet.)
+function updateAvatarRig() {
+ if (!avatarRig) return;
+ const eye = lockedSpinEye || camera.position;
+ avatarRig.position.x = eye.x;
+ avatarRig.position.z = eye.z;
+ avatarRig.rotation.y = spinYaw; // yaw with the look; ignore spinPitch entirely
+}
+
// 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) {
@@ -2232,7 +2417,12 @@ function onCanvasMouseMove(event) {
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 = 72 * Math.PI / 180; // ±72° off wing-facing (-z) — the look-vector z stays negative so the leftmost/rightmost boards + side wall stay in frame; never an empty room, never behind the wings
-const PITCH_MIN = -0.62; // look down ~36° (to the sample table)
+// Chunk K: deepened the downward clamp from -0.62 (~36°) to -1.05 (~60°) so the user can
+// actually pitch down far enough to look AT their own first-person body — the torso, lap,
+// hands and feet fill the lower frame, proving "BE Steve". At -36° the sight line shot well
+// past the body onto the floor 2m ahead (the body sat below the frame). -60° still keeps the
+// never-behind yaw clamp + lands on the consultation table when yawed to the nook.
+const PITCH_MIN = -1.05; // look down ~60° (to your own body / lap / 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).
@@ -2414,6 +2604,10 @@ function animate() {
}
}
+ // Chunk K — keep Steve's first-person body yawed with the look + pinned under the eye
+ // (POSITION + YAW only; pitch is the camera's alone so a look-down reveals the body).
+ updateAvatarRig();
+
// Animate wings (fan cascade — pivot rotation)
for (let i = animatingWings.length - 1; i >= 0; i--) {
const p = animatingWings[i], ud = p.userData;
@@ -3261,6 +3455,30 @@ window._qh = {
// spin past the clamp and reports the capped yaw, proving Slice-1's clamp survives.
get YAW_CLAMP() { return YAW_CLAMP; },
get spinYaw() { return spinYaw; },
+ // Chunk K — first-person avatar verification surface. avatarPresent proves the body is
+ // built; avatarState exposes the rig's yaw + position + the camera's pitch so the proof
+ // bar can assert YAW-ONLY parenting (rig.rotation.y tracks spinYaw, rig.rotation.x stays
+ // 0 even when the camera pitches down) and that the body is pinned under the fixed eye.
+ get avatarPresent() { return !!avatarRig; },
+ get spinPitch() { return spinPitch; },
+ avatarState() {
+ if (!avatarRig) return null;
+ return {
+ present: true,
+ rigYaw: avatarRig.rotation.y,
+ rigPitch: avatarRig.rotation.x, // must stay 0 (body never tilts with the look)
+ rigPos: { x: avatarRig.position.x, y: avatarRig.position.y, z: avatarRig.position.z },
+ spinYaw, spinPitch,
+ childCount: avatarRig.children.length,
+ eye: lockedSpinEye ? { x: lockedSpinEye.x, y: lockedSpinEye.y, z: lockedSpinEye.z } : null
+ };
+ },
+ // Drive the spin pose for headless verification (no key events in screenshot harness).
+ setSpin(yaw, pitch) {
+ if (typeof yaw === 'number') spinYaw = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, yaw));
+ if (typeof pitch === 'number') spinPitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, pitch));
+ applySpinPose(); updateAvatarRig();
+ },
testYawClamp() {
const prev = spinYaw;
spinYaw = Math.max(-YAW_CLAMP, Math.min(YAW_CLAMP, 99)); // try to drive far right
diff --git a/scripts/shot-chunkK-avatar.mjs b/scripts/shot-chunkK-avatar.mjs
new file mode 100644
index 0000000..c56f969
--- /dev/null
+++ b/scripts/shot-chunkK-avatar.mjs
@@ -0,0 +1,91 @@
+// Chunk K (first-person Steve avatar) + Chunk A′ (20'×20' room) PROOF BAR.
+// Real-GPU headed Chrome on the live :7690 canvas. Captures the 4 proof shots and
+// runs the asserts: camera.position constant across yaw (posDelta 0), body present +
+// yaw-only parented (rig pitch 0 even when camera pitches down), 50 boards, click still
+// → carousel + 4-wall clad, errorCount 0, room = 20'×20'.
+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/chunkK';
+mkdirSync(OUT, { recursive: true });
+
+const errors = [];
+const browser = await chromium.launch({ channel: 'chrome', headless: false, args: ['--use-angle=metal', '--ignore-gpu-blocklist', '--window-size=1440,900'] });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+page.on('pageerror', e => errors.push('pageerror: ' + e.message));
+page.on('console', m => { if (m.type() === 'error') errors.push('console.error: ' + m.text()); });
+
+await page.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length >= 50, { timeout: 25000 });
+await page.waitForTimeout(3500); // let the boot settle (resting-open middle)
+
+const result = {};
+
+// ---- baseline: room size + board count + avatar presence + camera pos ----
+result.room = await page.evaluate(() => ({ w: window._qh.CONFIG.room.width, d: window._qh.CONFIG.room.depth }));
+result.boardCount = await page.evaluate(() => window._qh.wingBoards.length);
+result.avatarPresent = await page.evaluate(() => window._qh.avatarPresent);
+result.avatarState0 = await page.evaluate(() => window._qh.avatarState());
+const camPos0 = await page.evaluate(() => { const p = window._qh.camera.position; return { x: p.x, y: p.y, z: p.z }; });
+
+// =================================================================
+// PROOF 1 — look-ahead: straight at the wing bank, body NOT blocking
+// =================================================================
+await page.evaluate(() => window._qh.setSpin(0, -0.08)); // dead-ahead, canonical rest pitch
+await page.waitForTimeout(900);
+await page.screenshot({ path: OUT + '/01-look-ahead.png' });
+result.lookAhead = await page.evaluate(() => ({ rigYaw: window._qh.avatarState().rigYaw, rigPitch: window._qh.avatarState().rigPitch, spinPitch: window._qh.spinPitch }));
+
+// =================================================================
+// PROOF 2 — look-down: pitch down → torso/hands/legs/feet visible
+// =================================================================
+await page.evaluate(() => window._qh.setSpin(0, -1.05)); // full down (~60°) — onto your own body
+await page.waitForTimeout(900);
+await page.screenshot({ path: OUT + '/02-look-down.png' });
+result.lookDown = await page.evaluate(() => window._qh.avatarState());
+// also a mid-down (~45°) shot — the natural "glance at hands" angle
+await page.evaluate(() => window._qh.setSpin(0, -0.78));
+await page.waitForTimeout(700);
+await page.screenshot({ path: OUT + '/02b-look-down-mid.png' });
+
+// =================================================================
+// PROOF 3 — yaw-with-body: yaw right; body turns WITH the view (rig yaw tracks),
+// rig pitch stays 0, boards still in frame, clamp holds, camera pos constant.
+// =================================================================
+await page.evaluate(() => window._qh.setSpin(0.9, -0.08)); // yaw right ~+51°, level-ish
+await page.waitForTimeout(900);
+await page.screenshot({ path: OUT + '/03-yaw-with-body.png' });
+result.yawRight = await page.evaluate(() => window._qh.avatarState());
+const camPosYaw = await page.evaluate(() => { const p = window._qh.camera.position; return { x: p.x, y: p.y, z: p.z }; });
+// yaw past the clamp to confirm it still holds with the body
+result.yawClamp = await page.evaluate(() => window._qh.testYawClamp());
+// yaw left for a second body-turns shot
+await page.evaluate(() => window._qh.setSpin(-0.9, -0.30));
+await page.waitForTimeout(700);
+await page.screenshot({ path: OUT + '/03b-yaw-left-down.png' });
+result.yawLeft = await page.evaluate(() => window._qh.avatarState());
+
+// posDelta across yaw — camera position MUST be constant (Slice-1)
+result.posDelta = {
+ yaw: Math.hypot(camPosYaw.x - camPos0.x, camPosYaw.y - camPos0.y, camPosYaw.z - camPos0.z)
+};
+
+// =================================================================
+// PROOF 4 — room-20ft: confirm larger room + bank fans correctly. Reset to a wide
+// establishing pose and snapshot the full fanned bank at a comfortable dist.
+// =================================================================
+await page.evaluate(() => window._qh.setSpin(0, -0.10));
+await page.waitForTimeout(700);
+await page.screenshot({ path: OUT + '/04-room-20ft.png' });
+
+// ---- regression: click still selects → carousel + 4-wall clad ----
+await page.evaluate(() => { const wb = window._qh.wingBoards; window._qh.setSpin(0, -0.08); window._qh.focusOnWing(wb[Math.floor(wb.length / 2) + 4]); });
+await page.waitForTimeout(4200); // carousel + flip + clad load
+result.cladSides = await page.evaluate(() => window._qh.cladSides);
+result.focused = await page.evaluate(() => !!window._qh.focusedWing);
+await page.screenshot({ path: OUT + '/05-click-clad-regression.png' });
+
+result.errorCount = errors.length;
+result.errors = errors.slice(0, 10);
+console.log(JSON.stringify(result, null, 2));
+await browser.close();
← 33391a6 Boot raycaster race fix: queue pre-ready canvas clicks + rep
·
back to Quadrille Showroom
·
Avatar polish: lighten 1st-person sweater 0x2e3138->0x3d424d 1c4a04e →