[object Object]

← back to Quadrille Showroom

Avatar nav: comfortable centred boot framing + grounded scroll/arrow walk, drag-turn, and always-available Return to Center

de7d9e4240c2fe4ce04e72054444d85feed6544a · 2026-06-30 13:18:57 -0700 · Steve

- Boot: pull eye back to z=0.7 + BOOT_PITCH -0.12 so the open book frames with headroom (was 'way too close')
- Single-ownership nav: disable OrbitControls rotate/pan/zoom in both modes (killed the dolly-too-close / aerial-tilt / behind-furniture vectors)
- Scroll wheel + arrows walk; drag or A/D turns (vertical drag ignored = always grounded); eye height never changes = never aerial
- Grounded walkable box clamp excludes the closet/rack/nook/olive keep-outs (no walking behind the closet)
- Add ⊕ Center button + C/Home key + recenterView() glide back to middle, facing forward
- All-ages hint copy refreshed across HUD + guided instruction

Files touched

Diff

commit de7d9e4240c2fe4ce04e72054444d85feed6544a
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 13:18:57 2026 -0700

    Avatar nav: comfortable centred boot framing + grounded scroll/arrow walk, drag-turn, and always-available Return to Center
    
    - Boot: pull eye back to z=0.7 + BOOT_PITCH -0.12 so the open book frames with headroom (was 'way too close')
    - Single-ownership nav: disable OrbitControls rotate/pan/zoom in both modes (killed the dolly-too-close / aerial-tilt / behind-furniture vectors)
    - Scroll wheel + arrows walk; drag or A/D turns (vertical drag ignored = always grounded); eye height never changes = never aerial
    - Grounded walkable box clamp excludes the closet/rack/nook/olive keep-outs (no walking behind the closet)
    - Add ⊕ Center button + C/Home key + recenterView() glide back to middle, facing forward
    - All-ages hint copy refreshed across HUD + guided instruction
---
 public/js/showroom.js | 206 ++++++++++++++++++++++++++++++++++++++++++--------
 public/showroom.html  |   3 +-
 2 files changed, 176 insertions(+), 33 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index 425a888..88830eb 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -36,9 +36,19 @@ const CONFIG = {
     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).
-  camera: { startPos: { x: 0.0, y: 1.6, z: 0.0 }, fov: 60 }
+  // startPos.z pulled back to +0.7 (Steve 2026-06-30: the centred eye at z=0 sat ~2.4m from
+  // the open book and framed "way too close"). +0.7 keeps the avatar essentially centred
+  // (room spans z ±3.05) while standing the open book off to a comfortable ~3.1m so the whole
+  // 6-ft spread frames with headroom instead of filling the screen.
+  camera: { startPos: { x: 0.0, y: 1.6, z: 0.7 }, fov: 60 }
 };
 
+// Boot / return-to-centre look pitch: a gentle downward tilt so the eye (1.6m) frames the
+// open book — whose vertical centre sits at ~0.95m — with headroom, reading as "looking
+// forward" rather than dead-level (which let the book's top crowd the ceiling) or staring
+// at the floor. Used by init, recenterView(), and the guided reset paths.
+const BOOT_PITCH = -0.12;
+
 // ============================================================
 // STATE
 // ============================================================
@@ -204,15 +214,19 @@ function init() {
   // 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.
-  controls.enableRotate = exploreMode;
-  controls.enablePan = exploreMode;
-  controls.enableZoom = exploreMode;
+  // SINGLE-OWNERSHIP NAVIGATION (Steve 2026-06-30): OrbitControls' own wheel/drag handlers
+  // self-call scope.update() on each event, dollying the eye in ("way too close") and tipping
+  // the look aerial / swinging it past the furniture — fighting the first-person rig that
+  // pins the eye every frame. We now OWN all navigation through the rig (arrow/scroll walk,
+  // drag/A-D turn, all position-clamped + grounded), so OrbitControls' interactions stay OFF
+  // in BOTH modes. The controls object survives only as the look-target holder.
+  controls.enableRotate = false;
+  controls.enablePan = false;
+  controls.enableZoom = false;
 
   // 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;
+  spinYaw = 0; spinPitch = BOOT_PITCH;
   applySpinPose();
 
   updateLoadStatus('Building materials...', 10);
@@ -231,6 +245,15 @@ function init() {
 
   canvas.addEventListener('click', onCanvasClick, false);
   canvas.addEventListener('mousemove', onCanvasMouseMove, false);
+  // Scroll-to-walk + drag-to-turn (Steve 2026-06-30: "using arrows or scroll mouse to move
+  // avatar"). Mouse-only users (all ages) get the same constrained, grounded navigation the
+  // arrow keys give — never a free orbit. { passive:false } so the wheel can preventDefault
+  // the page scroll.
+  canvas.addEventListener('wheel', onCanvasWheel, { passive: false });
+  canvas.addEventListener('pointerdown', onCanvasPointerDown, false);
+  canvas.addEventListener('pointermove', onCanvasPointerMove, false);
+  canvas.addEventListener('pointerup', onCanvasPointerUp, false);
+  canvas.addEventListener('pointercancel', onCanvasPointerUp, false);
   window.addEventListener('resize', onResize, false);
   window.addEventListener('keydown', e => {
     keysDown[e.code] = true;
@@ -239,6 +262,9 @@ function init() {
     // to A/D so the arrows don't both page AND turn.) ↑/↓ still walk.
     if (BOOK_MODE && (e.code === 'ArrowRight' || e.code === 'BracketRight' || e.code === 'KeyN')) { e.preventDefault(); pageCenterBook(1); return; }
     if (BOOK_MODE && (e.code === 'ArrowLeft'  || e.code === 'BracketLeft'  || e.code === 'KeyP')) { e.preventDefault(); pageCenterBook(-1); return; }
+    // Return to centre — 'C' or Home. Always available, every age: glides the avatar back to
+    // the middle of the room, facing the bank, eye level. Matches the on-screen ⊕ Center button.
+    if (e.code === 'KeyC' || e.code === 'Home') { e.preventDefault(); requestRecenter(); return; }
     if (e.code === 'KeyM') {
       minimapVisible = !minimapVisible;
       const mc = document.getElementById('minimap');
@@ -2370,6 +2396,8 @@ function onCanvasClick(event) {
     updateLoadStatus('Loading the collection — your selection is queued…', 90);
     return;
   }
+  // A drag that turned the body is NOT a board selection — swallow the trailing click.
+  if (_dragTurned) { _dragTurned = false; return; }
   // A click during the auto-tour stops it and keeps the current wing open
   if (peruseActive) { stopPeruse(true); return; }
   // If controls are locked (focused on a wing), ignore canvas clicks
@@ -2418,7 +2446,7 @@ function focusOnWing(pivot) {
   // The camera never leaves the fixed centre — reset the spin pose so the dead-front slot
   // (where this board is sliding to) is framed head-on. No camera fly, no lock.
   if (controlsLocked) unlockControls();
-  if (!exploreMode) { spinYaw = 0; spinPitch = -0.08; if (lockedSpinEye) camera.position.copy(lockedSpinEye); applySpinPose(); }
+  if (!exploreMode) { spinYaw = 0; spinPitch = BOOT_PITCH; if (lockedSpinEye) camera.position.copy(lockedSpinEye); applySpinPose(); }
   if (camera.fov !== FOV_DEFAULT) { targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix(); }
 
   // Conveyor the rack so this board's effective index lands on the centre slot, THEN open.
@@ -2577,7 +2605,7 @@ function unfocusWing(skipPanel) {
   // 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;
+    spinYaw = 0; spinPitch = BOOT_PITCH;
     smoothCameraTo(lockedSpinEye.clone(), (function () {
       const cp = lockedSpinEye, cy = Math.cos(spinPitch);
       return new THREE.Vector3(cp.x + Math.sin(spinYaw) * cy * TARGET_DIST,
@@ -2841,8 +2869,122 @@ function applySpinPose() {
   controls.target.set(cp.x + dir.x * TARGET_DIST, cp.y + dir.y * TARGET_DIST, cp.z + dir.z * TARGET_DIST);
 }
 
-// Half the room (minus a margin) the avatar may roam — keeps the body inside the walls.
-const WALK_HALF = (CONFIG.room.width / 2) - 0.5;
+// GROUNDED WALKABLE BOX (Steve 2026-06-30: "views always grounded… no behind the closet").
+// The avatar roams a safe central rectangle that excludes every furniture keep-out so it can
+// never clip into or walk behind the closet/sample-rack, the consultation nook, or the olive
+// planter — and, because the eye height is never touched, never goes aerial. Room spans x/z
+// ±3.05. Front limit z=-1.0 keeps the body BEHIND the nook (z=-1.1) + olive (z=-1.3) and well
+// clear of the open book / rack (z ≤ -2.4); the box stays inside the side walls (±1.7) and
+// stops short of the back wall (+2.3). Within this box the user wanders freely and always
+// faces the bank from a comfortable distance.
+const WALK_MIN_X = -1.7, WALK_MAX_X = 1.7;
+const WALK_MIN_Z = -1.0, WALK_MAX_Z =  2.3;
+function clampWalk(nx, nz) {
+  return [
+    Math.max(WALK_MIN_X, Math.min(WALK_MAX_X, nx)),
+    Math.max(WALK_MIN_Z, Math.min(WALK_MAX_Z, nz))
+  ];
+}
+
+// Move the avatar eye by `step` metres along its current facing (XZ floor projection),
+// clamped to the grounded walkable box. Shared by arrow-walk + scroll-walk. Eye height is
+// NEVER changed here — that is what keeps every view grounded (no aerial).
+function walkForward(step) {
+  if (!lockedSpinEye) return;
+  const fx = Math.sin(spinYaw), fz = -Math.cos(spinYaw);   // yaw=0 faces -Z
+  const [nx, nz] = clampWalk(lockedSpinEye.x + fx * step, lockedSpinEye.z + fz * step);
+  lockedSpinEye.x = nx; lockedSpinEye.z = nz;
+  if (camera) { camera.position.x = nx; camera.position.z = nz; }
+  applySpinPose();   // re-aim the look-target from the new eye position
+}
+
+// ============================================================
+// MOUSE NAVIGATION — scroll = walk, drag = turn (grounded, never orbits)
+// ============================================================
+const WHEEL_STEP      = 0.22;   // metres the eye walks per wheel notch (gentle)
+const DRAG_YAW_PER_PX = 0.005;  // radians of body-turn per pixel of horizontal drag
+const DRAG_THRESH     = 6;      // px of movement before a press is a drag (turn) not a click
+
+// Scroll wheel walks the avatar forward/back along its facing — the mouse-only twin of ↑/↓.
+// Disabled while focused/animating/recentering. Never dollies the camera (no orbit zoom),
+// so a scroll can never get "too close" past the walkable box.
+function onCanvasWheel(e) {
+  if (!exploreMode || controlsLocked || cameraAnim || recenterTween) return;
+  e.preventDefault();
+  walkForward((e.deltaY < 0 ? 1 : -1) * WHEEL_STEP);   // scroll up = forward
+  if (window._requestShadowUpdate) window._requestShadowUpdate(4);
+}
+
+// Drag-to-turn: horizontal drag yaws the body (the mouse twin of A/D). VERTICAL drag is
+// ignored on purpose — that is what keeps the mouse "always grounded" (no drag-to-aerial).
+// A press that never crosses DRAG_THRESH falls through to onCanvasClick as a normal select.
+let _ptr = null, _dragTurned = false;
+function onCanvasPointerDown(e) {
+  if (!exploreMode || controlsLocked) { _ptr = null; return; }
+  _ptr = { x0: e.clientX, y0: e.clientY, yaw0: spinYaw, moved: 0 };
+  _dragTurned = false;
+}
+function onCanvasPointerMove(e) {
+  if (!_ptr) return;
+  const dx = e.clientX - _ptr.x0;
+  _ptr.moved = Math.max(_ptr.moved, Math.abs(dx) + Math.abs(e.clientY - _ptr.y0));
+  if (_ptr.moved > DRAG_THRESH) {
+    _dragTurned = true;
+    spinYaw = _ptr.yaw0 + dx * DRAG_YAW_PER_PX;   // unclamped: free to look around the room (still grounded)
+    applySpinPose();
+  }
+}
+function onCanvasPointerUp() { _ptr = null; }
+
+// ============================================================
+// RETURN TO CENTRE — always-available reset to the boot pose
+// ============================================================
+// Glides the avatar back to the middle of the room, facing the bank, at eye level. Wired to
+// the ⊕ Center button, the 'C'/Home keys, and used as the deterministic boot pose.
+let recenterTween = null;
+function recenterView(animated) {
+  const sp = CONFIG.camera.startPos;
+  const toEye = new THREE.Vector3(sp.x, sp.y, sp.z);
+  if (!animated || !lockedSpinEye || !camera) {
+    lockedSpinEye = toEye.clone();
+    spinYaw = 0; spinPitch = BOOT_PITCH;
+    if (camera) camera.position.copy(lockedSpinEye);
+    applySpinPose();
+    if (typeof updateAvatarRig === 'function') updateAvatarRig();
+    recenterTween = null;
+    return;
+  }
+  recenterTween = {
+    fromEye: lockedSpinEye.clone(), toEye,
+    fromYaw: spinYaw, toYaw: 0,
+    fromPitch: spinPitch, toPitch: BOOT_PITCH,
+    t: 0, dur: 0.5
+  };
+  if (window._requestShadowUpdate) window._requestShadowUpdate(40);
+}
+
+// Public entry the button + keys call: if the user is focused on a wing, step out first
+// (that fly-back already re-centres the look); otherwise glide home.
+function requestRecenter() {
+  if (controlsLocked) { try { unfocusWing(); } catch (e) {} return; }
+  if (cameraAnim) return;
+  recenterView(true);
+}
+
+// Advance the recenter glide one frame; returns true while it owns the camera so animate()
+// skips updateWASD for that frame.
+function tickRecenter(dt) {
+  if (!recenterTween) return false;
+  recenterTween.t += dt / recenterTween.dur;
+  const e = recenterTween.t >= 1 ? 1 : (1 - Math.pow(1 - recenterTween.t, 3));  // cubic ease-out
+  lockedSpinEye.lerpVectors(recenterTween.fromEye, recenterTween.toEye, e);
+  spinYaw   = recenterTween.fromYaw   + (recenterTween.toYaw   - recenterTween.fromYaw)   * e;
+  spinPitch = recenterTween.fromPitch + (recenterTween.toPitch - recenterTween.fromPitch) * e;
+  if (camera) camera.position.copy(lockedSpinEye);
+  applySpinPose();
+  if (recenterTween.t >= 1) recenterTween = null;
+  return true;
+}
 
 function updateWASD(dt) {
   if (controlsLocked || cameraAnim) return; // no input when focused on a wing or animating
@@ -2860,18 +3002,7 @@ function updateWASD(dt) {
     // Turn the body first so a held forward walks along the NEW facing this frame.
     if (turnIn !== 0) { spinYaw += turnIn * SPIN_YAW_SPEED * dt; applySpinPose(); }
 
-    if (moveIn !== 0 && lockedSpinEye) {
-      // Forward = the look direction projected onto the floor (XZ). yaw=0 faces -Z.
-      const fx = Math.sin(spinYaw), fz = -Math.cos(spinYaw);
-      const step = moveIn * WALK_SPEED * dt;
-      let nx = lockedSpinEye.x + fx * step;
-      let nz = lockedSpinEye.z + fz * step;
-      nx = Math.max(-WALK_HALF, Math.min(WALK_HALF, nx));
-      nz = Math.max(-WALK_HALF, Math.min(WALK_HALF, nz));
-      lockedSpinEye.x = nx; lockedSpinEye.z = nz;
-      camera.position.x = nx; camera.position.z = nz;
-      applySpinPose();        // re-aim the look-target from the new eye position
-    }
+    if (moveIn !== 0) walkForward(moveIn * WALK_SPEED * dt);   // grounded, box-clamped
     return;
   }
 
@@ -3024,7 +3155,8 @@ function animate() {
     // or spins the look-target (guided); either way we then aim the camera directly at the
     // resolved look-target. We deliberately do NOT call controls.update() — OrbitControls
     // would re-derive the camera position around the target and fight the walk/spin pose.
-    updateWASD(dt);
+    // A return-to-centre glide, when active, owns the pose this frame (skip live input).
+    if (!tickRecenter(dt)) updateWASD(dt);
     const fixedEye = lockedSpinEye || camera.position;
     camera.position.copy(fixedEye);   // hard-pin the eye to the walked/centred point
     camera.lookAt(controls.target);
@@ -3184,17 +3316,20 @@ function setPeruseBtn(on) {
 // label/look, and the bottom instruction. OFF (default) = guided, no keyboard.
 function applyExploreMode() {
   if (controls && !controlsLocked) {
-    controls.enableRotate = exploreMode;
-    controls.enablePan = exploreMode;
-    controls.enableZoom = exploreMode;
+    // Single-ownership navigation (Steve 2026-06-30): the rig owns all movement in BOTH
+    // modes, so OrbitControls' self-updating rotate/pan/zoom stay OFF — no free orbit means
+    // no "too close" dolly, no aerial tilt, no swinging behind the furniture.
+    controls.enableRotate = false;
+    controls.enablePan = false;
+    controls.enableZoom = false;
   }
   const btn = document.getElementById('btn-explore');
   if (btn) {
     btn.classList.toggle('active', exploreMode);
     btn.textContent = exploreMode ? 'Explore: On' : 'Explore: Off';
     btn.title = exploreMode
-      ? 'Free walking is ON — use W A S D to walk and drag to look around. Tap to turn off.'
-      : 'Turn on free walking with the keyboard (W A S D). Off by default.';
+      ? 'Walking is ON — scroll or ↑↓ to walk, drag or A/D to turn, ⊕ Center to return. Tap to turn off.'
+      : 'Turn on walking — scroll or arrows to move around the room. Off by default.';
   }
   const bm = document.getElementById('btn-bookmatch');
   if (bm) bm.style.display = exploreMode ? '' : 'none';   // walk-up book-match is an explore-only affordance
@@ -3223,7 +3358,7 @@ function toggleExplore() {
     if (peruseActive) stopPeruse(true);
     if (focusedWing) unfocusWing();
     const t = document.getElementById('info-text');
-    if (t) t.textContent = 'Explore mode — W A S D to walk, drag to look around. Click a board to view it.';
+    if (t) t.textContent = 'Scroll or ↑↓ to walk · drag or A/D to turn · ⊕ Center to return · Click a board to view it.';
   } else {
     // Back to guided: refly to a board so the big buttons have something to drive.
     enterGuidedDefault();
@@ -3236,7 +3371,7 @@ function updateGuidedInstruction() {
   if (peruseActive) {
     el.innerHTML = 'Watching all designs&hellip; tap <strong>Pause</strong> to stop, or <strong>Next</strong> to step ahead.';
   } else if (exploreMode) {
-    el.innerHTML = 'Explore mode: use <strong>W A S D</strong> to walk, or use the big buttons below.';
+    el.innerHTML = '<strong>Scroll</strong> or <strong>&uarr;&darr;</strong> to walk &middot; <strong>drag</strong> to turn &middot; <strong>&#8853; Center</strong> to return.';
   } else {
     el.innerHTML = 'Tap <strong>Next</strong> to see the next design &mdash; or <strong>Auto Tour</strong> to watch them all.';
   }
@@ -3346,7 +3481,7 @@ function enterFixedCentre() {
   }
   if (!exploreMode) {
     camera.position.copy(lockedSpinEye);
-    spinYaw = 0; spinPitch = -0.08;
+    spinYaw = 0; spinPitch = BOOT_PITCH;
   }
   applySpinPose();
   if (camera.fov !== FOV_DEFAULT) { targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix(); }
@@ -3561,6 +3696,8 @@ function initHUD() {
   if (gNext) gNext.addEventListener('click', () => { stopPeruse(true); gotoBoardOffset(1); });
   if (gTour) gTour.addEventListener('click', () => togglePeruse());
   if (gGrid) gGrid.addEventListener('click', () => openAllDesignsGrid());
+  const gCenter = document.getElementById('g-center');
+  if (gCenter) gCenter.addEventListener('click', () => { stopPeruse(true); requestRecenter(); });
 
   const goClose = document.getElementById('go-close');
   if (goClose) goClose.addEventListener('click', () => closeAllDesignsGrid());
@@ -3855,6 +3992,11 @@ window._qh = {
   get restingOpenWing(){ return restingOpenWing; },
   get peruseActive(){ return peruseActive; },
   get exploreMode(){ return exploreMode; },
+  // NAVIGATION (Steve 2026-06-30) verification surface — recentre + the grounded walk box.
+  recenter(animated){ return recenterView(!!animated); },
+  walkForward(step){ return walkForward(step); },
+  get walkBox(){ return { minX: WALK_MIN_X, maxX: WALK_MAX_X, minZ: WALK_MIN_Z, maxZ: WALK_MAX_Z }; },
+  get recentering(){ return !!recenterTween; },
   // CENTER BOOK (Steve 2026-06-29) verification surface — proves the single mirrored book
   // is present, which catalog design it shows, and lets a test page through it.
   get bookMode()   { return BOOK_MODE; },
diff --git a/public/showroom.html b/public/showroom.html
index 689bbc7..b514df8 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -328,7 +328,7 @@
     </div>
 
     <div id="bottom-bar">
-      <span id="info-text">WASD to walk up to a wing &middot; within 4 ft it opens to the full continuous design &middot; Click to inspect &middot; P to Peruse</span>
+      <span id="info-text">Scroll or &uarr;&darr; to walk &middot; drag or A/D to turn &middot; &larr;&rarr; change design &middot; Click to inspect &middot; &#8853; Center to return</span>
       <span id="fps-counter">60 FPS</span>
     </div>
 
@@ -350,6 +350,7 @@
       <button class="g-btn" id="g-prev" title="See the previous design">&#9664; Previous</button>
       <button class="g-btn" id="g-tour" title="Watch every design hands-free">&#9655; Auto Tour</button>
       <button class="g-btn" id="g-next" title="See the next design">Next &#9654;</button>
+      <button class="g-btn" id="g-center" title="Stand back in the middle of the room, facing the wall (C)">&#8853; Center</button>
       <button class="g-btn" id="g-grid" title="See all designs as big pictures">&#9783; All Designs</button>
     </div>
   </div>

← b81c393 chore: vN minor bump (session close — Age View bands, walls-  ·  back to Quadrille Showroom  ·  chore: lint, refactor, v1.2.0 (session close) 4bb3eba →