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
@@ -15,9 +15,11 @@ import {
getElevatorShaftDepth, getElevatorShaftDepth,
getElevatorShaftWallThickness, getElevatorShaftWallThickness,
getElevatorShaftWidth, getElevatorShaftWidth,
getLevelDisplayName,
getLevelElevations, getLevelElevations,
getResolvedElevatorDoorStyle, getResolvedElevatorDoorStyle,
openElevatorDoor, openElevatorDoor,
pointInPolygon2D,
requestElevatorLevel, requestElevatorLevel,
resolveElevatorBuildingLevels, resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget, resolveElevatorDispatchTarget,
@@ -26,7 +28,22 @@ import {
useInteractive, useInteractive,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import {
BVHEcctrl,
type BVHEcctrlApi,
CROUCH_CAPSULE,
CROUCH_EYE_OFFSET,
CROUCH_FLOAT_HEIGHT,
CROUCH_RUN_SPEED,
CROUCH_WALK_SPEED,
EYE_LERP_SPEED,
type MovementInput,
STAND_CAPSULE,
STAND_CLEARANCE,
STAND_FLOAT_HEIGHT,
useViewer,
WALKTHROUGH_FOV,
} from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei' import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -39,6 +56,7 @@ import {
Mesh, Mesh,
MeshBasicMaterial, MeshBasicMaterial,
type Object3D, type Object3D,
type PerspectiveCamera,
Ray, Ray,
Raycaster, Raycaster,
Vector2, Vector2,
@@ -46,19 +64,22 @@ import {
} from 'three' } from 'three'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import '../../three-types' import '../../three-types'
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
import { import {
closeDoorOpenState, closeDoorOpenState,
DOOR_SWING_OPEN_ANGLE, DOOR_SWING_OPEN_ANGLE,
getDisplayedDoorValue,
isOperationDoorType, isOperationDoorType,
toggleDoorOpenState, toggleDoorOpenState,
} from '../../lib/door-interaction' } from '../../lib/door-interaction'
import { import {
closeWindowOpenState, closeWindowOpenState,
getDisplayedWindowValue,
isOperableWindowType, isOperableWindowType,
toggleWindowOpenState, toggleWindowOpenState,
} from '../../lib/window-interaction' } from '../../lib/window-interaction'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
import { WalkthroughHud } from '../walkthrough-hud'
import { import {
buildFirstPersonColliderWorldFromRegistry, buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn, deriveFirstPersonSpawn,
@@ -78,6 +99,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const VOID_FALL_RESPAWN_DEPTH = 12 const VOID_FALL_RESPAWN_DEPTH = 12
const HUD_LABEL_SAMPLE_FRAMES = 10
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'> type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
@@ -120,8 +142,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
canvas.focus({ preventScroll: true }) canvas.focus({ preventScroll: true })
} }
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) const cameraOffset = new Vector3()
const cameraEuler = new Euler(0, 0, 0, 'YXZ') const cameraEuler = new Euler(0, 0, 0, 'YXZ')
const standClearanceRaycaster = new Raycaster()
const standClearanceUp = new Vector3(0, 1, 0)
const centerScreenPoint = new Vector2(0, 0) const centerScreenPoint = new Vector2(0, 0)
const doorInteractionRaycaster = new Raycaster() const doorInteractionRaycaster = new Raycaster()
const doorLeafBox = new Box3() const doorLeafBox = new Box3()
@@ -146,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
const spawnWorldPosition = new Vector3() const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const windowInteractionRaycaster = new Raycaster() const windowInteractionRaycaster = new Raycaster()
const hudBuildingLocalEyePosition = new Vector3()
const hudWorldEyePosition = new Vector3()
const hudLevelBounds = new Box3()
type ElevatorColliderKind = type ElevatorColliderKind =
| 'cab-back' | 'cab-back'
@@ -202,6 +229,128 @@ type ElevatorButtonTarget = {
levelId?: AnyNodeId levelId?: AnyNodeId
} }
function getLevelChildren(
level: Extract<AnyNode, { type: 'level' }>,
nodes: Record<string, AnyNode>,
) {
const childIds = new Set<string>(level.children)
return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id))
}
function pointIsInLevelFootprint(
point: [number, number],
worldPoint: Vector3,
level: Extract<AnyNode, { type: 'level' }>,
nodes: Record<string, AnyNode>,
) {
const children = getLevelChildren(level, nodes)
const slabs = children.filter(
(node): node is Extract<AnyNode, { type: 'slab' }> =>
node.type === 'slab' && node.polygon.length >= 3,
)
const zones = children.filter(
(node): node is Extract<AnyNode, { type: 'zone' }> =>
node.type === 'zone' && node.polygon.length >= 3,
)
if (slabs.length > 0) {
return slabs.some(
(slab) =>
pointInPolygon2D(point, slab.polygon) &&
!slab.holes.some((hole) => pointInPolygon2D(point, hole)),
)
}
if (zones.length > 0) {
if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true
}
const levelObject = sceneRegistry.nodes.get(level.id)
if (!levelObject) return false
hudLevelBounds.setFromObject(levelObject)
return (
!hudLevelBounds.isEmpty() &&
worldPoint.x >= hudLevelBounds.min.x &&
worldPoint.x <= hudLevelBounds.max.x &&
worldPoint.z >= hudLevelBounds.min.z &&
worldPoint.z <= hudLevelBounds.max.z
)
}
function resolveFirstPersonHudLabels(worldPoint: Vector3) {
const nodes = useScene.getState().nodes
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
for (const building of Object.values(nodes)) {
if (building.type !== 'building') continue
const buildingObject = sceneRegistry.nodes.get(building.id)
if (!buildingObject) continue
buildingObject.updateWorldMatrix(true, true)
hudBuildingLocalEyePosition.copy(worldPoint)
buildingObject.worldToLocal(hudBuildingLocalEyePosition)
const levels = Object.values(nodes)
.filter((node) => node.type === 'level')
.filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
.sort(
(left, right) =>
(levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0),
)
let activeLevel: (typeof levels)[number] | null = null
for (const level of levels) {
const elevation = levelElevations.get(level.id)
if (!elevation) continue
if (
hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5
) {
activeLevel = level
}
}
if (!activeLevel) continue
const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z]
if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue
const zone = getLevelChildren(activeLevel, nodes).find(
(node) =>
node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon),
)
return {
floorLabel: getLevelDisplayName(activeLevel),
zoneLabel: zone?.type === 'zone' ? zone.name : null,
}
}
return { floorLabel: null, zoneLabel: null }
}
function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract {
if (!target) return null
if (target.type === 'elevator') {
return {
label: target.action === 'open-door' ? 'door button' : 'elevator button',
verb: 'press',
}
}
const node = useScene.getState().nodes[target.id]
if (target.type === 'window') {
if (node?.type !== 'window') return null
const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0
return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' }
}
if (node?.type !== 'door') return null
const isOpen = isOperationDoorType(node.doorType)
? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
: getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0
return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
}
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null { function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
let current: Object3D | null = object let current: Object3D | null = object
@@ -553,6 +702,11 @@ export const FirstPersonControls = () => {
const yawRef = useRef(0) const yawRef = useRef(0)
const pitchRef = useRef(0) const pitchRef = useRef(0)
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null) const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
const crouchKeyRef = useRef(false)
const suspendRef = useRef(false)
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
const [crouched, setCrouched] = useState(false)
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
const ridingElevatorRef = useRef<{ const ridingElevatorRef = useRef<{
elevatorId: AnyNodeId elevatorId: AnyNodeId
@@ -569,6 +723,35 @@ export const FirstPersonControls = () => {
yaw: number yaw: number
} | null>(null) } | null>(null)
useEffect(() => {
const previousCameraMode = useViewer.getState().cameraMode
if (previousCameraMode === 'orthographic') {
useViewer.getState().setCameraMode('perspective')
}
return () => {
if (previousCameraMode === 'orthographic') {
useViewer.getState().setCameraMode('orthographic')
}
}
}, [])
useEffect(() => {
const perspectiveCamera = camera as PerspectiveCamera
if (!perspectiveCamera.isPerspectiveCamera) return
const previousFov = perspectiveCamera.fov
perspectiveCamera.fov = WALKTHROUGH_FOV
perspectiveCamera.updateProjectionMatrix()
return () => {
perspectiveCamera.fov = previousFov
perspectiveCamera.updateProjectionMatrix()
}
}, [camera])
useEffect(() => {
useFirstPersonHud.getState().reset()
return () => useFirstPersonHud.getState().reset()
}, [])
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
worldRef.current?.dispose() worldRef.current?.dispose()
worldRef.current = nextWorld worldRef.current = nextWorld
@@ -979,9 +1162,15 @@ export const FirstPersonControls = () => {
const isLocked = document.pointerLockElement === canvas const isLocked = document.pointerLockElement === canvas
if (isLocked) { if (isLocked) {
hadPointerLockRef.current = true hadPointerLockRef.current = true
suspendRef.current = false
useViewer.getState().setWalkthroughSuspended(false)
return return
} }
// Deliberately released (screenshot pause) — stay in first person;
// clicking the canvas re-locks.
if (suspendRef.current) return
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
useEditor.getState().setFirstPersonMode(false) useEditor.getState().setFirstPersonMode(false)
} }
@@ -998,6 +1187,7 @@ export const FirstPersonControls = () => {
document.removeEventListener('click', handleClick) document.removeEventListener('click', handleClick)
document.removeEventListener('mousedown', handleMouseDown, true) document.removeEventListener('mousedown', handleMouseDown, true)
document.removeEventListener('pointerlockchange', handlePointerLockChange) document.removeEventListener('pointerlockchange', handlePointerLockChange)
useViewer.getState().setWalkthroughSuspended(false)
if (document.pointerLockElement === canvas) { if (document.pointerLockElement === canvas) {
document.exitPointerLock() document.exitPointerLock()
} }
@@ -1029,7 +1219,11 @@ export const FirstPersonControls = () => {
return return
} }
if (event.code === 'Escape') { if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
// screenshot) must not toggle it under the user.
if (!suspendRef.current) crouchKeyRef.current = true
} else if (event.code === 'Escape') {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (document.pointerLockElement === canvas) { if (document.pointerLockElement === canvas) {
@@ -1044,18 +1238,41 @@ export const FirstPersonControls = () => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
closeInteractableTarget() closeInteractableTarget()
} else if (event.code === 'KeyP') {
// P toggles a cursor pause (advertised in the HUD): frees the pointer
// without leaving first person — e.g. for an OS screenshot, which
// needs a movable cursor — and click or P resumes.
event.preventDefault()
event.stopPropagation()
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(() => {})
}
} }
} }
const handleKeyUp = (event: KeyboardEvent) => { const handleKeyUp = (event: KeyboardEvent) => {
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
crouchKeyRef.current = false
}
applyMovementKey(event, false) applyMovementKey(event, false)
} }
const handleBlur = () => {
if (!suspendRef.current) crouchKeyRef.current = false
}
document.addEventListener('keydown', handleKeyDown, true) document.addEventListener('keydown', handleKeyDown, true)
document.addEventListener('keyup', handleKeyUp, true) document.addEventListener('keyup', handleKeyUp, true)
window.addEventListener('blur', handleBlur)
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('keyup', handleKeyUp, true) document.removeEventListener('keyup', handleKeyUp, true)
window.removeEventListener('blur', handleBlur)
} }
}, [closeInteractableTarget, gl, toggleInteractableTarget]) }, [closeInteractableTarget, gl, toggleInteractableTarget])
@@ -1281,11 +1498,33 @@ export const FirstPersonControls = () => {
[camera, setElevatorRideLocked], [camera, setElevatorRideLocked],
) )
useFrame(() => { const hasStandingClearance = useCallback((position: Vector3) => {
standClearanceRaycaster.set(position, standClearanceUp)
standClearanceRaycaster.far = STAND_CLEARANCE
const meshes: Mesh[] = []
if (worldRef.current) meshes.push(worldRef.current.mesh)
for (const mesh of elevatorColliderMeshesRef.current) {
if (mesh.visible) meshes.push(mesh)
}
return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
}, [])
useFrame((_, delta) => {
if (!controllerRef.current?.group) return if (!controllerRef.current?.group) return
const group = controllerRef.current.group const group = controllerRef.current.group
// 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)
cameraOffset.set(0, eyeOffsetRef.current, 0)
// The site ground collider is effectively unbounded, but scenes without a // The site ground collider is effectively unbounded, but scenes without a
// site node only have finite fallback floors — if the controller still ends // site node only have finite fallback floors — if the controller still ends
// up below every collider it can never land, so put it back at the spawn. // up below every collider it can never land, so put it back at the spawn.
@@ -1326,6 +1565,17 @@ export const FirstPersonControls = () => {
interactableTargetRef.current = nextInteractableTarget interactableTargetRef.current = nextInteractableTarget
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null) useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
} }
useFirstPersonHud.getState().setHud({
interact: resolveHudInteract(nextInteractableTarget),
})
hudLabelFrameRef.current += 1
if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
hudLabelFrameRef.current = 0
camera.getWorldPosition(hudWorldEyePosition)
useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition))
}
}, 2.5) }, 2.5)
useEffect(() => { useEffect(() => {
@@ -1352,7 +1602,7 @@ export const FirstPersonControls = () => {
<BVHEcctrl <BVHEcctrl
acceleration={26} acceleration={26}
airDragFactor={0.3} airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]} colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
colliderMeshes={firstPersonColliderMeshes} colliderMeshes={firstPersonColliderMeshes}
collisionCheckIteration={3} collisionCheckIteration={3}
collisionPushBackDamping={0.1} collisionPushBackDamping={0.1}
@@ -1363,16 +1613,16 @@ export const FirstPersonControls = () => {
fallGravityFactor={4} fallGravityFactor={4}
floatCheckType="BOTH" floatCheckType="BOTH"
floatDampingC={36} floatDampingC={36}
floatHeight={0.5} floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
floatPullBackHeight={0.35} floatPullBackHeight={0.35}
floatSensorRadius={0.15} floatSensorRadius={0.15}
floatSpringK={1200} floatSpringK={1200}
gravity={9.81} gravity={9.81}
jumpVel={5} jumpVel={5}
key="first-person-controller" key="first-person-controller"
maxRunSpeed={5} maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
maxSlope={1.2} maxSlope={1.2}
maxWalkSpeed={2} maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
paused={isElevatorRideLocked} paused={isElevatorRideLocked}
position={controllerStart.position} position={controllerStart.position}
ref={setControllerApi} ref={setControllerApi}
@@ -1383,27 +1633,14 @@ export const FirstPersonControls = () => {
) )
} }
/**
* Overlay UI for first-person mode: crosshair, controls hint, exit button.
* Rendered as a regular DOM overlay (not inside the Canvas).
*/
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
const [isLocked, setIsLocked] = useState(false)
const hasPlacedSpawn = useScene((state) => const hasPlacedSpawn = useScene((state) =>
Object.values(state.nodes).some((node) => node.type === 'spawn'), Object.values(state.nodes).some((node) => node.type === 'spawn'),
) )
const floorLabel = useFirstPersonHud((state) => state.floorLabel)
useEffect(() => { const zoneLabel = useFirstPersonHud((state) => state.zoneLabel)
const handlePointerLockChange = () => { const interact = useFirstPersonHud((state) => state.interact)
setIsLocked(document.pointerLockElement != null) const suspended = useViewer((state) => state.walkthroughSuspended)
}
handlePointerLockChange()
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
}
}, [])
const handleExit = useCallback(() => { const handleExit = useCallback(() => {
if (document.pointerLockElement) { if (document.pointerLockElement) {
@@ -1413,86 +1650,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
}, [onExit]) }, [onExit])
return ( return (
<> <WalkthroughHud
{isLocked && ( floorLabel={floorLabel}
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center"> interact={interact}
<div className="relative h-7 w-7"> onExit={handleExit}
<div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" /> suspended={suspended}
<div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" /> zoneLabel={zoneLabel}
</div> >
</div>
)}
<div className="absolute top-4 right-4 z-50">
<button
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
onClick={handleExit}
type="button"
>
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
ESC
</kbd>
Exit Street View
</button>
</div>
{!hasPlacedSpawn && ( {!hasPlacedSpawn && (
<div className="absolute top-4 left-1/2 z-50 -translate-x-1/2"> <div className="corner-smooth rounded-full border border-border/40 bg-background/80 px-3 py-1 text-center text-muted-foreground text-xs shadow-elevation-3 backdrop-blur-xl">
<div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl"> Place a spawn point from the Build tab to control where walkthrough starts.
Place a Spawn Point from the Build tab to control where walkthrough starts.
</div>
</div> </div>
)} )}
</WalkthroughHud>
{isLocked && (
<div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<div className="h-px w-full bg-border/30" />
<InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint keyLabel="T" label="Close" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">
Click to look around
</span>
</div>
</div>
)}
</>
)
}
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
return (
<div className="flex flex-col items-center gap-1.5 text-center">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
{label}
</span>
<div className="flex flex-wrap items-center justify-center gap-1">
{keys.map((key) => (
<kbd
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
key={key}
>
{key}
</kbd>
))}
</div>
</div>
)
}
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
{label}
</span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
{keyLabel}
</kbd>
</div>
) )
} }
@@ -95,6 +95,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
export function HelperManager() { export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind) const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind)
const workspaceMode = useEditor((s) => s.workspaceMode) const workspaceMode = useEditor((s) => s.workspaceMode)
const scope = useInteractionScope((s) => s.scope) const scope = useInteractionScope((s) => s.scope)
@@ -148,6 +149,10 @@ export function HelperManager() {
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null if (isMobile) return null
// First-person walkthrough has its own HUD; editor shortcut hints (e.g. the
// Ctrl multi-select hint — Ctrl is crouch there) don't apply while walking.
if (isFirstPersonMode) return null
// The studio workspace (compose panel / gallery) has no scene selection or // The studio workspace (compose panel / gallery) has no scene selection or
// tools — editor shortcut hints would only mislead there. // tools — editor shortcut hints would only mislead there.
if (workspaceMode === 'studio') return null if (workspaceMode === 'studio') return null
+15 -654
View File
@@ -1,51 +1,9 @@
'use client' 'use client'
import { Icon } from '@iconify/react'
import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
emitter,
getLevelDisplayName,
type LevelNode,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import {
CLAY_PALETTE,
type EdgeMode,
getSceneTheme,
SCENE_THEMES,
useViewer,
} from '@pascal-app/viewer'
import {
ArrowLeft,
Box,
Camera,
Check,
ChevronRight,
Diamond,
Footprints,
Layers,
Palette,
PenLine,
Sparkles,
Square,
} from 'lucide-react'
import Link from 'next/link'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '../lib/utils'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
import { ActionButton } from './ui/action-menu/action-button' import { ViewerControlsBar } from './viewer/viewer-controls-bar'
import { import { ViewerSceneHeader } from './viewer/viewer-scene-header'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/primitives/dropdown-menu'
import { TooltipProvider } from './ui/primitives/tooltip'
type ProjectOwner = { type ProjectOwner = {
id: string id: string
@@ -72,218 +30,6 @@ function requestWalkthroughPointerLock() {
} }
} }
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
solo: 'Solo',
}
const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
manual: 'Stack',
stacked: 'Stack',
exploded: 'Exploded',
solo: 'Solo',
}
const wallModeConfig = {
up: {
icon: (props: any) => (
<img alt="Full Height" height={28} src="/icons/room.webp" width={28} {...props} />
),
label: 'Full Height',
},
cutaway: {
icon: (props: any) => (
<img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
),
label: 'Cutaway',
},
down: {
icon: (props: any) => (
<img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
),
label: 'Low',
},
translucent: {
icon: (props: any) => (
<img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
),
label: 'Translucent',
},
}
const SHADING_OPTIONS = [
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
] as const
const TEXTURE_OPTIONS = [
{ id: true, name: 'Colored', detail: 'Show materials, textures & colors', icon: Palette },
{ id: false, name: 'Monochrome', detail: 'Flat clay surfaces by role', icon: Square },
] as const
function RenderModeMenu() {
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
const ActiveIcon = active.icon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
label={`Render: ${active.name}`}
size="icon"
tooltipSide="top"
variant="ghost"
>
<ActiveIcon className="h-6 w-6" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-56" side="top">
{SHADING_OPTIONS.map((option) => {
const OptionIcon = option.icon
return (
<DropdownMenuItem
key={option.id}
onSelect={() => useViewer.getState().setShading(option.id)}
>
<OptionIcon />
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
{TEXTURE_OPTIONS.map((option) => {
const OptionIcon = option.icon
return (
<DropdownMenuItem
key={option.name}
onSelect={() => useViewer.getState().setTextures(option.id)}
>
<OptionIcon />
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{textures === option.id ? <Check className="ml-auto text-foreground" /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
function SceneThemeMenu() {
const sceneTheme = useViewer((s) => s.sceneTheme)
const active = getSceneTheme(sceneTheme)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
label={`Theme: ${active.name}`}
size="icon"
tooltipSide="top"
variant="ghost"
>
<Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-48" side="top">
{SCENE_THEMES.map((sceneThemeOption) => {
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
(role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
)
return (
<DropdownMenuItem
key={sceneThemeOption.id}
onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
>
<span
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
style={{ backgroundColor: sceneThemeOption.background }}
>
{swatches.map((color, index) => (
<span
key={`${sceneThemeOption.id}-${index}`}
style={{ backgroundColor: color }}
/>
))}
</span>
<span className="text-foreground">{sceneThemeOption.name}</span>
{sceneTheme === sceneThemeOption.id ? (
<Check className="ml-auto text-foreground" />
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
const EDGE_OPTIONS = [
{ id: 'off', name: 'Off', detail: 'No edge lines' },
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
function EdgesMenu() {
const edges = useViewer((s) => s.edges)
const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton
className={
edges === 'off'
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
: 'bg-white/10 text-foreground'
}
label={`Edges: ${active.name}`}
size="icon"
tooltipSide="top"
variant="ghost"
>
<PenLine className="h-6 w-6" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-56" side="top">
{EDGE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
onSelect={() => useViewer.getState().setEdges(option.id)}
>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name
if (node.type === 'wall') return 'Wall'
if (node.type === 'fence') return 'Fence'
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
if (node.type === 'slab') return 'Slab'
if (node.type === 'ceiling') return 'Ceiling'
if (node.type === 'roof') return 'Roof'
if (node.type === 'roof-segment') return 'Roof Segment'
return node.type
}
interface ViewerOverlayProps { interface ViewerOverlayProps {
projectName?: string | null projectName?: string | null
owner?: ProjectOwner | null owner?: ProjectOwner | null
@@ -298,401 +44,16 @@ export const ViewerOverlay = ({
canShowScans = true, canShowScans = true,
canShowGuides = true, canShowGuides = true,
onBack, onBack,
}: ViewerOverlayProps) => { }: ViewerOverlayProps) => (
const selection = useViewer((s) => s.selection) <>
const showScans = useViewer((s) => s.showScans) <ViewerSceneHeader onBack={onBack} owner={owner} projectName={projectName} />
const showGuides = useViewer((s) => s.showGuides) <ViewerControlsBar
const cameraMode = useViewer((s) => s.cameraMode) canShowGuides={canShowGuides}
const levelMode = useViewer((s) => s.levelMode) canShowScans={canShowScans}
const wallMode = useViewer((s) => s.wallMode) onWalkthroughToggle={() => {
flushSync(() => useEditor.getState().setFirstPersonMode(true))
// Subscribe only to the specific nodes we read so that creating an unrelated requestWalkthroughPointerLock()
// node elsewhere in the scene doesn't re-render this overlay. }}
const firstSelectedId = selection.selectedIds[0] ?? null />
const building = useScene((s) => </>
selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null, )
)
const level = useScene((s) =>
selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
)
const zone = useScene((s) =>
selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
)
const selectedNode = useScene((s) =>
firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
)
const levels = useScene(
useShallow((s) => {
if (!building) return []
return building.children
.map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
.filter((n): n is LevelNode => n?.type === 'level')
.sort((a, b) => a.level - b.level)
}),
)
const handleLevelClick = (levelId: LevelNode['id']) => {
// When switching levels, deselect zone and items
useViewer.getState().setSelection({ levelId })
}
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {
switch (depth) {
case 'root':
useViewer.getState().resetSelection()
break
case 'building':
useViewer.getState().setSelection({ levelId: null })
break
case 'level':
useViewer.getState().setSelection({ zoneId: null })
break
}
}
return (
<>
{/* Unified top-left card */}
<div className="dark absolute top-4 left-4 z-20 flex flex-col gap-3 text-foreground">
<div className="pointer-events-auto flex min-w-[200px] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
{/* Project info + back */}
<div className="flex items-center gap-3 px-3 py-2.5">
{onBack ? (
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
onClick={onBack}
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</button>
) : (
<Link
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
href="/"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</Link>
)}
<div className="min-w-0">
<div className="truncate font-medium text-foreground text-sm">
{projectName || 'Untitled'}
</div>
{owner?.username && (
<Link
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
href={`/u/${owner.username}`}
>
@{owner.username}
</Link>
)}
</div>
</div>
{/* Breadcrumb — only shown when navigated into a building */}
{building && (
<div className="border-border/40 border-t px-3 py-2">
<div className="flex items-center gap-1.5 text-xs">
<button
className="text-muted-foreground transition-colors hover:text-foreground"
onClick={() => handleBreadcrumbClick('root')}
>
Site
</button>
{building && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<button
className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
onClick={() => handleBreadcrumbClick('building')}
>
{building.name || 'Building'}
</button>
</>
)}
{level && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<button
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
onClick={() => handleBreadcrumbClick('level')}
>
{getLevelDisplayName(level)}
</button>
</>
)}
{zone && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span
className={`truncate transition-colors ${selectedNode ? 'text-muted-foreground' : 'font-medium text-foreground'}`}
>
{zone.name}
</span>
</>
)}
{selectedNode && zone && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate font-medium text-foreground">
{getNodeName(selectedNode)}
</span>
</>
)}
</div>
</div>
)}
</div>
{/* Level List (only when building is selected) */}
{building && levels.length > 0 && (
<div className="pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
<span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Levels
</span>
<div className="flex flex-col">
{levels.map((lvl) => {
const isSelected = lvl.id === selection.levelId
return (
<button
className={cn(
'group/row relative flex h-8 w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 text-sm transition-all duration-200',
isSelected
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
)}
key={lvl.id}
onClick={() => handleLevelClick(lvl.id)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
!isSelected && 'opacity-60 grayscale',
)}
>
<Layers className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1 truncate text-left">
{lvl.name || `Level ${lvl.level}`}
</div>
</div>
</button>
)
})}
</div>
</div>
)}
</div>
{/* Controls Panel - Bottom Center */}
<div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
<TooltipProvider delayDuration={0}>
<div className="pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
{/* Scans and Guides Visibility */}
{canShowScans && (
<ActionButton
className={
showScans
? 'bg-white/10'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
}
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
onClick={() => useViewer.getState().setShowScans(!showScans)}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Scans"
className="h-[28px] w-[28px] object-contain"
src="/icons/mesh.webp"
/>
</ActionButton>
)}
{canShowGuides && (
<ActionButton
className={
showGuides
? 'bg-white/10'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
}
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Guides"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.webp"
/>
</ActionButton>
)}
{(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
{/* Camera Mode */}
<ActionButton
className={
cameraMode === 'orthographic'
? 'bg-violet-500/20 text-violet-400'
: 'hover:bg-white/5 hover:text-violet-400'
}
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
onClick={() =>
useViewer
.getState()
.setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
}
size="icon"
tooltipSide="top"
variant="ghost"
>
<Camera className="h-6 w-6" />
</ActionButton>
<RenderModeMenu />
<SceneThemeMenu />
<EdgesMenu />
{/* Level Mode */}
<ActionButton
className={cn(
'p-0',
levelMode === 'stacked' || levelMode === 'manual'
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
: 'bg-white/10 text-foreground',
)}
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
onClick={() => {
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
}}
size="icon"
tooltipSide="top"
variant="ghost"
>
<span className="relative flex h-full w-full items-center justify-center pb-1">
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
{levelMode === 'exploded' && (
<Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
)}
{(levelMode === 'stacked' || levelMode === 'manual') && (
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
)}
<span
aria-hidden="true"
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
>
{levelModeBadgeLabels[levelMode]}
</span>
</span>
</ActionButton>
{/* Wall Mode */}
<ActionButton
className={
wallMode !== 'cutaway'
? 'bg-white/10'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
}
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
onClick={() => {
const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [
'cutaway',
'up',
'down',
'translucent',
]
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
}}
size="icon"
tooltipSide="top"
variant="ghost"
>
{(() => {
const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
return <Icon className="h-[28px] w-[28px]" />
})()}
</ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Camera Actions */}
<ActionButton
className="group hidden hover:bg-white/5 sm:inline-flex"
label="Orbit Left"
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Orbit Left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/rotate.webp"
/>
</ActionButton>
<ActionButton
className="group hidden hover:bg-white/5 sm:inline-flex"
label="Orbit Right"
onClick={() => emitter.emit('camera-controls:orbit-cw')}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Orbit Right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/rotate.webp"
/>
</ActionButton>
<ActionButton
className="group hover:bg-white/5"
label="Top View"
onClick={() => emitter.emit('camera-controls:top-view')}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Top View"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/topview.webp"
/>
</ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* First-person walkthrough */}
<ActionButton
className="hover:bg-white/5 hover:text-emerald-400"
label="Walkthrough"
onClick={() => {
flushSync(() => useEditor.getState().setFirstPersonMode(true))
requestWalkthroughPointerLock()
}}
size="icon"
tooltipSide="top"
variant="ghost"
>
<Footprints className="h-6 w-6" />
</ActionButton>
</div>
</TooltipProvider>
</div>
</>
)
}
@@ -0,0 +1,466 @@
'use client'
import { emitter } from '@pascal-app/core'
import {
CLAY_PALETTE,
type EdgeMode,
getSceneTheme,
SCENE_THEMES,
useViewer,
} from '@pascal-app/viewer'
import {
Box,
Camera,
Check,
Contrast,
Diamond,
Eye,
EyeOff,
Footprints,
Layers,
Layers2,
Palette,
PenLine,
SlidersHorizontal,
Sparkles,
Square,
SwatchBook,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { ActionButton } from '../ui/action-menu/action-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../ui/primitives/dropdown-menu'
import { TooltipProvider } from '../ui/primitives/tooltip'
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
solo: 'Solo',
}
const wallModeConfig = {
up: {
icon: (props: any) => (
<img alt="Full height" height={28} src="/icons/room.webp" width={28} {...props} />
),
label: 'Full height',
},
cutaway: {
icon: (props: any) => (
<img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
),
label: 'Cutaway',
},
down: {
icon: (props: any) => (
<img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
),
label: 'Low',
},
}
const SHADING_OPTIONS = [
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
] as const
const EDGE_OPTIONS = [
{ id: 'off', name: 'Off', detail: 'No edge lines' },
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
// Keep the dropdown open when flipping an in-place toggle row.
const keepOpen = (event: Event, fn: () => void) => {
event.preventDefault()
fn()
}
// Scans + guides folded into one control. A baked GLB carries none of its own,
// but the GLB viewer re-adds them from scene data when the privacy flags allow,
// so the toggle shows for whichever exist.
function VisibilityMenu({
canShowScans,
canShowGuides,
}: {
canShowScans: boolean
canShowGuides: boolean
}) {
const showScans = useViewer((s) => s.showScans)
const showGuides = useViewer((s) => s.showGuides)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton
className="hover:bg-white/5 hover:text-foreground"
label="Visibility"
size="icon"
tooltipSide="top"
variant="ghost"
>
<Eye className="h-5 w-5" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-44" side="top">
{canShowScans && (
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => useViewer.getState().setShowScans(!showScans))}
>
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.webp" />
<span>Scans</span>
{showScans ? (
<Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
)}
{canShowGuides && (
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => useViewer.getState().setShowGuides(!showGuides))}
>
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.webp" />
<span>Guides</span>
{showGuides ? (
<Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
// One "Display" button gathering shadows, camera projection, colors, render
// mode, scene theme and edges.
function DisplayMenu() {
const cameraMode = useViewer((s) => s.cameraMode)
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const shadows = useViewer((s) => s.shadows)
const sceneTheme = useViewer((s) => s.sceneTheme)
const edges = useViewer((s) => s.edges)
const activeShading = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
const activeTheme = getSceneTheme(sceneTheme)
const activeEdges = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton
className="hover:bg-white/5 hover:text-foreground"
label="Display settings"
size="icon"
tooltipSide="top"
variant="ghost"
>
<SlidersHorizontal className="h-5 w-5" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-56" side="top">
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => useViewer.getState().setShadows(!shadows))}
>
<Contrast className="h-4 w-4" />
<span>Shadows</span>
<span className="ml-auto text-muted-foreground text-xs">{shadows ? 'On' : 'Off'}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) =>
keepOpen(e, () =>
useViewer
.getState()
.setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'),
)
}
>
<Camera className="h-4 w-4" />
<span>Camera</span>
<span className="ml-auto text-muted-foreground text-xs">
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => useViewer.getState().setTextures(!textures))}
>
{textures ? <Palette className="h-4 w-4" /> : <Square className="h-4 w-4" />}
<span>Colors</span>
<span className="ml-auto text-muted-foreground text-xs">
{textures ? 'Colored' : 'Monochrome'}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<activeShading.icon className="h-4 w-4" />
<span>Render</span>
<span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-56">
{SHADING_OPTIONS.map((option) => {
const OptionIcon = option.icon
return (
<DropdownMenuItem
key={option.id}
onSelect={() => useViewer.getState().setShading(option.id)}
>
<OptionIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{shading === option.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SwatchBook className="h-4 w-4" />
<span>Theme</span>
<span className="ml-auto truncate text-muted-foreground text-xs">
{activeTheme.name}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-48">
{SCENE_THEMES.map((t) => {
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
(role) => t.clayTints?.[role] ?? CLAY_PALETTE[role],
)
return (
<DropdownMenuItem
className="gap-2"
key={t.id}
onSelect={() => useViewer.getState().setSceneTheme(t.id)}
>
<span
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
style={{ backgroundColor: t.background }}
>
{swatches.map((color, index) => (
<span key={`${t.id}-${index}`} style={{ backgroundColor: color }} />
))}
</span>
<span>{t.name}</span>
{sceneTheme === t.id ? <Check className="ml-auto h-4 w-4" /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PenLine className="h-4 w-4" />
<span>Edges</span>
<span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-56">
{EDGE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
onSelect={() => useViewer.getState().setEdges(option.id)}
>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{edges === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
export type ViewerControlsBarProps = {
canShowScans?: boolean
canShowGuides?: boolean
/** A baked GLB is the active artifact: hide controls it can't honor (wall
* modes aren't baked into the GLB). */
glbActive?: boolean
/** In GLB mode, whether scans/guides were re-added from scene data — so the
* visibility control surfaces the matching toggle even though the artifact
* itself carries none. */
glbHasScans?: boolean
glbHasGuides?: boolean
walkthroughActive?: boolean
onWalkthroughToggle: () => void
className?: string
}
export const ViewerControlsBar = ({
canShowScans = true,
canShowGuides = true,
glbActive = false,
glbHasScans = false,
glbHasGuides = false,
walkthroughActive = false,
onWalkthroughToggle,
className,
}: ViewerControlsBarProps) => {
const levelMode = useViewer((s) => s.levelMode)
const wallMode = useViewer((s) => s.wallMode)
// Sessions may carry a stale mode outside the cycle (e.g. the retired
// 'translucent'); render and cycle it as cutaway instead of crashing.
const safeWallMode = (
wallMode in wallModeConfig ? wallMode : 'cutaway'
) as keyof typeof wallModeConfig
const WallModeIcon = wallModeConfig[safeWallMode].icon
return (
<div
className={cn(
'dark absolute bottom-4 left-1/2 z-20 -translate-x-1/2 text-foreground sm:bottom-6',
className,
)}
>
<TooltipProvider delayDuration={0}>
<div className="corner-smooth pointer-events-auto flex h-12 max-w-[calc(100vw-1rem)] flex-row items-center justify-center gap-0.5 overflow-hidden rounded-2xl border border-border/40 bg-background/95 p-1 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out sm:h-14 sm:gap-1.5 sm:p-1.5">
{((canShowScans && (!glbActive || glbHasScans)) ||
(canShowGuides && (!glbActive || glbHasGuides))) && (
<>
<VisibilityMenu
canShowGuides={canShowGuides && (!glbActive || glbHasGuides)}
canShowScans={canShowScans && (!glbActive || glbHasScans)}
/>
<div className="mx-1 h-5 w-px bg-border/40" />
</>
)}
{/* Level mode */}
<ActionButton
className={
levelMode === 'stacked'
? 'hover:bg-white/5 hover:text-amber-400'
: 'bg-amber-500/20 text-amber-400'
}
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
onClick={() => {
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
}}
size="icon"
tooltipSide="top"
variant="ghost"
>
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
</ActionButton>
{/* Wall mode — parametric only; baked GLB walls are fixed-height. */}
{!glbActive && (
<ActionButton
className={
safeWallMode === 'cutaway'
? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
: 'bg-white/10'
}
label={`Walls: ${wallModeConfig[safeWallMode].label}`}
onClick={() => {
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
const nextIndex = (modes.indexOf(safeWallMode) + 1) % modes.length
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
}}
size="icon"
tooltipSide="top"
variant="ghost"
>
<WallModeIcon className="h-[28px] w-[28px]" />
</ActionButton>
)}
<div className="mx-1 h-5 w-px bg-border/40" />
<DisplayMenu />
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Walkthrough */}
<ActionButton
className={
walkthroughActive
? 'bg-emerald-500/20 text-emerald-400'
: 'hover:bg-white/5 hover:text-emerald-400'
}
label={`Walkthrough: ${walkthroughActive ? 'On' : 'Off'}`}
onClick={onWalkthroughToggle}
size="icon"
tooltipSide="top"
variant="ghost"
>
<Footprints className="h-6 w-6" />
</ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Camera actions */}
<ActionButton
className="group hidden hover:bg-white/5 sm:inline-flex"
label="Orbit left"
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Orbit left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/rotate.webp"
/>
</ActionButton>
<ActionButton
className="group hidden hover:bg-white/5 sm:inline-flex"
label="Orbit right"
onClick={() => emitter.emit('camera-controls:orbit-cw')}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Orbit right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/rotate.webp"
/>
</ActionButton>
<ActionButton
className="group hover:bg-white/5"
label="Top view"
onClick={() => emitter.emit('camera-controls:top-view')}
size="icon"
tooltipSide="top"
variant="ghost"
>
<img
alt="Top view"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/topview.webp"
/>
</ActionButton>
</div>
</TooltipProvider>
</div>
)
}
@@ -0,0 +1,235 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
getLevelDisplayName,
type LevelNode,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { ArrowLeft, ChevronRight, Layers } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '../../lib/utils'
const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name
if (node.type === 'wall') return 'Wall'
if (node.type === 'fence') return 'Fence'
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
if (node.type === 'slab') return 'Slab'
if (node.type === 'ceiling') return 'Ceiling'
if (node.type === 'roof') return 'Roof'
if (node.type === 'roof-segment') return 'Roof Segment'
return node.type
}
export type ViewerSceneHeaderProps = {
projectName?: string | null
owner?: { username?: string | null } | null
onBack?: () => void
/** Fallback destination when no `onBack` handler is supplied. Must already be
* sanitized by the caller. */
backHref?: string
/** Extra row under the project info (e.g. likes/fork actions). */
stats?: ReactNode
}
export const ViewerSceneHeader = ({
projectName,
owner,
onBack,
backHref = '/',
stats,
}: ViewerSceneHeaderProps) => {
const selection = useViewer((s) => s.selection)
// Subscribe only to the specific nodes we read so that creating an unrelated
// node elsewhere in the scene doesn't re-render this overlay.
const firstSelectedId = selection.selectedIds[0] ?? null
const building = useScene((s) =>
selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null,
)
const level = useScene((s) =>
selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
)
const zone = useScene((s) =>
selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
)
const selectedNode = useScene((s) =>
firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
)
// Highest first so the list reads top-down like a building section.
const levels = useScene(
useShallow((s) => {
if (!building) return []
return building.children
.map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
.filter((n): n is LevelNode => n?.type === 'level')
.sort((a, b) => b.level - a.level)
}),
)
const handleLevelClick = (levelId: LevelNode['id']) => {
// When switching levels, deselect zone and items
useViewer.getState().setSelection({ levelId })
}
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level') => {
switch (depth) {
case 'root':
useViewer.getState().resetSelection()
break
case 'building':
useViewer.getState().setSelection({ levelId: null })
break
case 'level':
useViewer.getState().setSelection({ zoneId: null })
break
}
}
return (
<div className="dark absolute top-4 left-4 z-20 flex flex-col gap-3 text-foreground">
<div className="corner-smooth pointer-events-auto flex min-w-[200px] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out">
{/* Project info + back */}
<div className="flex items-center gap-3 px-3 py-2.5">
{onBack ? (
<button
aria-label="Back"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
onClick={onBack}
type="button"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</button>
) : (
<Link
aria-label="Back"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
href={backHref}
prefetch={false}
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</Link>
)}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground text-sm">
{projectName || 'Untitled'}
</div>
{owner?.username && (
<Link
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
href={`/u/${owner.username}`}
>
@{owner.username}
</Link>
)}
</div>
</div>
{stats && (
<div className="flex items-center gap-1 border-border/40 border-t px-3 py-2">{stats}</div>
)}
{/* Breadcrumb — only shown when navigated into a building */}
{building && (
<div className="border-border/40 border-t px-3 py-2">
<div className="flex items-center gap-1.5 text-xs">
<button
className="text-muted-foreground transition-colors hover:text-foreground"
onClick={() => handleBreadcrumbClick('root')}
>
Site
</button>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<button
className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
onClick={() => handleBreadcrumbClick('building')}
>
{building.name || 'Building'}
</button>
{level && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<button
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
onClick={() => handleBreadcrumbClick('level')}
>
{getLevelDisplayName(level)}
</button>
</>
)}
{zone && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span
className={`truncate transition-colors ${selectedNode ? 'text-muted-foreground' : 'font-medium text-foreground'}`}
>
{zone.name}
</span>
</>
)}
{selectedNode && zone && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate font-medium text-foreground">
{getNodeName(selectedNode)}
</span>
</>
)}
</div>
</div>
)}
</div>
{/* Level list (only when a building is selected) */}
{building && levels.length > 0 && (
<div className="corner-smooth pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out">
<span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
Levels
</span>
<div className="flex flex-col">
{levels.map((lvl) => {
const isSelected = lvl.id === selection.levelId
return (
<button
className={cn(
'group/row relative flex h-8 w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 text-sm transition-all duration-200',
isSelected
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
)}
key={lvl.id}
onClick={() => handleLevelClick(lvl.id)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
!isSelected && 'opacity-60 grayscale',
)}
>
<Layers className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1 truncate text-left">
{getLevelDisplayName(lvl)}
</div>
</div>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,111 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '../lib/utils'
import type { WalkthroughInteract } from '../store/use-first-person-hud'
export type { WalkthroughInteract } from '../store/use-first-person-hud'
export type WalkthroughHudProps = {
floorLabel?: string | null
zoneLabel?: string | null
interact?: WalkthroughInteract
/** Pointer lock temporarily released (OS screenshot) — the pill flips to
* "Click to resume" and lets clicks fall through to the canvas. */
suspended?: boolean
onExit?: () => void
children?: ReactNode
}
export function WalkthroughHud({
floorLabel,
zoneLabel,
interact = null,
suspended = false,
onExit,
children,
}: WalkthroughHudProps) {
const kbdClass = 'rounded border border-border/60 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]'
const pillClass =
'flex items-center gap-1.5 rounded-full border border-border/40 bg-background/70 px-3 py-1 text-muted-foreground text-xs backdrop-blur-xl'
const exitContent = (
<>
<kbd className={kbdClass}>Esc</kbd>
to exit
</>
)
return (
<div className="dark pointer-events-none absolute inset-0 z-30 text-foreground">
<div className="absolute top-6 left-1/2 flex -translate-x-1/2 flex-col items-center gap-1.5">
{floorLabel && (
<div className="font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
{floorLabel}
</div>
)}
{zoneLabel && (
<div className="corner-smooth rounded-full border border-border/40 bg-background/80 px-3 py-1 font-medium text-sm shadow-elevation-3 backdrop-blur-xl">
{zoneLabel}
</div>
)}
{children}
</div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div
className={cn(
'rounded-full transition-all duration-150',
interact
? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
: 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
)}
/>
</div>
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center gap-2">
{suspended ? (
<div className={pillClass}>
<span className="font-medium text-foreground">Click</span>
<span>or</span>
<kbd className={kbdClass}>P</kbd>
<span>to resume</span>
<span className="text-muted-foreground/60">·</span>
{exitContent}
</div>
) : (
<>
<div className={pillClass}>
<kbd className={kbdClass}>P</kbd>
free cursor
</div>
{onExit ? (
<button
className={cn(pillClass, 'pointer-events-auto')}
onClick={onExit}
type="button"
>
{exitContent}
</button>
) : (
<div className={pillClass}>{exitContent}</div>
)}
</>
)}
</div>
{interact && (
<div className="absolute top-1/2 left-1/2 mt-7 -translate-x-1/2 whitespace-nowrap">
<div className="corner-smooth flex items-center gap-1.5 rounded-full border border-border/40 bg-background/80 px-3 py-1 text-xs shadow-elevation-3 backdrop-blur-xl">
<kbd className="rounded border border-border/60 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">
E
</kbd>
<span className="text-muted-foreground">or click to</span>
<span className="font-medium">
{interact.verb} {interact.label}
</span>
</div>
</div>
)}
</div>
)
}
+15
View File
@@ -26,6 +26,7 @@ export { default as Editor } from './components/editor'
// surface uses the shorter, shell-friendly names from the unified // surface uses the shorter, shell-friendly names from the unified
// preset-system spec. // preset-system spec.
export { BakeExporter } from './components/editor/bake-exporter' export { BakeExporter } from './components/editor/bake-exporter'
export { FirstPersonControls } from './components/editor/first-person-controls'
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
// Embed surface — the editor's real in-canvas affordances, so a host can mount // Embed surface — the editor's real in-canvas affordances, so a host can mount
// authentic selection handles, interactive build tools, and the mover on top // authentic selection handles, interactive build tools, and the mover on top
@@ -261,6 +262,19 @@ export {
SnapTargetBadge, SnapTargetBadge,
SnapTargetIcon, SnapTargetIcon,
} from './components/ui/snap-target-badge' } from './components/ui/snap-target-badge'
export {
ViewerControlsBar,
type ViewerControlsBarProps,
} from './components/viewer/viewer-controls-bar'
export {
ViewerSceneHeader,
type ViewerSceneHeaderProps,
} from './components/viewer/viewer-scene-header'
export {
WalkthroughHud,
type WalkthroughHudProps,
type WalkthroughInteract,
} from './components/walkthrough-hud'
export type { SaveStatus } from './hooks/use-auto-save' export type { SaveStatus } from './hooks/use-auto-save'
// useDragAction is the React-side glue for the registry's DragAction // useDragAction is the React-side glue for the registry's DragAction
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
@@ -470,6 +484,7 @@ export {
} from './store/use-editor' } from './store/use-editor'
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud'
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview' export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
export { export {
default as useInteractionScope, default as useInteractionScope,
+1 -1
View File
@@ -15,7 +15,7 @@ type DoorOpenAnimationOptions = {
persist?: boolean persist?: boolean
} }
function getDisplayedDoorValue( export function getDisplayedDoorValue(
doorId: AnyNodeId, doorId: AnyNodeId,
field: keyof DoorInteractiveState, field: keyof DoorInteractiveState,
nodeValue: number | undefined, nodeValue: number | undefined,
@@ -23,7 +23,7 @@ export function isOperableWindowType(windowType: string | undefined) {
) )
} }
function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) { export function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
const interactive = useInteractive.getState() const interactive = useInteractive.getState()
const runtimeValue = interactive.windows[windowId]?.operationState const runtimeValue = interactive.windows[windowId]?.operationState
if (runtimeValue !== undefined) return runtimeValue if (runtimeValue !== undefined) return runtimeValue
@@ -0,0 +1,40 @@
import { create } from 'zustand'
export type WalkthroughInteract = { label: string; verb: string } | null
export type FirstPersonHudState = {
floorLabel: string | null
zoneLabel: string | null
interact: WalkthroughInteract
setHud: (hud: Partial<Pick<FirstPersonHudState, 'floorLabel' | 'zoneLabel' | 'interact'>>) => void
reset: () => void
}
export const useFirstPersonHud = create<FirstPersonHudState>((set) => ({
floorLabel: null,
zoneLabel: null,
interact: null,
setHud: (hud) =>
set((state) => {
const floorLabel = hud.floorLabel === undefined ? state.floorLabel : hud.floorLabel
const zoneLabel = hud.zoneLabel === undefined ? state.zoneLabel : hud.zoneLabel
const interact = hud.interact === undefined ? state.interact : hud.interact
if (
floorLabel === state.floorLabel &&
zoneLabel === state.zoneLabel &&
interact?.label === state.interact?.label &&
interact?.verb === state.interact?.verb
) {
return state
}
return { floorLabel, zoneLabel, interact }
}),
reset: () =>
set((state) =>
state.floorLabel === null && state.zoneLabel === null && state.interact === null
? state
: { floorLabel: null, zoneLabel: null, interact: null },
),
}))
+1 -1
View File
@@ -111,7 +111,7 @@ export default function WallPanel() {
// renders at. `undefined` for walls with an explicit custom height. // renders at. `undefined` for walls with an explicit custom height.
const planeBoundHeightMeters = useScene((s) => { const planeBoundHeightMeters = useScene((s) => {
const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined
if (!wall || wall.type !== 'wall' || wall.height != null) return undefined if (wall?.type !== 'wall' || wall.height != null) return undefined
return resolveWallOpeningCeiling(wall, s.nodes) return resolveWallOpeningCeiling(wall, s.nodes)
}) })
@@ -923,7 +923,7 @@ export function GlbScene({
}) })
// E or click activates the openable in view. The click also re-locks the // 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(() => { const activateWalkDoor = useCallback(() => {
if (walkDoorRef.current) toggleOpenable(walkDoorRef.current) if (walkDoorRef.current) toggleOpenable(walkDoorRef.current)
}, [toggleOpenable]) }, [toggleOpenable])
@@ -17,6 +17,7 @@ import {
type Object3D, type Object3D,
type PerspectiveCamera, type PerspectiveCamera,
Quaternion, Quaternion,
Raycaster,
Vector3, Vector3,
} from 'three' } from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 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 { SCENE_LAYER } from '../../lib/layers'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' 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 // Eye/capsule geometry mirrors the editor's first-person controller so the
// baked walkthrough feels identical. The capsule centre sits below the eye; 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 LOOK_SENSITIVITY = 0.002
const VOID_FALL_RESPAWN_DEPTH = 12 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 // 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 // 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 // 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'] }, { 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 cameraEuler = new Euler(0, 0, 0, 'YXZ')
const spawnQuat = new Quaternion() const spawnQuat = new Quaternion()
const spawnEuler = new Euler(0, 0, 0, 'YXZ') 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 controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0) const yawRef = useRef(0)
const pitchRef = 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 [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
const [world, setWorld] = useState<GlbColliderWorld | 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) { if (event.code === 'Escape' && document.pointerLockElement !== canvas) {
useViewer.getState().setWalkthroughMode(false) 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 = () => { const onPointerLockChange = () => {
if (document.pointerLockElement === canvas) wasLocked = true if (document.pointerLockElement === canvas) {
else if (wasLocked) useViewer.getState().setWalkthroughMode(false) 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) document.addEventListener('mousemove', onMouseMove)
canvas.addEventListener('click', onClick) canvas.addEventListener('click', onClick)
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
document.addEventListener('pointerlockchange', onPointerLockChange) document.addEventListener('pointerlockchange', onPointerLockChange)
return () => { return () => {
document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mousemove', onMouseMove)
canvas.removeEventListener('click', onClick) canvas.removeEventListener('click', onClick)
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
document.removeEventListener('pointerlockchange', onPointerLockChange) document.removeEventListener('pointerlockchange', onPointerLockChange)
useViewer.getState().setWalkthroughSuspended(false)
if (document.pointerLockElement === canvas) document.exitPointerLock() if (document.pointerLockElement === canvas) document.exitPointerLock()
} }
}, [gl]) }, [gl])
@@ -367,8 +437,16 @@ export function GlbWalkthroughController({ url }: { url: string }) {
controllerRef.current = api 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. // Drive the camera from the capsule each frame + respawn if it falls into void.
useFrame(() => { useFrame((_, delta) => {
const group = controllerRef.current?.group const group = controllerRef.current?.group
if (!group) return if (!group) return
@@ -377,8 +455,18 @@ export function GlbWalkthroughController({ url }: { url: string }) {
controllerRef.current?.resetLinVel() 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 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') cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler) camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true) camera.updateMatrixWorld(true)
@@ -391,7 +479,7 @@ export function GlbWalkthroughController({ url }: { url: string }) {
<BVHEcctrl <BVHEcctrl
acceleration={26} acceleration={26}
airDragFactor={0.3} airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]} colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
colliderMeshes={[world.mesh]} colliderMeshes={[world.mesh]}
collisionCheckIteration={3} collisionCheckIteration={3}
collisionPushBackDamping={0.1} collisionPushBackDamping={0.1}
@@ -401,15 +489,15 @@ export function GlbWalkthroughController({ url }: { url: string }) {
fallGravityFactor={4} fallGravityFactor={4}
floatCheckType="BOTH" floatCheckType="BOTH"
floatDampingC={36} floatDampingC={36}
floatHeight={0.5} floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
floatPullBackHeight={0.35} floatPullBackHeight={0.35}
floatSensorRadius={0.15} floatSensorRadius={0.15}
floatSpringK={1200} floatSpringK={1200}
gravity={9.81} gravity={9.81}
jumpVel={5} jumpVel={5}
maxRunSpeed={5} maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
maxSlope={1.2} maxSlope={1.2}
maxWalkSpeed={2} maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
position={start.position} position={start.position}
ref={setControllerApi} 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, GlbScene,
type GlbWalkthrough, type GlbWalkthrough,
} from './components/viewer/glb-scene' } 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 type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export { export {
DEFAULT_HOVER_STYLES, DEFAULT_HOVER_STYLES,
SSGI_PARAMS, SSGI_PARAMS,
} from './components/viewer/post-processing' } from './components/viewer/post-processing'
export { SceneEnvironment } from './components/viewer/scene-environment' export { SceneEnvironment } from './components/viewer/scene-environment'
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
export { useAssetUrl } from './hooks/use-asset-url' export { useAssetUrl } from './hooks/use-asset-url'
export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2'
export { useNodeEvents } from './hooks/use-node-events' export { useNodeEvents } from './hooks/use-node-events'
+9 -1
View File
@@ -153,6 +153,11 @@ type ViewerState = {
walkthroughMode: boolean walkthroughMode: boolean
setWalkthroughMode: (mode: boolean) => void 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 cameraDragging: boolean
setCameraDragging: (dragging: boolean) => void setCameraDragging: (dragging: boolean) => void
@@ -515,7 +520,10 @@ const useViewer = create<ViewerState>()(
setDebugColors: (enabled) => set({ debugColors: enabled }), setDebugColors: (enabled) => set({ debugColors: enabled }),
walkthroughMode: false, walkthroughMode: false,
setWalkthroughMode: (mode) => set({ walkthroughMode: mode }), setWalkthroughMode: (mode) => set({ walkthroughMode: mode, walkthroughSuspended: false }),
walkthroughSuspended: false,
setWalkthroughSuspended: (suspended) => set({ walkthroughSuspended: suspended }),
cameraDragging: false, cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }), setCameraDragging: (dragging) => set({ cameraDragging: dragging }),