Refactor first-person and spawn selection handling

This commit is contained in:
sudhir
2026-04-29 10:35:34 +05:30
parent 4796ed8262
commit e688792c34
8 changed files with 80 additions and 46 deletions
+3 -5
View File
@@ -1,6 +1,6 @@
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber'
import type { Object3D } from 'three'
import mitt from 'mitt' import mitt from 'mitt'
import type { Object3D } from 'three'
import type { import type {
BuildingNode, BuildingNode,
CeilingNode, CeilingNode,
@@ -104,10 +104,8 @@ export interface ThumbnailGenerateEvent {
export interface CameraControlFitSceneEvent { export interface CameraControlFitSceneEvent {
/** /**
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the * XZ-plane axis-aligned bounds for camera framing. Omitted values let the
* scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The * listener choose its default framing pose.
* viewer's camera-controls listener frames the camera onto this box.
* Omitted values fall back to the camera's default pose.
*/ */
bounds?: { bounds?: {
min: [number, number] min: [number, number]
@@ -1,5 +1,11 @@
'use client' '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 { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
@@ -20,7 +20,6 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard' import { useKeyboard } from '../../hooks/use-keyboard'
import { import {
@@ -952,6 +951,8 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false) const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
const sidebarWidth = useSidebarStore((s) => s.width) const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -969,6 +970,39 @@ export default function Editor({
} }
}, [projectId]) }, [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) // Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -88,7 +88,9 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
return <>{cursor}</> return <>{cursor}</>
} }
export const MoveTool: React.FC = () => { export const MoveTool: React.FC<{
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onSpawnMoved }) => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null if (!movingNode) return null
@@ -102,7 +104,8 @@ export const MoveTool: React.FC = () => {
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} /> if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} /> if (movingNode.type === 'spawn')
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} /> return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} /> return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -3,12 +3,11 @@ import '../../../three-types'
import { import {
emitter, emitter,
type GridEvent, type GridEvent,
sceneRegistry,
type SpawnNode, type SpawnNode,
sceneRegistry,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' 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)] 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 [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
@@ -66,7 +68,7 @@ export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => {
committed = true committed = true
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position: nextPosition }) useScene.getState().updateNode(node.id, { position: nextPosition })
useViewer.getState().setSelection({ selectedIds: [node.id] }) onCommitted?.(node.id)
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
exitMoveMode() exitMoveMode()
@@ -91,7 +93,7 @@ export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => {
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
} }
} }
}, [exitMoveMode, node]) }, [exitMoveMode, node, onCommitted])
return ( return (
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} /> <CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
@@ -4,11 +4,11 @@ import {
emitter, emitter,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
sceneRegistry,
SpawnNode, SpawnNode,
type SpawnNode as SpawnNodeType,
sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three' import type { Group } from 'three'
import { Vector3 } from 'three' import { Vector3 } from 'three'
@@ -56,8 +56,12 @@ function getLevelLocalSpawnPosition(
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
} }
export const SpawnTool: React.FC = () => { type SpawnToolProps = {
const currentLevelId = useViewer((state) => state.selection.levelId) currentLevelId: LevelNode['id'] | null
onPlaced?: (spawnId: SpawnNodeType['id']) => void
}
export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null) const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
@@ -87,7 +91,7 @@ export const SpawnTool: React.FC = () => {
if (duplicateSpawnIds.length > 0) { if (duplicateSpawnIds.length > 0) {
useScene.getState().deleteNodes(duplicateSpawnIds) useScene.getState().deleteNodes(duplicateSpawnIds)
} }
useViewer.getState().setSelection({ selectedIds: [existingSpawnId] }) onPlaced?.(existingSpawnId)
} else { } else {
const spawn = SpawnNode.parse({ const spawn = SpawnNode.parse({
name: 'Spawn Point', name: 'Spawn Point',
@@ -95,7 +99,7 @@ export const SpawnTool: React.FC = () => {
rotation: 0, rotation: 0,
}) })
useScene.getState().createNode(spawn, currentLevelId) useScene.getState().createNode(spawn, currentLevelId)
useViewer.getState().setSelection({ selectedIds: [spawn.id] }) onPlaced?.(spawn.id)
} }
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
@@ -110,7 +114,7 @@ export const SpawnTool: React.FC = () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
} }
}, [currentLevelId]) }, [currentLevelId, onPlaced])
if (!currentLevelId) return null if (!currentLevelId) return null
@@ -44,7 +44,6 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
door: DoorTool, door: DoorTool,
item: ItemTool, item: ItemTool,
zone: ZoneTool, zone: ZoneTool,
spawn: SpawnTool,
window: WindowTool, window: WindowTool,
}, },
furnish: { furnish: {
@@ -63,8 +62,10 @@ export const ToolManager: React.FC = () => {
const curvingFence = useEditor((state) => state.curvingFence) const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedLevelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const setSelection = useViewer((state) => state.setSelection)
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
// Building transform for the local group — all building-relative tools live inside this group // 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 showBuildTool = mode === 'build' && tool !== null
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
const handleSpawnSelected = (nodeId: `spawn_${string}`) => {
setSelection({ selectedIds: [nodeId] })
}
return ( return (
<> <>
{showSiteBoundaryEditor && <SiteBoundaryEditor />} {showSiteBoundaryEditor && <SiteBoundaryEditor />}
{/* World-space tools: site boundary and building movement operate in world coordinates */} {/* World-space tools: site boundary and building movement operate in world coordinates */}
{movingNode?.type === 'building' && <MoveTool />} {movingNode?.type === 'building' && <MoveTool onSpawnMoved={handleSpawnSelected} />}
{/* Building-local group: all other tools are relative to the selected 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 Cursor visuals set positions in building-local space; this group applies the
@@ -154,7 +158,12 @@ export const ToolManager: React.FC = () => {
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />} {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />} {curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />} {curvingFence && <CurveFenceTool node={curvingFence} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />} {movingNode && movingNode.type !== 'building' && (
<MoveTool onSpawnMoved={handleSpawnSelected} />
)}
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handleSpawnSelected} />
)}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</group> </group>
</> </>
-22
View File
@@ -204,7 +204,6 @@ type EditorState = {
// First-person walkthrough mode (street view) // First-person walkthrough mode (street view)
isFirstPersonMode: boolean isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null _viewModeBeforeFirstPerson: ViewMode | null
_levelIdBeforeFirstPerson: LevelNode['id'] | null
setFirstPersonMode: (enabled: boolean) => void setFirstPersonMode: (enabled: boolean) => void
// Development-only camera debug flag for inspecting underside geometry // Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean allowUndergroundCamera: boolean
@@ -637,46 +636,25 @@ const useEditor = create<EditorState>()(
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
isFirstPersonMode: false, isFirstPersonMode: false,
_viewModeBeforeFirstPerson: null as ViewMode | null, _viewModeBeforeFirstPerson: null as ViewMode | null,
_levelIdBeforeFirstPerson: null as LevelNode['id'] | null,
setFirstPersonMode: (enabled) => { setFirstPersonMode: (enabled) => {
if (enabled) { if (enabled) {
const currentViewMode = get().viewMode const currentViewMode = get().viewMode
const currentLevelId = useViewer.getState().selection.levelId
useViewer.getState().setCameraMode('perspective')
useViewer.getState().setWallMode('up')
useViewer.getState().setWalkthroughMode(true)
set({ set({
isFirstPersonMode: true, isFirstPersonMode: true,
_viewModeBeforeFirstPerson: currentViewMode, _viewModeBeforeFirstPerson: currentViewMode,
_levelIdBeforeFirstPerson: currentLevelId,
viewMode: '3d', viewMode: '3d',
isFloorplanOpen: false, isFloorplanOpen: false,
mode: 'select', mode: 'select',
tool: null, tool: null,
catalogCategory: null, catalogCategory: null,
}) })
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else { } else {
const prevMode = get()._viewModeBeforeFirstPerson const prevMode = get()._viewModeBeforeFirstPerson
const prevLevelId = get()._levelIdBeforeFirstPerson
useViewer.getState().setWalkthroughMode(false)
set({ set({
isFirstPersonMode: false, isFirstPersonMode: false,
_viewModeBeforeFirstPerson: null, _viewModeBeforeFirstPerson: null,
_levelIdBeforeFirstPerson: null,
...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}), ...(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, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,