* sync: port 2D editing features from monorepo (v0.3.0) ## New Features ### Floorplan Panel (7.5K LOC) - Full 2D editing interface with wall drawing, measurement, and unit display - Interactive floorplan view with pan/zoom, grid snapping - Wall thickness visualization, door/window placement - Zone and slab polygon editing - Guide image overlay support - Metric/imperial unit toggle ### Command Palette Overhaul - Complete rewrite with new command registry system - Keyboard shortcuts UI with ShortcutToken component - Editor-specific commands (floorplan, measurements, camera focus) - Improved search and action organization ### Wall Measurements - Real-time wall length labels in 3D view - Metric/imperial conversion - Wall measurement UI component ### Enhanced Tools - Wall drafting utilities (grid snapping, validation) - Node action menu for quick operations - Improved polygon editing for zones/slabs/sites - Roof segment panel for granular roof control ## Package Changes ### @pascal-app/core@0.3.0 - New wall-footprint.ts: 2D wall footprint calculation - Wall mitering exports for floorplan view - camera-controls:focus event - Space detection undo pause/resume - Mark sibling nodes dirty on deletion (miter recalc) ### @pascal-app/viewer@0.3.0 - ErrorBoundary component for robust item rendering - Broken item fallback UI - Wall renderer: mark dirty on mount - Ground occluder: only lowest level punches through - Unit state (metric/imperial) in viewer store ### @pascal-app/editor@0.1.0 - 48 file changes (16 new, 32 modified) - New stores: useCommandRegistry, usePaletteViewRegistry - Tree node drag-and-drop system - Level selection utilities - Enhanced scene graph operations ## Apps/Editor - GeistPixelSquare font for pixel-perfect UI - Blueprint icon asset - Updated layout and globals for font support ## Security - Zero AI imports or internal package refs - All monorepo-specific code excluded - Clean audit: no API keys or secrets ## Testing - Security audit passed ✅ - All AI/internal code excluded ✅ - Version bumps applied ✅ 79 files modified, 16 files added ~2600 insertions, ~1400 deletions * fix: lint cleanup — suppress intentional dep warnings, fix missing dep, remove stale ignores - Add biome-ignore for 3 intentional useEffect reset patterns (levelId, selectedGuide, selectedId) - Fix actual missing dependency: currentBuildingId in handleSiteEditShortcutSelect callback - Remove 3 stale biome-ignore comments in r3f.d.ts (rule not active) Build and lint pass clean. * fix: type errors — polygon area guards, selection cast, readonly keywords, door guard - ceiling-panel, slab-panel, ceiling-tree-node, slab-tree-node, zone-tree-node: guard polygon[i]/polygon[j] array access before arithmetic (TS2532) - scene.ts: introduce toViewerSelection() helper to cast persisted string IDs to branded template literal types expected by useViewer.setSelection (TS2345) - door-panel: guard early return when node is undefined in setSegmentHeightRatio (TS18048) - editor-commands: remove 'as const' from inline command object, keywords is mutable string[] (TS2322) All type checks pass. Build and lint clean. --------- Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
145 lines
6.3 KiB
TypeScript
145 lines
6.3 KiB
TypeScript
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
|
|
}
|
|
|
|
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
|
|
}
|