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 = () => {
|
export const CustomCameraControls = () => {
|
||||||
const controls = useRef<CameraControlsImpl>(null!)
|
const controls = useRef<CameraControlsImpl>(null!)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const currentLevelId = selection.levelId
|
const currentLevelId = selection.levelId
|
||||||
@@ -38,7 +39,7 @@ export const CustomCameraControls = () => {
|
|||||||
}, [camera, raycaster])
|
}, [camera, raycaster])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPreviewMode) return // Preview mode uses auto-navigate instead
|
if (isPreviewMode || isFirstPersonMode) return
|
||||||
let targetY = 0
|
let targetY = 0
|
||||||
if (currentLevelId) {
|
if (currentLevelId) {
|
||||||
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
||||||
@@ -57,10 +58,10 @@ export const CustomCameraControls = () => {
|
|||||||
currentTarget.z,
|
currentTarget.z,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}, [currentLevelId, isPreviewMode])
|
}, [currentLevelId, isPreviewMode, isFirstPersonMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!controls.current) return
|
if (!controls.current || isFirstPersonMode) return
|
||||||
|
|
||||||
controls.current.maxPolarAngle = maxPolarAngle
|
controls.current.maxPolarAngle = maxPolarAngle
|
||||||
controls.current.minPolarAngle = 0
|
controls.current.minPolarAngle = 0
|
||||||
@@ -68,7 +69,7 @@ export const CustomCameraControls = () => {
|
|||||||
if (controls.current.polarAngle > maxPolarAngle) {
|
if (controls.current.polarAngle > maxPolarAngle) {
|
||||||
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
||||||
}
|
}
|
||||||
}, [maxPolarAngle])
|
}, [maxPolarAngle, isFirstPersonMode])
|
||||||
|
|
||||||
const focusNode = useCallback(
|
const focusNode = useCallback(
|
||||||
(nodeId: string) => {
|
(nodeId: string) => {
|
||||||
@@ -116,6 +117,8 @@ export const CustomCameraControls = () => {
|
|||||||
}, [cameraMode, isPreviewMode])
|
}, [cameraMode, isPreviewMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isFirstPersonMode) return
|
||||||
|
|
||||||
const keyState = {
|
const keyState = {
|
||||||
shiftRight: false,
|
shiftRight: false,
|
||||||
shiftLeft: false,
|
shiftLeft: false,
|
||||||
@@ -196,7 +199,7 @@ export const CustomCameraControls = () => {
|
|||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('keyup', onKeyUp)
|
document.removeEventListener('keyup', onKeyUp)
|
||||||
}
|
}
|
||||||
}, [cameraMode, isPreviewMode])
|
}, [cameraMode, isPreviewMode, isFirstPersonMode])
|
||||||
|
|
||||||
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
||||||
const previewTargetNodeId = isPreviewMode
|
const previewTargetNodeId = isPreviewMode
|
||||||
@@ -258,6 +261,8 @@ export const CustomCameraControls = () => {
|
|||||||
}, [isPreviewMode, previewTargetNodeId])
|
}, [isPreviewMode, previewTargetNodeId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isFirstPersonMode) return
|
||||||
|
|
||||||
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
||||||
if (!controls.current) return
|
if (!controls.current) return
|
||||||
|
|
||||||
@@ -349,7 +354,7 @@ export const CustomCameraControls = () => {
|
|||||||
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
||||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||||
}
|
}
|
||||||
}, [focusNode])
|
}, [focusNode, isFirstPersonMode])
|
||||||
|
|
||||||
const onTransitionStart = useCallback(() => {
|
const onTransitionStart = useCallback(() => {
|
||||||
useViewer.getState().setCameraDragging(true)
|
useViewer.getState().setCameraDragging(true)
|
||||||
@@ -359,6 +364,11 @@ export const CustomCameraControls = () => {
|
|||||||
useViewer.getState().setCameraDragging(false)
|
useViewer.getState().setCameraDragging(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// In first-person mode, don't render orbit controls — FirstPersonControls takes over
|
||||||
|
if (isFirstPersonMode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CameraControls
|
<CameraControls
|
||||||
makeDefault
|
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 type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
|
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||||
import { FloatingActionMenu } from './floating-action-menu'
|
import { FloatingActionMenu } from './floating-action-menu'
|
||||||
import { FloorplanPanel } from './floorplan-panel'
|
import { FloorplanPanel } from './floorplan-panel'
|
||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
@@ -334,6 +335,7 @@ export default function Editor({
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -415,14 +417,18 @@ export default function Editor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showLoader && isCameraControlsHintVisible ? (
|
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||||
<ViewerCanvasControlsHint
|
<ViewerCanvasControlsHint
|
||||||
isPreviewMode={isPreviewMode}
|
isPreviewMode={isPreviewMode}
|
||||||
onDismiss={dismissCameraControlsHint}
|
onDismiss={dismissCameraControlsHint}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading && isPreviewMode ? (
|
{isFirstPersonMode ? (
|
||||||
|
<FirstPersonOverlay
|
||||||
|
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
||||||
|
/>
|
||||||
|
) : !isLoading && isPreviewMode ? (
|
||||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -445,24 +451,27 @@ export default function Editor({
|
|||||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
<Viewer selectionManager={isPreviewMode || isFirstPersonMode ? 'default' : 'custom'}>
|
||||||
{!isPreviewMode && <SelectionManager />}
|
{!isPreviewMode && !isFirstPersonMode && <SelectionManager />}
|
||||||
{!isPreviewMode && <FloatingActionMenu />}
|
{!isPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
||||||
{!isPreviewMode && <WallMeasurementLabel />}
|
{!isPreviewMode && !isFirstPersonMode && <WallMeasurementLabel />}
|
||||||
<ExportManager />
|
<ExportManager />
|
||||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
{isPreviewMode || isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<RoofEditSystem />
|
<RoofEditSystem />
|
||||||
{!isPreviewMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
{!isPreviewMode && !isFirstPersonMode && (
|
||||||
{!(isPreviewMode || isLoading) && <ToolManager />}
|
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
|
||||||
|
)}
|
||||||
|
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ToolManager />}
|
||||||
<CustomCameraControls />
|
<CustomCameraControls />
|
||||||
|
{isFirstPersonMode && <FirstPersonControls />}
|
||||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
<PresetThumbnailGenerator />
|
<PresetThumbnailGenerator />
|
||||||
{!isPreviewMode && <SiteEdgeLabels />}
|
{!isPreviewMode && !isFirstPersonMode && <SiteEdgeLabels />}
|
||||||
{isPreviewMode && <InteractiveSystem />}
|
{(isPreviewMode || isFirstPersonMode) && <InteractiveSystem />}
|
||||||
</Viewer>
|
</Viewer>
|
||||||
</div>
|
</div>
|
||||||
{!(isPreviewMode || isLoading) && <ZoneLabelEditorSystem />}
|
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ZoneLabelEditorSystem />}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</PresetsProvider>
|
</PresetsProvider>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react'
|
||||||
import { emitter } from '@pascal-app/core'
|
import { emitter } from '@pascal-app/core'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
export function CameraActions() {
|
export function CameraActions() {
|
||||||
@@ -17,6 +19,10 @@ export function CameraActions() {
|
|||||||
emitter.emit('camera-controls:orbit-ccw')
|
emitter.emit('camera-controls:orbit-ccw')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enterStreetView = () => {
|
||||||
|
useEditor.getState().setFirstPersonMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Orbit CCW */}
|
{/* Orbit CCW */}
|
||||||
@@ -69,6 +75,23 @@ export function CameraActions() {
|
|||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { motion } from 'motion/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
|
import useEditor from '../store/use-editor'
|
||||||
import { ActionButton } from './ui/action-menu/action-button'
|
import { ActionButton } from './ui/action-menu/action-button'
|
||||||
import { TooltipProvider } from './ui/primitives/tooltip'
|
import { TooltipProvider } from './ui/primitives/tooltip'
|
||||||
|
|
||||||
@@ -491,6 +492,20 @@ export const ViewerOverlay = ({
|
|||||||
src="/icons/topview.png"
|
src="/icons/topview.png"
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</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>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export const useKeyboard = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In first-person mode, all shortcuts are handled by FirstPersonControls
|
||||||
|
if (useEditor.getState().isFirstPersonMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
_toolCancelConsumed = false
|
_toolCancelConsumed = false
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ type EditorState = {
|
|||||||
// Development-only camera debug flag for inspecting underside geometry
|
// Development-only camera debug flag for inspecting underside geometry
|
||||||
allowUndergroundCamera: boolean
|
allowUndergroundCamera: boolean
|
||||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||||
|
// First-person walkthrough mode (street view)
|
||||||
|
isFirstPersonMode: boolean
|
||||||
|
setFirstPersonMode: (enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersistedEditorUiState = Pick<
|
export type PersistedEditorUiState = Pick<
|
||||||
@@ -347,6 +350,23 @@ const useEditor = create<EditorState>()(
|
|||||||
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||||
allowUndergroundCamera: false,
|
allowUndergroundCamera: false,
|
||||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
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',
|
name: 'pascal-editor-ui-preferences',
|
||||||
|
|||||||
Reference in New Issue
Block a user