Every placement and move path now publishes its ghost pose to a single
`useFacingPose` store, drawn by one editor-side `<FacingPoseIndicator>` overlay,
instead of each path drawing its own triangle (which left the nodes-package and
PlacementBox paths invisible):
- column/shelf presets + all moves (PlacementBox via move-registry, and
DragBoundingBox) now publish the pose, so the triangle finally shows
- stair create + move use a declarative `facingIndicator: { reversed: true }`
(new registry resolver) so the triangle sits before the entry pointing out —
resolved in one place, so create and move match automatically
- stair placement defaults to single and respects the shared `point`
continuation (C) toggle, like the other placement tools
Checkpoint on the placement-interaction epic: also carries the in-flight
continuation-profile extraction (lib/continuation), grid surface (item #8), and
HUD work. Door/window still render their own legacy inline triangle and are
migrated to the overlay next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
459 lines
20 KiB
TypeScript
459 lines
20 KiB
TypeScript
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect } from 'react'
|
|
import { steppedRotation } from '../components/tools/item/placement-math'
|
|
import { toggleDoorOpenState } from '../lib/door-interaction'
|
|
import { runRedo, runUndo } from '../lib/history'
|
|
import {
|
|
copySelectedNodesToEditorClipboard,
|
|
pasteEditorClipboardToLevel,
|
|
} from '../lib/scene-clipboard'
|
|
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
|
|
import { toggleWindowOpenState } from '../lib/window-interaction'
|
|
import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
|
|
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
|
|
|
|
// 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 = ({
|
|
isVersionPreviewMode = false,
|
|
disabled = false,
|
|
}: {
|
|
isVersionPreviewMode?: boolean
|
|
disabled?: boolean
|
|
} = {}) => {
|
|
useEffect(() => {
|
|
if (disabled) {
|
|
return
|
|
}
|
|
|
|
// True while a door/window is being placed: either a fresh clone is moving
|
|
// (preset / duplicate path) or a door/window build tool is armed. The
|
|
// placement tool owns R/T then (flip the draft before commit), so the
|
|
// global selection-based R/T handler must stand down to avoid double-firing.
|
|
const isPlacingOpening = () => {
|
|
const ed = useEditor.getState()
|
|
const moving = getMovingNode()
|
|
if (moving?.type === 'door' || moving?.type === 'window') return true
|
|
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
|
|
}
|
|
|
|
// Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
|
|
// whenever there's an active snapping context — i.e. exactly when the HUD
|
|
// shows a snapping chip. That single source covers wall/fence/item drafting,
|
|
// every node move (including wall-hosted items + door/window openings, which
|
|
// now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
|
|
// never silently stop working. Force-place lives on Alt where a tool supports it.
|
|
const isSnappingCycleContext = () => getActiveSnapContext() != null
|
|
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
|
|
// between) cycles the grid step — same context as the Shift snapping-mode
|
|
// cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
|
|
// and is cleared the instant any other key fires, so chords like Ctrl+Z /
|
|
// Ctrl+C never cycle.
|
|
let ctrlTapClean = false
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Control' || e.key === 'Meta') {
|
|
// Only a fresh, modifier-free press starts a clean-tap candidate;
|
|
// ignore key-repeat and presses already part of a combo.
|
|
ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
|
|
} else {
|
|
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
|
|
// the clean tap.
|
|
ctrlTapClean = false
|
|
}
|
|
|
|
// Don't handle shortcuts if user is typing in an input
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
return
|
|
}
|
|
|
|
if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') {
|
|
// In paint mode Shift cycles the application scope (this surface →
|
|
// whole item / all matching / room) — the paint-mode analogue of the
|
|
// snapping-mode cycle below. The scope chip mirrors this key.
|
|
e.preventDefault()
|
|
useEditor.getState().cyclePaintScope()
|
|
sfxEmitter.emit('sfx:grid-snap')
|
|
return
|
|
}
|
|
|
|
if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
|
|
// Cycle the global snapping mode (grid → lines → angles → off).
|
|
// `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
|
|
e.preventDefault()
|
|
useEditor.getState().cycleSnappingMode()
|
|
sfxEmitter.emit('sfx:grid-snap')
|
|
return
|
|
}
|
|
|
|
if (
|
|
(e.key === 'c' || e.key === 'C') &&
|
|
!e.repeat &&
|
|
!e.metaKey &&
|
|
!e.ctrlKey &&
|
|
!e.shiftKey &&
|
|
!e.altKey
|
|
) {
|
|
const context = getActiveContinuationContext()
|
|
if (context) {
|
|
e.preventDefault()
|
|
useEditor.getState().cycleContinuation(context)
|
|
sfxEmitter.emit('sfx:grid-snap')
|
|
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) {
|
|
const currentPhase = useEditor.getState().phase
|
|
const currentStructureLayer = useEditor.getState().structureLayer
|
|
|
|
useInteractionScope
|
|
.getState()
|
|
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
|
|
|
// From zone mode, return to structure select
|
|
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
|
useEditor.getState().setStructureLayer('elements')
|
|
useEditor.getState().setMode('select')
|
|
} else {
|
|
// Return to the default select tool while keeping the active building/level context.
|
|
useEditor.getState().setMode('select')
|
|
}
|
|
|
|
useEditor.getState().setFloorplanSelectionTool('click')
|
|
|
|
// 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 === 'f' && !e.metaKey && !e.ctrlKey) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
useEditor.getState().setPhase('furnish')
|
|
useEditor.getState().setMode('build')
|
|
// Set the item tool explicitly so the active tool never inherits a
|
|
// stale tool from a prior build session.
|
|
useEditor.getState().setTool('item')
|
|
useEditor.getState().setActiveSidebarPanel('items')
|
|
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
useEditor.getState().setPhase('structure')
|
|
useEditor.getState().setStructureLayer('zones')
|
|
useEditor.getState().setMode('build')
|
|
// Set the zone tool explicitly so it never inherits a stale tool.
|
|
useEditor.getState().setTool('zone')
|
|
}
|
|
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
|
e.preventDefault()
|
|
useEditor.getState().setMode('select')
|
|
useEditor.getState().setFloorplanSelectionTool('click')
|
|
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
useEditor.getState().setPhase('structure')
|
|
useEditor.getState().setStructureLayer('elements')
|
|
useEditor.getState().setMode('build')
|
|
// Set the wall tool explicitly so B never inherits a stale tool
|
|
// (e.g. fence) left over from a prior build session.
|
|
useEditor.getState().setTool('wall')
|
|
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
useEditor.getState().setMode('delete')
|
|
} else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
useEditor.getState().primeMaterialPaintFromSelection()
|
|
useEditor.getState().setPhase('structure')
|
|
useEditor.getState().setStructureLayer('elements')
|
|
useEditor.getState().setMode('material-paint')
|
|
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
copySelectedNodesToEditorClipboard()
|
|
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
const result = pasteEditorClipboardToLevel()
|
|
if (result?.pastedIds.length) {
|
|
sfxEmitter.emit('sfx:item-place')
|
|
}
|
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
runUndo()
|
|
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
|
if (isVersionPreviewMode) return
|
|
e.preventDefault()
|
|
runRedo()
|
|
} 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]
|
|
const levels =
|
|
building?.type === 'building'
|
|
? building.children.filter(
|
|
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
|
)
|
|
: []
|
|
if (levels.length > 0) {
|
|
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
|
const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
|
|
if (nextIdx !== -1 && nextIdx !== currentIdx) {
|
|
useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
|
|
} else if (currentIdx === -1) {
|
|
useViewer.getState().setSelection({ levelId: levels[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]
|
|
const levels =
|
|
building?.type === 'building'
|
|
? building.children.filter(
|
|
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
|
)
|
|
: []
|
|
if (levels.length > 0) {
|
|
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
|
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
|
if (prevIdx !== -1 && prevIdx !== currentIdx) {
|
|
useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
|
|
} else if (currentIdx === -1) {
|
|
useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
|
|
}
|
|
}
|
|
}
|
|
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) {
|
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
|
// Doors use R to flip side (front ↔ back, rotation += π); their
|
|
// open/close toggle lives on E. Windows still use R to toggle
|
|
// their open/closed state.
|
|
//
|
|
// Skipped entirely while a door/window placement is active
|
|
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
|
|
// before commit), and the user can have a node selected at the same
|
|
// time — without this guard both would fire (double flip + sfx).
|
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
if (selectedNodeIds.length === 1) {
|
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
if (node?.type === 'door') {
|
|
e.preventDefault()
|
|
useScene.getState().updateNode(node.id, {
|
|
side: node.side === 'front' ? 'back' : 'front',
|
|
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
|
})
|
|
if (node.parentId) {
|
|
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
}
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
} else if (node?.type === 'window') {
|
|
// Windows: R flips side (front ↔ back, rotation += π). Open/
|
|
// close toggle for operable windows lives on E.
|
|
e.preventDefault()
|
|
useScene.getState().updateNode(node.id, {
|
|
side: node.side === 'front' ? 'back' : 'front',
|
|
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
|
})
|
|
if (node.parentId) {
|
|
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
}
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
|
|
// Registry-driven R action. Skylight uses this for open/
|
|
// close toggling; future kinds with custom R behaviour
|
|
// declare it on their `def.keyboardActions` without
|
|
// touching this hook. Door / window still use the legacy
|
|
// direct calls above (follow-up to migrate).
|
|
e.preventDefault()
|
|
nodeRegistry.get(node.type)?.keyboardActions?.r?.run(node)
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
} else if (node && 'rotation' in node) {
|
|
e.preventDefault()
|
|
// Round to the nearest 45° then step one increment (not a blind +45°).
|
|
if (typeof node.rotation === 'number') {
|
|
useScene
|
|
.getState()
|
|
.updateNode(node.id, { rotation: steppedRotation(node.rotation, 1) })
|
|
} else if (Array.isArray(node.rotation)) {
|
|
useScene.getState().updateNode(node.id, {
|
|
rotation: [
|
|
node.rotation[0],
|
|
steppedRotation(node.rotation[1], 1),
|
|
node.rotation[2],
|
|
],
|
|
})
|
|
}
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
}
|
|
}
|
|
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
|
|
// 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?.type === 'door') {
|
|
// Door's open/close moved to E; T is a no-op for doors so
|
|
// it doesn't free-rotate a wall-bound node by π/4.
|
|
e.preventDefault()
|
|
} else if (node?.type === 'window') {
|
|
// Window's open/close moved to E; T is a no-op so it doesn't
|
|
// free-rotate a wall-bound node by π/4.
|
|
e.preventDefault()
|
|
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
|
|
// Registry-driven T action. Same shape as the R arm above.
|
|
e.preventDefault()
|
|
nodeRegistry.get(node.type)?.keyboardActions?.t?.run(node)
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
} else if (node && 'rotation' in node) {
|
|
e.preventDefault()
|
|
// Round to the nearest 45° then step one increment back.
|
|
if (typeof node.rotation === 'number') {
|
|
useScene
|
|
.getState()
|
|
.updateNode(node.id, { rotation: steppedRotation(node.rotation, -1) })
|
|
} else if (Array.isArray(node.rotation)) {
|
|
useScene.getState().updateNode(node.id, {
|
|
rotation: [
|
|
node.rotation[0],
|
|
steppedRotation(node.rotation[1], -1),
|
|
node.rotation[2],
|
|
],
|
|
})
|
|
}
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
}
|
|
}
|
|
} else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) {
|
|
// Toggle door / operable-window open/closed state. Moved off R,
|
|
// which now flips the opening (side + π rotation).
|
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
if (selectedNodeIds.length === 1) {
|
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
if (node?.type === 'door' && node.openingKind !== 'opening') {
|
|
e.preventDefault()
|
|
toggleDoorOpenState(node.id)
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
} else if (
|
|
node?.type === 'window' &&
|
|
node.openingKind !== 'opening' &&
|
|
(node.windowType === 'sliding' ||
|
|
node.windowType === 'casement' ||
|
|
node.windowType === 'awning' ||
|
|
node.windowType === 'hopper' ||
|
|
node.windowType === 'single-hung' ||
|
|
node.windowType === 'double-hung' ||
|
|
node.windowType === 'louvered')
|
|
) {
|
|
e.preventDefault()
|
|
toggleWindowOpenState(node.id)
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
}
|
|
}
|
|
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
|
|
e.preventDefault()
|
|
|
|
// Check for a selected reference (guide/scan) first
|
|
const selectedRefId = useEditor.getState().selectedReferenceId
|
|
if (selectedRefId) {
|
|
const refNode = useScene.getState().nodes[selectedRefId as AnyNodeId]
|
|
if (refNode && (refNode.type === 'guide' || refNode.type === 'scan')) {
|
|
sfxEmitter.emit('sfx:structure-delete')
|
|
useScene.getState().deleteNode(selectedRefId as AnyNodeId)
|
|
useEditor.getState().setSelectedReferenceId(null)
|
|
return
|
|
}
|
|
}
|
|
|
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
|
|
if (selectedNodeIds.length > 0) {
|
|
// Guard against accidental bulk deletion (e.g. box-select all + Delete)
|
|
const BULK_DELETE_THRESHOLD = 10
|
|
if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
|
|
const confirmed = window.confirm(
|
|
`Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
|
)
|
|
if (!confirmed) return
|
|
}
|
|
|
|
// Play appropriate SFX based on what's being deleted
|
|
if (selectedNodeIds.length === 1) {
|
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
emitDeleteSFX(node?.type)
|
|
} else {
|
|
sfxEmitter.emit('sfx:structure-delete')
|
|
}
|
|
|
|
useScene.getState().deleteNodes(selectedNodeIds)
|
|
return
|
|
}
|
|
|
|
// Delete selected zone when no explicit element selection is active.
|
|
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
if (selectedZoneId) {
|
|
sfxEmitter.emit('sfx:structure-delete')
|
|
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
|
useViewer.getState().setSelection({ zoneId: null })
|
|
}
|
|
}
|
|
}
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
if (e.key === 'Control' || e.key === 'Meta') {
|
|
const wasClean = ctrlTapClean
|
|
ctrlTapClean = false
|
|
if (!wasClean) return
|
|
// Same scope as the Shift snapping-mode cycle, and never while typing
|
|
// in an input.
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
return
|
|
}
|
|
if (!isSnappingCycleContext()) return
|
|
// Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
|
|
useEditor.getState().cycleGridSnapStep()
|
|
sfxEmitter.emit('sfx:grid-snap')
|
|
return
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
window.addEventListener('keyup', handleKeyUp)
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
window.removeEventListener('keyup', handleKeyUp)
|
|
}
|
|
}, [disabled, isVersionPreviewMode])
|
|
|
|
return null
|
|
}
|