Merge pull request #173 from Yashism/feat/street-view

Add street view / walkthrough mode
This commit is contained in:
Pascal
2026-03-30 16:40:19 -04:00
committed by GitHub
7 changed files with 2120 additions and 1789 deletions
@@ -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>
)
}
+21 -12
View File
@@ -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
+20
View File
@@ -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',