diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index d57968c2d..9f4e43e85 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,22 @@ import { useInteractive, useScene, } 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 { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -39,6 +56,7 @@ import { Mesh, MeshBasicMaterial, type Object3D, + type PerspectiveCamera, Ray, Raycaster, Vector2, @@ -46,19 +64,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 +99,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 @@ -120,8 +142,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() @@ -146,6 +170,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 +229,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 +702,11 @@ export const FirstPersonControls = () => { const yawRef = useRef(0) 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 @@ -569,6 +723,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 @@ -979,9 +1162,15 @@ export const FirstPersonControls = () => { const isLocked = document.pointerLockElement === canvas if (isLocked) { hadPointerLockRef.current = true + suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) return } + // Deliberately released (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) } @@ -998,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() } @@ -1029,7 +1219,11 @@ export const FirstPersonControls = () => { 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.stopPropagation() if (document.pointerLockElement === canvas) { @@ -1044,18 +1238,41 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() 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 | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } } } const handleKeyUp = (event: KeyboardEvent) => { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = false + } applyMovementKey(event, false) } + const handleBlur = () => { + if (!suspendRef.current) 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]) @@ -1281,11 +1498,33 @@ 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. + // 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 // 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. @@ -1326,6 +1565,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(() => { @@ -1352,7 +1602,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} 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} @@ -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 }) => { - 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 suspended = useViewer((state) => state.walkthroughSuspended) const handleExit = useCallback(() => { if (document.pointerLockElement) { @@ -1413,86 +1650,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { }, [onExit]) return ( - <> - {isLocked && ( -
-
-
-
-
-
- )} - -
- -
- + {!hasPlacedSpawn && ( -
-
- Place a Spawn Point from the Build tab to control where walkthrough starts. -
-
- )} - - {isLocked && ( -
-
- -
- - - - -
- - Click to look around - -
+
+ Place a spawn point from the Build tab to control where walkthrough starts.
)} - - ) -} - -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/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 4ea8f6fda..83b1c2e3b 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/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index e60c5d662..7918f9f51 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 000000000..929dbd4a9 --- /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 000000000..1d44a9be0 --- /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/components/walkthrough-hud.tsx b/packages/editor/src/components/walkthrough-hud.tsx new file mode 100644 index 000000000..8af196ae6 --- /dev/null +++ b/packages/editor/src/components/walkthrough-hud.tsx @@ -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 = ( + <> + Esc + to exit + + ) + + return ( +
+
+ {floorLabel && ( +
+ {floorLabel} +
+ )} + {zoneLabel && ( +
+ {zoneLabel} +
+ )} + {children} +
+ +
+
+
+ +
+ {suspended ? ( +
+ Click + or + P + to resume + · + {exitContent} +
+ ) : ( + <> +
+ P + free cursor +
+ {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 25544bfbf..f7b6a46d7 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,19 @@ 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, + 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 +484,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 7d9efc48c..1ad720883 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 64c4ce993..2ed3ec4df 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 000000000..b78a0fd90 --- /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/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 2fd4729eb..22b47ae84 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-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index d3b26f9f0..e2e604dca 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 56d521631..0814ba7ea 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' @@ -25,7 +26,12 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { SCENE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' -import { WALKTHROUGH_FOV } from './walkthrough-controls' + +// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels +// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral +// awareness without wide-angle distortion. Applied only while walking — both +// walkthrough controllers read this and restore the orbit FOV on exit. +export const WALKTHROUGH_FOV = 60 // Eye/capsule geometry mirrors the editor's first-person controller so the // baked walkthrough feels identical. The capsule centre sits below the eye; the @@ -37,6 +43,28 @@ const SPAWN_EYE_HEIGHT = 1.65 const LOOK_SENSITIVITY = 0.002 const VOID_FALL_RESPAWN_DEPTH = 12 +// Crouch (hold Ctrl): swap to a short capsule — it shrinks around the centre, +// so a crouch mid-jump also lowers the head AND raises the feet, letting the +// player thread window openings. Standing back up is gated on headroom. +// The float gap counts toward the effective obstacle height (the capsule rides +// floatHeight above the ground), so crouching also lowers it: crouched span is +// CROUCH_FLOAT_HEIGHT + capsule = 0.25 + 0.7 = 0.95 m — fits a 1 m opening. +export const STAND_CAPSULE: [number, number, number, number] = [0.25, 0.8, 4, 8] +export const CROUCH_CAPSULE: [number, number, number, number] = [0.25, 0.2, 4, 8] +export const STAND_FLOAT_HEIGHT = 0.5 +export const CROUCH_FLOAT_HEIGHT = 0.25 +export const CROUCH_EYE_OFFSET = 0.1 +export const CROUCH_WALK_SPEED = 1 +export const CROUCH_RUN_SPEED = 1.4 +// Headroom (from the capsule centre, upward) required before uncrouching: +// standing raises the centre by half the length delta plus the float delta, +// and the standing capsule top sits standLength/2 + radius above the centre. +export const STAND_CLEARANCE = 1.25 +export const EYE_LERP_SPEED = 12 + +const standClearanceRaycaster = new Raycaster() +const UP = new Vector3(0, 1, 0) + // Kinds that must not block the player: room helpers, the spawn marker, the // ceiling/roof shell (you walk under them), and door/window leaves — excluding // the latter lets you pass any doorway whether the leaf is open or shut (the @@ -54,7 +82,7 @@ const keyboardMap: Array<{ name: Exclude; keys: { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, ] -const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraOffset = new Vector3() const cameraEuler = new Euler(0, 0, 0, 'YXZ') const spawnQuat = new Quaternion() const spawnEuler = new Euler(0, 0, 0, 'YXZ') @@ -238,6 +266,10 @@ export function GlbWalkthroughController({ url }: { url: string }) { const controllerRef = useRef(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) @@ -333,20 +365,58 @@ export function GlbWalkthroughController({ url }: { url: string }) { if (event.code === 'Escape' && document.pointerLockElement !== canvas) { useViewer.getState().setWalkthroughMode(false) } + // P toggles a cursor pause (advertised in the HUD): frees the pointer + // without leaving the walkthrough — e.g. for an OS screenshot, which + // needs a movable cursor — and click or P resumes. + if (event.code === 'KeyP') { + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } else if (suspendRef.current) { + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } + } + // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard screenshot) + // must not toggle it under the user. + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = true + } + } + const onKeyUp = (event: KeyboardEvent) => { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = false + } + } + const onBlur = () => { + if (!suspendRef.current) crouchKeyRef.current = false } const onPointerLockChange = () => { - if (document.pointerLockElement === canvas) wasLocked = true - else if (wasLocked) useViewer.getState().setWalkthroughMode(false) + if (document.pointerLockElement === canvas) { + wasLocked = true + suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) + } else if (suspendRef.current) { + // Deliberately released (screenshot pause) — stay in walkthrough. + } else if (wasLocked) { + useViewer.getState().setWalkthroughMode(false) + } } document.addEventListener('mousemove', onMouseMove) canvas.addEventListener('click', onClick) document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) document.addEventListener('pointerlockchange', onPointerLockChange) return () => { document.removeEventListener('mousemove', onMouseMove) canvas.removeEventListener('click', onClick) document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) document.removeEventListener('pointerlockchange', onPointerLockChange) + useViewer.getState().setWalkthroughSuspended(false) if (document.pointerLockElement === canvas) document.exitPointerLock() } }, [gl]) @@ -367,8 +437,16 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current = api }, []) + const hasStandingClearance = useCallback((position: Vector3) => { + const mesh = worldRef.current?.mesh + if (!mesh) return true + standClearanceRaycaster.set(position, UP) + standClearanceRaycaster.far = STAND_CLEARANCE + return standClearanceRaycaster.intersectObject(mesh, false).length === 0 + }, []) + // Drive the camera from the capsule each frame + respawn if it falls into void. - useFrame(() => { + useFrame((_, delta) => { const group = controllerRef.current?.group if (!group) return @@ -377,8 +455,18 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current?.resetLinVel() } + // Crouch follows the held key; standing back up waits for headroom. + // Frozen while the cursor pause is active. + if (!suspendRef.current && crouchKeyRef.current !== crouched) { + if (crouchKeyRef.current) setCrouched(true) + else if (hasStandingClearance(group.position)) setCrouched(false) + } + const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET + eyeOffsetRef.current += + (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) + group.rotation.y = 0 - camera.position.copy(group.position).add(cameraOffset) + camera.position.copy(group.position).add(cameraOffset.set(0, eyeOffsetRef.current, 0)) cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) @@ -391,7 +479,7 @@ export function GlbWalkthroughController({ url }: { url: string }) { 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 65c6b69f1..000000000 --- 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 8408cd88d..ba44676dd 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -34,14 +34,25 @@ export { GlbScene, type GlbWalkthrough, } from './components/viewer/glb-scene' -export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller' +export { + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_FLOAT_HEIGHT, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, + GlbWalkthroughController, + STAND_CAPSULE, + STAND_CLEARANCE, + STAND_FLOAT_HEIGHT, + WALKTHROUGH_FOV, +} from './components/viewer/glb-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, SSGI_PARAMS, } from './components/viewer/post-processing' export { SceneEnvironment } from './components/viewer/scene-environment' -export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 085efb599..1fd8bc20c 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -153,6 +153,11 @@ type ViewerState = { walkthroughMode: boolean setWalkthroughMode: (mode: boolean) => void + /** Pointer lock temporarily released mid-walkthrough (⌘/PrintScreen — OS + * screenshot needs a movable cursor); clicking the canvas re-locks. */ + walkthroughSuspended: boolean + setWalkthroughSuspended: (suspended: boolean) => void + cameraDragging: boolean setCameraDragging: (dragging: boolean) => void @@ -515,7 +520,10 @@ const useViewer = create()( setDebugColors: (enabled) => set({ debugColors: enabled }), walkthroughMode: false, - setWalkthroughMode: (mode) => set({ walkthroughMode: mode }), + setWalkthroughMode: (mode) => set({ walkthroughMode: mode, walkthroughSuspended: false }), + + walkthroughSuspended: false, + setWalkthroughSuspended: (suspended) => set({ walkthroughSuspended: suspended }), cameraDragging: false, setCameraDragging: (dragging) => set({ cameraDragging: dragging }),