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