feat: add street view mode with FPS-style walkthrough

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