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
@@ -1,377 +1,387 @@
'use client' 'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three' import { Box3, Vector3 } from 'three'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
const currentTarget = new Vector3() const currentTarget = new Vector3()
const tempBox = new Box3() const tempBox = new Box3()
const tempCenter = new Vector3() const tempCenter = new Vector3()
const tempDelta = new Vector3() const tempDelta = new Vector3()
const tempPosition = new Vector3() const tempPosition = new Vector3()
const tempSize = new Vector3() const tempSize = new Vector3()
const tempTarget = new Vector3() const tempTarget = new Vector3()
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 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 allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const selection = useViewer((s) => s.selection) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const currentLevelId = selection.levelId const selection = useViewer((s) => s.selection)
const firstLoad = useRef(true) const currentLevelId = selection.levelId
const maxPolarAngle = const firstLoad = useRef(true)
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const camera = useThree((state) => state.camera)
const raycaster = useThree((state) => state.raycaster) const camera = useThree((state) => state.camera)
useEffect(() => { const raycaster = useThree((state) => state.raycaster)
camera.layers.enable(EDITOR_LAYER) useEffect(() => {
raycaster.layers.enable(EDITOR_LAYER) camera.layers.enable(EDITOR_LAYER)
raycaster.layers.enable(ZONE_LAYER) raycaster.layers.enable(EDITOR_LAYER)
}, [camera, raycaster]) raycaster.layers.enable(ZONE_LAYER)
}, [camera, raycaster])
useEffect(() => {
if (isPreviewMode) return // Preview mode uses auto-navigate instead useEffect(() => {
let targetY = 0 if (isPreviewMode || isFirstPersonMode) return
if (currentLevelId) { let targetY = 0
const levelMesh = sceneRegistry.nodes.get(currentLevelId) if (currentLevelId) {
if (levelMesh) { const levelMesh = sceneRegistry.nodes.get(currentLevelId)
targetY = levelMesh.position.y if (levelMesh) {
} targetY = levelMesh.position.y
} }
if (firstLoad.current) { }
firstLoad.current = false if (firstLoad.current) {
;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) firstLoad.current = false
} ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)
;(controls.current as CameraControlsImpl).getTarget(currentTarget) }
;(controls.current as CameraControlsImpl).moveTo( ;(controls.current as CameraControlsImpl).getTarget(currentTarget)
currentTarget.x, ;(controls.current as CameraControlsImpl).moveTo(
targetY, currentTarget.x,
currentTarget.z, targetY,
true, currentTarget.z,
) true,
}, [currentLevelId, isPreviewMode]) )
}, [currentLevelId, isPreviewMode, isFirstPersonMode])
useEffect(() => {
if (!controls.current) return useEffect(() => {
if (!controls.current || isFirstPersonMode) return
controls.current.maxPolarAngle = maxPolarAngle
controls.current.minPolarAngle = 0 controls.current.maxPolarAngle = maxPolarAngle
controls.current.minPolarAngle = 0
if (controls.current.polarAngle > maxPolarAngle) {
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) if (controls.current.polarAngle > maxPolarAngle) {
} controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
}, [maxPolarAngle]) }
}, [maxPolarAngle, isFirstPersonMode])
const focusNode = useCallback(
(nodeId: string) => { const focusNode = useCallback(
if (isPreviewMode || !controls.current) return (nodeId: string) => {
if (isPreviewMode || !controls.current) return
const object3D = sceneRegistry.nodes.get(nodeId)
if (!object3D) return const object3D = sceneRegistry.nodes.get(nodeId)
if (!object3D) return
tempBox.setFromObject(object3D)
if (tempBox.isEmpty()) return tempBox.setFromObject(object3D)
if (tempBox.isEmpty()) return
tempBox.getCenter(tempCenter)
controls.current.getPosition(tempPosition) tempBox.getCenter(tempCenter)
controls.current.getTarget(tempTarget) controls.current.getPosition(tempPosition)
tempDelta.copy(tempCenter).sub(tempTarget) controls.current.getTarget(tempTarget)
tempDelta.copy(tempCenter).sub(tempTarget)
controls.current.setLookAt(
tempPosition.x + tempDelta.x, controls.current.setLookAt(
tempPosition.y + tempDelta.y, tempPosition.x + tempDelta.x,
tempPosition.z + tempDelta.z, tempPosition.y + tempDelta.y,
tempCenter.x, tempPosition.z + tempDelta.z,
tempCenter.y, tempCenter.x,
tempCenter.z, tempCenter.y,
true, tempCenter.z,
) true,
}, )
[isPreviewMode], },
) [isPreviewMode],
)
// Configure mouse buttons based on control mode and camera mode
const cameraMode = useViewer((state) => state.cameraMode) // Configure mouse buttons based on control mode and camera mode
const mouseButtons = useMemo(() => { const cameraMode = useViewer((state) => state.cameraMode)
// Use ZOOM for orthographic camera, DOLLY for perspective camera const mouseButtons = useMemo(() => {
const wheelAction = // Use ZOOM for orthographic camera, DOLLY for perspective camera
cameraMode === 'orthographic' const wheelAction =
? CameraControlsImpl.ACTION.ZOOM cameraMode === 'orthographic'
: CameraControlsImpl.ACTION.DOLLY ? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY
return {
left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE, return {
middle: CameraControlsImpl.ACTION.SCREEN_PAN, left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,
right: CameraControlsImpl.ACTION.ROTATE, middle: CameraControlsImpl.ACTION.SCREEN_PAN,
wheel: wheelAction, right: CameraControlsImpl.ACTION.ROTATE,
} wheel: wheelAction,
}, [cameraMode, isPreviewMode]) }
}, [cameraMode, isPreviewMode])
useEffect(() => {
const keyState = { useEffect(() => {
shiftRight: false, if (isFirstPersonMode) return
shiftLeft: false,
controlRight: false, const keyState = {
controlLeft: false, shiftRight: false,
space: false, shiftLeft: false,
} controlRight: false,
controlLeft: false,
const updateConfig = () => { space: false,
if (!controls.current) return }
const shift = keyState.shiftRight || keyState.shiftLeft const updateConfig = () => {
const control = keyState.controlRight || keyState.controlLeft if (!controls.current) return
const space = keyState.space
const shift = keyState.shiftRight || keyState.shiftLeft
const wheelAction = const control = keyState.controlRight || keyState.controlLeft
cameraMode === 'orthographic' const space = keyState.space
? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY const wheelAction =
controls.current.mouseButtons.wheel = wheelAction cameraMode === 'orthographic'
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN ? CameraControlsImpl.ACTION.ZOOM
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE : CameraControlsImpl.ACTION.DOLLY
if (isPreviewMode) { controls.current.mouseButtons.wheel = wheelAction
// In preview mode, left-click is always pan (viewer-style) controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
} else if (space) { if (isPreviewMode) {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN // In preview mode, left-click is always pan (viewer-style)
} else { controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE } else if (space) {
} controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
} } else {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
const onKeyDown = (event: KeyboardEvent) => { }
if (event.code === 'Space') { }
keyState.space = true
document.body.style.cursor = 'grab' const onKeyDown = (event: KeyboardEvent) => {
} if (event.code === 'Space') {
if (event.code === 'ShiftRight') { keyState.space = true
keyState.shiftRight = true document.body.style.cursor = 'grab'
} }
if (event.code === 'ShiftLeft') { if (event.code === 'ShiftRight') {
keyState.shiftLeft = true keyState.shiftRight = true
} }
if (event.code === 'ControlRight') { if (event.code === 'ShiftLeft') {
keyState.controlRight = true keyState.shiftLeft = true
} }
if (event.code === 'ControlLeft') { if (event.code === 'ControlRight') {
keyState.controlLeft = true keyState.controlRight = true
} }
updateConfig() if (event.code === 'ControlLeft') {
} keyState.controlLeft = true
}
const onKeyUp = (event: KeyboardEvent) => { updateConfig()
if (event.code === 'Space') { }
keyState.space = false
document.body.style.cursor = '' const onKeyUp = (event: KeyboardEvent) => {
} if (event.code === 'Space') {
if (event.code === 'ShiftRight') { keyState.space = false
keyState.shiftRight = false document.body.style.cursor = ''
} }
if (event.code === 'ShiftLeft') { if (event.code === 'ShiftRight') {
keyState.shiftLeft = false keyState.shiftRight = false
} }
if (event.code === 'ControlRight') { if (event.code === 'ShiftLeft') {
keyState.controlRight = false keyState.shiftLeft = false
} }
if (event.code === 'ControlLeft') { if (event.code === 'ControlRight') {
keyState.controlLeft = false keyState.controlRight = false
} }
updateConfig() if (event.code === 'ControlLeft') {
} keyState.controlLeft = false
}
document.addEventListener('keydown', onKeyDown) updateConfig()
document.addEventListener('keyup', onKeyUp) }
updateConfig()
document.addEventListener('keydown', onKeyDown)
return () => { document.addEventListener('keyup', onKeyUp)
document.removeEventListener('keydown', onKeyDown) updateConfig()
document.removeEventListener('keyup', onKeyUp)
} return () => {
}, [cameraMode, isPreviewMode]) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
// Preview mode: auto-navigate camera to selected node (viewer behavior) }
const previewTargetNodeId = isPreviewMode }, [cameraMode, isPreviewMode, isFirstPersonMode])
? (selection.zoneId ?? selection.levelId ?? selection.buildingId)
: null // Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode
useEffect(() => { ? (selection.zoneId ?? selection.levelId ?? selection.buildingId)
if (!(isPreviewMode && controls.current)) return : null
const nodes = useScene.getState().nodes useEffect(() => {
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null if (!(isPreviewMode && controls.current)) return
if (!previewTargetNodeId) { const nodes = useScene.getState().nodes
const site = Object.values(nodes).find((n) => n.type === 'site') let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
node = site || null
} if (!previewTargetNodeId) {
if (!node) return const site = Object.values(nodes).find((n) => n.type === 'site')
node = site || null
// Check if node has a saved camera }
if (node.camera) { if (!node) return
const { position, target } = node.camera
requestAnimationFrame(() => { // Check if node has a saved camera
if (!controls.current) return if (node.camera) {
controls.current.setLookAt( const { position, target } = node.camera
position[0], requestAnimationFrame(() => {
position[1], if (!controls.current) return
position[2], controls.current.setLookAt(
target[0], position[0],
target[1], position[1],
target[2], position[2],
true, target[0],
) target[1],
}) target[2],
return true,
} )
})
if (!previewTargetNodeId) return return
}
// Calculate camera position from bounding box
const object3D = sceneRegistry.nodes.get(previewTargetNodeId) if (!previewTargetNodeId) return
if (!object3D) return
// Calculate camera position from bounding box
tempBox.setFromObject(object3D) const object3D = sceneRegistry.nodes.get(previewTargetNodeId)
tempBox.getCenter(tempCenter) if (!object3D) return
tempBox.getSize(tempSize)
tempBox.setFromObject(object3D)
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) tempBox.getCenter(tempCenter)
const distance = Math.max(maxDim * 2, 15) tempBox.getSize(tempSize)
controls.current.setLookAt( const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
tempCenter.x + distance * 0.7, const distance = Math.max(maxDim * 2, 15)
tempCenter.y + distance * 0.5,
tempCenter.z + distance * 0.7, controls.current.setLookAt(
tempCenter.x, tempCenter.x + distance * 0.7,
tempCenter.y, tempCenter.y + distance * 0.5,
tempCenter.z, tempCenter.z + distance * 0.7,
true, tempCenter.x,
) tempCenter.y,
}, [isPreviewMode, previewTargetNodeId]) tempCenter.z,
true,
useEffect(() => { )
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { }, [isPreviewMode, previewTargetNodeId])
if (!controls.current) return
useEffect(() => {
const position = new Vector3() if (isFirstPersonMode) return
const target = new Vector3()
controls.current.getPosition(position) const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
controls.current.getTarget(target) if (!controls.current) return
const state = useScene.getState() const position = new Vector3()
const target = new Vector3()
state.updateNode(nodeId, { controls.current.getPosition(position)
camera: { controls.current.getTarget(target)
position: [position.x, position.y, position.z],
target: [target.x, target.y, target.z], const state = useScene.getState()
mode: useViewer.getState().cameraMode,
}, state.updateNode(nodeId, {
}) camera: {
} position: [position.x, position.y, position.z],
const handleNodeView = ({ nodeId }: CameraControlEvent) => { target: [target.x, target.y, target.z],
if (!controls.current) return mode: useViewer.getState().cameraMode,
},
const node = useScene.getState().nodes[nodeId] })
if (!node?.camera) return }
const { position, target } = node.camera const handleNodeView = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return
controls.current.setLookAt(
position[0], const node = useScene.getState().nodes[nodeId]
position[1], if (!node?.camera) return
position[2], const { position, target } = node.camera
target[0],
target[1], controls.current.setLookAt(
target[2], position[0],
true, position[1],
) position[2],
} target[0],
target[1],
const handleTopView = () => { target[2],
if (!controls.current) return true,
)
const currentPolarAngle = controls.current.polarAngle }
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45° const handleTopView = () => {
// Otherwise, go to top view (0°) if (!controls.current) return
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
const currentPolarAngle = controls.current.polarAngle
controls.current.rotatePolarTo(targetAngle, true)
} // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
// Otherwise, go to top view (0°)
const handleOrbitCW = () => { const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
if (!controls.current) return
controls.current.rotatePolarTo(targetAngle, true)
const currentAzimuth = controls.current.azimuthAngle }
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° clockwise const handleOrbitCW = () => {
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) if (!controls.current) return
const target = rounded - Math.PI / 2
const currentAzimuth = controls.current.azimuthAngle
controls.current.rotateTo(target, currentPolar, true) const currentPolar = controls.current.polarAngle
} // Round to nearest 90° increment, then rotate 90° clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const handleOrbitCCW = () => { const target = rounded - Math.PI / 2
if (!controls.current) return
controls.current.rotateTo(target, currentPolar, true)
const currentAzimuth = controls.current.azimuthAngle }
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° counter-clockwise const handleOrbitCCW = () => {
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) if (!controls.current) return
const target = rounded + Math.PI / 2
const currentAzimuth = controls.current.azimuthAngle
controls.current.rotateTo(target, currentPolar, true) const currentPolar = controls.current.polarAngle
} // Round to nearest 90° increment, then rotate 90° counter-clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const handleNodeFocus = ({ nodeId }: CameraControlEvent) => { const target = rounded + Math.PI / 2
focusNode(nodeId)
} controls.current.rotateTo(target, currentPolar, true)
}
emitter.on('camera-controls:capture', handleNodeCapture)
emitter.on('camera-controls:focus', handleNodeFocus) const handleNodeFocus = ({ nodeId }: CameraControlEvent) => {
emitter.on('camera-controls:view', handleNodeView) focusNode(nodeId)
emitter.on('camera-controls:top-view', handleTopView) }
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) emitter.on('camera-controls:capture', handleNodeCapture)
emitter.on('camera-controls:focus', handleNodeFocus)
return () => { emitter.on('camera-controls:view', handleNodeView)
emitter.off('camera-controls:capture', handleNodeCapture) emitter.on('camera-controls:top-view', handleTopView)
emitter.off('camera-controls:focus', handleNodeFocus) emitter.on('camera-controls:orbit-cw', handleOrbitCW)
emitter.off('camera-controls:view', handleNodeView) emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
emitter.off('camera-controls:top-view', handleTopView)
emitter.off('camera-controls:orbit-cw', handleOrbitCW) return () => {
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) emitter.off('camera-controls:capture', handleNodeCapture)
} emitter.off('camera-controls:focus', handleNodeFocus)
}, [focusNode]) emitter.off('camera-controls:view', handleNodeView)
emitter.off('camera-controls:top-view', handleTopView)
const onTransitionStart = useCallback(() => { emitter.off('camera-controls:orbit-cw', handleOrbitCW)
useViewer.getState().setCameraDragging(true) emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
}, []) }
}, [focusNode, isFirstPersonMode])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false) const onTransitionStart = useCallback(() => {
}, []) useViewer.getState().setCameraDragging(true)
}, [])
return (
<CameraControls const onRest = useCallback(() => {
makeDefault useViewer.getState().setCameraDragging(false)
maxDistance={100} }, [])
maxPolarAngle={maxPolarAngle}
minDistance={10} // In first-person mode, don't render orbit controls — FirstPersonControls takes over
minPolarAngle={0} if (isFirstPersonMode) {
mouseButtons={mouseButtons} return null
onRest={onRest} }
onSleep={onRest}
onTransitionStart={onTransitionStart} return (
ref={controls} <CameraControls
restThreshold={0.01} makeDefault
/> maxDistance={100}
) maxPolarAngle={maxPolarAngle}
} minDistance={10}
minPolarAngle={0}
mouseButtons={mouseButtons}
onRest={onRest}
onSleep={onRest}
onTransitionStart={onTransitionStart}
ref={controls}
restThreshold={0.01}
/>
)
}
@@ -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>
)
}
+479 -470
View File
@@ -1,470 +1,479 @@
'use client' 'use client'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { type ReactNode, useCallback, useEffect, useState } from 'react' import { type ReactNode, useCallback, useEffect, useState } from 'react'
import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard' import { useKeyboard } from '../../hooks/use-keyboard'
import { import {
applySceneGraphToEditor, applySceneGraphToEditor,
loadSceneFromLocalStorage, loadSceneFromLocalStorage,
type SceneGraph, type SceneGraph,
writePersistedSelection, writePersistedSelection,
} from '../../lib/scene' } from '../../lib/scene'
import { initSFXBus } from '../../lib/sfx-bus' import { initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
import { ZoneSystem } from '../systems/zone/zone-system' import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager' import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu' import { ActionMenu } from '../ui/action-menu'
import { HelperManager } from '../ui/helpers/helper-manager' import { HelperManager } from '../ui/helpers/helper-manager'
import { PanelManager } from '../ui/panels/panel-manager' import { PanelManager } from '../ui/panels/panel-manager'
import { ErrorBoundary } from '../ui/primitives/error-boundary' import { ErrorBoundary } from '../ui/primitives/error-boundary'
import { SidebarProvider } from '../ui/primitives/sidebar' import { SidebarProvider } from '../ui/primitives/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
import { SceneLoader } from '../ui/scene-loader' import { SceneLoader } from '../ui/scene-loader'
import { AppSidebar } from '../ui/sidebar/app-sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar'
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' 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 { FloatingActionMenu } from './floating-action-menu' import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
import { FloorplanPanel } from './floorplan-panel' import { FloatingActionMenu } from './floating-action-menu'
import { Grid } from './grid' import { FloorplanPanel } from './floorplan-panel'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { Grid } from './grid'
import { SelectionManager } from './selection-manager' import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { SiteEdgeLabels } from './site-edge-labels' import { SelectionManager } from './selection-manager'
import { ThumbnailGenerator } from './thumbnail-generator' import { SiteEdgeLabels } from './site-edge-labels'
import { WallMeasurementLabel } from './wall-measurement-label' import { ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label'
let hasInitializedEditorRuntime = false
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' let hasInitializedEditorRuntime = false
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
function initializeEditorRuntime() {
if (hasInitializedEditorRuntime) return function initializeEditorRuntime() {
initSpatialGridSync() if (hasInitializedEditorRuntime) return
initSpaceDetectionSync(useScene, useEditor) initSpatialGridSync()
initSFXBus() initSpaceDetectionSync(useScene, useEditor)
initSFXBus()
hasInitializedEditorRuntime = true
} hasInitializedEditorRuntime = true
export interface EditorProps { }
// UI slots export interface EditorProps {
appMenuButton?: ReactNode // UI slots
sidebarTop?: ReactNode appMenuButton?: ReactNode
projectId?: string | null sidebarTop?: ReactNode
projectId?: string | null
// Persistence — defaults to localStorage when omitted
onLoad?: () => Promise<SceneGraph | null> // Persistence — defaults to localStorage when omitted
onSave?: (scene: SceneGraph) => Promise<void> onLoad?: () => Promise<SceneGraph | null>
onDirty?: () => void onSave?: (scene: SceneGraph) => Promise<void>
onSaveStatusChange?: (status: SaveStatus) => void onDirty?: () => void
onSaveStatusChange?: (status: SaveStatus) => void
// Version preview
previewScene?: SceneGraph // Version preview
isVersionPreviewMode?: boolean previewScene?: SceneGraph
isVersionPreviewMode?: boolean
// Loading indicator (e.g. project fetching in community mode)
isLoading?: boolean // Loading indicator (e.g. project fetching in community mode)
isLoading?: boolean
// Thumbnail
onThumbnailCapture?: (blob: Blob) => void // Thumbnail
onThumbnailCapture?: (blob: Blob) => void
// Panel config (passed through to sidebar panels)
settingsPanelProps?: SettingsPanelProps // Panel config (passed through to sidebar panels)
sitePanelProps?: SitePanelProps settingsPanelProps?: SettingsPanelProps
sitePanelProps?: SitePanelProps
// Presets storage backend (defaults to localStorage)
presetsAdapter?: PresetsAdapter // Presets storage backend (defaults to localStorage)
} presetsAdapter?: PresetsAdapter
}
function EditorSceneCrashFallback() {
return ( function EditorSceneCrashFallback() {
<div className="fixed inset-0 z-80 flex items-center justify-center bg-background/95 p-4 text-foreground"> return (
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl"> <div className="fixed inset-0 z-80 flex items-center justify-center bg-background/95 p-4 text-foreground">
<h2 className="font-semibold text-lg">The editor scene failed to render</h2> <div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<p className="mt-2 text-muted-foreground text-sm"> <h2 className="font-semibold text-lg">The editor scene failed to render</h2>
You can retry the scene or return home without reloading the whole app shell. <p className="mt-2 text-muted-foreground text-sm">
</p> You can retry the scene or return home without reloading the whole app shell.
<div className="mt-4 flex items-center gap-2"> </p>
<button <div className="mt-4 flex items-center gap-2">
className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80" <button
onClick={() => window.location.reload()} className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
type="button" onClick={() => window.location.reload()}
> type="button"
Reload editor >
</button> Reload editor
<a </button>
className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40" <a
href="/" className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
> href="/"
Back to home >
</a> Back to home
</div> </a>
</div> </div>
</div> </div>
) </div>
} )
}
function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
const selection = useViewer((state) => state.selection) function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
const selection = useViewer((state) => state.selection)
useEffect(() => {
if (!enabled) { useEffect(() => {
return if (!enabled) {
} return
}
writePersistedSelection(selection)
}, [enabled, selection]) writePersistedSelection(selection)
}, [enabled, selection])
return null
} return null
}
type ShortcutKey = {
value: string type ShortcutKey = {
} value: string
}
type CameraControlHint = {
action: string type CameraControlHint = {
keys: ShortcutKey[] action: string
alternativeKeys?: ShortcutKey[] keys: ShortcutKey[]
} alternativeKeys?: ShortcutKey[]
}
const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
{ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
action: 'Pan', {
keys: [{ value: 'Space' }, { value: 'Left click' }], action: 'Pan',
}, keys: [{ value: 'Space' }, { value: 'Left click' }],
{ action: 'Rotate', keys: [{ value: 'Right click' }] }, },
{ action: 'Zoom', keys: [{ value: 'Scroll' }] }, { action: 'Rotate', keys: [{ value: 'Right click' }] },
] { action: 'Zoom', keys: [{ value: 'Scroll' }] },
]
const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
{ action: 'Pan', keys: [{ value: 'Left click' }] }, const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
{ action: 'Rotate', keys: [{ value: 'Right click' }] }, { action: 'Pan', keys: [{ value: 'Left click' }] },
{ action: 'Zoom', keys: [{ value: 'Scroll' }] }, { action: 'Rotate', keys: [{ value: 'Right click' }] },
] { action: 'Zoom', keys: [{ value: 'Scroll' }] },
]
const CAMERA_SHORTCUT_KEY_META: Record<string, { icon?: string; label: string; text?: string }> = {
'Left click': { const CAMERA_SHORTCUT_KEY_META: Record<string, { icon?: string; label: string; text?: string }> = {
icon: 'ph:mouse-left-click-fill', 'Left click': {
label: 'Left click', icon: 'ph:mouse-left-click-fill',
}, label: 'Left click',
'Middle click': { },
icon: 'qlementine-icons:mouse-middle-button-16', 'Middle click': {
label: 'Middle click', icon: 'qlementine-icons:mouse-middle-button-16',
}, label: 'Middle click',
'Right click': { },
icon: 'ph:mouse-right-click-fill', 'Right click': {
label: 'Right click', icon: 'ph:mouse-right-click-fill',
}, label: 'Right click',
Scroll: { },
icon: 'qlementine-icons:mouse-middle-button-16', Scroll: {
label: 'Scroll wheel', icon: 'qlementine-icons:mouse-middle-button-16',
}, label: 'Scroll wheel',
Space: { },
icon: 'lucide:space', Space: {
label: 'Space', icon: 'lucide:space',
}, label: 'Space',
} },
}
function readCameraControlsHintDismissed(): boolean {
if (typeof window === 'undefined') { function readCameraControlsHintDismissed(): boolean {
return false if (typeof window === 'undefined') {
} return false
}
try {
return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1' try {
} catch { return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1'
return false } catch {
} return false
} }
}
function writeCameraControlsHintDismissed(dismissed: boolean) {
if (typeof window === 'undefined') { function writeCameraControlsHintDismissed(dismissed: boolean) {
return if (typeof window === 'undefined') {
} return
}
try {
if (dismissed) { try {
window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1') if (dismissed) {
return window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1')
} return
}
window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY)
} catch {} window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY)
} } catch {}
}
function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) {
const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value] function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) {
const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value]
if (meta?.icon) {
return ( if (meta?.icon) {
<span return (
aria-label={meta.label} <span
className="inline-flex items-center text-foreground/90" aria-label={meta.label}
role="img" className="inline-flex items-center text-foreground/90"
title={meta.label} role="img"
> title={meta.label}
<Icon aria-hidden="true" color="currentColor" height={16} icon={meta.icon} width={16} /> >
<span className="sr-only">{meta.label}</span> <Icon aria-hidden="true" color="currentColor" height={16} icon={meta.icon} width={16} />
</span> <span className="sr-only">{meta.label}</span>
) </span>
} )
}
return (
<span className="font-medium text-[11px] text-foreground/90"> return (
{meta?.text ?? shortcutKey.value} <span className="font-medium text-[11px] text-foreground/90">
</span> {meta?.text ?? shortcutKey.value}
) </span>
} )
}
function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) {
return ( function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) {
<div className="flex flex-wrap items-center gap-1"> return (
{keys.map((key, index) => ( <div className="flex flex-wrap items-center gap-1">
<div className="flex items-center gap-1" key={`${key.value}-${index}`}> {keys.map((key, index) => (
{index > 0 ? <span className="text-[10px] text-muted-foreground/70">+</span> : null} <div className="flex items-center gap-1" key={`${key.value}-${index}`}>
<InlineShortcutKey shortcutKey={key} /> {index > 0 ? <span className="text-[10px] text-muted-foreground/70">+</span> : null}
</div> <InlineShortcutKey shortcutKey={key} />
))} </div>
</div> ))}
) </div>
} )
}
function CameraControlHintItem({ hint }: { hint: CameraControlHint }) {
return ( function CameraControlHintItem({ hint }: { hint: CameraControlHint }) {
<div className="flex min-w-0 flex-col items-center gap-1.5 px-4 text-center first:pl-0 last:pr-0"> return (
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]"> <div className="flex min-w-0 flex-col items-center gap-1.5 px-4 text-center first:pl-0 last:pr-0">
{hint.action} <span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
</span> {hint.action}
<div className="flex flex-wrap items-center justify-center gap-1.5"> </span>
<ShortcutSequence keys={hint.keys} /> <div className="flex flex-wrap items-center justify-center gap-1.5">
{hint.alternativeKeys ? ( <ShortcutSequence keys={hint.keys} />
<> {hint.alternativeKeys ? (
<span className="text-[10px] text-muted-foreground/40">/</span> <>
<ShortcutSequence keys={hint.alternativeKeys} /> <span className="text-[10px] text-muted-foreground/40">/</span>
</> <ShortcutSequence keys={hint.alternativeKeys} />
) : null} </>
</div> ) : null}
</div> </div>
) </div>
} )
}
function ViewerCanvasControlsHint({
isPreviewMode, function ViewerCanvasControlsHint({
onDismiss, isPreviewMode,
}: { onDismiss,
isPreviewMode: boolean }: {
onDismiss: () => void isPreviewMode: boolean
}) { onDismiss: () => void
const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS }) {
const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
return (
<div className="pointer-events-none fixed top-4 left-1/2 z-40 max-w-[calc(100vw-2rem)] -translate-x-1/2"> return (
<section <div className="pointer-events-none fixed top-4 left-1/2 z-40 max-w-[calc(100vw-2rem)] -translate-x-1/2">
aria-label="Camera controls hint" <section
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl" aria-label="Camera controls hint"
> className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18"> >
{hints.map((hint) => ( <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
<CameraControlHintItem hint={hint} key={hint.action} /> {hints.map((hint) => (
))} <CameraControlHintItem hint={hint} key={hint.action} />
</div> ))}
<Tooltip> </div>
<TooltipTrigger asChild> <Tooltip>
<button <TooltipTrigger asChild>
aria-label="Dismiss camera controls hint" <button
className="flex h-5 shrink-0 items-center justify-center self-center border-border/18 border-l pl-3 text-muted-foreground/70 transition-colors hover:text-foreground" aria-label="Dismiss camera controls hint"
onClick={onDismiss} className="flex h-5 shrink-0 items-center justify-center self-center border-border/18 border-l pl-3 text-muted-foreground/70 transition-colors hover:text-foreground"
type="button" onClick={onDismiss}
> type="button"
<Icon >
aria-hidden="true" <Icon
color="currentColor" aria-hidden="true"
height={14} color="currentColor"
icon="lucide:x" height={14}
width={14} icon="lucide:x"
/> width={14}
</button> />
</TooltipTrigger> </button>
<TooltipContent side="bottom" sideOffset={8}> </TooltipTrigger>
Dismiss <TooltipContent side="bottom" sideOffset={8}>
</TooltipContent> Dismiss
</Tooltip> </TooltipContent>
</section> </Tooltip>
</div> </section>
) </div>
} )
}
export default function Editor({
appMenuButton, export default function Editor({
sidebarTop, appMenuButton,
projectId, sidebarTop,
onLoad, projectId,
onSave, onLoad,
onDirty, onSave,
onSaveStatusChange, onDirty,
previewScene, onSaveStatusChange,
isVersionPreviewMode = false, previewScene,
isLoading = false, isVersionPreviewMode = false,
onThumbnailCapture, isLoading = false,
settingsPanelProps, onThumbnailCapture,
sitePanelProps, settingsPanelProps,
presetsAdapter, sitePanelProps,
}: EditorProps) { presetsAdapter,
useKeyboard() }: EditorProps) {
useKeyboard()
const { isLoadingSceneRef } = useAutoSave({
onSave, const { isLoadingSceneRef } = useAutoSave({
onDirty, onSave,
onSaveStatusChange, onDirty,
isVersionPreviewMode, onSaveStatusChange,
}) isVersionPreviewMode,
})
const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const [isSceneLoading, setIsSceneLoading] = useState(false)
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>( const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
null, const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
) null,
const isPreviewMode = useEditor((s) => s.isPreviewMode) )
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useEffect(() => { const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
initializeEditorRuntime()
}, []) useEffect(() => {
initializeEditorRuntime()
useEffect(() => { }, [])
useViewer.getState().setProjectId(projectId ?? null)
useEffect(() => {
return () => { useViewer.getState().setProjectId(projectId ?? null)
useViewer.getState().setProjectId(null)
} return () => {
}, [projectId]) useViewer.getState().setProjectId(null)
}
// Load scene on mount (or when onLoad identity changes, e.g. project switch) }, [projectId])
useEffect(() => {
let cancelled = false // Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => {
async function load() { let cancelled = false
isLoadingSceneRef.current = true
setHasLoadedInitialScene(false) async function load() {
setIsSceneLoading(true) isLoadingSceneRef.current = true
setHasLoadedInitialScene(false)
try { setIsSceneLoading(true)
const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
if (!cancelled) { try {
applySceneGraphToEditor(sceneGraph) const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
} if (!cancelled) {
} catch { applySceneGraphToEditor(sceneGraph)
if (!cancelled) applySceneGraphToEditor(null) }
} finally { } catch {
if (!cancelled) { if (!cancelled) applySceneGraphToEditor(null)
setIsSceneLoading(false) } finally {
setHasLoadedInitialScene(true) if (!cancelled) {
requestAnimationFrame(() => { setIsSceneLoading(false)
isLoadingSceneRef.current = false setHasLoadedInitialScene(true)
}) requestAnimationFrame(() => {
} isLoadingSceneRef.current = false
} })
} }
}
load() }
return () => { load()
cancelled = true
} return () => {
}, [onLoad, isLoadingSceneRef]) cancelled = true
}
// Apply preview scene when version preview mode changes }, [onLoad, isLoadingSceneRef])
useEffect(() => {
if (isVersionPreviewMode && previewScene) { // Apply preview scene when version preview mode changes
applySceneGraphToEditor(previewScene) useEffect(() => {
} if (isVersionPreviewMode && previewScene) {
}, [isVersionPreviewMode, previewScene]) applySceneGraphToEditor(previewScene)
}
useEffect(() => { }, [isVersionPreviewMode, previewScene])
document.body.classList.add('dark')
return () => { useEffect(() => {
document.body.classList.remove('dark') document.body.classList.add('dark')
} return () => {
}, []) document.body.classList.remove('dark')
}
useEffect(() => { }, [])
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
}, []) useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
const showLoader = isLoading || isSceneLoading }, [])
const dismissCameraControlsHint = useCallback(() => {
setIsCameraControlsHintVisible(false) const showLoader = isLoading || isSceneLoading
writeCameraControlsHintDismissed(true) const dismissCameraControlsHint = useCallback(() => {
}, []) setIsCameraControlsHintVisible(false)
writeCameraControlsHintDismissed(true)
return ( }, [])
<PresetsProvider adapter={presetsAdapter}>
<div className="dark h-full w-full text-foreground"> return (
{showLoader && ( <PresetsProvider adapter={presetsAdapter}>
<div className="fixed inset-0 z-60"> <div className="dark h-full w-full text-foreground">
<SceneLoader /> {showLoader && (
</div> <div className="fixed inset-0 z-60">
)} <SceneLoader />
</div>
{!showLoader && isCameraControlsHintVisible ? ( )}
<ViewerCanvasControlsHint
isPreviewMode={isPreviewMode} {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
onDismiss={dismissCameraControlsHint} <ViewerCanvasControlsHint
/> isPreviewMode={isPreviewMode}
) : null} onDismiss={dismissCameraControlsHint}
/>
{!isLoading && isPreviewMode ? ( ) : null}
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
) : ( {isFirstPersonMode ? (
<> <FirstPersonOverlay
<ActionMenu /> onExit={() => useEditor.getState().setFirstPersonMode(false)}
<PanelManager /> />
{isFloorplanOpen && <FloorplanPanel />} ) : !isLoading && isPreviewMode ? (
<HelperManager /> <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
) : (
<SidebarProvider className="fixed z-20"> <>
<AppSidebar <ActionMenu />
appMenuButton={appMenuButton} <PanelManager />
settingsPanelProps={settingsPanelProps} {isFloorplanOpen && <FloorplanPanel />}
sidebarTop={sidebarTop} <HelperManager />
sitePanelProps={sitePanelProps}
/> <SidebarProvider className="fixed z-20">
</SidebarProvider> <AppSidebar
</> appMenuButton={appMenuButton}
)} settingsPanelProps={settingsPanelProps}
sidebarTop={sidebarTop}
<ErrorBoundary fallback={<EditorSceneCrashFallback />}> sitePanelProps={sitePanelProps}
<div className="h-full w-full"> />
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} /> </SidebarProvider>
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}> </>
{!isPreviewMode && <SelectionManager />} )}
{!isPreviewMode && <FloatingActionMenu />}
{!isPreviewMode && <WallMeasurementLabel />} <ErrorBoundary fallback={<EditorSceneCrashFallback />}>
<ExportManager /> <div className="h-full w-full">
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />} <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
<CeilingSystem /> <Viewer selectionManager={isPreviewMode || isFirstPersonMode ? 'default' : 'custom'}>
<RoofEditSystem /> {!isPreviewMode && !isFirstPersonMode && <SelectionManager />}
{!isPreviewMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />} {!isPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!(isPreviewMode || isLoading) && <ToolManager />} {!isPreviewMode && !isFirstPersonMode && <WallMeasurementLabel />}
<CustomCameraControls /> <ExportManager />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} /> {isPreviewMode || isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<PresetThumbnailGenerator /> <CeilingSystem />
{!isPreviewMode && <SiteEdgeLabels />} <RoofEditSystem />
{isPreviewMode && <InteractiveSystem />} {!isPreviewMode && !isFirstPersonMode && (
</Viewer> <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
</div> )}
{!(isPreviewMode || isLoading) && <ZoneLabelEditorSystem />} {!(isPreviewMode || isFirstPersonMode || isLoading) && <ToolManager />}
</ErrorBoundary> <CustomCameraControls />
</div> {isFirstPersonMode && <FirstPersonControls />}
</PresetsProvider> <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
) <PresetThumbnailGenerator />
} {!isPreviewMode && !isFirstPersonMode && <SiteEdgeLabels />}
{(isPreviewMode || isFirstPersonMode) && <InteractiveSystem />}
</Viewer>
</div>
{!(isPreviewMode || isFirstPersonMode || isLoading) && <ZoneLabelEditorSystem />}
</ErrorBoundary>
</div>
</PresetsProvider>
)
}
@@ -1,74 +1,97 @@
'use client' 'use client'
import { emitter } from '@pascal-app/core' import { Icon } from '@iconify/react'
import Image from 'next/image' import { emitter } from '@pascal-app/core'
import { ActionButton } from './action-button' import Image from 'next/image'
import useEditor from '../../../store/use-editor'
export function CameraActions() { import { ActionButton } from './action-button'
const goToTopView = () => {
emitter.emit('camera-controls:top-view') export function CameraActions() {
} const goToTopView = () => {
emitter.emit('camera-controls:top-view')
const orbitCW = () => { }
emitter.emit('camera-controls:orbit-cw')
} const orbitCW = () => {
emitter.emit('camera-controls:orbit-cw')
const orbitCCW = () => { }
emitter.emit('camera-controls:orbit-ccw')
} const orbitCCW = () => {
emitter.emit('camera-controls:orbit-ccw')
return ( }
<div className="flex items-center gap-1">
{/* Orbit CCW */} const enterStreetView = () => {
<ActionButton useEditor.getState().setFirstPersonMode(true)
className="group hover:bg-white/5" }
label="Orbit Left"
onClick={orbitCCW} return (
size="icon" <div className="flex items-center gap-1">
variant="ghost" {/* Orbit CCW */}
> <ActionButton
<Image className="group hover:bg-white/5"
alt="Orbit Left" label="Orbit Left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100" onClick={orbitCCW}
height={28} size="icon"
src="/icons/rotate.png" variant="ghost"
width={28} >
/> <Image
</ActionButton> alt="Orbit Left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
{/* Orbit CW */} height={28}
<ActionButton src="/icons/rotate.png"
className="group hover:bg-white/5" width={28}
label="Orbit Right" />
onClick={orbitCW} </ActionButton>
size="icon"
variant="ghost" {/* Orbit CW */}
> <ActionButton
<Image className="group hover:bg-white/5"
alt="Orbit Right" label="Orbit Right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" onClick={orbitCW}
height={28} size="icon"
src="/icons/rotate.png" variant="ghost"
width={28} >
/> <Image
</ActionButton> alt="Orbit Right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
{/* Top View */} height={28}
<ActionButton src="/icons/rotate.png"
className="group hover:bg-white/5" width={28}
label="Top View" />
onClick={goToTopView} </ActionButton>
size="icon"
variant="ghost" {/* Top View */}
> <ActionButton
<Image className="group hover:bg-white/5"
alt="Top View" label="Top View"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" onClick={goToTopView}
height={28} size="icon"
src="/icons/topview.png" variant="ghost"
width={28} >
/> <Image
</ActionButton> alt="Top View"
</div> className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
) height={28}
} src="/icons/topview.png"
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>
)
}
File diff suppressed because it is too large Load Diff
@@ -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
+389 -369
View File
@@ -1,369 +1,389 @@
'use client' 'use client'
import type { AssetInput } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
import { import {
type BuildingNode, type BuildingNode,
type DoorNode, type DoorNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type Space, type Space,
useScene, useScene,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
export type Phase = 'site' | 'structure' | 'furnish' export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build' export type Mode = 'select' | 'edit' | 'delete' | 'build'
// Structure mode tools (building elements) // Structure mode tools (building elements)
export type StructureTool = export type StructureTool =
| 'wall' | 'wall'
| 'room' | 'room'
| 'custom-room' | 'custom-room'
| 'slab' | 'slab'
| 'ceiling' | 'ceiling'
| 'roof' | 'roof'
| 'column' | 'column'
| 'stair' | 'stair'
| 'item' | 'item'
| 'zone' | 'zone'
| 'window' | 'window'
| 'door' | 'door'
// Furnish mode tools (items and decoration) // Furnish mode tools (items and decoration)
export type FurnishTool = 'item' export type FurnishTool = 'item'
// Site mode tools // Site mode tools
export type SiteTool = 'property-line' export type SiteTool = 'property-line'
// Catalog categories for furnish mode items // Catalog categories for furnish mode items
export type CatalogCategory = export type CatalogCategory =
| 'furniture' | 'furniture'
| 'appliance' | 'appliance'
| 'bathroom' | 'bathroom'
| 'kitchen' | 'kitchen'
| 'outdoor' | 'outdoor'
| 'window' | 'window'
| 'door' | 'door'
export type StructureLayer = 'zones' | 'elements' export type StructureLayer = 'zones' | 'elements'
// Combined tool type // Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool export type Tool = SiteTool | StructureTool | FurnishTool
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
mode: Mode mode: Mode
setMode: (mode: Mode) => void setMode: (mode: Mode) => void
tool: Tool | null tool: Tool | null
setTool: (tool: Tool | null) => void setTool: (tool: Tool | null) => void
structureLayer: StructureLayer structureLayer: StructureLayer
setStructureLayer: (layer: StructureLayer) => void setStructureLayer: (layer: StructureLayer) => void
catalogCategory: CatalogCategory | null catalogCategory: CatalogCategory | null
setCatalogCategory: (category: CatalogCategory | null) => void setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null selectedItem: AssetInput | null
setSelectedItem: (item: AssetInput) => void setSelectedItem: (item: AssetInput) => void
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null
setMovingNode: ( setMovingNode: (
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
) => void ) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
spaces: Record<string, Space> spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void setSpaces: (spaces: Record<string, Space>) => void
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes) // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
editingHole: { nodeId: string; holeIndex: number } | null editingHole: { nodeId: string; holeIndex: number } | null
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
// Preview mode (viewer-like experience inside the editor) // Preview mode (viewer-like experience inside the editor)
isPreviewMode: boolean isPreviewMode: boolean
setPreviewMode: (preview: boolean) => void setPreviewMode: (preview: boolean) => void
// Toggleable 2D floorplan overlay // Toggleable 2D floorplan overlay
isFloorplanOpen: boolean isFloorplanOpen: boolean
setFloorplanOpen: (open: boolean) => void setFloorplanOpen: (open: boolean) => void
toggleFloorplanOpen: () => void toggleFloorplanOpen: () => void
isFloorplanHovered: boolean isFloorplanHovered: boolean
setFloorplanHovered: (hovered: boolean) => void setFloorplanHovered: (hovered: boolean) => void
// 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
export type PersistedEditorUiState = Pick< setFirstPersonMode: (enabled: boolean) => void
EditorState, }
'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen'
> export type PersistedEditorUiState = Pick<
EditorState,
export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = { 'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen'
phase: 'site', >
mode: 'select',
tool: null, export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
structureLayer: 'elements', phase: 'site',
catalogCategory: null, mode: 'select',
isFloorplanOpen: false, tool: null,
} structureLayer: 'elements',
catalogCategory: null,
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { isFloorplanOpen: false,
if (phase === 'site') { }
return mode === 'edit' ? 'edit' : 'select'
} function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
if (phase === 'site') {
return mode === 'build' || mode === 'delete' ? mode : 'select' return mode === 'edit' ? 'edit' : 'select'
} }
export function normalizePersistedEditorUiState( return mode === 'build' || mode === 'delete' ? mode : 'select'
state: Partial<PersistedEditorUiState> | null | undefined, }
): PersistedEditorUiState {
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site' export function normalizePersistedEditorUiState(
const mode = normalizeModeForPhase(phase, state?.mode) state: Partial<PersistedEditorUiState> | null | undefined,
const isFloorplanOpen = Boolean(state?.isFloorplanOpen) ): PersistedEditorUiState {
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
if (phase === 'site') { const mode = normalizeModeForPhase(phase, state?.mode)
return { const isFloorplanOpen = Boolean(state?.isFloorplanOpen)
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
phase, if (phase === 'site') {
mode, return {
isFloorplanOpen, ...DEFAULT_PERSISTED_EDITOR_UI_STATE,
} phase,
} mode,
isFloorplanOpen,
if (phase === 'furnish') { }
return { }
phase,
mode, if (phase === 'furnish') {
tool: mode === 'build' ? 'item' : null, return {
structureLayer: 'elements', phase,
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null, mode,
isFloorplanOpen, tool: mode === 'build' ? 'item' : null,
} structureLayer: 'elements',
} catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
isFloorplanOpen,
const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements' }
}
if (mode !== 'build') {
return { const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
phase,
mode, if (mode !== 'build') {
tool: null, return {
structureLayer, phase,
catalogCategory: null, mode,
isFloorplanOpen, tool: null,
} structureLayer,
} catalogCategory: null,
isFloorplanOpen,
if (structureLayer === 'zones') { }
return { }
phase,
mode, if (structureLayer === 'zones') {
tool: 'zone', return {
structureLayer, phase,
catalogCategory: null, mode,
isFloorplanOpen, tool: 'zone',
} structureLayer,
} catalogCategory: null,
isFloorplanOpen,
return { }
phase, }
mode,
tool: return {
state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall', phase,
structureLayer, mode,
catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null, tool:
isFloorplanOpen, state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
} structureLayer,
} catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
isFloorplanOpen,
export function hasCustomPersistedEditorUiState( }
state: Partial<PersistedEditorUiState> | null | undefined, }
): boolean {
const normalizedState = normalizePersistedEditorUiState(state) export function hasCustomPersistedEditorUiState(
state: Partial<PersistedEditorUiState> | null | undefined,
return ( ): boolean {
normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase || const normalizedState = normalizePersistedEditorUiState(state)
normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||
normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool || return (
normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer || normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase ||
normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory || normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
) normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
} normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen
const useEditor = create<EditorState>()( )
persist( }
(set, get) => ({
phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase, const useEditor = create<EditorState>()(
setPhase: (phase) => { persist(
const currentPhase = get().phase (set, get) => ({
if (currentPhase === phase) return phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
setPhase: (phase) => {
set({ phase }) const currentPhase = get().phase
if (currentPhase === phase) return
const { mode, structureLayer } = get()
set({ phase })
if (mode === 'build') {
// Stay in build mode, select the first tool for the new phase const { mode, structureLayer } = get()
if (phase === 'site') {
set({ tool: 'property-line', catalogCategory: null }) if (mode === 'build') {
} else if (phase === 'structure' && structureLayer === 'zones') { // Stay in build mode, select the first tool for the new phase
set({ tool: 'zone', catalogCategory: null }) if (phase === 'site') {
} else if (phase === 'structure') { set({ tool: 'property-line', catalogCategory: null })
set({ tool: 'wall', catalogCategory: null }) } else if (phase === 'structure' && structureLayer === 'zones') {
} else if (phase === 'furnish') { set({ tool: 'zone', catalogCategory: null })
set({ tool: 'item', catalogCategory: 'furniture' }) } else if (phase === 'structure') {
} set({ tool: 'wall', catalogCategory: null })
} else { } else if (phase === 'furnish') {
// Reset to select mode and clear tool/catalog when switching phases set({ tool: 'item', catalogCategory: 'furniture' })
set({ mode: 'select', tool: null, catalogCategory: null }) }
} } else {
// Reset to select mode and clear tool/catalog when switching phases
const viewer = useViewer.getState() set({ mode: 'select', tool: null, catalogCategory: null })
const scene = useScene.getState() }
// Helper to find building and level 0 const viewer = useViewer.getState()
const selectBuildingAndLevel0 = () => { const scene = useScene.getState()
let buildingId = viewer.selection.buildingId
// Helper to find building and level 0
// If no building selected, find the first one from site's children const selectBuildingAndLevel0 = () => {
if (!buildingId) { let buildingId = viewer.selection.buildingId
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
if (siteNode?.type === 'site') { // If no building selected, find the first one from site's children
const firstBuilding = siteNode.children if (!buildingId) {
.map((child) => (typeof child === 'string' ? scene.nodes[child] : child)) const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
.find((node) => node?.type === 'building') if (siteNode?.type === 'site') {
if (firstBuilding) { const firstBuilding = siteNode.children
buildingId = firstBuilding.id as BuildingNode['id'] .map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
viewer.setSelection({ buildingId }) .find((node) => node?.type === 'building')
} if (firstBuilding) {
} buildingId = firstBuilding.id as BuildingNode['id']
} viewer.setSelection({ buildingId })
}
// If no level selected, find level 0 in the building }
if (buildingId && !viewer.selection.levelId) { }
const buildingNode = scene.nodes[buildingId] as BuildingNode
const level0Id = buildingNode.children.find((childId) => { // If no level selected, find level 0 in the building
const levelNode = scene.nodes[childId] as LevelNode if (buildingId && !viewer.selection.levelId) {
return levelNode?.type === 'level' && levelNode.level === 0 const buildingNode = scene.nodes[buildingId] as BuildingNode
}) const level0Id = buildingNode.children.find((childId) => {
if (level0Id) { const levelNode = scene.nodes[childId] as LevelNode
viewer.setSelection({ levelId: level0Id as LevelNode['id'] }) return levelNode?.type === 'level' && levelNode.level === 0
} else if (buildingNode.children[0]) { })
// Fallback to first level if level 0 doesn't exist if (level0Id) {
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] }) viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
} } else if (buildingNode.children[0]) {
} // Fallback to first level if level 0 doesn't exist
} viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
}
switch (phase) { }
case 'site': }
// In Site mode, we zoom out and deselect specific levels/buildings
viewer.resetSelection() switch (phase) {
break case 'site':
// In Site mode, we zoom out and deselect specific levels/buildings
case 'structure': viewer.resetSelection()
selectBuildingAndLevel0() break
break
case 'structure':
case 'furnish': selectBuildingAndLevel0()
selectBuildingAndLevel0() break
// Furnish mode only supports elements layer, not zones
set({ structureLayer: 'elements' }) case 'furnish':
break selectBuildingAndLevel0()
} // Furnish mode only supports elements layer, not zones
}, set({ structureLayer: 'elements' })
mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode, break
setMode: (mode) => { }
set({ mode }) },
mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
const { phase, structureLayer, tool } = get() setMode: (mode) => {
set({ mode })
if (mode === 'build') {
// Ensure a tool is selected in build mode const { phase, structureLayer, tool } = get()
if (!tool) {
if (phase === 'structure' && structureLayer === 'zones') { if (mode === 'build') {
set({ tool: 'zone' }) // Ensure a tool is selected in build mode
} else if (phase === 'structure' && structureLayer === 'elements') { if (!tool) {
set({ tool: 'wall' }) if (phase === 'structure' && structureLayer === 'zones') {
} else if (phase === 'furnish') { set({ tool: 'zone' })
set({ tool: 'item', catalogCategory: 'furniture' }) } else if (phase === 'structure' && structureLayer === 'elements') {
} set({ tool: 'wall' })
} } else if (phase === 'furnish') {
} set({ tool: 'item', catalogCategory: 'furniture' })
// When leaving build mode, clear tool }
else if (tool) { }
set({ tool: null }) }
} // When leaving build mode, clear tool
}, else if (tool) {
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, set({ tool: null })
setTool: (tool) => set({ tool }), }
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer, },
setStructureLayer: (layer) => { tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
const { mode } = get() setTool: (tool) => set({ tool }),
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
if (mode === 'build') { setStructureLayer: (layer) => {
const tool = layer === 'zones' ? 'zone' : 'wall' const { mode } = get()
set({ structureLayer: layer, tool })
} else { if (mode === 'build') {
set({ structureLayer: layer, mode: 'select', tool: null }) const tool = layer === 'zones' ? 'zone' : 'wall'
} set({ structureLayer: layer, tool })
} else {
const viewer = useViewer.getState() set({ structureLayer: layer, mode: 'select', tool: null })
viewer.setSelection({ }
selectedIds: [],
zoneId: null, const viewer = useViewer.getState()
}) viewer.setSelection({
}, selectedIds: [],
catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory, zoneId: null,
setCatalogCategory: (category) => set({ catalogCategory: category }), })
selectedItem: null, },
setSelectedItem: (item) => set({ selectedItem: item }), catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, setCatalogCategory: (category) => set({ catalogCategory: category }),
setMovingNode: (node) => set({ movingNode: node }), selectedItem: null,
selectedReferenceId: null, setSelectedItem: (item) => set({ selectedItem: item }),
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
spaces: {}, setMovingNode: (node) => set({ movingNode: node }),
setSpaces: (spaces) => set({ spaces }), selectedReferenceId: null,
editingHole: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
setEditingHole: (hole) => set({ editingHole: hole }), spaces: {},
isPreviewMode: false, setSpaces: (spaces) => set({ spaces }),
setPreviewMode: (preview) => { editingHole: null,
if (preview) { setEditingHole: (hole) => set({ editingHole: hole }),
set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null }) isPreviewMode: false,
// Clear zone/item selection for clean viewer drill-down hierarchy setPreviewMode: (preview) => {
useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) if (preview) {
} else { set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
set({ isPreviewMode: false }) // Clear zone/item selection for clean viewer drill-down hierarchy
} useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
}, } else {
isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen, set({ isPreviewMode: false })
setFloorplanOpen: (open) => set({ isFloorplanOpen: open }), }
toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })), },
isFloorplanHovered: false, isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }), setFloorplanOpen: (open) => set({ isFloorplanOpen: open }),
allowUndergroundCamera: false, toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })),
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFloorplanHovered: false,
}), setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
{ allowUndergroundCamera: false,
name: 'pascal-editor-ui-preferences', setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
merge: (persistedState, currentState) => ({ isFirstPersonMode: false,
...currentState, setFirstPersonMode: (enabled) => {
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorUiState>), if (enabled) {
}), // Force perspective camera and full-height walls for immersive walkthrough
partialize: (state) => ({ useViewer.getState().setCameraMode('perspective')
phase: state.phase, useViewer.getState().setWallMode('up')
mode: state.mode, set({
tool: state.tool, isFirstPersonMode: true,
structureLayer: state.structureLayer, mode: 'select',
catalogCategory: state.catalogCategory, tool: null,
isFloorplanOpen: state.isFloorplanOpen, catalogCategory: null,
}), })
}, useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
), } else {
) set({ isFirstPersonMode: false })
}
export default useEditor },
}),
{
name: 'pascal-editor-ui-preferences',
merge: (persistedState, currentState) => ({
...currentState,
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorUiState>),
}),
partialize: (state) => ({
phase: state.phase,
mode: state.mode,
tool: state.tool,
structureLayer: state.structureLayer,
catalogCategory: state.catalogCategory,
isFloorplanOpen: state.isFloorplanOpen,
}),
},
),
)
export default useEditor