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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user