Merge pull request #173 from Yashism/feat/street-view
Add street view / walkthrough mode
This commit is contained in:
@@ -22,6 +22,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
||||
export const CustomCameraControls = () => {
|
||||
const controls = useRef<CameraControlsImpl>(null!)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const currentLevelId = selection.levelId
|
||||
@@ -38,7 +39,7 @@ export const CustomCameraControls = () => {
|
||||
}, [camera, raycaster])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreviewMode) return // Preview mode uses auto-navigate instead
|
||||
if (isPreviewMode || isFirstPersonMode) return
|
||||
let targetY = 0
|
||||
if (currentLevelId) {
|
||||
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
||||
@@ -57,10 +58,10 @@ export const CustomCameraControls = () => {
|
||||
currentTarget.z,
|
||||
true,
|
||||
)
|
||||
}, [currentLevelId, isPreviewMode])
|
||||
}, [currentLevelId, isPreviewMode, isFirstPersonMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!controls.current) return
|
||||
if (!controls.current || isFirstPersonMode) return
|
||||
|
||||
controls.current.maxPolarAngle = maxPolarAngle
|
||||
controls.current.minPolarAngle = 0
|
||||
@@ -68,7 +69,7 @@ export const CustomCameraControls = () => {
|
||||
if (controls.current.polarAngle > maxPolarAngle) {
|
||||
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
||||
}
|
||||
}, [maxPolarAngle])
|
||||
}, [maxPolarAngle, isFirstPersonMode])
|
||||
|
||||
const focusNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
@@ -116,6 +117,8 @@ export const CustomCameraControls = () => {
|
||||
}, [cameraMode, isPreviewMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstPersonMode) return
|
||||
|
||||
const keyState = {
|
||||
shiftRight: false,
|
||||
shiftLeft: false,
|
||||
@@ -196,7 +199,7 @@ export const CustomCameraControls = () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [cameraMode, isPreviewMode])
|
||||
}, [cameraMode, isPreviewMode, isFirstPersonMode])
|
||||
|
||||
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
||||
const previewTargetNodeId = isPreviewMode
|
||||
@@ -258,6 +261,8 @@ export const CustomCameraControls = () => {
|
||||
}, [isPreviewMode, previewTargetNodeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstPersonMode) return
|
||||
|
||||
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
||||
if (!controls.current) return
|
||||
|
||||
@@ -349,7 +354,7 @@ export const CustomCameraControls = () => {
|
||||
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||
}
|
||||
}, [focusNode])
|
||||
}, [focusNode, isFirstPersonMode])
|
||||
|
||||
const onTransitionStart = useCallback(() => {
|
||||
useViewer.getState().setCameraDragging(true)
|
||||
@@ -359,6 +364,11 @@ export const CustomCameraControls = () => {
|
||||
useViewer.getState().setCameraDragging(false)
|
||||
}, [])
|
||||
|
||||
// In first-person mode, don't render orbit controls — FirstPersonControls takes over
|
||||
if (isFirstPersonMode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CameraControls
|
||||
makeDefault
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Euler, Vector3 } from 'three'
|
||||
import useEditor from '../../store/use-editor'
|
||||
|
||||
// Average human eye height in meters
|
||||
const EYE_HEIGHT = 1.65
|
||||
// Movement speed in meters per second
|
||||
const MOVE_SPEED = 5
|
||||
// Sprint multiplier when holding Shift
|
||||
const SPRINT_MULTIPLIER = 2
|
||||
// Vertical float speed in meters per second
|
||||
const VERTICAL_SPEED = 3
|
||||
// Mouse look sensitivity
|
||||
const MOUSE_SENSITIVITY = 0.002
|
||||
// Min Y position (eye height above ground)
|
||||
const MIN_Y = EYE_HEIGHT
|
||||
|
||||
// Reusable vectors to avoid allocations in the render loop
|
||||
const _forward = new Vector3()
|
||||
const _right = new Vector3()
|
||||
const _moveVector = new Vector3()
|
||||
const _euler = new Euler(0, 0, 0, 'YXZ')
|
||||
|
||||
export const FirstPersonControls = () => {
|
||||
const { camera, gl } = useThree()
|
||||
const keysRef = useRef<Set<string>>(new Set())
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const isLockedRef = useRef(false)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
// Initialize camera for first-person view: start at center of scene, on the ground
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return
|
||||
initializedRef.current = true
|
||||
|
||||
// Place camera at the origin (center of grid) at eye height, looking along +X
|
||||
camera.position.set(0, EYE_HEIGHT, 0)
|
||||
yawRef.current = 0
|
||||
pitchRef.current = 0
|
||||
}, [camera])
|
||||
|
||||
// Pointer lock and event handlers
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
|
||||
const requestLock = () => {
|
||||
if (!isLockedRef.current) {
|
||||
canvas.requestPointerLock()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerLockChange = () => {
|
||||
isLockedRef.current = document.pointerLockElement === canvas
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isLockedRef.current) return
|
||||
|
||||
yawRef.current -= e.movementX * MOUSE_SENSITIVITY
|
||||
pitchRef.current -= e.movementY * MOUSE_SENSITIVITY
|
||||
// Clamp pitch to prevent flipping (almost straight up/down)
|
||||
pitchRef.current = Math.max(
|
||||
-Math.PI / 2 + 0.05,
|
||||
Math.min(Math.PI / 2 - 0.05, pitchRef.current),
|
||||
)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const code = e.code
|
||||
|
||||
// Movement keys
|
||||
if (
|
||||
code === 'KeyW' ||
|
||||
code === 'KeyA' ||
|
||||
code === 'KeyS' ||
|
||||
code === 'KeyD' ||
|
||||
code === 'KeyQ' ||
|
||||
code === 'KeyE' ||
|
||||
code === 'ShiftLeft' ||
|
||||
code === 'ShiftRight'
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
keysRef.current.add(code)
|
||||
}
|
||||
|
||||
// ESC exits first-person mode
|
||||
if (code === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (document.pointerLockElement === canvas) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
useEditor.getState().setFirstPersonMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
keysRef.current.delete(e.code)
|
||||
}
|
||||
|
||||
canvas.addEventListener('click', requestLock)
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
// Use capture phase so we intercept movement keys before the global keyboard handler
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('click', requestLock)
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
if (document.pointerLockElement === canvas) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
keysRef.current.clear()
|
||||
}
|
||||
}, [gl])
|
||||
|
||||
// Per-frame movement and camera rotation
|
||||
useFrame((_, delta) => {
|
||||
// Clamp delta to avoid huge jumps (e.g. tab switching)
|
||||
const dt = Math.min(delta, 0.1)
|
||||
const keys = keysRef.current
|
||||
|
||||
const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight')
|
||||
const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1)
|
||||
|
||||
// Calculate forward and right vectors on the XZ plane (ignore pitch for movement)
|
||||
_forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
|
||||
_right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
|
||||
|
||||
_moveVector.set(0, 0, 0)
|
||||
|
||||
if (keys.has('KeyW')) _moveVector.add(_forward)
|
||||
if (keys.has('KeyS')) _moveVector.sub(_forward)
|
||||
if (keys.has('KeyA')) _moveVector.sub(_right)
|
||||
if (keys.has('KeyD')) _moveVector.add(_right)
|
||||
|
||||
// Normalize diagonal movement so it's not faster
|
||||
if (_moveVector.lengthSq() > 0) {
|
||||
_moveVector.normalize().multiplyScalar(speed * dt)
|
||||
camera.position.add(_moveVector)
|
||||
}
|
||||
|
||||
// Vertical movement (Q = up, E = down)
|
||||
if (keys.has('KeyQ')) {
|
||||
camera.position.y += VERTICAL_SPEED * dt
|
||||
}
|
||||
if (keys.has('KeyE')) {
|
||||
camera.position.y -= VERTICAL_SPEED * dt
|
||||
}
|
||||
|
||||
// Clamp Y so camera never goes below ground level + eye height
|
||||
if (camera.position.y < MIN_Y) {
|
||||
camera.position.y = MIN_Y
|
||||
}
|
||||
|
||||
// Apply look rotation
|
||||
_euler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||
camera.quaternion.setFromEuler(_euler)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 handleExit = useCallback(() => {
|
||||
if (document.pointerLockElement) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
onExit()
|
||||
}, [onExit])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Crosshair */}
|
||||
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
|
||||
<div className="relative h-6 w-6">
|
||||
<div className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-white/60" />
|
||||
<div className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-white/60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exit button — top-right */}
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<button
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
|
||||
onClick={handleExit}
|
||||
type="button"
|
||||
>
|
||||
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
Exit Street View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls hint — bottom-center */}
|
||||
<div className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2">
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-border/35 bg-background/80 px-5 py-3 shadow-lg backdrop-blur-xl">
|
||||
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
|
||||
<div className="h-5 w-px bg-border/30" />
|
||||
<ControlHint label="Up" keys={['Q']} />
|
||||
<ControlHint label="Down" keys={['E']} />
|
||||
<div className="h-5 w-px bg-border/30" />
|
||||
<ControlHint label="Sprint" keys={['Shift']} />
|
||||
<div className="h-5 w-px bg-border/30" />
|
||||
<span className="text-muted-foreground/60 text-xs">Click to look around</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
|
||||
key={key}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
||||
import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { FloorplanPanel } from './floorplan-panel'
|
||||
import { Grid } from './grid'
|
||||
@@ -334,6 +335,7 @@ export default function Editor({
|
||||
null,
|
||||
)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -415,14 +417,18 @@ export default function Editor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showLoader && isCameraControlsHintVisible ? (
|
||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||
<ViewerCanvasControlsHint
|
||||
isPreviewMode={isPreviewMode}
|
||||
onDismiss={dismissCameraControlsHint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && isPreviewMode ? (
|
||||
{isFirstPersonMode ? (
|
||||
<FirstPersonOverlay
|
||||
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
||||
/>
|
||||
) : !isLoading && isPreviewMode ? (
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
) : (
|
||||
<>
|
||||
@@ -445,24 +451,27 @@ export default function Editor({
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<div className="h-full w-full">
|
||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
{!isPreviewMode && <WallMeasurementLabel />}
|
||||
<Viewer selectionManager={isPreviewMode || isFirstPersonMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && !isFirstPersonMode && <SelectionManager />}
|
||||
{!isPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
||||
{!isPreviewMode && !isFirstPersonMode && <WallMeasurementLabel />}
|
||||
<ExportManager />
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
{isPreviewMode || isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
{!isPreviewMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||
{!(isPreviewMode || isLoading) && <ToolManager />}
|
||||
{!isPreviewMode && !isFirstPersonMode && (
|
||||
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
|
||||
)}
|
||||
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
{isFirstPersonMode && <FirstPersonControls />}
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
{!isPreviewMode && !isFirstPersonMode && <SiteEdgeLabels />}
|
||||
{(isPreviewMode || isFirstPersonMode) && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
</div>
|
||||
{!(isPreviewMode || isLoading) && <ZoneLabelEditorSystem />}
|
||||
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</PresetsProvider>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import Image from 'next/image'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export function CameraActions() {
|
||||
@@ -17,6 +19,10 @@ export function CameraActions() {
|
||||
emitter.emit('camera-controls:orbit-ccw')
|
||||
}
|
||||
|
||||
const enterStreetView = () => {
|
||||
useEditor.getState().setFirstPersonMode(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Orbit CCW */}
|
||||
@@ -69,6 +75,23 @@ export function CameraActions() {
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
{/* Street View */}
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Street View"
|
||||
onClick={enterStreetView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon
|
||||
className="opacity-70 transition-opacity group-hover:opacity-100"
|
||||
color="currentColor"
|
||||
height={22}
|
||||
icon="mdi:walk"
|
||||
width={22}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
|
||||
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Footprints, Sun } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '../lib/utils'
|
||||
import useEditor from '../store/use-editor'
|
||||
import { ActionButton } from './ui/action-menu/action-button'
|
||||
import { TooltipProvider } from './ui/primitives/tooltip'
|
||||
|
||||
@@ -491,6 +492,20 @@ export const ViewerOverlay = ({
|
||||
src="/icons/topview.png"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* Street View */}
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Street View"
|
||||
onClick={() => useEditor.getState().setFirstPersonMode(true)}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Footprints className="h-5 w-5 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,11 @@ export const useKeyboard = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// In first-person mode, all shortcuts are handled by FirstPersonControls
|
||||
if (useEditor.getState().isFirstPersonMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
_toolCancelConsumed = false
|
||||
|
||||
@@ -93,6 +93,9 @@ type EditorState = {
|
||||
// Development-only camera debug flag for inspecting underside geometry
|
||||
allowUndergroundCamera: boolean
|
||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||
// First-person walkthrough mode (street view)
|
||||
isFirstPersonMode: boolean
|
||||
setFirstPersonMode: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export type PersistedEditorUiState = Pick<
|
||||
@@ -347,6 +350,23 @@ const useEditor = create<EditorState>()(
|
||||
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||
allowUndergroundCamera: false,
|
||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||
isFirstPersonMode: false,
|
||||
setFirstPersonMode: (enabled) => {
|
||||
if (enabled) {
|
||||
// Force perspective camera and full-height walls for immersive walkthrough
|
||||
useViewer.getState().setCameraMode('perspective')
|
||||
useViewer.getState().setWallMode('up')
|
||||
set({
|
||||
isFirstPersonMode: true,
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
catalogCategory: null,
|
||||
})
|
||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||
} else {
|
||||
set({ isFirstPersonMode: false })
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'pascal-editor-ui-preferences',
|
||||
|
||||
Reference in New Issue
Block a user