From 6d964e5e9fbb948a7b60227b326539322a2920cb Mon Sep 17 00:00:00 2001 From: yashinode Date: Wed, 25 Mar 2026 15:23:44 +0000 Subject: [PATCH] feat: add street view mode with FPS-style walkthrough Adds a first-person walkthrough mode so you can explore your home from the inside at eye level. WASD to move, mouse to look around, Q/E to float up/down. Press ESC to exit. --- .../editor/custom-camera-controls.tsx | 764 +++++++------ .../editor/first-person-controls.tsx | 249 ++++ .../editor/src/components/editor/index.tsx | 949 +++++++-------- .../ui/action-menu/camera-actions.tsx | 171 +-- .../editor/src/components/viewer-overlay.tsx | 1013 +++++++++-------- packages/editor/src/hooks/use-keyboard.ts | 293 ++--- packages/editor/src/store/use-editor.tsx | 758 ++++++------ 7 files changed, 2264 insertions(+), 1933 deletions(-) create mode 100644 packages/editor/src/components/editor/first-person-controls.tsx diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 331f10f9..4744187a 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,377 +1,387 @@ -'use client' - -import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' -import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' -import { CameraControls, CameraControlsImpl } from '@react-three/drei' -import { useThree } from '@react-three/fiber' -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { Box3, Vector3 } from 'three' -import { EDITOR_LAYER } from '../../lib/constants' -import useEditor from '../../store/use-editor' - -const currentTarget = new Vector3() -const tempBox = new Box3() -const tempCenter = new Vector3() -const tempDelta = new Vector3() -const tempPosition = new Vector3() -const tempSize = new Vector3() -const tempTarget = new Vector3() -const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 -const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 - -export const CustomCameraControls = () => { - const controls = useRef(null!) - const isPreviewMode = useEditor((s) => s.isPreviewMode) - const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) - const selection = useViewer((s) => s.selection) - const currentLevelId = selection.levelId - const firstLoad = useRef(true) - const maxPolarAngle = - !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE - - const camera = useThree((state) => state.camera) - const raycaster = useThree((state) => state.raycaster) - useEffect(() => { - camera.layers.enable(EDITOR_LAYER) - raycaster.layers.enable(EDITOR_LAYER) - raycaster.layers.enable(ZONE_LAYER) - }, [camera, raycaster]) - - useEffect(() => { - if (isPreviewMode) return // Preview mode uses auto-navigate instead - let targetY = 0 - if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId) - if (levelMesh) { - targetY = levelMesh.position.y - } - } - if (firstLoad.current) { - firstLoad.current = false - ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) - } - ;(controls.current as CameraControlsImpl).getTarget(currentTarget) - ;(controls.current as CameraControlsImpl).moveTo( - currentTarget.x, - targetY, - currentTarget.z, - true, - ) - }, [currentLevelId, isPreviewMode]) - - useEffect(() => { - if (!controls.current) return - - controls.current.maxPolarAngle = maxPolarAngle - controls.current.minPolarAngle = 0 - - if (controls.current.polarAngle > maxPolarAngle) { - controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) - } - }, [maxPolarAngle]) - - const focusNode = useCallback( - (nodeId: string) => { - if (isPreviewMode || !controls.current) return - - const object3D = sceneRegistry.nodes.get(nodeId) - if (!object3D) return - - tempBox.setFromObject(object3D) - if (tempBox.isEmpty()) return - - tempBox.getCenter(tempCenter) - controls.current.getPosition(tempPosition) - controls.current.getTarget(tempTarget) - tempDelta.copy(tempCenter).sub(tempTarget) - - controls.current.setLookAt( - tempPosition.x + tempDelta.x, - tempPosition.y + tempDelta.y, - tempPosition.z + tempDelta.z, - tempCenter.x, - tempCenter.y, - tempCenter.z, - true, - ) - }, - [isPreviewMode], - ) - - // Configure mouse buttons based on control mode and camera mode - const cameraMode = useViewer((state) => state.cameraMode) - const mouseButtons = useMemo(() => { - // Use ZOOM for orthographic camera, DOLLY for perspective camera - const wheelAction = - cameraMode === 'orthographic' - ? CameraControlsImpl.ACTION.ZOOM - : CameraControlsImpl.ACTION.DOLLY - - return { - left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE, - middle: CameraControlsImpl.ACTION.SCREEN_PAN, - right: CameraControlsImpl.ACTION.ROTATE, - wheel: wheelAction, - } - }, [cameraMode, isPreviewMode]) - - useEffect(() => { - const keyState = { - shiftRight: false, - shiftLeft: false, - controlRight: false, - controlLeft: false, - space: false, - } - - const updateConfig = () => { - if (!controls.current) return - - const shift = keyState.shiftRight || keyState.shiftLeft - const control = keyState.controlRight || keyState.controlLeft - const space = keyState.space - - const wheelAction = - cameraMode === 'orthographic' - ? CameraControlsImpl.ACTION.ZOOM - : CameraControlsImpl.ACTION.DOLLY - controls.current.mouseButtons.wheel = wheelAction - controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN - controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE - if (isPreviewMode) { - // In preview mode, left-click is always pan (viewer-style) - controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN - } else if (space) { - controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN - } else { - controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE - } - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.code === 'Space') { - keyState.space = true - document.body.style.cursor = 'grab' - } - if (event.code === 'ShiftRight') { - keyState.shiftRight = true - } - if (event.code === 'ShiftLeft') { - keyState.shiftLeft = true - } - if (event.code === 'ControlRight') { - keyState.controlRight = true - } - if (event.code === 'ControlLeft') { - keyState.controlLeft = true - } - updateConfig() - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.code === 'Space') { - keyState.space = false - document.body.style.cursor = '' - } - if (event.code === 'ShiftRight') { - keyState.shiftRight = false - } - if (event.code === 'ShiftLeft') { - keyState.shiftLeft = false - } - if (event.code === 'ControlRight') { - keyState.controlRight = false - } - if (event.code === 'ControlLeft') { - keyState.controlLeft = false - } - updateConfig() - } - - document.addEventListener('keydown', onKeyDown) - document.addEventListener('keyup', onKeyUp) - updateConfig() - - return () => { - document.removeEventListener('keydown', onKeyDown) - document.removeEventListener('keyup', onKeyUp) - } - }, [cameraMode, isPreviewMode]) - - // Preview mode: auto-navigate camera to selected node (viewer behavior) - const previewTargetNodeId = isPreviewMode - ? (selection.zoneId ?? selection.levelId ?? selection.buildingId) - : null - - useEffect(() => { - if (!(isPreviewMode && controls.current)) return - - const nodes = useScene.getState().nodes - let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null - - if (!previewTargetNodeId) { - const site = Object.values(nodes).find((n) => n.type === 'site') - node = site || null - } - if (!node) return - - // Check if node has a saved camera - if (node.camera) { - const { position, target } = node.camera - requestAnimationFrame(() => { - if (!controls.current) return - controls.current.setLookAt( - position[0], - position[1], - position[2], - target[0], - target[1], - target[2], - true, - ) - }) - return - } - - if (!previewTargetNodeId) return - - // Calculate camera position from bounding box - const object3D = sceneRegistry.nodes.get(previewTargetNodeId) - if (!object3D) return - - tempBox.setFromObject(object3D) - tempBox.getCenter(tempCenter) - tempBox.getSize(tempSize) - - const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) - const distance = Math.max(maxDim * 2, 15) - - controls.current.setLookAt( - tempCenter.x + distance * 0.7, - tempCenter.y + distance * 0.5, - tempCenter.z + distance * 0.7, - tempCenter.x, - tempCenter.y, - tempCenter.z, - true, - ) - }, [isPreviewMode, previewTargetNodeId]) - - useEffect(() => { - const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return - - const position = new Vector3() - const target = new Vector3() - controls.current.getPosition(position) - controls.current.getTarget(target) - - const state = useScene.getState() - - state.updateNode(nodeId, { - camera: { - position: [position.x, position.y, position.z], - target: [target.x, target.y, target.z], - mode: useViewer.getState().cameraMode, - }, - }) - } - const handleNodeView = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return - - const node = useScene.getState().nodes[nodeId] - if (!node?.camera) return - const { position, target } = node.camera - - controls.current.setLookAt( - position[0], - position[1], - position[2], - target[0], - target[1], - target[2], - true, - ) - } - - const handleTopView = () => { - if (!controls.current) return - - const currentPolarAngle = controls.current.polarAngle - - // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45° - // Otherwise, go to top view (0°) - const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 - - controls.current.rotatePolarTo(targetAngle, true) - } - - const handleOrbitCW = () => { - if (!controls.current) return - - const currentAzimuth = controls.current.azimuthAngle - const currentPolar = controls.current.polarAngle - // Round to nearest 90° increment, then rotate 90° clockwise - const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) - const target = rounded - Math.PI / 2 - - controls.current.rotateTo(target, currentPolar, true) - } - - const handleOrbitCCW = () => { - if (!controls.current) return - - const currentAzimuth = controls.current.azimuthAngle - const currentPolar = controls.current.polarAngle - // Round to nearest 90° increment, then rotate 90° counter-clockwise - const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) - const target = rounded + Math.PI / 2 - - controls.current.rotateTo(target, currentPolar, true) - } - - const handleNodeFocus = ({ nodeId }: CameraControlEvent) => { - focusNode(nodeId) - } - - emitter.on('camera-controls:capture', handleNodeCapture) - emitter.on('camera-controls:focus', handleNodeFocus) - emitter.on('camera-controls:view', handleNodeView) - emitter.on('camera-controls:top-view', handleTopView) - emitter.on('camera-controls:orbit-cw', handleOrbitCW) - emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) - - return () => { - emitter.off('camera-controls:capture', handleNodeCapture) - emitter.off('camera-controls:focus', handleNodeFocus) - emitter.off('camera-controls:view', handleNodeView) - emitter.off('camera-controls:top-view', handleTopView) - emitter.off('camera-controls:orbit-cw', handleOrbitCW) - emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) - } - }, [focusNode]) - - const onTransitionStart = useCallback(() => { - useViewer.getState().setCameraDragging(true) - }, []) - - const onRest = useCallback(() => { - useViewer.getState().setCameraDragging(false) - }, []) - - return ( - - ) -} +'use client' + +import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' +import { CameraControls, CameraControlsImpl } from '@react-three/drei' +import { useThree } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { Box3, Vector3 } from 'three' +import { EDITOR_LAYER } from '../../lib/constants' +import useEditor from '../../store/use-editor' + +const currentTarget = new Vector3() +const tempBox = new Box3() +const tempCenter = new Vector3() +const tempDelta = new Vector3() +const tempPosition = new Vector3() +const tempSize = new Vector3() +const tempTarget = new Vector3() +const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 +const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 + +export const CustomCameraControls = () => { + const controls = useRef(null!) + const isPreviewMode = useEditor((s) => s.isPreviewMode) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) + const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) + const selection = useViewer((s) => s.selection) + const currentLevelId = selection.levelId + const firstLoad = useRef(true) + const maxPolarAngle = + !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE + + const camera = useThree((state) => state.camera) + const raycaster = useThree((state) => state.raycaster) + useEffect(() => { + camera.layers.enable(EDITOR_LAYER) + raycaster.layers.enable(EDITOR_LAYER) + raycaster.layers.enable(ZONE_LAYER) + }, [camera, raycaster]) + + useEffect(() => { + if (isPreviewMode || isFirstPersonMode) return + let targetY = 0 + if (currentLevelId) { + const levelMesh = sceneRegistry.nodes.get(currentLevelId) + if (levelMesh) { + targetY = levelMesh.position.y + } + } + if (firstLoad.current) { + firstLoad.current = false + ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) + } + ;(controls.current as CameraControlsImpl).getTarget(currentTarget) + ;(controls.current as CameraControlsImpl).moveTo( + currentTarget.x, + targetY, + currentTarget.z, + true, + ) + }, [currentLevelId, isPreviewMode, isFirstPersonMode]) + + useEffect(() => { + if (!controls.current || isFirstPersonMode) return + + controls.current.maxPolarAngle = maxPolarAngle + controls.current.minPolarAngle = 0 + + if (controls.current.polarAngle > maxPolarAngle) { + controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) + } + }, [maxPolarAngle, isFirstPersonMode]) + + const focusNode = useCallback( + (nodeId: string) => { + if (isPreviewMode || !controls.current) return + + const object3D = sceneRegistry.nodes.get(nodeId) + if (!object3D) return + + tempBox.setFromObject(object3D) + if (tempBox.isEmpty()) return + + tempBox.getCenter(tempCenter) + controls.current.getPosition(tempPosition) + controls.current.getTarget(tempTarget) + tempDelta.copy(tempCenter).sub(tempTarget) + + controls.current.setLookAt( + tempPosition.x + tempDelta.x, + tempPosition.y + tempDelta.y, + tempPosition.z + tempDelta.z, + tempCenter.x, + tempCenter.y, + tempCenter.z, + true, + ) + }, + [isPreviewMode], + ) + + // Configure mouse buttons based on control mode and camera mode + const cameraMode = useViewer((state) => state.cameraMode) + const mouseButtons = useMemo(() => { + // Use ZOOM for orthographic camera, DOLLY for perspective camera + const wheelAction = + cameraMode === 'orthographic' + ? CameraControlsImpl.ACTION.ZOOM + : CameraControlsImpl.ACTION.DOLLY + + return { + left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE, + middle: CameraControlsImpl.ACTION.SCREEN_PAN, + right: CameraControlsImpl.ACTION.ROTATE, + wheel: wheelAction, + } + }, [cameraMode, isPreviewMode]) + + useEffect(() => { + if (isFirstPersonMode) return + + const keyState = { + shiftRight: false, + shiftLeft: false, + controlRight: false, + controlLeft: false, + space: false, + } + + const updateConfig = () => { + if (!controls.current) return + + const shift = keyState.shiftRight || keyState.shiftLeft + const control = keyState.controlRight || keyState.controlLeft + const space = keyState.space + + const wheelAction = + cameraMode === 'orthographic' + ? CameraControlsImpl.ACTION.ZOOM + : CameraControlsImpl.ACTION.DOLLY + controls.current.mouseButtons.wheel = wheelAction + controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN + controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE + if (isPreviewMode) { + // In preview mode, left-click is always pan (viewer-style) + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN + } else if (space) { + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN + } else { + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE + } + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Space') { + keyState.space = true + document.body.style.cursor = 'grab' + } + if (event.code === 'ShiftRight') { + keyState.shiftRight = true + } + if (event.code === 'ShiftLeft') { + keyState.shiftLeft = true + } + if (event.code === 'ControlRight') { + keyState.controlRight = true + } + if (event.code === 'ControlLeft') { + keyState.controlLeft = true + } + updateConfig() + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === 'Space') { + keyState.space = false + document.body.style.cursor = '' + } + if (event.code === 'ShiftRight') { + keyState.shiftRight = false + } + if (event.code === 'ShiftLeft') { + keyState.shiftLeft = false + } + if (event.code === 'ControlRight') { + keyState.controlRight = false + } + if (event.code === 'ControlLeft') { + keyState.controlLeft = false + } + updateConfig() + } + + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + updateConfig() + + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + } + }, [cameraMode, isPreviewMode, isFirstPersonMode]) + + // Preview mode: auto-navigate camera to selected node (viewer behavior) + const previewTargetNodeId = isPreviewMode + ? (selection.zoneId ?? selection.levelId ?? selection.buildingId) + : null + + useEffect(() => { + if (!(isPreviewMode && controls.current)) return + + const nodes = useScene.getState().nodes + let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null + + if (!previewTargetNodeId) { + const site = Object.values(nodes).find((n) => n.type === 'site') + node = site || null + } + if (!node) return + + // Check if node has a saved camera + if (node.camera) { + const { position, target } = node.camera + requestAnimationFrame(() => { + if (!controls.current) return + controls.current.setLookAt( + position[0], + position[1], + position[2], + target[0], + target[1], + target[2], + true, + ) + }) + return + } + + if (!previewTargetNodeId) return + + // Calculate camera position from bounding box + const object3D = sceneRegistry.nodes.get(previewTargetNodeId) + if (!object3D) return + + tempBox.setFromObject(object3D) + tempBox.getCenter(tempCenter) + tempBox.getSize(tempSize) + + const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) + const distance = Math.max(maxDim * 2, 15) + + controls.current.setLookAt( + tempCenter.x + distance * 0.7, + tempCenter.y + distance * 0.5, + tempCenter.z + distance * 0.7, + tempCenter.x, + tempCenter.y, + tempCenter.z, + true, + ) + }, [isPreviewMode, previewTargetNodeId]) + + useEffect(() => { + if (isFirstPersonMode) return + + const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { + if (!controls.current) return + + const position = new Vector3() + const target = new Vector3() + controls.current.getPosition(position) + controls.current.getTarget(target) + + const state = useScene.getState() + + state.updateNode(nodeId, { + camera: { + position: [position.x, position.y, position.z], + target: [target.x, target.y, target.z], + mode: useViewer.getState().cameraMode, + }, + }) + } + const handleNodeView = ({ nodeId }: CameraControlEvent) => { + if (!controls.current) return + + const node = useScene.getState().nodes[nodeId] + if (!node?.camera) return + const { position, target } = node.camera + + controls.current.setLookAt( + position[0], + position[1], + position[2], + target[0], + target[1], + target[2], + true, + ) + } + + const handleTopView = () => { + if (!controls.current) return + + const currentPolarAngle = controls.current.polarAngle + + // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45° + // Otherwise, go to top view (0°) + const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 + + controls.current.rotatePolarTo(targetAngle, true) + } + + const handleOrbitCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded - Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + const handleOrbitCCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° counter-clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded + Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + const handleNodeFocus = ({ nodeId }: CameraControlEvent) => { + focusNode(nodeId) + } + + emitter.on('camera-controls:capture', handleNodeCapture) + emitter.on('camera-controls:focus', handleNodeFocus) + emitter.on('camera-controls:view', handleNodeView) + emitter.on('camera-controls:top-view', handleTopView) + emitter.on('camera-controls:orbit-cw', handleOrbitCW) + emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) + + return () => { + emitter.off('camera-controls:capture', handleNodeCapture) + emitter.off('camera-controls:focus', handleNodeFocus) + emitter.off('camera-controls:view', handleNodeView) + emitter.off('camera-controls:top-view', handleTopView) + emitter.off('camera-controls:orbit-cw', handleOrbitCW) + emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) + } + }, [focusNode, isFirstPersonMode]) + + const onTransitionStart = useCallback(() => { + useViewer.getState().setCameraDragging(true) + }, []) + + const onRest = useCallback(() => { + useViewer.getState().setCameraDragging(false) + }, []) + + // In first-person mode, don't render orbit controls — FirstPersonControls takes over + if (isFirstPersonMode) { + return null + } + + return ( + + ) +} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx new file mode 100644 index 00000000..b90bcdf3 --- /dev/null +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -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>(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 */} +
+
+
+
+
+
+ + {/* Exit button — top-right */} +
+ +
+ + {/* Controls hint — bottom-center */} +
+
+ +
+ + +
+ +
+ Click to look around +
+
+ + ) +} + +function ControlHint({ label, keys }: { label: string; keys: string[] }) { + return ( +
+ + {label} + +
+ {keys.map((key) => ( + + {key} + + ))} +
+
+ ) +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index c1400bac..5061ece7 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1,470 +1,479 @@ -'use client' - -import { Icon } from '@iconify/react' -import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' -import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' -import { type ReactNode, useCallback, useEffect, useState } from 'react' -import { ViewerOverlay } from '../../components/viewer-overlay' -import { ViewerZoneSystem } from '../../components/viewer-zone-system' -import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' -import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' -import { useKeyboard } from '../../hooks/use-keyboard' -import { - applySceneGraphToEditor, - loadSceneFromLocalStorage, - type SceneGraph, - writePersistedSelection, -} from '../../lib/scene' -import { initSFXBus } from '../../lib/sfx-bus' -import useEditor from '../../store/use-editor' -import { CeilingSystem } from '../systems/ceiling/ceiling-system' -import { RoofEditSystem } from '../systems/roof/roof-edit-system' -import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' -import { ZoneSystem } from '../systems/zone/zone-system' -import { ToolManager } from '../tools/tool-manager' -import { ActionMenu } from '../ui/action-menu' -import { HelperManager } from '../ui/helpers/helper-manager' -import { PanelManager } from '../ui/panels/panel-manager' -import { ErrorBoundary } from '../ui/primitives/error-boundary' -import { SidebarProvider } from '../ui/primitives/sidebar' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' -import { SceneLoader } from '../ui/scene-loader' -import { AppSidebar } from '../ui/sidebar/app-sidebar' -import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' -import type { SitePanelProps } from '../ui/sidebar/panels/site-panel' -import { CustomCameraControls } from './custom-camera-controls' -import { ExportManager } from './export-manager' -import { FloatingActionMenu } from './floating-action-menu' -import { FloorplanPanel } from './floorplan-panel' -import { Grid } from './grid' -import { PresetThumbnailGenerator } from './preset-thumbnail-generator' -import { SelectionManager } from './selection-manager' -import { SiteEdgeLabels } from './site-edge-labels' -import { ThumbnailGenerator } from './thumbnail-generator' -import { WallMeasurementLabel } from './wall-measurement-label' - -let hasInitializedEditorRuntime = false -const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' - -function initializeEditorRuntime() { - if (hasInitializedEditorRuntime) return - initSpatialGridSync() - initSpaceDetectionSync(useScene, useEditor) - initSFXBus() - - hasInitializedEditorRuntime = true -} -export interface EditorProps { - // UI slots - appMenuButton?: ReactNode - sidebarTop?: ReactNode - projectId?: string | null - - // Persistence — defaults to localStorage when omitted - onLoad?: () => Promise - onSave?: (scene: SceneGraph) => Promise - onDirty?: () => void - onSaveStatusChange?: (status: SaveStatus) => void - - // Version preview - previewScene?: SceneGraph - isVersionPreviewMode?: boolean - - // Loading indicator (e.g. project fetching in community mode) - isLoading?: boolean - - // Thumbnail - onThumbnailCapture?: (blob: Blob) => void - - // Panel config (passed through to sidebar panels) - settingsPanelProps?: SettingsPanelProps - sitePanelProps?: SitePanelProps - - // Presets storage backend (defaults to localStorage) - presetsAdapter?: PresetsAdapter -} - -function EditorSceneCrashFallback() { - return ( -
-
-

The editor scene failed to render

-

- You can retry the scene or return home without reloading the whole app shell. -

-
- - - Back to home - -
-
-
- ) -} - -function SelectionPersistenceManager({ enabled }: { enabled: boolean }) { - const selection = useViewer((state) => state.selection) - - useEffect(() => { - if (!enabled) { - return - } - - writePersistedSelection(selection) - }, [enabled, selection]) - - return null -} - -type ShortcutKey = { - value: string -} - -type CameraControlHint = { - action: string - keys: ShortcutKey[] - alternativeKeys?: ShortcutKey[] -} - -const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ - { - action: 'Pan', - keys: [{ value: 'Space' }, { value: 'Left click' }], - }, - { action: 'Rotate', keys: [{ value: 'Right click' }] }, - { action: 'Zoom', keys: [{ value: 'Scroll' }] }, -] - -const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ - { action: 'Pan', keys: [{ value: 'Left click' }] }, - { action: 'Rotate', keys: [{ value: 'Right click' }] }, - { action: 'Zoom', keys: [{ value: 'Scroll' }] }, -] - -const CAMERA_SHORTCUT_KEY_META: Record = { - 'Left click': { - icon: 'ph:mouse-left-click-fill', - label: 'Left click', - }, - 'Middle click': { - icon: 'qlementine-icons:mouse-middle-button-16', - label: 'Middle click', - }, - 'Right click': { - icon: 'ph:mouse-right-click-fill', - label: 'Right click', - }, - Scroll: { - icon: 'qlementine-icons:mouse-middle-button-16', - label: 'Scroll wheel', - }, - Space: { - icon: 'lucide:space', - label: 'Space', - }, -} - -function readCameraControlsHintDismissed(): boolean { - if (typeof window === 'undefined') { - return false - } - - try { - return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1' - } catch { - return false - } -} - -function writeCameraControlsHintDismissed(dismissed: boolean) { - if (typeof window === 'undefined') { - return - } - - try { - if (dismissed) { - window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1') - return - } - - window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) - } catch {} -} - -function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) { - const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value] - - if (meta?.icon) { - return ( - - - ) - } - - return ( - - {meta?.text ?? shortcutKey.value} - - ) -} - -function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) { - return ( -
- {keys.map((key, index) => ( -
- {index > 0 ? + : null} - -
- ))} -
- ) -} - -function CameraControlHintItem({ hint }: { hint: CameraControlHint }) { - return ( -
- - {hint.action} - -
- - {hint.alternativeKeys ? ( - <> - / - - - ) : null} -
-
- ) -} - -function ViewerCanvasControlsHint({ - isPreviewMode, - onDismiss, -}: { - isPreviewMode: boolean - onDismiss: () => void -}) { - const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS - - return ( -
-
-
- {hints.map((hint) => ( - - ))} -
- - - - - - Dismiss - - -
-
- ) -} - -export default function Editor({ - appMenuButton, - sidebarTop, - projectId, - onLoad, - onSave, - onDirty, - onSaveStatusChange, - previewScene, - isVersionPreviewMode = false, - isLoading = false, - onThumbnailCapture, - settingsPanelProps, - sitePanelProps, - presetsAdapter, -}: EditorProps) { - useKeyboard() - - const { isLoadingSceneRef } = useAutoSave({ - onSave, - onDirty, - onSaveStatusChange, - isVersionPreviewMode, - }) - - const [isSceneLoading, setIsSceneLoading] = useState(false) - const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) - const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState( - null, - ) - const isPreviewMode = useEditor((s) => s.isPreviewMode) - const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) - - useEffect(() => { - initializeEditorRuntime() - }, []) - - useEffect(() => { - useViewer.getState().setProjectId(projectId ?? null) - - return () => { - useViewer.getState().setProjectId(null) - } - }, [projectId]) - - // Load scene on mount (or when onLoad identity changes, e.g. project switch) - useEffect(() => { - let cancelled = false - - async function load() { - isLoadingSceneRef.current = true - setHasLoadedInitialScene(false) - setIsSceneLoading(true) - - try { - const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() - if (!cancelled) { - applySceneGraphToEditor(sceneGraph) - } - } catch { - if (!cancelled) applySceneGraphToEditor(null) - } finally { - if (!cancelled) { - setIsSceneLoading(false) - setHasLoadedInitialScene(true) - requestAnimationFrame(() => { - isLoadingSceneRef.current = false - }) - } - } - } - - load() - - return () => { - cancelled = true - } - }, [onLoad, isLoadingSceneRef]) - - // Apply preview scene when version preview mode changes - useEffect(() => { - if (isVersionPreviewMode && previewScene) { - applySceneGraphToEditor(previewScene) - } - }, [isVersionPreviewMode, previewScene]) - - useEffect(() => { - document.body.classList.add('dark') - return () => { - document.body.classList.remove('dark') - } - }, []) - - useEffect(() => { - setIsCameraControlsHintVisible(!readCameraControlsHintDismissed()) - }, []) - - const showLoader = isLoading || isSceneLoading - const dismissCameraControlsHint = useCallback(() => { - setIsCameraControlsHintVisible(false) - writeCameraControlsHintDismissed(true) - }, []) - - return ( - -
- {showLoader && ( -
- -
- )} - - {!showLoader && isCameraControlsHintVisible ? ( - - ) : null} - - {!isLoading && isPreviewMode ? ( - useEditor.getState().setPreviewMode(false)} /> - ) : ( - <> - - - {isFloorplanOpen && } - - - - - - - )} - - }> -
- - - {!isPreviewMode && } - {!isPreviewMode && } - {!isPreviewMode && } - - {isPreviewMode ? : } - - - {!isPreviewMode && } - {!(isPreviewMode || isLoading) && } - - - - {!isPreviewMode && } - {isPreviewMode && } - -
- {!(isPreviewMode || isLoading) && } -
-
-
- ) -} +'use client' + +import { Icon } from '@iconify/react' +import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' +import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' +import { type ReactNode, useCallback, useEffect, useState } from 'react' +import { ViewerOverlay } from '../../components/viewer-overlay' +import { ViewerZoneSystem } from '../../components/viewer-zone-system' +import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' +import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' +import { useKeyboard } from '../../hooks/use-keyboard' +import { + applySceneGraphToEditor, + loadSceneFromLocalStorage, + type SceneGraph, + writePersistedSelection, +} from '../../lib/scene' +import { initSFXBus } from '../../lib/sfx-bus' +import useEditor from '../../store/use-editor' +import { CeilingSystem } from '../systems/ceiling/ceiling-system' +import { RoofEditSystem } from '../systems/roof/roof-edit-system' +import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' +import { ZoneSystem } from '../systems/zone/zone-system' +import { ToolManager } from '../tools/tool-manager' +import { ActionMenu } from '../ui/action-menu' +import { HelperManager } from '../ui/helpers/helper-manager' +import { PanelManager } from '../ui/panels/panel-manager' +import { ErrorBoundary } from '../ui/primitives/error-boundary' +import { SidebarProvider } from '../ui/primitives/sidebar' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' +import { SceneLoader } from '../ui/scene-loader' +import { AppSidebar } from '../ui/sidebar/app-sidebar' +import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' +import type { SitePanelProps } from '../ui/sidebar/panels/site-panel' +import { CustomCameraControls } from './custom-camera-controls' +import { ExportManager } from './export-manager' +import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls' +import { FloatingActionMenu } from './floating-action-menu' +import { FloorplanPanel } from './floorplan-panel' +import { Grid } from './grid' +import { PresetThumbnailGenerator } from './preset-thumbnail-generator' +import { SelectionManager } from './selection-manager' +import { SiteEdgeLabels } from './site-edge-labels' +import { ThumbnailGenerator } from './thumbnail-generator' +import { WallMeasurementLabel } from './wall-measurement-label' + +let hasInitializedEditorRuntime = false +const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' + +function initializeEditorRuntime() { + if (hasInitializedEditorRuntime) return + initSpatialGridSync() + initSpaceDetectionSync(useScene, useEditor) + initSFXBus() + + hasInitializedEditorRuntime = true +} +export interface EditorProps { + // UI slots + appMenuButton?: ReactNode + sidebarTop?: ReactNode + projectId?: string | null + + // Persistence — defaults to localStorage when omitted + onLoad?: () => Promise + onSave?: (scene: SceneGraph) => Promise + onDirty?: () => void + onSaveStatusChange?: (status: SaveStatus) => void + + // Version preview + previewScene?: SceneGraph + isVersionPreviewMode?: boolean + + // Loading indicator (e.g. project fetching in community mode) + isLoading?: boolean + + // Thumbnail + onThumbnailCapture?: (blob: Blob) => void + + // Panel config (passed through to sidebar panels) + settingsPanelProps?: SettingsPanelProps + sitePanelProps?: SitePanelProps + + // Presets storage backend (defaults to localStorage) + presetsAdapter?: PresetsAdapter +} + +function EditorSceneCrashFallback() { + return ( +
+
+

The editor scene failed to render

+

+ You can retry the scene or return home without reloading the whole app shell. +

+
+ + + Back to home + +
+
+
+ ) +} + +function SelectionPersistenceManager({ enabled }: { enabled: boolean }) { + const selection = useViewer((state) => state.selection) + + useEffect(() => { + if (!enabled) { + return + } + + writePersistedSelection(selection) + }, [enabled, selection]) + + return null +} + +type ShortcutKey = { + value: string +} + +type CameraControlHint = { + action: string + keys: ShortcutKey[] + alternativeKeys?: ShortcutKey[] +} + +const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ + { + action: 'Pan', + keys: [{ value: 'Space' }, { value: 'Left click' }], + }, + { action: 'Rotate', keys: [{ value: 'Right click' }] }, + { action: 'Zoom', keys: [{ value: 'Scroll' }] }, +] + +const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ + { action: 'Pan', keys: [{ value: 'Left click' }] }, + { action: 'Rotate', keys: [{ value: 'Right click' }] }, + { action: 'Zoom', keys: [{ value: 'Scroll' }] }, +] + +const CAMERA_SHORTCUT_KEY_META: Record = { + 'Left click': { + icon: 'ph:mouse-left-click-fill', + label: 'Left click', + }, + 'Middle click': { + icon: 'qlementine-icons:mouse-middle-button-16', + label: 'Middle click', + }, + 'Right click': { + icon: 'ph:mouse-right-click-fill', + label: 'Right click', + }, + Scroll: { + icon: 'qlementine-icons:mouse-middle-button-16', + label: 'Scroll wheel', + }, + Space: { + icon: 'lucide:space', + label: 'Space', + }, +} + +function readCameraControlsHintDismissed(): boolean { + if (typeof window === 'undefined') { + return false + } + + try { + return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1' + } catch { + return false + } +} + +function writeCameraControlsHintDismissed(dismissed: boolean) { + if (typeof window === 'undefined') { + return + } + + try { + if (dismissed) { + window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1') + return + } + + window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) + } catch {} +} + +function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) { + const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value] + + if (meta?.icon) { + return ( + + + ) + } + + return ( + + {meta?.text ?? shortcutKey.value} + + ) +} + +function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) { + return ( +
+ {keys.map((key, index) => ( +
+ {index > 0 ? + : null} + +
+ ))} +
+ ) +} + +function CameraControlHintItem({ hint }: { hint: CameraControlHint }) { + return ( +
+ + {hint.action} + +
+ + {hint.alternativeKeys ? ( + <> + / + + + ) : null} +
+
+ ) +} + +function ViewerCanvasControlsHint({ + isPreviewMode, + onDismiss, +}: { + isPreviewMode: boolean + onDismiss: () => void +}) { + const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS + + return ( +
+
+
+ {hints.map((hint) => ( + + ))} +
+ + + + + + Dismiss + + +
+
+ ) +} + +export default function Editor({ + appMenuButton, + sidebarTop, + projectId, + onLoad, + onSave, + onDirty, + onSaveStatusChange, + previewScene, + isVersionPreviewMode = false, + isLoading = false, + onThumbnailCapture, + settingsPanelProps, + sitePanelProps, + presetsAdapter, +}: EditorProps) { + useKeyboard() + + const { isLoadingSceneRef } = useAutoSave({ + onSave, + onDirty, + onSaveStatusChange, + isVersionPreviewMode, + }) + + const [isSceneLoading, setIsSceneLoading] = useState(false) + const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) + const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState( + null, + ) + const isPreviewMode = useEditor((s) => s.isPreviewMode) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) + const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) + + useEffect(() => { + initializeEditorRuntime() + }, []) + + useEffect(() => { + useViewer.getState().setProjectId(projectId ?? null) + + return () => { + useViewer.getState().setProjectId(null) + } + }, [projectId]) + + // Load scene on mount (or when onLoad identity changes, e.g. project switch) + useEffect(() => { + let cancelled = false + + async function load() { + isLoadingSceneRef.current = true + setHasLoadedInitialScene(false) + setIsSceneLoading(true) + + try { + const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() + if (!cancelled) { + applySceneGraphToEditor(sceneGraph) + } + } catch { + if (!cancelled) applySceneGraphToEditor(null) + } finally { + if (!cancelled) { + setIsSceneLoading(false) + setHasLoadedInitialScene(true) + requestAnimationFrame(() => { + isLoadingSceneRef.current = false + }) + } + } + } + + load() + + return () => { + cancelled = true + } + }, [onLoad, isLoadingSceneRef]) + + // Apply preview scene when version preview mode changes + useEffect(() => { + if (isVersionPreviewMode && previewScene) { + applySceneGraphToEditor(previewScene) + } + }, [isVersionPreviewMode, previewScene]) + + useEffect(() => { + document.body.classList.add('dark') + return () => { + document.body.classList.remove('dark') + } + }, []) + + useEffect(() => { + setIsCameraControlsHintVisible(!readCameraControlsHintDismissed()) + }, []) + + const showLoader = isLoading || isSceneLoading + const dismissCameraControlsHint = useCallback(() => { + setIsCameraControlsHintVisible(false) + writeCameraControlsHintDismissed(true) + }, []) + + return ( + +
+ {showLoader && ( +
+ +
+ )} + + {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( + + ) : null} + + {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} + /> + ) : !isLoading && isPreviewMode ? ( + useEditor.getState().setPreviewMode(false)} /> + ) : ( + <> + + + {isFloorplanOpen && } + + + + + + + )} + + }> +
+ + + {!isPreviewMode && !isFirstPersonMode && } + {!isPreviewMode && !isFirstPersonMode && } + {!isPreviewMode && !isFirstPersonMode && } + + {isPreviewMode || isFirstPersonMode ? : } + + + {!isPreviewMode && !isFirstPersonMode && ( + + )} + {!(isPreviewMode || isFirstPersonMode || isLoading) && } + + {isFirstPersonMode && } + + + {!isPreviewMode && !isFirstPersonMode && } + {(isPreviewMode || isFirstPersonMode) && } + +
+ {!(isPreviewMode || isFirstPersonMode || isLoading) && } +
+
+
+ ) +} diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx index f65363cc..71f2263f 100644 --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -1,74 +1,97 @@ -'use client' - -import { emitter } from '@pascal-app/core' -import Image from 'next/image' -import { ActionButton } from './action-button' - -export function CameraActions() { - const goToTopView = () => { - emitter.emit('camera-controls:top-view') - } - - const orbitCW = () => { - emitter.emit('camera-controls:orbit-cw') - } - - const orbitCCW = () => { - emitter.emit('camera-controls:orbit-ccw') - } - - return ( -
- {/* Orbit CCW */} - - Orbit Left - - - {/* Orbit CW */} - - Orbit Right - - - {/* Top View */} - - Top View - -
- ) -} +'use client' + +import { Icon } from '@iconify/react' +import { emitter } from '@pascal-app/core' +import Image from 'next/image' +import useEditor from '../../../store/use-editor' +import { ActionButton } from './action-button' + +export function CameraActions() { + const goToTopView = () => { + emitter.emit('camera-controls:top-view') + } + + const orbitCW = () => { + emitter.emit('camera-controls:orbit-cw') + } + + const orbitCCW = () => { + emitter.emit('camera-controls:orbit-ccw') + } + + const enterStreetView = () => { + useEditor.getState().setFirstPersonMode(true) + } + + return ( +
+ {/* Orbit CCW */} + + Orbit Left + + + {/* Orbit CW */} + + Orbit Right + + + {/* Top View */} + + Top View + + + {/* Street View */} + + + +
+ ) +} diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 06f19a13..589a3415 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -1,499 +1,514 @@ -'use client' - -import { Icon } from '@iconify/react' -import { - type AnyNode, - type AnyNodeId, - type BuildingNode, - emitter, - type LevelNode, - useScene, - type ZoneNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react' -import { motion } from 'motion/react' -import Link from 'next/link' -import { cn } from '../lib/utils' -import { ActionButton } from './ui/action-menu/action-button' -import { TooltipProvider } from './ui/primitives/tooltip' - -type ProjectOwner = { - id: string - name: string - username: string | null - image: string | null -} - -const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { - stacked: 'Stacked', - exploded: 'Exploded', - solo: 'Solo', -} - -const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { - manual: 'Stack', - stacked: 'Stack', - exploded: 'Exploded', - solo: 'Solo', -} - -const wallModeConfig = { - up: { - icon: (props: any) => ( - Full Height - ), - label: 'Full Height', - }, - cutaway: { - icon: (props: any) => ( - Cutaway - ), - label: 'Cutaway', - }, - down: { - icon: (props: any) => ( - Low - ), - label: 'Low', - }, -} - -const getNodeName = (node: AnyNode): string => { - if ('name' in node && node.name) return node.name - if (node.type === 'wall') return 'Wall' - if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' - if (node.type === 'slab') return 'Slab' - if (node.type === 'ceiling') return 'Ceiling' - if (node.type === 'roof') return 'Roof' - if (node.type === 'roof-segment') return 'Roof Segment' - return node.type -} - -interface ViewerOverlayProps { - projectName?: string | null - owner?: ProjectOwner | null - canShowScans?: boolean - canShowGuides?: boolean - onBack?: () => void -} - -export const ViewerOverlay = ({ - projectName, - owner, - canShowScans = true, - canShowGuides = true, - onBack, -}: ViewerOverlayProps) => { - const selection = useViewer((s) => s.selection) - const nodes = useScene((s) => s.nodes) - const showScans = useViewer((s) => s.showScans) - const showGuides = useViewer((s) => s.showGuides) - const cameraMode = useViewer((s) => s.cameraMode) - const levelMode = useViewer((s) => s.levelMode) - const wallMode = useViewer((s) => s.wallMode) - const theme = useViewer((s) => s.theme) - - const building = selection.buildingId - ? (nodes[selection.buildingId] as BuildingNode | undefined) - : null - const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null - const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null - - // Get the first selected item (if any) - const selectedNode = - selection.selectedIds.length > 0 - ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined) - : null - - // Get all levels for the selected building - const levels = - building?.children - .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined) - .filter((n): n is LevelNode => n?.type === 'level') - .sort((a, b) => a.level - b.level) ?? [] - - const handleLevelClick = (levelId: LevelNode['id']) => { - // When switching levels, deselect zone and items - useViewer.getState().setSelection({ levelId }) - } - - const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => { - switch (depth) { - case 'root': - useViewer.getState().resetSelection() - break - case 'building': - useViewer.getState().setSelection({ levelId: null }) - break - case 'level': - useViewer.getState().setSelection({ zoneId: null }) - break - } - } - - return ( - <> - {/* Unified top-left card */} -
-
- {/* Project info + back */} -
- {onBack ? ( - - ) : ( - - - - )} -
-
- {projectName || 'Untitled'} -
- {owner?.username && ( - - @{owner.username} - - )} -
-
- - {/* Breadcrumb — only shown when navigated into a building */} - {building && ( -
-
- - - {building && ( - <> - - - - )} - - {level && ( - <> - - - - )} - - {zone && ( - <> - - - {zone.name} - - - )} - - {selectedNode && zone && ( - <> - - - {getNodeName(selectedNode)} - - - )} -
-
- )} -
- - {/* Level List (only when building is selected) */} - {building && levels.length > 0 && ( -
- - Levels - -
- {levels.map((lvl) => { - const isSelected = lvl.id === selection.levelId - return ( - - ) - })} -
-
- )} -
- - {/* Controls Panel - Bottom Center */} -
- -
- {/* Theme Toggle */} - - -
- - {/* Scans and Guides Visibility */} - {canShowScans && ( - useViewer.getState().setShowScans(!showScans)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Scans - - )} - - {canShowGuides && ( - useViewer.getState().setShowGuides(!showGuides)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Guides - - )} - - {(canShowScans || canShowGuides) &&
} - - {/* Camera Mode */} - - useViewer - .getState() - .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') - } - size="icon" - tooltipSide="top" - variant="ghost" - > - - - - {/* Level Mode */} - { - if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') - const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] - const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length - useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - - {levelMode === 'solo' && } - {levelMode === 'exploded' && ( - - )} - {(levelMode === 'stacked' || levelMode === 'manual') && ( - - )} - - - - - {/* Wall Mode */} - { - const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] - const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length - useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - {(() => { - const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon - return - })()} - - -
- - {/* Camera Actions */} - emitter.emit('camera-controls:orbit-ccw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Left - - - emitter.emit('camera-controls:orbit-cw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Right - - - emitter.emit('camera-controls:top-view')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Top View - -
- -
- - ) -} +'use client' + +import { Icon } from '@iconify/react' +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + emitter, + type LevelNode, + useScene, + type ZoneNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Footprints, Sun } from 'lucide-react' +import { motion } from 'motion/react' +import Link from 'next/link' +import { cn } from '../lib/utils' +import useEditor from '../store/use-editor' +import { ActionButton } from './ui/action-menu/action-button' +import { TooltipProvider } from './ui/primitives/tooltip' + +type ProjectOwner = { + id: string + name: string + username: string | null + image: string | null +} + +const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { + stacked: 'Stacked', + exploded: 'Exploded', + solo: 'Solo', +} + +const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { + manual: 'Stack', + stacked: 'Stack', + exploded: 'Exploded', + solo: 'Solo', +} + +const wallModeConfig = { + up: { + icon: (props: any) => ( + Full Height + ), + label: 'Full Height', + }, + cutaway: { + icon: (props: any) => ( + Cutaway + ), + label: 'Cutaway', + }, + down: { + icon: (props: any) => ( + Low + ), + label: 'Low', + }, +} + +const getNodeName = (node: AnyNode): string => { + if ('name' in node && node.name) return node.name + if (node.type === 'wall') return 'Wall' + if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' + if (node.type === 'slab') return 'Slab' + if (node.type === 'ceiling') return 'Ceiling' + if (node.type === 'roof') return 'Roof' + if (node.type === 'roof-segment') return 'Roof Segment' + return node.type +} + +interface ViewerOverlayProps { + projectName?: string | null + owner?: ProjectOwner | null + canShowScans?: boolean + canShowGuides?: boolean + onBack?: () => void +} + +export const ViewerOverlay = ({ + projectName, + owner, + canShowScans = true, + canShowGuides = true, + onBack, +}: ViewerOverlayProps) => { + const selection = useViewer((s) => s.selection) + const nodes = useScene((s) => s.nodes) + const showScans = useViewer((s) => s.showScans) + const showGuides = useViewer((s) => s.showGuides) + const cameraMode = useViewer((s) => s.cameraMode) + const levelMode = useViewer((s) => s.levelMode) + const wallMode = useViewer((s) => s.wallMode) + const theme = useViewer((s) => s.theme) + + const building = selection.buildingId + ? (nodes[selection.buildingId] as BuildingNode | undefined) + : null + const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null + const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null + + // Get the first selected item (if any) + const selectedNode = + selection.selectedIds.length > 0 + ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined) + : null + + // Get all levels for the selected building + const levels = + building?.children + .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined) + .filter((n): n is LevelNode => n?.type === 'level') + .sort((a, b) => a.level - b.level) ?? [] + + const handleLevelClick = (levelId: LevelNode['id']) => { + // When switching levels, deselect zone and items + useViewer.getState().setSelection({ levelId }) + } + + const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => { + switch (depth) { + case 'root': + useViewer.getState().resetSelection() + break + case 'building': + useViewer.getState().setSelection({ levelId: null }) + break + case 'level': + useViewer.getState().setSelection({ zoneId: null }) + break + } + } + + return ( + <> + {/* Unified top-left card */} +
+
+ {/* Project info + back */} +
+ {onBack ? ( + + ) : ( + + + + )} +
+
+ {projectName || 'Untitled'} +
+ {owner?.username && ( + + @{owner.username} + + )} +
+
+ + {/* Breadcrumb — only shown when navigated into a building */} + {building && ( +
+
+ + + {building && ( + <> + + + + )} + + {level && ( + <> + + + + )} + + {zone && ( + <> + + + {zone.name} + + + )} + + {selectedNode && zone && ( + <> + + + {getNodeName(selectedNode)} + + + )} +
+
+ )} +
+ + {/* Level List (only when building is selected) */} + {building && levels.length > 0 && ( +
+ + Levels + +
+ {levels.map((lvl) => { + const isSelected = lvl.id === selection.levelId + return ( + + ) + })} +
+
+ )} +
+ + {/* Controls Panel - Bottom Center */} +
+ +
+ {/* Theme Toggle */} + + +
+ + {/* Scans and Guides Visibility */} + {canShowScans && ( + useViewer.getState().setShowScans(!showScans)} + size="icon" + tooltipSide="top" + variant="ghost" + > + Scans + + )} + + {canShowGuides && ( + useViewer.getState().setShowGuides(!showGuides)} + size="icon" + tooltipSide="top" + variant="ghost" + > + Guides + + )} + + {(canShowScans || canShowGuides) &&
} + + {/* Camera Mode */} + + useViewer + .getState() + .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') + } + size="icon" + tooltipSide="top" + variant="ghost" + > + + + + {/* Level Mode */} + { + if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') + const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] + const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length + useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + + {levelMode === 'solo' && } + {levelMode === 'exploded' && ( + + )} + {(levelMode === 'stacked' || levelMode === 'manual') && ( + + )} + + + + + {/* Wall Mode */} + { + const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length + useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + {(() => { + const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon + return + })()} + + +
+ + {/* Camera Actions */} + emitter.emit('camera-controls:orbit-ccw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit Left + + + emitter.emit('camera-controls:orbit-cw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit Right + + + emitter.emit('camera-controls:top-view')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Top View + + +
+ + {/* Street View */} + useEditor.getState().setFirstPersonMode(true)} + size="icon" + tooltipSide="top" + variant="ghost" + > + + +
+ +
+ + ) +} diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index c2abffff..6b4ad05a 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,144 +1,149 @@ -import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useEffect } from 'react' -import { sfxEmitter } from '../lib/sfx-bus' -import useEditor from '../store/use-editor' - -export const useKeyboard = () => { - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't handle shortcuts if user is typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return - } - - if (e.key === 'Escape') { - e.preventDefault() - emitter.emit('tool:cancel') - - // Return to the default select tool while keeping the active building/level context. - useEditor.getState().setEditingHole(null) - useEditor.getState().setMode('select') - - // Clear selections to close UI panels, but KEEP the active building and level context. - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) - useEditor.getState().setSelectedReferenceId(null) - } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setPhase('site') - useEditor.getState().setMode('select') - } else if (e.key === '2' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setPhase('structure') - useEditor.getState().setMode('select') - } else if (e.key === '3' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setPhase('furnish') - useEditor.getState().setMode('select') - } else if (e.key === 's' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setPhase('structure') - useEditor.getState().setStructureLayer('elements') - } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setPhase('furnish') - } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setPhase('structure') - useEditor.getState().setStructureLayer('zones') - } - if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setMode('select') - } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setMode('build') - } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - useScene.temporal.getState().undo() - } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - useScene.temporal.getState().redo() - } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - const { buildingId, levelId } = useViewer.getState().selection - if (buildingId) { - const building = useScene.getState().nodes[buildingId] - if (building && building.type === 'building' && building.children.length > 0) { - const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1 - const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx - if (nextIdx !== -1 && nextIdx !== currentIdx) { - useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any }) - } else if (currentIdx === -1) { - useViewer.getState().setSelection({ levelId: building.children[0] as any }) - } - } - } - } else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - const { buildingId, levelId } = useViewer.getState().selection - if (buildingId) { - const building = useScene.getState().nodes[buildingId] - if (building && building.type === 'building' && building.children.length > 0) { - const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1 - const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx - if (prevIdx !== -1 && prevIdx !== currentIdx) { - useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any }) - } else if (currentIdx === -1) { - useViewer - .getState() - .setSelection({ levelId: building.children[building.children.length - 1] as any }) - } - } - } - } else if (e.key === 'r' || e.key === 'R') { - // Rotate selected node if it supports rotation (items, roofs, etc.) - const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] - if (selectedNodeIds.length === 1) { - const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node && 'rotation' in node) { - e.preventDefault() - const ROTATION_STEP = Math.PI / 4 - let newRotationY = 0 - - // Handle different rotation types (number for roof, array for items/windows/doors) - if (typeof node.rotation === 'number') { - newRotationY = node.rotation + ROTATION_STEP - useScene.getState().updateNode(node.id, { rotation: newRotationY }) - } else if (Array.isArray(node.rotation)) { - newRotationY = node.rotation[1] + ROTATION_STEP - useScene.getState().updateNode(node.id, { - rotation: [node.rotation[0], newRotationY, node.rotation[2]], - }) - } - sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback - } - } - } else if (e.key === 'Delete' || e.key === 'Backspace') { - e.preventDefault() - - const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] - - if (selectedNodeIds.length > 0) { - // Play appropriate SFX based on what's being deleted - if (selectedNodeIds.length === 1) { - const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node?.type === 'item') { - sfxEmitter.emit('sfx:item-delete') - } else { - sfxEmitter.emit('sfx:structure-delete') - } - } else { - sfxEmitter.emit('sfx:structure-delete') - } - - useScene.getState().deleteNodes(selectedNodeIds) - } - } - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, []) - - return null -} +import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect } from 'react' +import { sfxEmitter } from '../lib/sfx-bus' +import useEditor from '../store/use-editor' + +export const useKeyboard = () => { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't handle shortcuts if user is typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + // In first-person mode, all shortcuts are handled by FirstPersonControls + if (useEditor.getState().isFirstPersonMode) { + return + } + + if (e.key === 'Escape') { + e.preventDefault() + emitter.emit('tool:cancel') + + // Return to the default select tool while keeping the active building/level context. + useEditor.getState().setEditingHole(null) + useEditor.getState().setMode('select') + + // Clear selections to close UI panels, but KEEP the active building and level context. + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + useEditor.getState().setSelectedReferenceId(null) + } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('site') + useEditor.getState().setMode('select') + } else if (e.key === '2' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('structure') + useEditor.getState().setMode('select') + } else if (e.key === '3' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('furnish') + useEditor.getState().setMode('select') + } else if (e.key === 's' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') + } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('furnish') + } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('zones') + } + if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setMode('select') + } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setMode('build') + } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + useScene.temporal.getState().undo() + } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + useScene.temporal.getState().redo() + } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + const { buildingId, levelId } = useViewer.getState().selection + if (buildingId) { + const building = useScene.getState().nodes[buildingId] + if (building && building.type === 'building' && building.children.length > 0) { + const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1 + const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx + if (nextIdx !== -1 && nextIdx !== currentIdx) { + useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any }) + } else if (currentIdx === -1) { + useViewer.getState().setSelection({ levelId: building.children[0] as any }) + } + } + } + } else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + const { buildingId, levelId } = useViewer.getState().selection + if (buildingId) { + const building = useScene.getState().nodes[buildingId] + if (building && building.type === 'building' && building.children.length > 0) { + const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1 + const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx + if (prevIdx !== -1 && prevIdx !== currentIdx) { + useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any }) + } else if (currentIdx === -1) { + useViewer + .getState() + .setSelection({ levelId: building.children[building.children.length - 1] as any }) + } + } + } + } else if (e.key === 'r' || e.key === 'R') { + // Rotate selected node if it supports rotation (items, roofs, etc.) + const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] + if (selectedNodeIds.length === 1) { + const node = useScene.getState().nodes[selectedNodeIds[0]!] + if (node && 'rotation' in node) { + e.preventDefault() + const ROTATION_STEP = Math.PI / 4 + let newRotationY = 0 + + // Handle different rotation types (number for roof, array for items/windows/doors) + if (typeof node.rotation === 'number') { + newRotationY = node.rotation + ROTATION_STEP + useScene.getState().updateNode(node.id, { rotation: newRotationY }) + } else if (Array.isArray(node.rotation)) { + newRotationY = node.rotation[1] + ROTATION_STEP + useScene.getState().updateNode(node.id, { + rotation: [node.rotation[0], newRotationY, node.rotation[2]], + }) + } + sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback + } + } + } else if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + + const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] + + if (selectedNodeIds.length > 0) { + // Play appropriate SFX based on what's being deleted + if (selectedNodeIds.length === 1) { + const node = useScene.getState().nodes[selectedNodeIds[0]!] + if (node?.type === 'item') { + sfxEmitter.emit('sfx:item-delete') + } else { + sfxEmitter.emit('sfx:structure-delete') + } + } else { + sfxEmitter.emit('sfx:structure-delete') + } + + useScene.getState().deleteNodes(selectedNodeIds) + } + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + + return null +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 90662549..c0e1a40f 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -1,369 +1,389 @@ -'use client' - -import type { AssetInput } from '@pascal-app/core' -import { - type BuildingNode, - type DoorNode, - type ItemNode, - type LevelNode, - type RoofNode, - type RoofSegmentNode, - type Space, - useScene, - type WindowNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { create } from 'zustand' -import { persist } from 'zustand/middleware' - -export type Phase = 'site' | 'structure' | 'furnish' - -export type Mode = 'select' | 'edit' | 'delete' | 'build' - -// Structure mode tools (building elements) -export type StructureTool = - | 'wall' - | 'room' - | 'custom-room' - | 'slab' - | 'ceiling' - | 'roof' - | 'column' - | 'stair' - | 'item' - | 'zone' - | 'window' - | 'door' - -// Furnish mode tools (items and decoration) -export type FurnishTool = 'item' - -// Site mode tools -export type SiteTool = 'property-line' - -// Catalog categories for furnish mode items -export type CatalogCategory = - | 'furniture' - | 'appliance' - | 'bathroom' - | 'kitchen' - | 'outdoor' - | 'window' - | 'door' - -export type StructureLayer = 'zones' | 'elements' - -// Combined tool type -export type Tool = SiteTool | StructureTool | FurnishTool - -type EditorState = { - phase: Phase - setPhase: (phase: Phase) => void - mode: Mode - setMode: (mode: Mode) => void - tool: Tool | null - setTool: (tool: Tool | null) => void - structureLayer: StructureLayer - setStructureLayer: (layer: StructureLayer) => void - catalogCategory: CatalogCategory | null - setCatalogCategory: (category: CatalogCategory | null) => void - selectedItem: AssetInput | null - setSelectedItem: (item: AssetInput) => void - movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null - setMovingNode: ( - node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, - ) => void - selectedReferenceId: string | null - setSelectedReferenceId: (id: string | null) => void - // Space detection for cutaway mode - spaces: Record - setSpaces: (spaces: Record) => void - // Generic hole editing (works for slabs, ceilings, and any future polygon nodes) - editingHole: { nodeId: string; holeIndex: number } | null - setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void - // Preview mode (viewer-like experience inside the editor) - isPreviewMode: boolean - setPreviewMode: (preview: boolean) => void - // Toggleable 2D floorplan overlay - isFloorplanOpen: boolean - setFloorplanOpen: (open: boolean) => void - toggleFloorplanOpen: () => void - isFloorplanHovered: boolean - setFloorplanHovered: (hovered: boolean) => void - // Development-only camera debug flag for inspecting underside geometry - allowUndergroundCamera: boolean - setAllowUndergroundCamera: (enabled: boolean) => void -} - -export type PersistedEditorUiState = Pick< - EditorState, - 'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' -> - -export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = { - phase: 'site', - mode: 'select', - tool: null, - structureLayer: 'elements', - catalogCategory: null, - isFloorplanOpen: false, -} - -function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { - if (phase === 'site') { - return mode === 'edit' ? 'edit' : 'select' - } - - return mode === 'build' || mode === 'delete' ? mode : 'select' -} - -export function normalizePersistedEditorUiState( - state: Partial | null | undefined, -): PersistedEditorUiState { - const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site' - const mode = normalizeModeForPhase(phase, state?.mode) - const isFloorplanOpen = Boolean(state?.isFloorplanOpen) - - if (phase === 'site') { - return { - ...DEFAULT_PERSISTED_EDITOR_UI_STATE, - phase, - mode, - isFloorplanOpen, - } - } - - if (phase === 'furnish') { - return { - phase, - mode, - tool: mode === 'build' ? 'item' : null, - structureLayer: 'elements', - catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null, - isFloorplanOpen, - } - } - - const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements' - - if (mode !== 'build') { - return { - phase, - mode, - tool: null, - structureLayer, - catalogCategory: null, - isFloorplanOpen, - } - } - - if (structureLayer === 'zones') { - return { - phase, - mode, - tool: 'zone', - structureLayer, - catalogCategory: null, - isFloorplanOpen, - } - } - - return { - phase, - mode, - tool: - state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall', - structureLayer, - catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null, - isFloorplanOpen, - } -} - -export function hasCustomPersistedEditorUiState( - state: Partial | null | undefined, -): boolean { - const normalizedState = normalizePersistedEditorUiState(state) - - return ( - normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase || - normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode || - normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool || - normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer || - normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory || - normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen - ) -} - -const useEditor = create()( - persist( - (set, get) => ({ - phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase, - setPhase: (phase) => { - const currentPhase = get().phase - if (currentPhase === phase) return - - set({ phase }) - - const { mode, structureLayer } = get() - - if (mode === 'build') { - // Stay in build mode, select the first tool for the new phase - if (phase === 'site') { - set({ tool: 'property-line', catalogCategory: null }) - } else if (phase === 'structure' && structureLayer === 'zones') { - set({ tool: 'zone', catalogCategory: null }) - } else if (phase === 'structure') { - set({ tool: 'wall', catalogCategory: null }) - } else if (phase === 'furnish') { - set({ tool: 'item', catalogCategory: 'furniture' }) - } - } else { - // Reset to select mode and clear tool/catalog when switching phases - set({ mode: 'select', tool: null, catalogCategory: null }) - } - - const viewer = useViewer.getState() - const scene = useScene.getState() - - // Helper to find building and level 0 - const selectBuildingAndLevel0 = () => { - let buildingId = viewer.selection.buildingId - - // If no building selected, find the first one from site's children - if (!buildingId) { - const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null - if (siteNode?.type === 'site') { - const firstBuilding = siteNode.children - .map((child) => (typeof child === 'string' ? scene.nodes[child] : child)) - .find((node) => node?.type === 'building') - if (firstBuilding) { - buildingId = firstBuilding.id as BuildingNode['id'] - viewer.setSelection({ buildingId }) - } - } - } - - // If no level selected, find level 0 in the building - if (buildingId && !viewer.selection.levelId) { - const buildingNode = scene.nodes[buildingId] as BuildingNode - const level0Id = buildingNode.children.find((childId) => { - const levelNode = scene.nodes[childId] as LevelNode - return levelNode?.type === 'level' && levelNode.level === 0 - }) - if (level0Id) { - viewer.setSelection({ levelId: level0Id as LevelNode['id'] }) - } else if (buildingNode.children[0]) { - // Fallback to first level if level 0 doesn't exist - viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] }) - } - } - } - - switch (phase) { - case 'site': - // In Site mode, we zoom out and deselect specific levels/buildings - viewer.resetSelection() - break - - case 'structure': - selectBuildingAndLevel0() - break - - case 'furnish': - selectBuildingAndLevel0() - // Furnish mode only supports elements layer, not zones - set({ structureLayer: 'elements' }) - break - } - }, - mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode, - setMode: (mode) => { - set({ mode }) - - const { phase, structureLayer, tool } = get() - - if (mode === 'build') { - // Ensure a tool is selected in build mode - if (!tool) { - if (phase === 'structure' && structureLayer === 'zones') { - set({ tool: 'zone' }) - } else if (phase === 'structure' && structureLayer === 'elements') { - set({ tool: 'wall' }) - } else if (phase === 'furnish') { - set({ tool: 'item', catalogCategory: 'furniture' }) - } - } - } - // When leaving build mode, clear tool - else if (tool) { - set({ tool: null }) - } - }, - tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, - setTool: (tool) => set({ tool }), - structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer, - setStructureLayer: (layer) => { - const { mode } = get() - - if (mode === 'build') { - const tool = layer === 'zones' ? 'zone' : 'wall' - set({ structureLayer: layer, tool }) - } else { - set({ structureLayer: layer, mode: 'select', tool: null }) - } - - const viewer = useViewer.getState() - viewer.setSelection({ - selectedIds: [], - zoneId: null, - }) - }, - catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory, - setCatalogCategory: (category) => set({ catalogCategory: category }), - selectedItem: null, - setSelectedItem: (item) => set({ selectedItem: item }), - movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, - setMovingNode: (node) => set({ movingNode: node }), - selectedReferenceId: null, - setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), - spaces: {}, - setSpaces: (spaces) => set({ spaces }), - editingHole: null, - setEditingHole: (hole) => set({ editingHole: hole }), - isPreviewMode: false, - setPreviewMode: (preview) => { - if (preview) { - set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null }) - // Clear zone/item selection for clean viewer drill-down hierarchy - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) - } else { - set({ isPreviewMode: false }) - } - }, - isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen, - setFloorplanOpen: (open) => set({ isFloorplanOpen: open }), - toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })), - isFloorplanHovered: false, - setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }), - allowUndergroundCamera: false, - setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), - }), - { - name: 'pascal-editor-ui-preferences', - merge: (persistedState, currentState) => ({ - ...currentState, - ...normalizePersistedEditorUiState(persistedState as Partial), - }), - partialize: (state) => ({ - phase: state.phase, - mode: state.mode, - tool: state.tool, - structureLayer: state.structureLayer, - catalogCategory: state.catalogCategory, - isFloorplanOpen: state.isFloorplanOpen, - }), - }, - ), -) - -export default useEditor +'use client' + +import type { AssetInput } from '@pascal-app/core' +import { + type BuildingNode, + type DoorNode, + type ItemNode, + type LevelNode, + type RoofNode, + type RoofSegmentNode, + type Space, + useScene, + type WindowNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export type Phase = 'site' | 'structure' | 'furnish' + +export type Mode = 'select' | 'edit' | 'delete' | 'build' + +// Structure mode tools (building elements) +export type StructureTool = + | 'wall' + | 'room' + | 'custom-room' + | 'slab' + | 'ceiling' + | 'roof' + | 'column' + | 'stair' + | 'item' + | 'zone' + | 'window' + | 'door' + +// Furnish mode tools (items and decoration) +export type FurnishTool = 'item' + +// Site mode tools +export type SiteTool = 'property-line' + +// Catalog categories for furnish mode items +export type CatalogCategory = + | 'furniture' + | 'appliance' + | 'bathroom' + | 'kitchen' + | 'outdoor' + | 'window' + | 'door' + +export type StructureLayer = 'zones' | 'elements' + +// Combined tool type +export type Tool = SiteTool | StructureTool | FurnishTool + +type EditorState = { + phase: Phase + setPhase: (phase: Phase) => void + mode: Mode + setMode: (mode: Mode) => void + tool: Tool | null + setTool: (tool: Tool | null) => void + structureLayer: StructureLayer + setStructureLayer: (layer: StructureLayer) => void + catalogCategory: CatalogCategory | null + setCatalogCategory: (category: CatalogCategory | null) => void + selectedItem: AssetInput | null + setSelectedItem: (item: AssetInput) => void + movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null + setMovingNode: ( + node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, + ) => void + selectedReferenceId: string | null + setSelectedReferenceId: (id: string | null) => void + // Space detection for cutaway mode + spaces: Record + setSpaces: (spaces: Record) => void + // Generic hole editing (works for slabs, ceilings, and any future polygon nodes) + editingHole: { nodeId: string; holeIndex: number } | null + setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void + // Preview mode (viewer-like experience inside the editor) + isPreviewMode: boolean + setPreviewMode: (preview: boolean) => void + // Toggleable 2D floorplan overlay + isFloorplanOpen: boolean + setFloorplanOpen: (open: boolean) => void + toggleFloorplanOpen: () => void + isFloorplanHovered: boolean + setFloorplanHovered: (hovered: boolean) => void + // Development-only camera debug flag for inspecting underside geometry + allowUndergroundCamera: boolean + setAllowUndergroundCamera: (enabled: boolean) => void + // First-person walkthrough mode (street view) + isFirstPersonMode: boolean + setFirstPersonMode: (enabled: boolean) => void +} + +export type PersistedEditorUiState = Pick< + EditorState, + 'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' +> + +export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = { + phase: 'site', + mode: 'select', + tool: null, + structureLayer: 'elements', + catalogCategory: null, + isFloorplanOpen: false, +} + +function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { + if (phase === 'site') { + return mode === 'edit' ? 'edit' : 'select' + } + + return mode === 'build' || mode === 'delete' ? mode : 'select' +} + +export function normalizePersistedEditorUiState( + state: Partial | null | undefined, +): PersistedEditorUiState { + const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site' + const mode = normalizeModeForPhase(phase, state?.mode) + const isFloorplanOpen = Boolean(state?.isFloorplanOpen) + + if (phase === 'site') { + return { + ...DEFAULT_PERSISTED_EDITOR_UI_STATE, + phase, + mode, + isFloorplanOpen, + } + } + + if (phase === 'furnish') { + return { + phase, + mode, + tool: mode === 'build' ? 'item' : null, + structureLayer: 'elements', + catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null, + isFloorplanOpen, + } + } + + const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements' + + if (mode !== 'build') { + return { + phase, + mode, + tool: null, + structureLayer, + catalogCategory: null, + isFloorplanOpen, + } + } + + if (structureLayer === 'zones') { + return { + phase, + mode, + tool: 'zone', + structureLayer, + catalogCategory: null, + isFloorplanOpen, + } + } + + return { + phase, + mode, + tool: + state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall', + structureLayer, + catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null, + isFloorplanOpen, + } +} + +export function hasCustomPersistedEditorUiState( + state: Partial | null | undefined, +): boolean { + const normalizedState = normalizePersistedEditorUiState(state) + + return ( + normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase || + normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode || + normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool || + normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer || + normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory || + normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen + ) +} + +const useEditor = create()( + persist( + (set, get) => ({ + phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase, + setPhase: (phase) => { + const currentPhase = get().phase + if (currentPhase === phase) return + + set({ phase }) + + const { mode, structureLayer } = get() + + if (mode === 'build') { + // Stay in build mode, select the first tool for the new phase + if (phase === 'site') { + set({ tool: 'property-line', catalogCategory: null }) + } else if (phase === 'structure' && structureLayer === 'zones') { + set({ tool: 'zone', catalogCategory: null }) + } else if (phase === 'structure') { + set({ tool: 'wall', catalogCategory: null }) + } else if (phase === 'furnish') { + set({ tool: 'item', catalogCategory: 'furniture' }) + } + } else { + // Reset to select mode and clear tool/catalog when switching phases + set({ mode: 'select', tool: null, catalogCategory: null }) + } + + const viewer = useViewer.getState() + const scene = useScene.getState() + + // Helper to find building and level 0 + const selectBuildingAndLevel0 = () => { + let buildingId = viewer.selection.buildingId + + // If no building selected, find the first one from site's children + if (!buildingId) { + const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null + if (siteNode?.type === 'site') { + const firstBuilding = siteNode.children + .map((child) => (typeof child === 'string' ? scene.nodes[child] : child)) + .find((node) => node?.type === 'building') + if (firstBuilding) { + buildingId = firstBuilding.id as BuildingNode['id'] + viewer.setSelection({ buildingId }) + } + } + } + + // If no level selected, find level 0 in the building + if (buildingId && !viewer.selection.levelId) { + const buildingNode = scene.nodes[buildingId] as BuildingNode + const level0Id = buildingNode.children.find((childId) => { + const levelNode = scene.nodes[childId] as LevelNode + return levelNode?.type === 'level' && levelNode.level === 0 + }) + if (level0Id) { + viewer.setSelection({ levelId: level0Id as LevelNode['id'] }) + } else if (buildingNode.children[0]) { + // Fallback to first level if level 0 doesn't exist + viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] }) + } + } + } + + switch (phase) { + case 'site': + // In Site mode, we zoom out and deselect specific levels/buildings + viewer.resetSelection() + break + + case 'structure': + selectBuildingAndLevel0() + break + + case 'furnish': + selectBuildingAndLevel0() + // Furnish mode only supports elements layer, not zones + set({ structureLayer: 'elements' }) + break + } + }, + mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode, + setMode: (mode) => { + set({ mode }) + + const { phase, structureLayer, tool } = get() + + if (mode === 'build') { + // Ensure a tool is selected in build mode + if (!tool) { + if (phase === 'structure' && structureLayer === 'zones') { + set({ tool: 'zone' }) + } else if (phase === 'structure' && structureLayer === 'elements') { + set({ tool: 'wall' }) + } else if (phase === 'furnish') { + set({ tool: 'item', catalogCategory: 'furniture' }) + } + } + } + // When leaving build mode, clear tool + else if (tool) { + set({ tool: null }) + } + }, + tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, + setTool: (tool) => set({ tool }), + structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer, + setStructureLayer: (layer) => { + const { mode } = get() + + if (mode === 'build') { + const tool = layer === 'zones' ? 'zone' : 'wall' + set({ structureLayer: layer, tool }) + } else { + set({ structureLayer: layer, mode: 'select', tool: null }) + } + + const viewer = useViewer.getState() + viewer.setSelection({ + selectedIds: [], + zoneId: null, + }) + }, + catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory, + setCatalogCategory: (category) => set({ catalogCategory: category }), + selectedItem: null, + setSelectedItem: (item) => set({ selectedItem: item }), + movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, + setMovingNode: (node) => set({ movingNode: node }), + selectedReferenceId: null, + setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + spaces: {}, + setSpaces: (spaces) => set({ spaces }), + editingHole: null, + setEditingHole: (hole) => set({ editingHole: hole }), + isPreviewMode: false, + setPreviewMode: (preview) => { + if (preview) { + set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null }) + // Clear zone/item selection for clean viewer drill-down hierarchy + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + } else { + set({ isPreviewMode: false }) + } + }, + isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen, + setFloorplanOpen: (open) => set({ isFloorplanOpen: open }), + toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })), + isFloorplanHovered: false, + setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }), + allowUndergroundCamera: false, + setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), + isFirstPersonMode: false, + setFirstPersonMode: (enabled) => { + if (enabled) { + // Force perspective camera and full-height walls for immersive walkthrough + useViewer.getState().setCameraMode('perspective') + useViewer.getState().setWallMode('up') + set({ + isFirstPersonMode: true, + mode: 'select', + tool: null, + catalogCategory: null, + }) + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + } else { + set({ isFirstPersonMode: false }) + } + }, + }), + { + name: 'pascal-editor-ui-preferences', + merge: (persistedState, currentState) => ({ + ...currentState, + ...normalizePersistedEditorUiState(persistedState as Partial), + }), + partialize: (state) => ({ + phase: state.phase, + mode: state.mode, + tool: state.tool, + structureLayer: state.structureLayer, + catalogCategory: state.catalogCategory, + isFloorplanOpen: state.isFloorplanOpen, + }), + }, + ), +) + +export default useEditor