From e688792c348943ca6a84687b1cfddb249c17c77e Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 29 Apr 2026 10:35:34 +0530 Subject: [PATCH] Refactor first-person and spawn selection handling --- packages/core/src/events/bus.ts | 8 ++--- .../editor/custom-camera-controls.tsx | 8 ++++- .../editor/src/components/editor/index.tsx | 36 ++++++++++++++++++- .../src/components/tools/item/move-tool.tsx | 7 ++-- .../tools/spawn/move-spawn-tool.tsx | 12 ++++--- .../src/components/tools/spawn/spawn-tool.tsx | 18 ++++++---- .../src/components/tools/tool-manager.tsx | 15 ++++++-- packages/editor/src/store/use-editor.tsx | 22 ------------ 8 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7774fe0e..6cb4215e 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' -import type { Object3D } from 'three' import mitt from 'mitt' +import type { Object3D } from 'three' import type { BuildingNode, CeilingNode, @@ -104,10 +104,8 @@ export interface ThumbnailGenerateEvent { export interface CameraControlFitSceneEvent { /** - * XZ-plane axis-aligned bounds of the scene's geometry, computed from the - * scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The - * viewer's camera-controls listener frames the camera onto this box. - * Omitted values fall back to the camera's default pose. + * XZ-plane axis-aligned bounds for camera framing. Omitted values let the + * listener choose its default framing pose. */ bounds?: { min: [number, number] diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 5ac03fed..4d968001 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,5 +1,11 @@ 'use client' -import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' +import { + type CameraControlEvent, + type CameraControlFitSceneEvent, + 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' diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5bd2b173..e0126337 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -20,7 +20,6 @@ import { import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' -import { useAutoFrame } from '../../hooks/use-auto-frame' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' import { @@ -952,6 +951,8 @@ export default function Editor({ const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const isPreviewMode = useEditor((s) => s.isPreviewMode) + const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) + const wasFirstPersonModeRef = useRef(isFirstPersonMode) const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) @@ -969,6 +970,39 @@ export default function Editor({ } }, [projectId]) + useEffect(() => { + const wasFirstPersonMode = wasFirstPersonModeRef.current + wasFirstPersonModeRef.current = isFirstPersonMode + + if (isFirstPersonMode && !wasFirstPersonMode) { + const viewer = useViewer.getState() + firstPersonPreviousLevelRef.current = viewer.selection.levelId + viewer.setCameraMode('perspective') + viewer.setWallMode('up') + viewer.setWalkthroughMode(true) + viewer.setSelection({ selectedIds: [], zoneId: null }) + return + } + + if (!(wasFirstPersonMode && !isFirstPersonMode)) return + + const viewer = useViewer.getState() + const previousLevelId = firstPersonPreviousLevelRef.current + firstPersonPreviousLevelRef.current = null + viewer.setWalkthroughMode(false) + + if (!previousLevelId) return + + const previousLevelNode = useScene.getState().nodes[previousLevelId] + if (previousLevelNode?.type === 'level') { + viewer.setSelection({ + levelId: previousLevelId, + zoneId: null, + selectedIds: [], + }) + } + }, [isFirstPersonMode]) + // Load scene on mount (or when onLoad identity changes, e.g. project switch) useEffect(() => { let cancelled = false diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 018e6933..b6c1397a 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -88,7 +88,9 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { return <>{cursor} } -export const MoveTool: React.FC = () => { +export const MoveTool: React.FC<{ + onSpawnMoved?: (nodeId: SpawnNode['id']) => void +}> = ({ onSpawnMoved }) => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null @@ -102,7 +104,8 @@ export const MoveTool: React.FC = () => { if (movingNode.type === 'wall') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return - if (movingNode.type === 'spawn') return + if (movingNode.type === 'spawn') + return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return return diff --git a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx index 386d3f82..6eef48a6 100644 --- a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx +++ b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx @@ -3,12 +3,11 @@ import '../../../three-types' import { emitter, type GridEvent, - sceneRegistry, type SpawnNode, + sceneRegistry, useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import { Vector3 } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -35,7 +34,10 @@ function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] } -export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => { +export const MoveSpawnTool: React.FC<{ + node: SpawnNode + onCommitted?: (nodeId: SpawnNode['id']) => void +}> = ({ node, onCommitted }) => { const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) const exitMoveMode = useCallback(() => { @@ -66,7 +68,7 @@ export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => { committed = true useScene.temporal.getState().resume() useScene.getState().updateNode(node.id, { position: nextPosition }) - useViewer.getState().setSelection({ selectedIds: [node.id] }) + onCommitted?.(node.id) useLiveTransforms.getState().clear(node.id) sfxEmitter.emit('sfx:item-place') exitMoveMode() @@ -91,7 +93,7 @@ export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => { useScene.temporal.getState().resume() } } - }, [exitMoveMode, node]) + }, [exitMoveMode, node, onCommitted]) return ( diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx index 3bd1da3e..30d294ee 100644 --- a/packages/editor/src/components/tools/spawn/spawn-tool.tsx +++ b/packages/editor/src/components/tools/spawn/spawn-tool.tsx @@ -4,11 +4,11 @@ import { emitter, type GridEvent, type LevelNode, - sceneRegistry, SpawnNode, + type SpawnNode as SpawnNodeType, + sceneRegistry, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import type { Group } from 'three' import { Vector3 } from 'three' @@ -56,8 +56,12 @@ function getLevelLocalSpawnPosition( return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] } -export const SpawnTool: React.FC = () => { - const currentLevelId = useViewer((state) => state.selection.levelId) +type SpawnToolProps = { + currentLevelId: LevelNode['id'] | null + onPlaced?: (spawnId: SpawnNodeType['id']) => void +} + +export const SpawnTool: React.FC = ({ currentLevelId, onPlaced }) => { const [, setCursorPosition] = useState<[number, number, number] | null>(null) const cursorRef = useRef(null) @@ -87,7 +91,7 @@ export const SpawnTool: React.FC = () => { if (duplicateSpawnIds.length > 0) { useScene.getState().deleteNodes(duplicateSpawnIds) } - useViewer.getState().setSelection({ selectedIds: [existingSpawnId] }) + onPlaced?.(existingSpawnId) } else { const spawn = SpawnNode.parse({ name: 'Spawn Point', @@ -95,7 +99,7 @@ export const SpawnTool: React.FC = () => { rotation: 0, }) useScene.getState().createNode(spawn, currentLevelId) - useViewer.getState().setSelection({ selectedIds: [spawn.id] }) + onPlaced?.(spawn.id) } sfxEmitter.emit('sfx:structure-build') @@ -110,7 +114,7 @@ export const SpawnTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) } - }, [currentLevelId]) + }, [currentLevelId, onPlaced]) if (!currentLevelId) return null diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index d6e461d3..0938a254 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -44,7 +44,6 @@ const tools: Record>> = { door: DoorTool, item: ItemTool, zone: ZoneTool, - spawn: SpawnTool, window: WindowTool, }, furnish: { @@ -63,8 +62,10 @@ export const ToolManager: React.FC = () => { const curvingFence = useEditor((state) => state.curvingFence) const editingHole = useEditor((state) => state.editingHole) const selectedZoneId = useViewer((state) => state.selection.zoneId) + const selectedLevelId = useViewer((state) => state.selection.levelId) const buildingId = useViewer((state) => state.selection.buildingId) const selectedIds = useViewer((state) => state.selection.selectedIds) + const setSelection = useViewer((state) => state.setSelection) const nodes = useScene((state) => state.nodes) // Building transform for the local group — all building-relative tools live inside this group @@ -125,12 +126,15 @@ export const ToolManager: React.FC = () => { const showBuildTool = mode === 'build' && tool !== null const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null + const handleSpawnSelected = (nodeId: `spawn_${string}`) => { + setSelection({ selectedIds: [nodeId] }) + } return ( <> {showSiteBoundaryEditor && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && } {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -154,7 +158,12 @@ export const ToolManager: React.FC = () => { {movingFenceEndpoint && } {curvingWall && } {curvingFence && } - {movingNode && movingNode.type !== 'building' && } + {movingNode && movingNode.type !== 'building' && ( + + )} + {!movingNode && showBuildTool && tool === 'spawn' && ( + + )} {!movingNode && BuildToolComponent && } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 0cd2691a..867f7c16 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -204,7 +204,6 @@ type EditorState = { // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null - _levelIdBeforeFirstPerson: LevelNode['id'] | null setFirstPersonMode: (enabled: boolean) => void // Development-only camera debug flag for inspecting underside geometry allowUndergroundCamera: boolean @@ -637,46 +636,25 @@ const useEditor = create()( setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFirstPersonMode: false, _viewModeBeforeFirstPerson: null as ViewMode | null, - _levelIdBeforeFirstPerson: null as LevelNode['id'] | null, setFirstPersonMode: (enabled) => { if (enabled) { const currentViewMode = get().viewMode - const currentLevelId = useViewer.getState().selection.levelId - useViewer.getState().setCameraMode('perspective') - useViewer.getState().setWallMode('up') - useViewer.getState().setWalkthroughMode(true) set({ isFirstPersonMode: true, _viewModeBeforeFirstPerson: currentViewMode, - _levelIdBeforeFirstPerson: currentLevelId, viewMode: '3d', isFloorplanOpen: false, mode: 'select', tool: null, catalogCategory: null, }) - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { const prevMode = get()._viewModeBeforeFirstPerson - const prevLevelId = get()._levelIdBeforeFirstPerson - useViewer.getState().setWalkthroughMode(false) set({ isFirstPersonMode: false, _viewModeBeforeFirstPerson: null, - _levelIdBeforeFirstPerson: null, ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}), }) - - if (prevLevelId) { - const prevLevelNode = useScene.getState().nodes[prevLevelId] - if (prevLevelNode?.type === 'level') { - useViewer.getState().setSelection({ - levelId: prevLevelId, - zoneId: null, - selectedIds: [], - }) - } - } } }, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,