From 03a3c384330f6bfb410e69495cd2f925ef70c772 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 15:31:14 -0400 Subject: [PATCH 1/8] 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 --- .../editor/first-person-controls.tsx | 290 ++++++++++++------ .../editor/src/components/walkthrough-hud.tsx | 91 ++++++ packages/editor/src/index.tsx | 7 + packages/editor/src/lib/door-interaction.ts | 2 +- packages/editor/src/lib/window-interaction.ts | 2 +- .../editor/src/store/use-first-person-hud.ts | 40 +++ .../src/components/viewer/glb-scene.tsx | 2 +- .../viewer/glb-walkthrough-controller.tsx | 7 +- .../viewer/walkthrough-controls.tsx | 156 ---------- packages/viewer/src/index.ts | 6 +- 10 files changed, 344 insertions(+), 259 deletions(-) create mode 100644 packages/editor/src/components/walkthrough-hud.tsx create mode 100644 packages/editor/src/store/use-first-person-hud.ts delete mode 100644 packages/viewer/src/components/viewer/walkthrough-controls.tsx diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index d57968c2d3..5c5bf9bdab 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -15,9 +15,11 @@ import { getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, + getLevelDisplayName, getLevelElevations, getResolvedElevatorDoorStyle, openElevatorDoor, + pointInPolygon2D, requestElevatorLevel, resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, @@ -26,7 +28,13 @@ import { useInteractive, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { + BVHEcctrl, + type BVHEcctrlApi, + type MovementInput, + useViewer, + WALKTHROUGH_FOV, +} from '@pascal-app/viewer' import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -39,6 +47,7 @@ import { Mesh, MeshBasicMaterial, type Object3D, + type PerspectiveCamera, Ray, Raycaster, Vector2, @@ -46,19 +55,22 @@ import { } from 'three' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import '../../three-types' -import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, + getDisplayedDoorValue, isOperationDoorType, toggleDoorOpenState, } from '../../lib/door-interaction' import { closeWindowOpenState, + getDisplayedWindowValue, isOperableWindowType, toggleWindowOpenState, } from '../../lib/window-interaction' import useEditor from '../../store/use-editor' +import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud' +import { WalkthroughHud } from '../walkthrough-hud' import { buildFirstPersonColliderWorldFromRegistry, deriveFirstPersonSpawn, @@ -78,6 +90,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const VOID_FALL_RESPAWN_DEPTH = 12 +const HUD_LABEL_SAMPLE_FRAMES = 10 type MovementKeyName = Exclude @@ -146,6 +159,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false }) const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const windowInteractionRaycaster = new Raycaster() +const hudBuildingLocalEyePosition = new Vector3() +const hudWorldEyePosition = new Vector3() +const hudLevelBounds = new Box3() type ElevatorColliderKind = | 'cab-back' @@ -202,6 +218,128 @@ type ElevatorButtonTarget = { levelId?: AnyNodeId } +function getLevelChildren( + level: Extract, + nodes: Record, +) { + const childIds = new Set(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, + nodes: Record, +) { + const children = getLevelChildren(level, nodes) + const slabs = children.filter( + (node): node is Extract => + node.type === 'slab' && node.polygon.length >= 3, + ) + const zones = children.filter( + (node): node is Extract => + 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) + + 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 { let current: Object3D | null = object @@ -553,6 +691,7 @@ export const FirstPersonControls = () => { const yawRef = useRef(0) const pitchRef = useRef(0) const interactableTargetRef = useRef(null) + const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) const ridingElevatorRef = useRef<{ elevatorId: AnyNodeId @@ -569,6 +708,35 @@ export const FirstPersonControls = () => { yaw: number } | 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) => { worldRef.current?.dispose() worldRef.current = nextWorld @@ -1326,6 +1494,17 @@ export const FirstPersonControls = () => { interactableTargetRef.current = nextInteractableTarget 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) useEffect(() => { @@ -1383,27 +1562,13 @@ 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 }) => { - const [isLocked, setIsLocked] = useState(false) const hasPlacedSpawn = useScene((state) => Object.values(state.nodes).some((node) => node.type === 'spawn'), ) - - useEffect(() => { - const handlePointerLockChange = () => { - setIsLocked(document.pointerLockElement != null) - } - - handlePointerLockChange() - document.addEventListener('pointerlockchange', handlePointerLockChange) - return () => { - document.removeEventListener('pointerlockchange', handlePointerLockChange) - } - }, []) + const floorLabel = useFirstPersonHud((state) => state.floorLabel) + const zoneLabel = useFirstPersonHud((state) => state.zoneLabel) + const interact = useFirstPersonHud((state) => state.interact) const handleExit = useCallback(() => { if (document.pointerLockElement) { @@ -1413,86 +1578,17 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { }, [onExit]) return ( - <> - {isLocked && ( -
-
-
-
-
-
- )} - -
- -
- + {!hasPlacedSpawn && ( -
-
- 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.
)} - - {isLocked && ( -
-
- -
- - - - -
- - Click to look around - -
-
- )} - - ) -} - -function ControlHint({ label, keys }: { label: string; keys: string[] }) { - return ( -
- - {label} - -
- {keys.map((key) => ( - - {key} - - ))} -
-
- ) -} - -function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { - return ( -
- - {label} - - - {keyLabel} - -
+ ) } diff --git a/packages/editor/src/components/walkthrough-hud.tsx b/packages/editor/src/components/walkthrough-hud.tsx new file mode 100644 index 0000000000..ae77a2d19c --- /dev/null +++ b/packages/editor/src/components/walkthrough-hud.tsx @@ -0,0 +1,91 @@ +'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 + onExit?: () => void + children?: ReactNode +} + +export function WalkthroughHud({ + floorLabel, + zoneLabel, + interact = null, + onExit, + children, +}: WalkthroughHudProps) { + const exitContent = ( + <> + + Esc + + to exit + + ) + + return ( +
+
+ {floorLabel && ( +
+ {floorLabel} +
+ )} + {zoneLabel && ( +
+ {zoneLabel} +
+ )} + {children} +
+ +
+
+
+ +
+ {onExit ? ( + + ) : ( +
+ {exitContent} +
+ )} +
+ + {interact && ( +
+
+ + E + + or click to + + {interact.verb} {interact.label} + +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 25544bfbf9..c10f74bed3 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -26,6 +26,7 @@ export { default as Editor } from './components/editor' // surface uses the shorter, shell-friendly names from the unified // preset-system spec. 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' // 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 @@ -261,6 +262,11 @@ export { SnapTargetBadge, SnapTargetIcon, } from './components/ui/snap-target-badge' +export { + WalkthroughHud, + type WalkthroughHudProps, + type WalkthroughInteract, +} from './components/walkthrough-hud' export type { SaveStatus } from './hooks/use-auto-save' // useDragAction is the React-side glue for the registry's DragAction // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) @@ -470,6 +476,7 @@ export { } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' 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 { default as useInteractionScope, diff --git a/packages/editor/src/lib/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts index 7d9efc48c9..1ad720883d 100644 --- a/packages/editor/src/lib/door-interaction.ts +++ b/packages/editor/src/lib/door-interaction.ts @@ -15,7 +15,7 @@ type DoorOpenAnimationOptions = { persist?: boolean } -function getDisplayedDoorValue( +export function getDisplayedDoorValue( doorId: AnyNodeId, field: keyof DoorInteractiveState, nodeValue: number | undefined, diff --git a/packages/editor/src/lib/window-interaction.ts b/packages/editor/src/lib/window-interaction.ts index 64c4ce993c..2ed3ec4dfb 100644 --- a/packages/editor/src/lib/window-interaction.ts +++ b/packages/editor/src/lib/window-interaction.ts @@ -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 runtimeValue = interactive.windows[windowId]?.operationState if (runtimeValue !== undefined) return runtimeValue diff --git a/packages/editor/src/store/use-first-person-hud.ts b/packages/editor/src/store/use-first-person-hud.ts new file mode 100644 index 0000000000..b78a0fd906 --- /dev/null +++ b/packages/editor/src/store/use-first-person-hud.ts @@ -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>) => void + reset: () => void +} + +export const useFirstPersonHud = create((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 }, + ), +})) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index d3b26f9f09..e2e604dca0 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -923,7 +923,7 @@ export function GlbScene({ }) // E or click activates the openable in view. The click also re-locks the - // pointer via WalkthroughControls — harmless overlap; no selection happens. + // pointer through the walkthrough controller; no selection happens. const activateWalkDoor = useCallback(() => { if (walkDoorRef.current) toggleOpenable(walkDoorRef.current) }, [toggleOpenable]) diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 56d5216314..b57a297463 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -25,7 +25,12 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { SCENE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' -import { WALKTHROUGH_FOV } from './walkthrough-controls' + +// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels +// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral +// awareness without wide-angle distortion. Applied only while walking — both +// walkthrough controllers read this and restore the orbit FOV on exit. +export const WALKTHROUGH_FOV = 60 // Eye/capsule geometry mirrors the editor's first-person controller so the // baked walkthrough feels identical. The capsule centre sits below the eye; the diff --git a/packages/viewer/src/components/viewer/walkthrough-controls.tsx b/packages/viewer/src/components/viewer/walkthrough-controls.tsx deleted file mode 100644 index 65c6b69f1c..0000000000 --- a/packages/viewer/src/components/viewer/walkthrough-controls.tsx +++ /dev/null @@ -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(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 -} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8408cd88d8..4014768a0b 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -34,14 +34,16 @@ export { GlbScene, type GlbWalkthrough, } from './components/viewer/glb-scene' -export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller' +export { + GlbWalkthroughController, + WALKTHROUGH_FOV, +} from './components/viewer/glb-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, SSGI_PARAMS, } from './components/viewer/post-processing' export { SceneEnvironment } from './components/viewer/scene-environment' -export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' From b7b749c26b897e62c975f9f0a5f0386bc3c789c6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 15:54:53 -0400 Subject: [PATCH 2/8] 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 --- .../editor/src/components/viewer-overlay.tsx | 669 +----------------- .../components/viewer/viewer-controls-bar.tsx | 466 ++++++++++++ .../components/viewer/viewer-scene-header.tsx | 235 ++++++ packages/editor/src/index.tsx | 8 + 4 files changed, 724 insertions(+), 654 deletions(-) create mode 100644 packages/editor/src/components/viewer/viewer-controls-bar.tsx create mode 100644 packages/editor/src/components/viewer/viewer-scene-header.tsx diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index e60c5d662e..7918f9f515 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -1,51 +1,9 @@ '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 { useShallow } from 'zustand/react/shallow' -import { cn } from '../lib/utils' import useEditor from '../store/use-editor' -import { ActionButton } from './ui/action-menu/action-button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from './ui/primitives/dropdown-menu' -import { TooltipProvider } from './ui/primitives/tooltip' +import { ViewerControlsBar } from './viewer/viewer-controls-bar' +import { ViewerSceneHeader } from './viewer/viewer-scene-header' type ProjectOwner = { 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) => ( - Full Height - ), - label: 'Full Height', - }, - cutaway: { - icon: (props: any) => ( - Cutaway - ), - label: 'Cutaway', - }, - down: { - icon: (props: any) => ( - Low - ), - label: 'Low', - }, - translucent: { - icon: (props: any) => ( - Translucent - ), - 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 ( - - - - - - - - {SHADING_OPTIONS.map((option) => { - const OptionIcon = option.icon - return ( - useViewer.getState().setShading(option.id)} - > - -
- {option.name} - {option.detail} -
- {shading === option.id ? : null} -
- ) - })} - - {TEXTURE_OPTIONS.map((option) => { - const OptionIcon = option.icon - return ( - useViewer.getState().setTextures(option.id)} - > - -
- {option.name} - {option.detail} -
- {textures === option.id ? : null} -
- ) - })} -
-
- ) -} - -function SceneThemeMenu() { - const sceneTheme = useViewer((s) => s.sceneTheme) - const active = getSceneTheme(sceneTheme) - return ( - - - - - - - - {SCENE_THEMES.map((sceneThemeOption) => { - const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( - (role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role], - ) - return ( - useViewer.getState().setSceneTheme(sceneThemeOption.id)} - > - - {swatches.map((color, index) => ( - - ))} - - {sceneThemeOption.name} - {sceneTheme === sceneThemeOption.id ? ( - - ) : null} - - ) - })} - - - ) -} - -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 ( - - - - - - - - {EDGE_OPTIONS.map((option) => ( - useViewer.getState().setEdges(option.id)} - > -
- {option.name} - {option.detail} -
- {edges === option.id ? : null} -
- ))} -
-
- ) -} - -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 { projectName?: string | null owner?: ProjectOwner | null @@ -298,401 +44,16 @@ export const ViewerOverlay = ({ canShowScans = true, canShowGuides = true, onBack, -}: ViewerOverlayProps) => { - const selection = useViewer((s) => s.selection) - const showScans = useViewer((s) => s.showScans) - const showGuides = useViewer((s) => s.showGuides) - const cameraMode = useViewer((s) => s.cameraMode) - const levelMode = useViewer((s) => s.levelMode) - const wallMode = useViewer((s) => s.wallMode) - - // 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, - ) - 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 */} -
-
- {/* Project info + back */} -
- {onBack ? ( - - ) : ( - - - - )} -
-
- {projectName || 'Untitled'} -
- {owner?.username && ( - - @{owner.username} - - )} -
-
- - {/* Breadcrumb — only shown when navigated into a building */} - {building && ( -
-
- - - {building && ( - <> - - - - )} - - {level && ( - <> - - - - )} - - {zone && ( - <> - - - {zone.name} - - - )} - - {selectedNode && zone && ( - <> - - - {getNodeName(selectedNode)} - - - )} -
-
- )} -
- - {/* Level List (only when building is selected) */} - {building && levels.length > 0 && ( -
- - Levels - -
- {levels.map((lvl) => { - const isSelected = lvl.id === selection.levelId - return ( - - ) - })} -
-
- )} -
- - {/* Controls Panel - Bottom Center */} -
- -
- {/* Scans and Guides Visibility */} - {canShowScans && ( - useViewer.getState().setShowScans(!showScans)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Scans - - )} - - {canShowGuides && ( - useViewer.getState().setShowGuides(!showGuides)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Guides - - )} - - {(canShowScans || canShowGuides) &&
} - - {/* Camera Mode */} - - useViewer - .getState() - .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') - } - size="icon" - tooltipSide="top" - variant="ghost" - > - - - - - - - - - - {/* Level Mode */} - { - 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' && } - {levelMode === 'exploded' && ( - - )} - {(levelMode === 'stacked' || levelMode === 'manual') && ( - - )} - - - - - {/* Wall Mode */} - { - 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 - })()} - - -
- - {/* Camera Actions */} - emitter.emit('camera-controls:orbit-ccw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Left - - - emitter.emit('camera-controls:orbit-cw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Right - - - emitter.emit('camera-controls:top-view')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Top View - - -
- - {/* First-person walkthrough */} - { - flushSync(() => useEditor.getState().setFirstPersonMode(true)) - requestWalkthroughPointerLock() - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - - -
- -
- - ) -} +}: ViewerOverlayProps) => ( + <> + + { + flushSync(() => useEditor.getState().setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }} + /> + +) diff --git a/packages/editor/src/components/viewer/viewer-controls-bar.tsx b/packages/editor/src/components/viewer/viewer-controls-bar.tsx new file mode 100644 index 0000000000..929dbd4a99 --- /dev/null +++ b/packages/editor/src/components/viewer/viewer-controls-bar.tsx @@ -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) => ( + Full height + ), + label: 'Full height', + }, + cutaway: { + icon: (props: any) => ( + Cutaway + ), + label: 'Cutaway', + }, + down: { + icon: (props: any) => ( + Low + ), + 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 ( + + + + + + + + {canShowScans && ( + keepOpen(e, () => useViewer.getState().setShowScans(!showScans))} + > + + Scans + {showScans ? ( + + ) : ( + + )} + + )} + {canShowGuides && ( + keepOpen(e, () => useViewer.getState().setShowGuides(!showGuides))} + > + + Guides + {showGuides ? ( + + ) : ( + + )} + + )} + + + ) +} + +// 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 ( + + + + + + + + keepOpen(e, () => useViewer.getState().setShadows(!shadows))} + > + + Shadows + {shadows ? 'On' : 'Off'} + + + keepOpen(e, () => + useViewer + .getState() + .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'), + ) + } + > + + Camera + + {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + + + keepOpen(e, () => useViewer.getState().setTextures(!textures))} + > + {textures ? : } + Colors + + {textures ? 'Colored' : 'Monochrome'} + + + + + + + + + Render + {activeShading.name} + + + {SHADING_OPTIONS.map((option) => { + const OptionIcon = option.icon + return ( + useViewer.getState().setShading(option.id)} + > + +
+ {option.name} + {option.detail} +
+ {shading === option.id ? ( + + ) : null} +
+ ) + })} +
+
+ + + + + Theme + + {activeTheme.name} + + + + {SCENE_THEMES.map((t) => { + const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( + (role) => t.clayTints?.[role] ?? CLAY_PALETTE[role], + ) + return ( + useViewer.getState().setSceneTheme(t.id)} + > + + {swatches.map((color, index) => ( + + ))} + + {t.name} + {sceneTheme === t.id ? : null} + + ) + })} + + + + + + + Edges + {activeEdges.name} + + + {EDGE_OPTIONS.map((option) => ( + useViewer.getState().setEdges(option.id)} + > +
+ {option.name} + {option.detail} +
+ {edges === option.id ? : null} +
+ ))} +
+
+
+
+ ) +} + +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 ( +
+ +
+ {((canShowScans && (!glbActive || glbHasScans)) || + (canShowGuides && (!glbActive || glbHasGuides))) && ( + <> + +
+ + )} + + {/* Level mode */} + { + 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' && } + {levelMode === 'exploded' && } + {(levelMode === 'stacked' || levelMode === 'manual') && } + + + {/* Wall mode — parametric only; baked GLB walls are fixed-height. */} + {!glbActive && ( + { + 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" + > + + + )} + +
+ + + +
+ + {/* Walkthrough */} + + + + +
+ + {/* Camera actions */} + emitter.emit('camera-controls:orbit-ccw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit left + + + emitter.emit('camera-controls:orbit-cw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit right + + + emitter.emit('camera-controls:top-view')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Top view + +
+ +
+ ) +} diff --git a/packages/editor/src/components/viewer/viewer-scene-header.tsx b/packages/editor/src/components/viewer/viewer-scene-header.tsx new file mode 100644 index 0000000000..1d44a9be00 --- /dev/null +++ b/packages/editor/src/components/viewer/viewer-scene-header.tsx @@ -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 ( +
+
+ {/* Project info + back */} +
+ {onBack ? ( + + ) : ( + + + + )} +
+
+ {projectName || 'Untitled'} +
+ {owner?.username && ( + + @{owner.username} + + )} +
+
+ + {stats && ( +
{stats}
+ )} + + {/* Breadcrumb — only shown when navigated into a building */} + {building && ( +
+
+ + + + + + {level && ( + <> + + + + )} + + {zone && ( + <> + + + {zone.name} + + + )} + + {selectedNode && zone && ( + <> + + + {getNodeName(selectedNode)} + + + )} +
+
+ )} +
+ + {/* Level list (only when a building is selected) */} + {building && levels.length > 0 && ( +
+ + Levels + +
+ {levels.map((lvl) => { + const isSelected = lvl.id === selection.levelId + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index c10f74bed3..f7b6a46d73 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -262,6 +262,14 @@ export { SnapTargetBadge, SnapTargetIcon, } 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, From 8b44ad1372d74f5ea7392a71d4842b5db171b69b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 16:10:23 -0400 Subject: [PATCH 3/8] feat(walkthrough): hold-Ctrl crouch + P screenshot pause in both controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../editor/first-person-controls.tsx | 69 ++++++++++++++-- .../viewer/glb-walkthrough-controller.tsx | 82 +++++++++++++++++-- packages/viewer/src/index.ts | 7 ++ 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 5c5bf9bdab..74cd31984a 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -31,7 +31,14 @@ import { import { BVHEcctrl, type BVHEcctrlApi, + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, type MovementInput, + STAND_CAPSULE, + STAND_CLEARANCE, useViewer, WALKTHROUGH_FOV, } from '@pascal-app/viewer' @@ -133,8 +140,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) { 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 standClearanceRaycaster = new Raycaster() +const standClearanceUp = new Vector3(0, 1, 0) const centerScreenPoint = new Vector2(0, 0) const doorInteractionRaycaster = new Raycaster() const doorLeafBox = new Box3() @@ -692,6 +701,10 @@ export const FirstPersonControls = () => { const pitchRef = useRef(0) const interactableTargetRef = useRef(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 ridingElevatorRef = useRef<{ elevatorId: AnyNodeId @@ -1147,9 +1160,14 @@ export const FirstPersonControls = () => { const isLocked = document.pointerLockElement === canvas if (isLocked) { hadPointerLockRef.current = true + suspendRef.current = false return } + // Deliberately released via P (screenshot pause) — stay in first person; + // clicking the canvas re-locks. + if (suspendRef.current) return + if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { useEditor.getState().setFirstPersonMode(false) } @@ -1197,7 +1215,9 @@ export const FirstPersonControls = () => { return } - if (event.code === 'Escape') { + if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + crouchKeyRef.current = true + } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() if (document.pointerLockElement === canvas) { @@ -1212,18 +1232,34 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() closeInteractableTarget() + } else if (event.code === 'KeyP' && document.pointerLockElement === canvas) { + // P frees the cursor without leaving first person (e.g. to take an OS + // screenshot, which needs a movable pointer); clicking re-locks. + event.preventDefault() + event.stopPropagation() + suspendRef.current = true + document.exitPointerLock() } } const handleKeyUp = (event: KeyboardEvent) => { + if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + crouchKeyRef.current = false + } applyMovementKey(event, false) } + const handleBlur = () => { + crouchKeyRef.current = false + } + document.addEventListener('keydown', handleKeyDown, true) document.addEventListener('keyup', handleKeyUp, true) + window.addEventListener('blur', handleBlur) return () => { document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keyup', handleKeyUp, true) + window.removeEventListener('blur', handleBlur) } }, [closeInteractableTarget, gl, toggleInteractableTarget]) @@ -1449,11 +1485,32 @@ export const FirstPersonControls = () => { [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 const group = controllerRef.current.group + // Crouch follows the held key; standing back up waits for headroom. + if (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 // 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. @@ -1531,7 +1588,7 @@ export const FirstPersonControls = () => { { gravity={9.81} jumpVel={5} key="first-person-controller" - maxRunSpeed={5} + maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5} maxSlope={1.2} - maxWalkSpeed={2} + maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2} paused={isElevatorRideLocked} position={controllerStart.position} ref={setControllerApi} diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index b57a297463..a8280dc45e 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -17,6 +17,7 @@ import { type Object3D, type PerspectiveCamera, Quaternion, + Raycaster, Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' @@ -42,6 +43,23 @@ const SPAWN_EYE_HEIGHT = 1.65 const LOOK_SENSITIVITY = 0.002 const VOID_FALL_RESPAWN_DEPTH = 12 +// Crouch (hold Ctrl): swap to a short capsule — it shrinks around the centre, +// so a crouch mid-jump also lowers the head AND raises the feet, letting the +// player thread window openings. Standing back up is gated on headroom. +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.3, 4, 8] +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: the +// centre floats back up by half the length delta and the standing capsule top +// sits standLength/2 + radius above it. +export const STAND_CLEARANCE = 0.95 +export const EYE_LERP_SPEED = 12 + +const standClearanceRaycaster = new Raycaster() +const UP = new Vector3(0, 1, 0) + // Kinds that must not block the player: room helpers, the spawn marker, the // ceiling/roof shell (you walk under them), and door/window leaves — excluding // the latter lets you pass any doorway whether the leaf is open or shut (the @@ -59,7 +77,7 @@ const keyboardMap: Array<{ name: Exclude; keys: { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, ] -const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraOffset = new Vector3() const cameraEuler = new Euler(0, 0, 0, 'YXZ') const spawnQuat = new Quaternion() const spawnEuler = new Euler(0, 0, 0, 'YXZ') @@ -243,6 +261,10 @@ export function GlbWalkthroughController({ url }: { url: string }) { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) + const crouchKeyRef = useRef(false) + const suspendRef = useRef(false) + const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) + const [crouched, setCrouched] = useState(false) const [start, setStart] = useState<{ position: [number, number, number] } | null>(null) const [world, setWorld] = useState(null) @@ -338,19 +360,46 @@ export function GlbWalkthroughController({ url }: { url: string }) { if (event.code === 'Escape' && document.pointerLockElement !== canvas) { useViewer.getState().setWalkthroughMode(false) } + // P frees the cursor without leaving the walkthrough (e.g. to take an OS + // screenshot, which needs a movable pointer); clicking re-locks. + if (event.code === 'KeyP' && document.pointerLockElement === canvas) { + suspendRef.current = true + document.exitPointerLock() + } + if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + crouchKeyRef.current = true + } + } + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + crouchKeyRef.current = false + } + } + const onBlur = () => { + crouchKeyRef.current = false } const onPointerLockChange = () => { - if (document.pointerLockElement === canvas) wasLocked = true - else if (wasLocked) useViewer.getState().setWalkthroughMode(false) + if (document.pointerLockElement === canvas) { + wasLocked = true + suspendRef.current = false + } else if (suspendRef.current) { + // Deliberately released (screenshot pause) — stay in walkthrough. + } else if (wasLocked) { + useViewer.getState().setWalkthroughMode(false) + } } document.addEventListener('mousemove', onMouseMove) canvas.addEventListener('click', onClick) document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) document.addEventListener('pointerlockchange', onPointerLockChange) return () => { document.removeEventListener('mousemove', onMouseMove) canvas.removeEventListener('click', onClick) document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) document.removeEventListener('pointerlockchange', onPointerLockChange) if (document.pointerLockElement === canvas) document.exitPointerLock() } @@ -372,8 +421,16 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current = api }, []) + const hasStandingClearance = useCallback((position: Vector3) => { + const mesh = worldRef.current?.mesh + if (!mesh) return true + standClearanceRaycaster.set(position, UP) + standClearanceRaycaster.far = STAND_CLEARANCE + return standClearanceRaycaster.intersectObject(mesh, false).length === 0 + }, []) + // Drive the camera from the capsule each frame + respawn if it falls into void. - useFrame(() => { + useFrame((_, delta) => { const group = controllerRef.current?.group if (!group) return @@ -382,8 +439,17 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current?.resetLinVel() } + // Crouch follows the held key; standing back up waits for headroom. + if (crouchKeyRef.current !== crouched) { + if (crouchKeyRef.current) setCrouched(true) + else if (hasStandingClearance(group.position)) setCrouched(false) + } + const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET + eyeOffsetRef.current += + (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) + group.rotation.y = 0 - camera.position.copy(group.position).add(cameraOffset) + camera.position.copy(group.position).add(cameraOffset.set(0, eyeOffsetRef.current, 0)) cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) @@ -396,7 +462,7 @@ export function GlbWalkthroughController({ url }: { url: string }) { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 4014768a0b..02a1269ca7 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -35,7 +35,14 @@ export { type GlbWalkthrough, } from './components/viewer/glb-scene' export { + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, GlbWalkthroughController, + STAND_CAPSULE, + STAND_CLEARANCE, WALKTHROUGH_FOV, } from './components/viewer/glb-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' From b0007d62c52cb26ce5e94ed1bed952d038cc424d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 16:18:29 -0400 Subject: [PATCH 4/8] fix(walkthrough): crouched profile fits ~1 m openings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/editor/first-person-controls.tsx | 4 +++- .../viewer/glb-walkthrough-controller.tsx | 17 +++++++++++------ packages/viewer/src/index.ts | 2 ++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 74cd31984a..76042b2463 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -33,12 +33,14 @@ import { 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' @@ -1599,7 +1601,7 @@ export const FirstPersonControls = () => { fallGravityFactor={4} floatCheckType="BOTH" floatDampingC={36} - floatHeight={0.5} + floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT} floatPullBackHeight={0.35} floatSensorRadius={0.15} floatSpringK={1200} diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index a8280dc45e..b3f4333494 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -46,15 +46,20 @@ 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.3, 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: the -// centre floats back up by half the length delta and the standing capsule top -// sits standLength/2 + radius above it. -export const STAND_CLEARANCE = 0.95 +// 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() @@ -472,7 +477,7 @@ export function GlbWalkthroughController({ url }: { url: string }) { fallGravityFactor={4} floatCheckType="BOTH" floatDampingC={36} - floatHeight={0.5} + floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT} floatPullBackHeight={0.35} floatSensorRadius={0.15} floatSpringK={1200} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 02a1269ca7..ba44676ddc 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -37,12 +37,14 @@ export { 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' From 6c76fc0bbd65c8309c5b440839c6b7230dbeaed4 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 16:24:47 -0400 Subject: [PATCH 5/8] =?UTF-8?q?feat(walkthrough):=20seamless=20screenshot?= =?UTF-8?q?=20pause=20=E2=80=94=20auto-release=20pointer=20lock=20on=20?= =?UTF-8?q?=E2=8C=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../editor/first-person-controls.tsx | 20 +++++++++++++------ .../editor/src/components/walkthrough-hud.tsx | 12 ++++++++++- .../viewer/glb-walkthrough-controller.tsx | 14 ++++++++++--- packages/viewer/src/store/use-viewer.ts | 10 +++++++++- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 76042b2463..944fbd6588 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1163,10 +1163,11 @@ export const FirstPersonControls = () => { if (isLocked) { hadPointerLockRef.current = true suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) return } - // Deliberately released via P (screenshot pause) — stay in first person; + // Deliberately released (screenshot pause) — stay in first person; // clicking the canvas re-locks. if (suspendRef.current) return @@ -1186,6 +1187,7 @@ export const FirstPersonControls = () => { document.removeEventListener('click', handleClick) document.removeEventListener('mousedown', handleMouseDown, true) document.removeEventListener('pointerlockchange', handlePointerLockChange) + useViewer.getState().setWalkthroughSuspended(false) if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -1234,12 +1236,16 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() closeInteractableTarget() - } else if (event.code === 'KeyP' && document.pointerLockElement === canvas) { - // P frees the cursor without leaving first person (e.g. to take an OS - // screenshot, which needs a movable pointer); clicking re-locks. - event.preventDefault() - event.stopPropagation() + } else if ( + (event.code === 'MetaLeft' || event.code === 'MetaRight' || event.code === 'PrintScreen') && + document.pointerLockElement === canvas + ) { + // ⌘ (or PrintScreen) frees the cursor without leaving first person: + // macOS swallows the full ⇧⌘4 but the ⌘-down still reaches the page, + // so the native screenshot flow gets a movable pointer with zero + // ceremony. Clicking the canvas re-locks. suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) document.exitPointerLock() } } @@ -1628,6 +1634,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { const floorLabel = useFirstPersonHud((state) => state.floorLabel) const zoneLabel = useFirstPersonHud((state) => state.zoneLabel) const interact = useFirstPersonHud((state) => state.interact) + const suspended = useViewer((state) => state.walkthroughSuspended) const handleExit = useCallback(() => { if (document.pointerLockElement) { @@ -1641,6 +1648,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { floorLabel={floorLabel} interact={interact} onExit={handleExit} + suspended={suspended} zoneLabel={zoneLabel} > {!hasPlacedSpawn && ( diff --git a/packages/editor/src/components/walkthrough-hud.tsx b/packages/editor/src/components/walkthrough-hud.tsx index ae77a2d19c..243e525f18 100644 --- a/packages/editor/src/components/walkthrough-hud.tsx +++ b/packages/editor/src/components/walkthrough-hud.tsx @@ -10,6 +10,9 @@ 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 } @@ -18,6 +21,7 @@ export function WalkthroughHud({ floorLabel, zoneLabel, interact = null, + suspended = false, onExit, children, }: WalkthroughHudProps) { @@ -58,7 +62,13 @@ export function WalkthroughHud({
- {onExit ? ( + {suspended ? ( +
+ Click to resume + · + {exitContent} +
+ ) : onExit ? (
-
+
{suspended ? ( -
- Click to resume +
+ Click + or + P + to resume · {exitContent}
- ) : onExit ? ( - ) : ( -
- {exitContent} -
+ <> +
+ P + free cursor +
+ {onExit ? ( + + ) : ( +
{exitContent}
+ )} + )}
diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 5781695b0a..f51a2993d9 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -365,17 +365,18 @@ export function GlbWalkthroughController({ url }: { url: string }) { if (event.code === 'Escape' && document.pointerLockElement !== canvas) { useViewer.getState().setWalkthroughMode(false) } - // ⌘ (or PrintScreen) frees the cursor without leaving the walkthrough: - // macOS swallows the full ⇧⌘4 but the ⌘-down still reaches the page, so - // the native screenshot flow gets a movable pointer with zero ceremony. - // Clicking the canvas re-locks. - if ( - (event.code === 'MetaLeft' || event.code === 'MetaRight' || event.code === 'PrintScreen') && - document.pointerLockElement === canvas - ) { - suspendRef.current = true - useViewer.getState().setWalkthroughSuspended(true) - document.exitPointerLock() + // 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 | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } } if (event.code === 'ControlLeft' || event.code === 'ControlRight') { crouchKeyRef.current = true From 699625621f60659b30b250d317f0eaaa738c485b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 16:35:41 -0400 Subject: [PATCH 7/8] fix(walkthrough): freeze crouch during cursor pause; no editor hints in first person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/editor/first-person-controls.tsx | 14 ++++++++++---- .../components/ui/helpers/helper-manager.tsx | 5 +++++ .../viewer/glb-walkthrough-controller.tsx | 17 +++++++++++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 81d175cb3e..ff70c9da2a 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1220,7 +1220,9 @@ export const FirstPersonControls = () => { } if (event.code === 'ControlLeft' || event.code === 'ControlRight') { - crouchKeyRef.current = true + // 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.stopPropagation() @@ -1254,14 +1256,17 @@ export const FirstPersonControls = () => { } const handleKeyUp = (event: KeyboardEvent) => { - if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + if ( + (event.code === 'ControlLeft' || event.code === 'ControlRight') && + !suspendRef.current + ) { crouchKeyRef.current = false } applyMovementKey(event, false) } const handleBlur = () => { - crouchKeyRef.current = false + if (!suspendRef.current) crouchKeyRef.current = false } document.addEventListener('keydown', handleKeyDown, true) @@ -1513,7 +1518,8 @@ export const FirstPersonControls = () => { const group = controllerRef.current.group // Crouch follows the held key; standing back up waits for headroom. - if (crouchKeyRef.current !== crouched) { + // 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) } diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 4ea8f6fda1..83b1c2e3b9 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -95,6 +95,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind) const workspaceMode = useEditor((s) => s.workspaceMode) const scope = useInteractionScope((s) => s.scope) @@ -148,6 +149,10 @@ export function HelperManager() { // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. 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 // tools — editor shortcut hints would only mislead there. if (workspaceMode === 'studio') return null diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index f51a2993d9..6dedeb5230 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -378,17 +378,25 @@ export function GlbWalkthroughController({ url }: { url: string }) { if (result && typeof result.catch === 'function') result.catch(() => {}) } } - 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 ( + (event.code === 'ControlLeft' || event.code === 'ControlRight') && + !suspendRef.current + ) { crouchKeyRef.current = true } } const onKeyUp = (event: KeyboardEvent) => { - if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + if ( + (event.code === 'ControlLeft' || event.code === 'ControlRight') && + !suspendRef.current + ) { crouchKeyRef.current = false } } const onBlur = () => { - crouchKeyRef.current = false + if (!suspendRef.current) crouchKeyRef.current = false } const onPointerLockChange = () => { if (document.pointerLockElement === canvas) { @@ -454,7 +462,8 @@ export function GlbWalkthroughController({ url }: { url: string }) { } // Crouch follows the held key; standing back up waits for headroom. - if (crouchKeyRef.current !== crouched) { + // 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) } From cdbb5b1bc496a7bef8a0d7f65abbdd66d39a7966 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 17:00:05 -0400 Subject: [PATCH 8/8] 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 --- .../src/components/editor/first-person-controls.tsx | 5 +---- packages/editor/src/components/walkthrough-hud.tsx | 6 +++++- packages/nodes/src/wall/panel.tsx | 2 +- .../components/viewer/glb-walkthrough-controller.tsx | 10 ++-------- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index ff70c9da2a..9f4e43e850 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1256,10 +1256,7 @@ export const FirstPersonControls = () => { } const handleKeyUp = (event: KeyboardEvent) => { - if ( - (event.code === 'ControlLeft' || event.code === 'ControlRight') && - !suspendRef.current - ) { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { crouchKeyRef.current = false } applyMovementKey(event, false) diff --git a/packages/editor/src/components/walkthrough-hud.tsx b/packages/editor/src/components/walkthrough-hud.tsx index cea79dfef2..8af196ae62 100644 --- a/packages/editor/src/components/walkthrough-hud.tsx +++ b/packages/editor/src/components/walkthrough-hud.tsx @@ -79,7 +79,11 @@ export function WalkthroughHud({ free cursor
{onExit ? ( - ) : ( diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 2fd4729eb2..22b47ae843 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -111,7 +111,7 @@ export default function WallPanel() { // renders at. `undefined` for walls with an explicit custom height. const planeBoundHeightMeters = useScene((s) => { 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) }) diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 6dedeb5230..0814ba7ea1 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -380,18 +380,12 @@ export function GlbWalkthroughController({ url }: { url: string }) { } // 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 - ) { + 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 - ) { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { crouchKeyRef.current = false } }