[object Object]

← 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

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 →