shortcuts and deletion
This commit is contained in:
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user