← 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
M public/js/showroom.jsM public/showroom.html
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… 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>↑↓</strong> to walk · <strong>drag</strong> to turn · <strong>⊕ Center</strong> to return.';
} else {
el.innerHTML = 'Tap <strong>Next</strong> to see the next design — 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 · within 4 ft it opens to the full continuous design · Click to inspect · P to Peruse</span>
+ <span id="info-text">Scroll or ↑↓ to walk · drag or A/D to turn · ←→ change design · Click to inspect · ⊕ 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">◀ Previous</button>
<button class="g-btn" id="g-tour" title="Watch every design hands-free">▷ Auto Tour</button>
<button class="g-btn" id="g-next" title="See the next design">Next ▶</button>
+ <button class="g-btn" id="g-center" title="Stand back in the middle of the room, facing the wall (C)">⊕ Center</button>
<button class="g-btn" id="g-grid" title="See all designs as big pictures">☷ 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 →