editor: unify walkthrough + viewer UI, crouch, screenshot pause (#529)

* feat(walkthrough): unify first-person and baked walkthrough UI into a shared HUD

The builder first-person overlay (crosshair, Exit Street View button, hints
card) and the baked-GLB walkthrough HUD were two divergent UIs. Extract the
GLB-style HUD (reticle, floor/room labels, Esc pill, interact prompt) into a
shared WalkthroughHud in packages/editor, feed it from FirstPersonControls via
a small useFirstPersonHud store (interact target each frame, floor/zone labels
sampled from the camera), and align FOV/projection handling with the baked
controller. The now-unused WalkthroughControls glide controller is removed
from packages/viewer (WALKTHROUGH_FOV moves to the GLB controller module).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer-ui): shared ViewerControlsBar + ViewerSceneHeader for preview and embedders

Preview mode's ViewerOverlay was an older copy of the community viewer UI
(separate scan/guide/camera buttons, own render/theme/edges menus, 4-state
wall mode). Extract the community design into shared prop-driven components:
ViewerControlsBar (visibility, level/wall modes, display menu, walkthrough,
orbit/top view) and ViewerSceneHeader (back, project info, optional stats
slot, breadcrumb, levels card). ViewerOverlay is now a thin composition of
them. The display menu gains the edges submenu everywhere; the vestigial
translucent wall mode is dropped (a stale value renders as cutaway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(walkthrough): hold-Ctrl crouch + P screenshot pause in both controllers

Crouch swaps the capsule for a short one (shrinks around the centre, so a
mid-jump crouch lowers the head and raises the feet — enough to thread window
openings), lowers the eye with a short lerp, and slows movement; standing back
up is gated on headroom via an upward raycast against the collider world.
Tuning constants live in the GLB controller module and are shared with the
editor first-person controller.

P releases the pointer lock without leaving the walkthrough so the cursor is
free for an OS screenshot (macOS region capture needs a movable pointer);
clicking the canvas re-locks and resumes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(walkthrough): crouched profile fits ~1 m openings

The float gap counts toward the effective obstacle height — the capsule rides
floatHeight (0.5 m) above the ground, so the old crouch spanned 0.5–1.3 m and
a 1.14 m opening still blocked it. Crouching now also lowers the float gap
(0.25 m) and uses a shorter capsule (0.7 m), for an effective 0.25–0.95 m
span; the stand-up headroom check grows to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(walkthrough): seamless screenshot pause — auto-release pointer lock on ⌘

Replace the P shortcut: macOS swallows the full ⇧⌘4 but the ⌘-down keystroke
still reaches the page, so the moment ⌘ (or PrintScreen) goes down while
locked the cursor is released without leaving the walkthrough — the native
screenshot flow just works, no user education. The HUD pill flips to "Click
to resume" (click-through, so the resuming click lands on the canvas) via a
new walkthroughSuspended flag on the viewer store, reset on lock/exit/unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(walkthrough): screenshot pause is P again, advertised in the HUD

The ⌘ auto-release fired on every command combo and felt broken. Back to an
explicit P toggle, now discoverable: the HUD bottom shows a "P free cursor"
pill next to "Esc to exit", and while paused it flips to "Click or P to
resume · Esc to exit" (click-through so the resuming click lands on the
canvas). P re-locks as well as releasing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(walkthrough): freeze crouch during cursor pause; no editor hints in first person

While the P pause is active, Ctrl no longer toggles crouch — ⌃⇧⌘4 (clipboard
screenshot) was crouching the player mid-capture; the held state stays frozen
until resume. HelperManager now renders nothing in first-person mode, so the
Ctrl multi-select hint no longer pops over the walkthrough HUD (Ctrl is
crouch there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: biome formatting + pre-existing useOptionalChain fix in wall panel

The wall-panel lint error predates this branch (#526); fixed here to unblock
the quality gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-21 17:04:00 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent de13119f86
commit cb6fadbc28
16 changed files with 1284 additions and 931 deletions
@@ -923,7 +923,7 @@ export function GlbScene({
})
// E or click activates the openable in view. The click also re-locks the
// pointer via WalkthroughControls — harmless overlap; no selection happens.
// pointer through the walkthrough controller; no selection happens.
const activateWalkDoor = useCallback(() => {
if (walkDoorRef.current) toggleOpenable(walkDoorRef.current)
}, [toggleOpenable])
@@ -17,6 +17,7 @@ import {
type Object3D,
type PerspectiveCamera,
Quaternion,
Raycaster,
Vector3,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
@@ -25,7 +26,12 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
import { SCENE_LAYER } from '../../lib/layers'
import useViewer from '../../store/use-viewer'
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
import { WALKTHROUGH_FOV } from './walkthrough-controls'
// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels
// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral
// awareness without wide-angle distortion. Applied only while walking — both
// walkthrough controllers read this and restore the orbit FOV on exit.
export const WALKTHROUGH_FOV = 60
// Eye/capsule geometry mirrors the editor's first-person controller so the
// baked walkthrough feels identical. The capsule centre sits below the eye; the
@@ -37,6 +43,28 @@ const SPAWN_EYE_HEIGHT = 1.65
const LOOK_SENSITIVITY = 0.002
const VOID_FALL_RESPAWN_DEPTH = 12
// Crouch (hold Ctrl): swap to a short capsule — it shrinks around the centre,
// so a crouch mid-jump also lowers the head AND raises the feet, letting the
// player thread window openings. Standing back up is gated on headroom.
// The float gap counts toward the effective obstacle height (the capsule rides
// floatHeight above the ground), so crouching also lowers it: crouched span is
// CROUCH_FLOAT_HEIGHT + capsule = 0.25 + 0.7 = 0.95 m — fits a 1 m opening.
export const STAND_CAPSULE: [number, number, number, number] = [0.25, 0.8, 4, 8]
export const CROUCH_CAPSULE: [number, number, number, number] = [0.25, 0.2, 4, 8]
export const STAND_FLOAT_HEIGHT = 0.5
export const CROUCH_FLOAT_HEIGHT = 0.25
export const CROUCH_EYE_OFFSET = 0.1
export const CROUCH_WALK_SPEED = 1
export const CROUCH_RUN_SPEED = 1.4
// Headroom (from the capsule centre, upward) required before uncrouching:
// standing raises the centre by half the length delta plus the float delta,
// and the standing capsule top sits standLength/2 + radius above the centre.
export const STAND_CLEARANCE = 1.25
export const EYE_LERP_SPEED = 12
const standClearanceRaycaster = new Raycaster()
const UP = new Vector3(0, 1, 0)
// Kinds that must not block the player: room helpers, the spawn marker, the
// ceiling/roof shell (you walk under them), and door/window leaves — excluding
// the latter lets you pass any doorway whether the leaf is open or shut (the
@@ -54,7 +82,7 @@ const keyboardMap: Array<{ name: Exclude<keyof MovementInput, 'joystick'>; keys:
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
]
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraOffset = new Vector3()
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
const spawnQuat = new Quaternion()
const spawnEuler = new Euler(0, 0, 0, 'YXZ')
@@ -238,6 +266,10 @@ export function GlbWalkthroughController({ url }: { url: string }) {
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const crouchKeyRef = useRef(false)
const suspendRef = useRef(false)
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
const [crouched, setCrouched] = useState(false)
const [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
const [world, setWorld] = useState<GlbColliderWorld | null>(null)
@@ -333,20 +365,58 @@ export function GlbWalkthroughController({ url }: { url: string }) {
if (event.code === 'Escape' && document.pointerLockElement !== canvas) {
useViewer.getState().setWalkthroughMode(false)
}
// P toggles a cursor pause (advertised in the HUD): frees the pointer
// without leaving the walkthrough — e.g. for an OS screenshot, which
// needs a movable cursor — and click or P resumes.
if (event.code === 'KeyP') {
if (document.pointerLockElement === canvas) {
suspendRef.current = true
useViewer.getState().setWalkthroughSuspended(true)
document.exitPointerLock()
} else if (suspendRef.current) {
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
if (result && typeof result.catch === 'function') result.catch(() => {})
}
}
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard screenshot)
// must not toggle it under the user.
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
crouchKeyRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
crouchKeyRef.current = false
}
}
const onBlur = () => {
if (!suspendRef.current) crouchKeyRef.current = false
}
const onPointerLockChange = () => {
if (document.pointerLockElement === canvas) wasLocked = true
else if (wasLocked) useViewer.getState().setWalkthroughMode(false)
if (document.pointerLockElement === canvas) {
wasLocked = true
suspendRef.current = false
useViewer.getState().setWalkthroughSuspended(false)
} else if (suspendRef.current) {
// Deliberately released (screenshot pause) — stay in walkthrough.
} else if (wasLocked) {
useViewer.getState().setWalkthroughMode(false)
}
}
document.addEventListener('mousemove', onMouseMove)
canvas.addEventListener('click', onClick)
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
document.addEventListener('pointerlockchange', onPointerLockChange)
return () => {
document.removeEventListener('mousemove', onMouseMove)
canvas.removeEventListener('click', onClick)
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
document.removeEventListener('pointerlockchange', onPointerLockChange)
useViewer.getState().setWalkthroughSuspended(false)
if (document.pointerLockElement === canvas) document.exitPointerLock()
}
}, [gl])
@@ -367,8 +437,16 @@ export function GlbWalkthroughController({ url }: { url: string }) {
controllerRef.current = api
}, [])
const hasStandingClearance = useCallback((position: Vector3) => {
const mesh = worldRef.current?.mesh
if (!mesh) return true
standClearanceRaycaster.set(position, UP)
standClearanceRaycaster.far = STAND_CLEARANCE
return standClearanceRaycaster.intersectObject(mesh, false).length === 0
}, [])
// Drive the camera from the capsule each frame + respawn if it falls into void.
useFrame(() => {
useFrame((_, delta) => {
const group = controllerRef.current?.group
if (!group) return
@@ -377,8 +455,18 @@ export function GlbWalkthroughController({ url }: { url: string }) {
controllerRef.current?.resetLinVel()
}
// Crouch follows the held key; standing back up waits for headroom.
// Frozen while the cursor pause is active.
if (!suspendRef.current && crouchKeyRef.current !== crouched) {
if (crouchKeyRef.current) setCrouched(true)
else if (hasStandingClearance(group.position)) setCrouched(false)
}
const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
eyeOffsetRef.current +=
(targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
group.rotation.y = 0
camera.position.copy(group.position).add(cameraOffset)
camera.position.copy(group.position).add(cameraOffset.set(0, eyeOffsetRef.current, 0))
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true)
@@ -391,7 +479,7 @@ export function GlbWalkthroughController({ url }: { url: string }) {
<BVHEcctrl
acceleration={26}
airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
colliderMeshes={[world.mesh]}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
@@ -401,15 +489,15 @@ export function GlbWalkthroughController({ url }: { url: string }) {
fallGravityFactor={4}
floatCheckType="BOTH"
floatDampingC={36}
floatHeight={0.5}
floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
floatPullBackHeight={0.35}
floatSensorRadius={0.15}
floatSpringK={1200}
gravity={9.81}
jumpVel={5}
maxRunSpeed={5}
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
maxSlope={1.2}
maxWalkSpeed={2}
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
position={start.position}
ref={setControllerApi}
/>
@@ -1,156 +0,0 @@
'use client'
import { PointerLockControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { type PerspectiveCamera, Vector3 } from 'three'
import useViewer from '../../store/use-viewer'
const MOVE_SPEED = 5
const EYE_HEIGHT = 1.6
// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels
// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral
// awareness without wide-angle distortion. Applied only while walking — both
// walkthrough controllers read this and restore the orbit FOV on exit.
export const WALKTHROUGH_FOV = 60
const _direction = new Vector3()
const _forward = new Vector3()
const _right = new Vector3()
export const WalkthroughControls = () => {
const controlsRef = useRef<any>(null!)
const walkthroughMode = useViewer((s: any) => s.walkthroughMode)
const keys = useRef({ w: false, a: false, s: false, d: false })
const camera = useThree((s) => s.camera)
// Set initial eye height
useEffect(() => {
if (walkthroughMode) {
camera.position.y = EYE_HEIGHT
}
}, [walkthroughMode, camera])
// Widen FOV while walking; restore the orbit FOV on exit.
useEffect(() => {
if (!walkthroughMode) return
const cam = camera as PerspectiveCamera
if (!cam.isPerspectiveCamera) return
const prevFov = cam.fov
cam.fov = WALKTHROUGH_FOV
cam.updateProjectionMatrix()
return () => {
cam.fov = prevFov
cam.updateProjectionMatrix()
}
}, [walkthroughMode, camera])
// Keyboard handlers
useEffect(() => {
if (!walkthroughMode) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
const key = e.key.toLowerCase()
// ESC exits walkthrough mode completely
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
useViewer.getState().setWalkthroughMode(false)
return
}
if (key === 'w' || key === 'arrowup') keys.current.w = true
if (key === 'a' || key === 'arrowleft') keys.current.a = true
if (key === 's' || key === 'arrowdown') keys.current.s = true
if (key === 'd' || key === 'arrowright') keys.current.d = true
}
const onKeyUp = (e: KeyboardEvent) => {
const key = e.key.toLowerCase()
if (key === 'w' || key === 'arrowup') keys.current.w = false
if (key === 'a' || key === 'arrowleft') keys.current.a = false
if (key === 's' || key === 'arrowdown') keys.current.s = false
if (key === 'd' || key === 'arrowright') keys.current.d = false
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
// Reset keys on cleanup
keys.current = { w: false, a: false, s: false, d: false }
}
}, [walkthroughMode])
// Release pointer lock when walkthrough mode is turned off
useEffect(() => {
if (!walkthroughMode && document.pointerLockElement) {
document.exitPointerLock()
}
}, [walkthroughMode])
// Movement loop
useFrame((_, delta) => {
if (!(walkthroughMode && controlsRef.current)) return
_direction.set(0, 0, 0)
// Get camera forward and right vectors (XZ plane only)
camera.getWorldDirection(_forward)
_forward.y = 0
_forward.normalize()
_right.crossVectors(_forward, camera.up).normalize()
if (keys.current.w) _direction.add(_forward)
if (keys.current.s) _direction.sub(_forward)
if (keys.current.d) _direction.add(_right)
if (keys.current.a) _direction.sub(_right)
if (_direction.lengthSq() > 0) {
_direction.normalize().multiplyScalar(MOVE_SPEED * delta)
camera.position.add(_direction)
// Keep eye height constant
camera.position.y = EYE_HEIGHT
}
})
const handleClick = useCallback(() => {
if (walkthroughMode && controlsRef.current) {
// Feature detection: some browsers (Facebook/Instagram in-app, older Safari)
// don't support pointer lock on the canvas element
if (typeof controlsRef.current.lock === 'function') {
try {
controlsRef.current.lock()
} catch {
// Silently ignore — pointer lock unavailable in this browser context
}
}
}
}, [walkthroughMode])
// Click to lock
useEffect(() => {
if (!walkthroughMode) return
const canvas = document.querySelector('canvas')
if (!canvas) return
canvas.addEventListener('click', handleClick)
return () => canvas.removeEventListener('click', handleClick)
}, [walkthroughMode, handleClick])
if (!walkthroughMode) return null
// Skip PointerLockControls on browsers that don't support pointer lock
// (Facebook/Instagram in-app browsers, some iOS WebViews)
if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) {
return null
}
return <PointerLockControls ref={controlsRef} />
}
+13 -2
View File
@@ -34,14 +34,25 @@ export {
GlbScene,
type GlbWalkthrough,
} from './components/viewer/glb-scene'
export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller'
export {
CROUCH_CAPSULE,
CROUCH_EYE_OFFSET,
CROUCH_FLOAT_HEIGHT,
CROUCH_RUN_SPEED,
CROUCH_WALK_SPEED,
EYE_LERP_SPEED,
GlbWalkthroughController,
STAND_CAPSULE,
STAND_CLEARANCE,
STAND_FLOAT_HEIGHT,
WALKTHROUGH_FOV,
} from './components/viewer/glb-walkthrough-controller'
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export {
DEFAULT_HOVER_STYLES,
SSGI_PARAMS,
} from './components/viewer/post-processing'
export { SceneEnvironment } from './components/viewer/scene-environment'
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
export { useAssetUrl } from './hooks/use-asset-url'
export { useGLTFKTX2 } from './hooks/use-gltf-ktx2'
export { useNodeEvents } from './hooks/use-node-events'
+9 -1
View File
@@ -153,6 +153,11 @@ type ViewerState = {
walkthroughMode: boolean
setWalkthroughMode: (mode: boolean) => void
/** Pointer lock temporarily released mid-walkthrough (⌘/PrintScreen — OS
* screenshot needs a movable cursor); clicking the canvas re-locks. */
walkthroughSuspended: boolean
setWalkthroughSuspended: (suspended: boolean) => void
cameraDragging: boolean
setCameraDragging: (dragging: boolean) => void
@@ -515,7 +520,10 @@ const useViewer = create<ViewerState>()(
setDebugColors: (enabled) => set({ debugColors: enabled }),
walkthroughMode: false,
setWalkthroughMode: (mode) => set({ walkthroughMode: mode }),
setWalkthroughMode: (mode) => set({ walkthroughMode: mode, walkthroughSuspended: false }),
walkthroughSuspended: false,
setWalkthroughSuspended: (suspended) => set({ walkthroughSuspended: suspended }),
cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),