shortcuts and deletion

This commit is contained in:
wass08
2026-01-23 10:30:24 +09:00
parent cc9d9916b7
commit 4f20586ae9
4 changed files with 132 additions and 9 deletions
+3
View File
@@ -9,6 +9,7 @@ import { MathUtils, type Mesh } from 'three'
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl' import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useKeyboard } from '@/hooks/use-keyboard'
import { ToolManager } from '../tools/tool-manager' import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu' import { ActionMenu } from '../ui/action-menu'
import { SidebarProvider } from '../ui/primitives/sidebar' import { SidebarProvider } from '../ui/primitives/sidebar'
@@ -20,6 +21,8 @@ initSpatialGridSync()
useScene.getState().loadScene() useScene.getState().loadScene()
export default function Editor() { export default function Editor() {
useKeyboard()
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
{/* <LevelModeSwitcher /> */} {/* <LevelModeSwitcher /> */}
@@ -243,6 +243,36 @@ export const ItemTool: React.FC = () => {
emitter.on('wall:click', onWallClick) emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave) emitter.on('wall:leave', onWallLeave)
// Keyboard rotation handlers
const ROTATION_STEP = Math.PI / 2 // 90 degrees
const onKeyDown = (event: KeyboardEvent) => {
if (!draftItem.current) return
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') {
rotationDelta = ROTATION_STEP // Counter-clockwise
} else if (event.key === 't' || event.key === 'T') {
rotationDelta = -ROTATION_STEP // Clockwise
}
if (rotationDelta !== 0) {
event.preventDefault()
const currentRotation = draftItem.current.rotation
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
draftItem.current.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
useScene.getState().updateNode(draftItem.current.id, {
rotation: draftItem.current.rotation,
})
// Update cursor rotation to match
cursorRef.current.rotation.y = newRotationY
checkCanPlace()
}
}
window.addEventListener('keydown', onKeyDown)
const setupBoundingBox = () => { const setupBoundingBox = () => {
const boxGeometry = new BoxGeometry( const boxGeometry = new BoxGeometry(
selectedItem.dimensions[0], selectedItem.dimensions[0],
@@ -265,6 +295,7 @@ export const ItemTool: React.FC = () => {
emitter.off('wall:leave', onWallLeave) emitter.off('wall:leave', onWallLeave)
emitter.off('wall:click', onWallClick) emitter.off('wall:click', onWallClick)
emitter.off('wall:move', onWallMove) emitter.off('wall:move', onWallMove)
window.removeEventListener('keydown', onKeyDown)
} }
}, [selectedItem, canPlaceOnFloor, canPlaceOnWall]) }, [selectedItem, canPlaceOnFloor, canPlaceOnWall])
+59
View File
@@ -0,0 +1,59 @@
import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
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()
// Emit tool:cancel event - each tool handles its own cancellation logic
// if (useEditor.getState().controlMode === 'building') {
// emitter.emit('tool:cancel', undefined)
// }
// if (selectedNodeIds.length > 0) {
// handleClear()
// }
} 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')
} if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('select')
} 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 === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
const selectedNodeIds = useViewer.getState().selection.selectedIds
if (selectedNodeIds.length > 0) {
useScene.getState().deleteNodes(selectedNodeIds)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return null
}
@@ -2,10 +2,33 @@ import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-ap
import { Clone } from '@react-three/drei/core/Clone' import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf' import { useGLTF } from '@react-three/drei/core/Gltf'
import { Suspense, useEffect, useRef } from 'react' import { Suspense, useEffect, useRef } from 'react'
import type { Group, Mesh } from 'three' import type { Group, Material, Mesh } from 'three'
import { MeshStandardNodeMaterial } from 'three/webgpu' import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
// Shared materials to avoid creating new instances for every mesh
const defaultMaterial = new MeshStandardNodeMaterial({
color: 0xffffff,
roughness: 0.8,
metalness: 0,
})
const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
color: 'skyblue',
roughness: 0.8,
metalness: 0,
transparent: true,
opacity: 0.25,
})
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
if (original.name.toLowerCase() === 'glass') {
return glassMaterial
}
return defaultMaterial
}
export const ItemRenderer = ({ node }: { node: ItemNode }) => { export const ItemRenderer = ({ node }: { node: ItemNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
@@ -37,14 +60,21 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
useEffect(() => { useEffect(() => {
scene.traverse((child) => { scene.traverse((child) => {
if ((child as Mesh).isMesh) { if ((child as Mesh).isMesh) {
child.castShadow = true const mesh = child as Mesh
child.receiveShadow = true if (mesh.name === 'cutout') {
// TODO: do it with a shared material child.visible = false
child.material = new MeshStandardNodeMaterial({ return
color: 0xffffff, }
roughness: 0.8,
metalness: 0, mesh.castShadow = true
}) mesh.receiveShadow = true
// Handle both single material and material array cases
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
} else {
mesh.material = getMaterialForOriginal(mesh.material)
}
} }
}) })
}, [scene]) }, [scene])