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])