merge: resolve conflict with main — keep first-person mode bypass + tool cancel pattern + delete mode + counter-clockwise rotation

This commit is contained in:
Pascal
2026-03-30 20:06:47 +00:00
36 changed files with 1120 additions and 871 deletions
@@ -4,13 +4,15 @@ import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
export function ExportManager() {
const scene = useThree((state) => state.scene)
const setExportScene = useViewer((state) => state.setExportScene)
useEffect(() => {
const exportFn = async () => {
const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
// Find the scene renderer group by name
const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) {
@@ -18,20 +20,33 @@ export function ExportManager() {
return
}
const exporter = new GLTFExporter()
const date = new Date().toISOString().split('T')[0]
if (format === 'stl') {
const exporter = new STLExporter()
const result = exporter.parse(sceneGroup, { binary: true })
const blob = new Blob([result], { type: 'model/stl' })
downloadBlob(blob, `model_${date}.stl`)
return
}
if (format === 'obj') {
const exporter = new OBJExporter()
const result = exporter.parse(sceneGroup)
const blob = new Blob([result], { type: 'model/obj' })
downloadBlob(blob, `model_${date}.obj`)
return
}
// Default: GLB export (existing behavior)
const exporter = new GLTFExporter()
return new Promise<void>((resolve, reject) => {
exporter.parse(
sceneGroup,
(gltf) => {
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `model_${date}.glb`
link.click()
URL.revokeObjectURL(url)
downloadBlob(blob, `model_${date}.glb`)
resolve()
},
(error) => {
@@ -52,3 +67,12 @@ export function ExportManager() {
return null
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
@@ -20,12 +20,14 @@ import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment']
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment', 'wall', 'slab']
const DELETE_ONLY_TYPES = ['wall', 'slab']
export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const nodes = useScene((s) => s.nodes)
const deleteNode = useScene((s) => s.deleteNode)
const mode = useEditor((s) => s.mode)
const setMode = useEditor((s) => s.setMode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection)
@@ -46,8 +48,10 @@ export function FloatingActionMenu() {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position slightly above the object
groupRef.current.position.set(center.x, box.max.y + 0.3, center.z)
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type)
const yOffset = isDeleteOnly ? 0.8 : 0.3
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
}
}
})
@@ -151,16 +155,14 @@ export function FloatingActionMenu() {
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNodeId)
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
// Activate delete mode (sledgehammer tool) instead of deleting directly
setSelection({ selectedIds: [] })
setMode('delete')
},
[selectedId, node, deleteNode, setSelection],
[setSelection, setMode],
)
if (!(selectedId && node && isValidType && !isFloorplanHovered)) return null
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
return (
<group ref={groupRef}>
@@ -174,8 +176,8 @@ export function FloatingActionMenu() {
>
<NodeActionMenu
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onMove={handleMove}
onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
@@ -5,8 +5,8 @@ import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = {
onDelete: MouseEventHandler<HTMLButtonElement>
onDuplicate: MouseEventHandler<HTMLButtonElement>
onMove: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement>
onPointerDown?: PointerEventHandler<HTMLDivElement>
onPointerUp?: PointerEventHandler<HTMLDivElement>
onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -30,24 +30,28 @@ export function NodeActionMenu({
onPointerLeave={onPointerLeave}
onPointerUp={onPointerUp}
>
<button
aria-label="Move"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onMove}
title="Move"
type="button"
>
<Move className="h-4 w-4" />
</button>
<button
aria-label="Duplicate"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onDuplicate}
title="Duplicate"
type="button"
>
<Copy className="h-4 w-4" />
</button>
{onMove && (
<button
aria-label="Move"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onMove}
title="Move"
type="button"
>
<Move className="h-4 w-4" />
</button>
)}
{onDuplicate && (
<button
aria-label="Duplicate"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onDuplicate}
title="Duplicate"
type="button"
>
<Copy className="h-4 w-4" />
</button>
)}
<button
aria-label="Delete"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
@@ -11,7 +11,9 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -264,6 +266,79 @@ export const SelectionManager = () => {
}
}, [])
// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
if (mode !== 'delete') return
const onClick = (event: NodeEvent) => {
const node = event.node
if (!isNodeInCurrentLevel(node)) return
event.stopPropagation()
// Play appropriate SFX
if (node.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNode(node.id as AnyNodeId)
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
// Clear hover since the node is gone
if (useViewer.getState().hoveredId === node.id) {
useViewer.setState({ hoveredId: null })
}
}
const onEnter = (event: NodeEvent) => {
const node = event.node
if (!isNodeInCurrentLevel(node)) return
if (node.type === 'building' || node.type === 'site') return
event.stopPropagation()
useViewer.setState({ hoveredId: node.id })
}
const onLeave = (event: NodeEvent) => {
const nodeId = event?.node?.id
if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
}
}
const onGridClick = () => {
// Clicking empty space in delete mode does nothing (stay in delete mode)
}
const allTypes = [
'wall',
'item',
'slab',
'ceiling',
'roof',
'roof-segment',
'window',
'door',
'zone',
]
allTypes.forEach((type) => {
emitter.on(`${type}:click` as any, onClick as any)
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
})
emitter.on('grid:click', onGridClick)
return () => {
allTypes.forEach((type) => {
emitter.off(`${type}:click` as any, onClick as any)
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
})
emitter.off('grid:click', onGridClick)
}
}, [mode])
useEffect(() => {
if (mode !== 'select') return
if (movingNode) return
@@ -475,12 +550,30 @@ export const SelectionManager = () => {
return (
<>
<DeleteModeCursor />
<SelectionStateSync />
<EditorOutlinerSync />
</>
)
}
const DeleteModeCursor = () => {
const mode = useEditor((s) => s.mode)
const gl = useThree((s) => s.gl)
useEffect(() => {
const canvas = gl.domElement
if (mode === 'delete') {
canvas.style.cursor = 'crosshair'
return () => {
canvas.style.cursor = ''
}
}
}, [mode, gl])
return null
}
const SelectionStateSync = () => {
useEffect(() => {
return useScene.subscribe((state) => {
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { mix, positionLocal } from 'three/tsl'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -183,6 +184,7 @@ export const CeilingTool: React.FC = () => {
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
@@ -13,6 +13,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -232,6 +233,7 @@ export const RoofTool: React.FC = () => {
const onCancel = () => {
if (corner1Ref.current) {
markToolCancelConsumed()
corner1Ref.current = null
outlineRef.current.visible = false
setPreview((prev) => ({ ...prev, corner1: null }))
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pa
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -150,6 +151,7 @@ export const SlabTool: React.FC = () => {
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
@@ -1,40 +1,32 @@
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
export type WallPlanPoint = [number, number]
export const WALL_GRID_STEP = 0.5
export const WALL_JOIN_SNAP_RADIUS = 0.35
export const WALL_MIN_LENGTH = 0.01
export const WALL_MIN_LENGTH = 0.5
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
return Math.round(value / step) * step
}
export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
}
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
const dx = cursor[0] - start[0]
const dz = cursor[1] - start[1]
const angle = Math.atan2(dz, dx)
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
const distance = Math.sqrt(dx * dx + dz * dz)
return snapPointToGrid([
start[0] + Math.cos(snappedAngle) * distance,
start[1] + Math.sin(snappedAngle) * distance,
])
}
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
const [x1, z1] = wall.start
const [x2, z2] = wall.end
@@ -44,15 +36,12 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
if (lengthSquared < 1e-9) {
return null
}
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
if (t <= 0 || t >= 1) {
return null
}
return [x1 + dx * t, z1 + dz * t]
}
export function findWallSnapTarget(
point: WallPlanPoint,
walls: WallNode[],
@@ -62,12 +51,10 @@ export function findWallSnapTarget(
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
let bestTarget: WallPlanPoint | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY
for (const wall of walls) {
if (ignoreWallIds.has(wall.id)) {
continue
}
const candidates: Array<WallPlanPoint | null> = [
wall.start,
wall.end,
@@ -77,7 +64,6 @@ export function findWallSnapTarget(
if (!candidate) {
continue
}
const candidateDistanceSquared = distanceSquared(point, candidate)
if (
candidateDistanceSquared > radiusSquared ||
@@ -85,15 +71,12 @@ export function findWallSnapTarget(
) {
continue
}
bestTarget = candidate
bestDistanceSquared = candidateDistanceSquared
}
}
return bestTarget
}
export function snapWallDraftPoint(args: {
point: WallPlanPoint
walls: WallNode[]
@@ -103,38 +86,31 @@ export function snapWallDraftPoint(args: {
}): WallPlanPoint {
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
return (
findWallSnapTarget(basePoint, walls, {
ignoreWallIds,
}) ?? basePoint
)
}
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
}
export function createWallOnCurrentLevel(
start: WallPlanPoint,
end: WallPlanPoint,
): WallNode | null {
const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState()
if (!(currentLevelId && isWallLongEnough(start, end))) {
return null
}
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
const wall = WallSchema.parse({
name: `Wall ${wallCount + 1}`,
start,
end,
})
createNode(wall, currentLevelId)
sfxEmitter.emit('sfx:structure-build')
return wall
}
@@ -2,10 +2,11 @@ import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
import { createWallOnCurrentLevel, snapWallDraftPoint, WALL_MIN_LENGTH, type WallPlanPoint } from './wall-drafting'
const WALL_HEIGHT = 2.5
@@ -17,7 +18,7 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
if (length < WALL_MIN_LENGTH) {
mesh.visible = false
return
}
@@ -142,7 +143,7 @@ export const WallTool: React.FC = () => {
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
if (dx * dx + dz * dz < WALL_MIN_LENGTH * WALL_MIN_LENGTH) return
createWallOnCurrentLevel(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
@@ -166,6 +167,7 @@ export const WallTool: React.FC = () => {
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
buildingState.current = 0
wallPreviewRef.current.visible = false
}
@@ -20,8 +20,8 @@ export function SliderControl({
label,
value,
onChange,
min = 0,
max = 100,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
precision = 0,
step = 1,
className,
@@ -32,23 +32,12 @@ export function SliderControl({
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
// Track the original value and bounds when dragging starts
const [dragStartValue, setDragStartValue] = useState<number | null>(null)
const [dragMin, setDragMin] = useState<number | null>(null)
const [dragMax, setDragMax] = useState<number | null>(null)
const trackRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
const labelRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
useEffect(() => {
if (!isEditing) {
@@ -56,123 +45,91 @@ export function SliderControl({
}
}, [value, precision, isEditing])
// Wheel support on the label
useEffect(() => {
const container = containerRef.current
if (!container) return
const el = labelRef.current
if (!el) return
const handleWheel = (e: WheelEvent) => {
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
let s = step
if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision))
if (final !== valueRef.current) onChange(final)
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
// Arrow key support while hovered
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
let direction = 0
if (e.key === 'ArrowUp') direction = 1
else if (e.key === 'ArrowDown') direction = -1
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') direction = 1
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) {
e.preventDefault()
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision))
if (final !== valueRef.current) onChange(final)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (isEditing) return
e.preventDefault()
const track = trackRef.current
if (!track) return
e.currentTarget.setPointerCapture(e.pointerId)
dragRef.current = { startX: e.clientX, startValue: valueRef.current }
setIsDragging(true)
setDragStartValue(value)
setDragMin(min)
setDragMax(max)
useScene.temporal.getState().pause()
const rect = track.getBoundingClientRect()
const updateValueFromEvent = (clientX: number) => {
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
const rawValue = min + percent * (max - min)
// snap to step
const snapped = Math.round(rawValue / step) * step
const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision))
onChange(finalValue)
}
updateValueFromEvent(e.clientX)
const handlePointerMove = (moveEvent: PointerEvent) => {
updateValueFromEvent(moveEvent.clientX)
}
const handlePointerUp = (e: PointerEvent) => {
// Only stop dragging if we didn't release on the reset button
// Let the reset button's onPointerDown handle its own cleanup
if ((e.target as HTMLElement).closest('button')) {
return
}
setIsDragging(false)
const startVal = dragStartValue
const finalVal = valueRef.current
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (startVal !== null && startVal !== finalVal) {
// Revert to start value while paused so the undo baseline is clean
onChange(startVal)
useScene.temporal.getState().resume()
// Apply final value while recording
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, min, max, step, precision, clamp, onChange, dragStartValue, value],
[isEditing],
)
const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const { startX, startValue } = dragRef.current
const dx = e.clientX - startX
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
// 4 px per step at default sensitivity
const newValue = clamp(Number.parseFloat((startValue + (dx / 4) * s).toFixed(precision)))
onChange(newValue)
},
[step, precision, clamp, onChange],
)
const handleLabelPointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const { startValue } = dragRef.current
const finalVal = valueRef.current
dragRef.current = null
setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId)
if (startValue !== finalVal) {
onChange(startValue)
useScene.temporal.getState().resume()
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
},
[onChange],
)
const handleValueClick = useCallback(() => {
@@ -180,10 +137,6 @@ export function SliderControl({
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (Number.isNaN(numValue)) {
@@ -194,10 +147,6 @@ export function SliderControl({
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
@@ -220,104 +169,61 @@ export function SliderControl({
[submitValue, value, precision, step, clamp, onChange],
)
const currentMin = isDragging && dragMin !== null ? dragMin : min
const currentMax = isDragging && dragMax !== null ? dragMax : max
const percent = Math.max(
0,
Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100),
)
const startPercent =
dragStartValue !== null
? Math.max(
0,
Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100),
)
: null
return (
<div
className={cn(
'group relative flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors',
isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
'group flex h-7 w-full select-none items-center rounded-lg px-2 transition-colors',
isDragging ? 'bg-white/5' : 'hover:bg-white/5',
className,
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={containerRef}
>
{/* Reset button that appears when dragged away from start */}
{isDragging && dragStartValue !== null && dragStartValue !== value && (
<button
className="pointer-events-auto absolute -top-10 right-0 z-50 cursor-pointer rounded-md bg-[#2C2C2E] px-2 py-1 font-medium text-[10px] text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground"
onPointerDown={(e) => {
e.stopPropagation()
onChange(dragStartValue)
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
setIsDragging(false)
useScene.temporal.getState().resume()
}}
>
Reset
</button>
)}
<div className="w-[80px] shrink-0 select-none truncate text-muted-foreground">{label}</div>
{/* Label — drag handle */}
<div
className={cn(
'relative mx-2 flex h-full flex-1 touch-none items-center justify-center',
isDragging ? 'cursor-grabbing' : 'cursor-grab',
'flex shrink-0 cursor-ew-resize items-center gap-1.5 text-xs transition-colors',
isDragging ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80',
)}
onPointerDown={handlePointerDown}
ref={trackRef}
onPointerDown={handleLabelPointerDown}
onPointerMove={handleLabelPointerMove}
onPointerUp={handleLabelPointerUp}
ref={labelRef}
>
{/* Track dots background */}
<div className="pointer-events-none absolute inset-x-0 flex items-center justify-between px-1 opacity-30">
{[...Array(9)].map((_, i) => (
<div className="h-[3px] w-[3px] rounded-full bg-current" key={i} />
))}
</div>
{/* Original Thumb Ghost */}
{isDragging && startPercent !== null && (
<div
className="pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/20 shadow-sm"
style={{ left: `${startPercent}%` }}
/>
)}
{/* Active Thumb */}
{/* Grip dots — 2×3 grid */}
<div
className={cn(
'pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition',
isDragging
? 'scale-y-110 bg-foreground'
: 'bg-foreground/60 group-hover:bg-foreground/80',
'grid grid-cols-2 gap-[2.5px] transition-opacity',
isDragging ? 'opacity-70' : 'opacity-25 group-hover:opacity-50',
)}
style={{ left: `${percent}%` }}
/>
>
{[...Array(6)].map((_, i) => (
<div className="h-[2px] w-[2px] rounded-full bg-current" key={i} />
))}
</div>
<span className="font-medium">{label}</span>
</div>
<div className="flex w-[50px] shrink-0 justify-end">
<div className="flex-1" />
{/* Value — click to edit */}
<div className="flex items-center text-xs">
{isEditing ? (
<div className="flex items-center">
<>
<input
autoFocus
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
onBlur={handleInputBlur}
onChange={handleInputChange}
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
onBlur={submitValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
</>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground/60 transition-colors hover:text-foreground"
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
@@ -190,6 +190,8 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
alt={item.name}
className="rounded-lg object-cover"
fill
loading="eager"
sizes="56px"
src={resolveCdnUrl(item.thumbnail) || ''}
/>
{attachmentIcon && (
@@ -25,6 +25,29 @@ export function WallPanel() {
[selectedId, updateNode],
)
// Função mágica para a Issue #191: Atualiza o comprimento via cálculo vetorial
const handleUpdateLength = useCallback((newLength: number) => {
if (!node || newLength <= 0) return
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const currentLength = Math.sqrt(dx * dx + dz * dz)
if (currentLength === 0) return
// Calcula a direção (vetor unitário)
const dirX = dx / currentLength
const dirZ = dz / currentLength
// Define o novo ponto final baseado no novo comprimento
const newEnd: [number, number] = [
node.start[0] + dirX * newLength,
node.start[1] + dirZ * newLength
]
handleUpdate({ end: newEnd })
}, [node, handleUpdate])
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -46,6 +69,17 @@ export function WallPanel() {
width={280}
>
<PanelSection title="Dimensions">
{/* Adicionando o controle de Length solicitado na Issue #191 */}
<SliderControl
label="Length"
max={20}
min={0.1}
onChange={handleUpdateLength}
precision={2}
step={0.01}
unit="m"
value={length}
/>
<SliderControl
label="Height"
max={6}
@@ -67,13 +101,6 @@ export function WallPanel() {
value={Math.round(thickness * 1000) / 1000}
/>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
<span>Length</span>
<span className="font-mono text-white">{length.toFixed(2)} m</span>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -209,8 +209,6 @@ export function WindowPanel() {
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
max={10}
min={-10}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
precision={2}
step={0.1}
@@ -223,8 +221,6 @@ export function WindowPanel() {
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
max={10}
min={-10}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
precision={2}
step={0.1}
@@ -244,8 +240,7 @@ export function WindowPanel() {
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={5}
min={0.2}
min={0}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
step={0.1}
@@ -254,8 +249,7 @@ export function WindowPanel() {
/>
<SliderControl
label="Height"
max={5}
min={0.2}
min={0}
onChange={(v) => handleUpdate({ height: v })}
precision={2}
step={0.1}
@@ -267,8 +261,7 @@ export function WindowPanel() {
<PanelSection title="Frame">
<SliderControl
label="Thickness"
max={0.2}
min={0.01}
min={0}
onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3}
step={0.01}
@@ -277,8 +270,7 @@ export function WindowPanel() {
/>
<SliderControl
label="Depth"
max={0.3}
min={0.01}
min={0}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
@@ -390,8 +382,7 @@ export function WindowPanel() {
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
max={0.5}
min={0.01}
min={0}
onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
@@ -400,8 +391,7 @@ export function WindowPanel() {
/>
<SliderControl
label="Thickness"
max={0.2}
min={0.005}
min={0}
onChange={(v) => handleUpdate({ sillThickness: v })}
precision={3}
step={0.01}
@@ -202,9 +202,9 @@ export function SettingsPanel({
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
const handleExport = async () => {
const handleExport = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
if (exportScene) {
await exportScene()
await exportScene(format)
}
}
@@ -318,9 +318,17 @@ export function SettingsPanel({
{/* Export Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
<Button className="w-full justify-start gap-2" onClick={handleExport} variant="outline">
<Button className="w-full justify-start gap-2" onClick={() => handleExport('glb')} variant="outline">
<Download className="size-4" />
Export 3D Model
Export as GLB
</Button>
<Button className="w-full justify-start gap-2" onClick={() => handleExport('stl')} variant="outline">
<Download className="size-4" />
Export as STL
</Button>
<Button className="w-full justify-start gap-2" onClick={() => handleExport('obj')} variant="outline">
<Download className="size-4" />
Export as OBJ
</Button>
</div>
+187 -149
View File
@@ -1,149 +1,187 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { sfxEmitter } from '../lib/sfx-bus'
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
}
// In first-person mode, all shortcuts are handled by FirstPersonControls
if (useEditor.getState().isFirstPersonMode) {
return
}
if (e.key === 'Escape') {
e.preventDefault()
emitter.emit('tool:cancel')
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
// Clear selections to close UI panels, but KEEP the active building and level context.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
} 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')
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('furnish')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones')
}
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('select')
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('build')
} 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 === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
if (nextIdx !== -1 && nextIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
} else if (currentIdx === -1) {
useViewer.getState().setSelection({ levelId: building.children[0] as any })
}
}
}
} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
if (prevIdx !== -1 && prevIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
} else if (currentIdx === -1) {
useViewer
.getState()
.setSelection({ levelId: building.children[building.children.length - 1] as any })
}
}
}
} else if (e.key === 'r' || e.key === 'R') {
// Rotate selected node if it supports rotation (items, roofs, etc.)
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
let newRotationY = 0
// Handle different rotation types (number for roof, array for items/windows/doors)
if (typeof node.rotation === 'number') {
newRotationY = node.rotation + ROTATION_STEP
useScene.getState().updateNode(node.id, { rotation: newRotationY })
} else if (Array.isArray(node.rotation)) {
newRotationY = node.rotation[1] + ROTATION_STEP
useScene.getState().updateNode(node.id, {
rotation: [node.rotation[0], newRotationY, node.rotation[2]],
})
}
sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback
}
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length > 0) {
// Play appropriate SFX based on what's being deleted
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
} else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNodes(selectedNodeIds)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return null
}
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor'
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
// so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false
export const markToolCancelConsumed = () => {
_toolCancelConsumed = true
}
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
}
// In first-person mode, all shortcuts are handled by FirstPersonControls
if (useEditor.getState().isFirstPersonMode) {
return
}
if (e.key === 'Escape') {
e.preventDefault()
_toolCancelConsumed = false
emitter.emit('tool:cancel')
// Only switch to select mode if no tool had an active mid-action to cancel.
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
if (!_toolCancelConsumed) {
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
// Clear selections to close UI panels, but KEEP the active building and level context.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
}
} 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')
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('furnish')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones')
}
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('select')
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
const phase = useEditor.getState().phase
if (phase === 'structure' || phase === 'furnish') {
useEditor.getState().setMode('delete')
useViewer.getState().setSelection({ selectedIds: [] })
}
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('build')
} 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 === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
if (nextIdx !== -1 && nextIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
} else if (currentIdx === -1) {
useViewer.getState().setSelection({ levelId: building.children[0] as any })
}
}
}
} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
if (prevIdx !== -1 && prevIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
} else if (currentIdx === -1) {
useViewer
.getState()
.setSelection({ levelId: building.children[building.children.length - 1] as any })
}
}
}
} else if (e.key === 'r' || e.key === 'R') {
// Rotate selected node if it supports rotation (items, roofs, etc.)
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
let newRotationY = 0
// Handle different rotation types (number for roof, array for items/windows/doors)
if (typeof node.rotation === 'number') {
newRotationY = node.rotation + ROTATION_STEP
useScene.getState().updateNode(node.id, { rotation: newRotationY })
} else if (Array.isArray(node.rotation)) {
newRotationY = node.rotation[1] + ROTATION_STEP
useScene.getState().updateNode(node.id, {
rotation: [node.rotation[0], newRotationY, node.rotation[2]],
})
}
sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback
}
}
} else if (e.key === 't' || e.key === 'T') {
// Rotate selected node counter-clockwise
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
if (typeof node.rotation === 'number') {
useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP })
} else if (Array.isArray(node.rotation)) {
useScene.getState().updateNode(node.id, {
rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]],
})
}
sfxEmitter.emit('sfx:item-rotate')
}
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length > 0) {
// Play appropriate SFX based on what's being deleted
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
} else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNodes(selectedNodeIds)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return null
}