diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index 692a78a2..58111041 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -9,6 +9,7 @@ import { MathUtils, type Mesh } from 'three'
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
+import { useKeyboard } from '@/hooks/use-keyboard'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
import { SidebarProvider } from '../ui/primitives/sidebar'
@@ -20,6 +21,8 @@ initSpatialGridSync()
useScene.getState().loadScene()
export default function Editor() {
+ useKeyboard()
+
return (
{/* */}
diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx
index 6e4c0413..ca9045ea 100644
--- a/apps/editor/components/tools/item/item-tool.tsx
+++ b/apps/editor/components/tools/item/item-tool.tsx
@@ -243,6 +243,36 @@ export const ItemTool: React.FC = () => {
emitter.on('wall:click', onWallClick)
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 boxGeometry = new BoxGeometry(
selectedItem.dimensions[0],
@@ -265,6 +295,7 @@ export const ItemTool: React.FC = () => {
emitter.off('wall:leave', onWallLeave)
emitter.off('wall:click', onWallClick)
emitter.off('wall:move', onWallMove)
+ window.removeEventListener('keydown', onKeyDown)
}
}, [selectedItem, canPlaceOnFloor, canPlaceOnWall])
diff --git a/apps/editor/hooks/use-keyboard.ts b/apps/editor/hooks/use-keyboard.ts
new file mode 100644
index 00000000..66460c70
--- /dev/null
+++ b/apps/editor/hooks/use-keyboard.ts
@@ -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
+}
diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx
index adb7a0fe..bb5ec710 100644
--- a/packages/viewer/src/components/renderers/item/item-renderer.tsx
+++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx
@@ -2,10 +2,33 @@ import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-ap
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
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 { 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 }) => {
const ref = useRef(null!)
@@ -37,14 +60,21 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
useEffect(() => {
scene.traverse((child) => {
if ((child as Mesh).isMesh) {
- child.castShadow = true
- child.receiveShadow = true
- // TODO: do it with a shared material
- child.material = new MeshStandardNodeMaterial({
- color: 0xffffff,
- roughness: 0.8,
- metalness: 0,
- })
+ const mesh = child as Mesh
+ if (mesh.name === 'cutout') {
+ child.visible = false
+ return
+ }
+
+ 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])