diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index ddb0ea38a..5684e3780 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -276,6 +276,12 @@ type RoomPresetEvents = { } type SelectionEvents = { + /** + * A node click accepted by an editor canvas selection path after proxy and + * phase routing. Hosts can react to the user's 2D/3D selection intent + * without treating programmatic selection changes as canvas clicks. + */ + 'selection:canvas-node-click': AnyNode /** * "Reveal this node" intent — the editor's node action menu emits it with the * selected node; whoever owns the node's catalog/panel (host browser, a diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index c8ef7df1f..2502bf1e2 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -21,6 +21,7 @@ import { computeAffectedSiblingIds, floorplanHandleDoubleClickAffordance, InteractiveGeometry, + isFloorplanOpeningPlacementState, splitFloorplanOverlay, subscribeFloorplanAffordanceToolCancel, } from './floorplan-registry-layer' @@ -195,6 +196,35 @@ describe('floorplan affordance cancellation', () => { }) }) +describe('floorplan opening placement interaction routing', () => { + test('passes entries through only while an opening tool or moving opening is active', () => { + expect( + isFloorplanOpeningPlacementState({ + phase: 'structure', + mode: 'build', + tool: 'window', + movingNodeHasWallOpeningPlacement: false, + }), + ).toBe(true) + expect( + isFloorplanOpeningPlacementState({ + phase: 'structure', + mode: 'select', + tool: null, + movingNodeHasWallOpeningPlacement: true, + }), + ).toBe(true) + expect( + isFloorplanOpeningPlacementState({ + phase: 'structure', + mode: 'select', + tool: null, + movingNodeHasWallOpeningPlacement: false, + }), + ).toBe(false) + }) +}) + describe('floorplan vertex double-click routing', () => { test('routes polygon vertex handles to the kind-owned delete affordance', () => { expect( diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 1b530e16a..783e52a31 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -69,6 +69,7 @@ import { isIdle, tangentReshapeScope, } from '../../../lib/interaction/scope' +import { emitCanvasNodeSelection } from '../../../lib/selection-routing' import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' @@ -77,6 +78,7 @@ import useEditor from '../../../store/use-editor' import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility' import useFloorplanPreflight from '../../../store/use-floorplan-preflight' import useInteractionScope, { + getMovingNode, useEndpointReshape, useMovingNode, } from '../../../store/use-interaction-scope' @@ -348,6 +350,35 @@ const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const const MOVE_CURSOR_STYLE = { cursor: 'move' } as const const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const +export function isFloorplanOpeningPlacementState({ + phase, + mode, + tool, + movingNodeHasWallOpeningPlacement, +}: { + phase: string + mode: string + tool: string | null + movingNodeHasWallOpeningPlacement: boolean +}): boolean { + return ( + (phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) || + movingNodeHasWallOpeningPlacement + ) +} + +function isFloorplanOpeningPlacementActiveNow(): boolean { + const { phase, mode, tool } = useEditor.getState() + const movingNode = getMovingNode() + return isFloorplanOpeningPlacementState({ + phase, + mode, + tool, + movingNodeHasWallOpeningPlacement: + movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement, + }) +} + function snapshotNode(node: AnyNode): NodeSnapshot { // Shallow-clone every non-id, non-type field. Arrays / vec tuples are // deep-cloned to detach from the live store reference. @@ -431,17 +462,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // wall's registry entry would otherwise swallow the click via // `handleClickStop` / `handleSelect`, so the placement never fires. // Pass clicks through in that case. - const editorPhase = useEditor((s) => s.phase) const editorMode = useEditor((s) => s.mode) - const editorTool = useEditor((s) => s.tool) const structureLayer = useEditor((s) => s.structureLayer) const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) const endpointReshape = useEndpointReshape() - const isOpeningPlacementActive = - (editorPhase === 'structure' && - editorMode === 'build' && - (editorTool === 'door' || editorTool === 'window')) || - (movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement) const isMarqueeSelectionActive = editorMode === 'select' && floorplanSelectionTool === 'marquee' && @@ -557,13 +581,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const applyEntrySelection = useCallback( (id: AnyNodeId, shouldToggle: boolean) => { const currentSelectedIds = useViewer.getState().selection.selectedIds - setSelection({ - selectedIds: shouldToggle - ? currentSelectedIds.includes(id) - ? currentSelectedIds.filter((selectedId) => selectedId !== id) - : [...currentSelectedIds, id] - : [id], - }) + const nextSelectedIds = shouldToggle + ? currentSelectedIds.includes(id) + ? currentSelectedIds.filter((selectedId) => selectedId !== id) + : [...currentSelectedIds, id] + : [id] + setSelection({ selectedIds: nextSelectedIds }) + if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) { + const node = useScene.getState().nodes[id] + if (node) emitCanvasNodeSelection(node) + } // Setting selection re-renders the entry — the overlay pass mounts // (endpoint handles, etc.), reshuffling DOM under the cursor between // pointerdown and click. If the click target ends up on the SVG @@ -587,6 +614,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) const handleClickStop = useCallback((event: React.MouseEvent) => { + if (isFloorplanOpeningPlacementActiveNow()) return event.stopPropagation() }, []) @@ -829,6 +857,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const handleEntryPointerDown = useCallback( (id: AnyNodeId, event: ReactPointerEvent) => { + // Keep this handler mounted during opening placement and arbitrate from + // the stores at event time. Commit clears the interaction scope before + // React paints the next frame; a render-time `undefined` handler leaves + // a short dead zone where the first post-placement selection is lost. + if (isFloorplanOpeningPlacementActiveNow()) return if (startDirectMoveDrag(id, event)) return if (startDirectRotateDrag(id, event)) return if (startGroupMoveDrag(id, event)) return @@ -1325,7 +1358,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // still propagate normally inside the registry tree. @@ -1347,7 +1380,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)} interactiveElevators={interactiveElevators} isMarqueeSelectionActive={isMarqueeSelectionActive} - isOpeningPlacementActive={isOpeningPlacementActive} key={`base-${entry.id}`} levelDataCacheRef={levelDataCacheRef} levelNodeIdsByType={floorplanData.levelNodeIdsByType} @@ -1401,7 +1433,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)} interactiveElevators={interactiveElevators} isMarqueeSelectionActive={isMarqueeSelectionActive} - isOpeningPlacementActive={isOpeningPlacementActive} key={`overlay-${entry.id}`} levelDataCacheRef={levelDataCacheRef} levelNodeIdsByType={floorplanData.levelNodeIdsByType} @@ -1735,7 +1766,6 @@ type FloorplanRegistryEntryProps = { hoveredHandleId: string | null interactiveElevators: unknown isMarqueeSelectionActive: boolean - isOpeningPlacementActive: boolean levelDataCacheRef: { current: Map } levelNodeIdsByType: ReadonlyMap moving: boolean @@ -1792,7 +1822,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ hoveredHandleId, interactiveElevators, isMarqueeSelectionActive, - isOpeningPlacementActive, levelDataCacheRef, levelNodeIdsByType, moving, @@ -1941,9 +1970,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ : visibleGeometry if (!geometry) return null - const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop - const entryPointerDown = - isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown + const entryClick = isMarqueeSelectionActive ? undefined : onClickStop + const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown return ( state.request) + const cancel = useDeleteConfirmation((state) => state.cancel) + const confirm = useDeleteConfirmation((state) => state.confirm) + + useEffect(() => cancel, [cancel]) + + return ( + !open && cancel()} open={request !== null}> + + + Delete {request?.count ?? 0} elements? + + This removes every selected element. You can undo the deletion while it remains in the + editor history. + + + + + + + + + ) +} diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 983ff5d36..9572367db 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -3,11 +3,13 @@ import { type AnyNodeId, bboxCornerAnchors, collectAlignmentAnchors, + emitter, pauseSceneHistory, pauseSpaceDetection, resolveAlignment, resumeSceneHistory, resumeSpaceDetection, + type SceneMaterialId, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -16,9 +18,15 @@ import { useViewer } from '@pascal-app/viewer' import { Plane, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help' import { clientToPlan } from '../../lib/floorplan/plan-coords' -import { duplicateNodesToLevel } from '../../lib/scene-clipboard' -import { sfxEmitter } from '../../lib/sfx-bus' +import { + copySelectedNodesToEditorClipboard, + duplicateNodesToLevel, + getEditorClipboardSnapshot, + pasteSystemEditorClipboardToLevel, +} from '../../lib/scene-clipboard' +import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import useAlignmentGuides from '../../store/use-alignment-guides' +import useDeleteConfirmation from '../../store/use-delete-confirmation' import useEditor, { isAlignmentGuideActive, isGridSnapActive, @@ -79,7 +87,7 @@ export function canGroupPickUp(): boolean { * and drag them along with the copies. */ export function startGroupPickUp( - opts: { onCancel?: () => void; scopeToSelection?: boolean } = {}, + opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {}, ): boolean { const { selectedIds, levelId } = useViewer.getState().selection const participantIds = groupParticipantIds() @@ -202,16 +210,18 @@ export function startGroupPickUp( const applyMove = (e: PointerEvent) => { const plan = resolvePlanPoint(e) if (!plan) return - // Delta-relative to where tracking starts so the group never teleports - // to the cursor. - if (!startPlan) { + // Ordinary moves are delta-relative so the group never teleports. A + // pasted selection instead arrives centered under the cursor. + if (!opts.positionAtCursor && !startPlan) { startPlan = plan return } const step = useEditor.getState().gridSnapStep const snap = isGridSnapActive() && step > 0 - let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0] - let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1] + const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0] + const rawDz = opts.positionAtCursor ? plan[1] - restCenter[1] : plan[1] - startPlan![1] + let dx = snap ? Math.round(rawDx / step) * step : rawDx + let dz = snap ? Math.round(rawDz / step) * step : rawDz if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) { const result = resolveAlignment({ @@ -361,6 +371,7 @@ export function startGroupPickUp( // Only a press over a tracked surface commits; a click on side panels or // the toolbar keeps the pick-up alive. if (!resolvePlanPoint(e)) return + if (opts.positionAtCursor && !lastDelta) applyMove(e) e.preventDefault() e.stopPropagation() commitPointerId = e.pointerId @@ -380,6 +391,17 @@ export function startGroupPickUp( const onKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase() + if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) { + // A clipboard chord replaces the current carry. Let the global keyboard + // arm receive the same event after this cancellation. Capture C/X first: + // pasted or duplicated carries delete their transient selection while + // cancelling, so the global arm would otherwise see nothing. + if (key === 'c' || key === 'x') { + copySelectedNodesToEditorClipboard() + } + cancel() + return + } if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { e.preventDefault() e.stopPropagation() @@ -437,6 +459,103 @@ export function duplicateSelectionAndPickUp(): boolean { return true } +function sceneReferencesMaterial(materialId: string) { + const reference = `scene:${materialId}` + const containsReference = (value: unknown): boolean => { + if (value === reference) return true + if (Array.isArray(value)) return value.some(containsReference) + if (value && typeof value === 'object') { + return Object.values(value).some(containsReference) + } + return false + } + return Object.values(useScene.getState().nodes).some(containsReference) +} + +function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) { + for (const materialId of materialIds) { + if (!sceneReferencesMaterial(materialId)) { + useScene.getState().removeSceneMaterial(materialId) + } + } +} + +/** + * Paste the Pascal scene payload from the browser clipboard onto the active + * level, then carry the clones under the cursor until click-to-place. Escape + * removes the uncommitted clones and any scene materials imported with them. + */ +export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promise { + const activeScope = useInteractionScope.getState().scope + if (activeScope.kind === 'placing' || activeScope.kind === 'moving') { + emitter.emit('tool:cancel') + } + + const result = await pasteSystemEditorClipboardToLevel(targetLevelId) + if (!result || result.pastedIds.length === 0) return false + + const discardPaste = () => { + useScene.getState().deleteNodes(result.pastedIds) + removeUnusedPasteMaterials(result.createdMaterialIds) + useViewer.getState().setSelection({ selectedIds: [] }) + } + if (result.pastedIds.length === 1) { + const rootId = result.pastedIds[0]! + const root = useScene.getState().nodes[rootId] + if (root?.type === 'door' || root?.type === 'window') { + const metadata = + root.metadata && typeof root.metadata === 'object' && !Array.isArray(root.metadata) + ? (root.metadata as Record) + : {} + const draft = { ...root, metadata: { ...metadata, isNew: true } } + useScene.getState().updateNode(rootId, { metadata: draft.metadata }) + useViewer.getState().setSelection({ selectedIds: [] }) + const unsubscribe = useInteractionScope.subscribe((state, previous) => { + const previousOwnsDraft = + (previous.scope.kind === 'placing' || previous.scope.kind === 'moving') && + previous.scope.nodeId === rootId + const currentOwnsDraft = + (state.scope.kind === 'placing' || state.scope.kind === 'moving') && + state.scope.nodeId === rootId + if (!previousOwnsDraft || currentOwnsDraft) return + unsubscribe() + removeUnusedPasteMaterials(result.createdMaterialIds) + }) + useEditor.getState().setMovingNode(draft) + sfxEmitter.emit('sfx:item-pick') + return true + } + } + + const started = startGroupPickUp({ + positionAtCursor: true, + scopeToSelection: true, + onCancel: discardPaste, + }) + if (!started) sfxEmitter.emit('sfx:item-place') + return true +} + +/** + * Cut uses the same cross-tab clipboard payload as Copy, then removes exactly + * the copied roots. Promoted subtree selections (such as all modules in one + * cabinet run) therefore remove the same root that Paste will recreate. + */ +export function cutSelectionToEditorClipboard(): boolean { + if (!copySelectedNodesToEditorClipboard()) return false + const payload = getEditorClipboardSnapshot() + if (!payload || payload.rootIds.length === 0) return false + + if (payload.rootIds.length === 1) { + emitDeleteSFX(useScene.getState().nodes[payload.rootIds[0]!]?.type) + } else { + sfxEmitter.emit('sfx:structure-delete') + } + useScene.getState().deleteNodes(payload.rootIds) + useViewer.getState().setSelection({ selectedIds: [] }) + return true +} + /** * Delete every selected node — same semantics as the keyboard Delete arm, * including the accidental-bulk-delete confirm. @@ -444,14 +563,25 @@ export function duplicateSelectionAndPickUp(): boolean { export function deleteSelection(): boolean { const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedIds.length === 0) return false + + const commitDelete = () => { + if (selectedIds.length === 1) { + emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type) + } else { + sfxEmitter.emit('sfx:structure-delete') + } + useScene.getState().deleteNodes(selectedIds) + useViewer.getState().setSelection({ selectedIds: [] }) + } + if (selectedIds.length >= BULK_DELETE_THRESHOLD) { - const confirmed = window.confirm( - `Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`, - ) - if (!confirmed) return false + useDeleteConfirmation.getState().requestConfirmation({ + count: selectedIds.length, + onConfirm: commitDelete, + }) + return true } - sfxEmitter.emit('sfx:structure-delete') - useScene.getState().deleteNodes(selectedIds) - useViewer.getState().setSelection({ selectedIds: [] }) + + commitDelete() return true } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 78c0f25d6..a03b9ac7b 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -58,6 +58,7 @@ import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel' import type { SidebarTab } from '../ui/sidebar/tab-bar' import { useHostPanels } from '../ui/sidebar/use-plugin-panels' import { CustomCameraControls } from './custom-camera-controls' +import { DeleteConfirmationDialog } from './delete-confirmation-dialog' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' import { FenceTangentLines3D } from './fence-tangent-lines-3d' @@ -1037,6 +1038,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ 2d / 3d / split alike) can anchor to this container's bottom-left. */}
+ {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
{ useEffect(() => { if (mode !== 'select') return let owns = false - let prevKey = '' + let prevKey = '\0' const applyCursor = () => { const { selection, hoveredId } = useViewer.getState() const selectedIds = selection.selectedIds @@ -1648,6 +1649,7 @@ export const SelectionManager = () => { modifierKeysRef.current, selectedIdsBeforeRouting, ) + emitCanvasNodeSelection(nodeToSelect) let nextMaterialTargetHandled = false diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 591902c4a..b5300752d 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -36,21 +36,15 @@ import { useEffect, useRef, useState, - useSyncExternalStore, } from 'react' import { useShallow } from 'zustand/react/shallow' +import { pasteSelectionAndPickUp } from '../editor/group-actions' import { buildLevelDuplicateCreateOps, type LevelDuplicatePreset, } from '../../lib/level-duplication' import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' -import { - getEditorClipboardSnapshot, - pasteEditorClipboardToLevel, - subscribeEditorClipboard, -} from '../../lib/scene-clipboard' -import { sfxEmitter } from '../../lib/sfx-bus' import { useLinearDisplay } from '../../lib/use-linear-display' import { cn } from '../../lib/utils' import { ActionButton } from './controls/action-button' @@ -399,11 +393,6 @@ export function FloatingLevelSelector() { const [deletingLevel, setDeletingLevel] = useState(null) const [draggingLevelId, setDraggingLevelId] = useState(null) - const clipboardSnapshot = useSyncExternalStore( - subscribeEditorClipboard, - getEditorClipboardSnapshot, - getEditorClipboardSnapshot, - ) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 }, @@ -522,10 +511,7 @@ export function FloatingLevelSelector() { ) const handlePasteToLevel = useCallback((level: LevelNode) => { - const result = pasteEditorClipboardToLevel(level.id) - if (result?.pastedIds.length) { - sfxEmitter.emit('sfx:item-place') - } + void pasteSelectionAndPickUp(level.id) }, []) const handleDragStart = useCallback((event: DragStartEvent) => { @@ -627,9 +613,7 @@ export function FloatingLevelSelector() { isSelected={isSelected} level={level} onDuplicate={(preset) => handleDuplicateLevel(level, preset)} - onPaste={ - clipboardSnapshot ? () => handlePasteToLevel(level) : undefined - } + onPaste={() => handlePasteToLevel(level)} onRequestDelete={() => setDeletingLevel(level)} onSelect={() => setSelection( diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index 94da6539c..09385f060 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -69,6 +69,22 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { title: 'Selection', shortcuts: [ + { + keys: ['Cmd/Ctrl', 'C'], + action: 'Copy the selected objects', + note: 'The copied selection can be pasted into another level, project, or browser tab.', + }, + { + keys: ['Cmd/Ctrl', 'X'], + action: 'Cut the selected objects', + note: 'Copies the selection to the clipboard, then removes it from this scene.', + }, + { + keys: ['Cmd/Ctrl', 'V'], + action: 'Paste and place copied objects', + note: + 'Carries a preview under the cursor. Click to place it, or press Escape to cancel.', + }, { keys: ['Cmd/Ctrl', 'Left click'], action: 'Add or remove an object from multi-selection', diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 63ff20e32..1eaafd691 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -10,6 +10,11 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { Vector3 } from 'three' +import { + cutSelectionToEditorClipboard, + deleteSelection, + pasteSelectionAndPickUp, +} from '../components/editor/group-actions' import { classifyParticipant, collectParticipants, @@ -24,12 +29,10 @@ import { toggleDoorOpenState } from '../lib/door-interaction' import { guideEmitter } from '../lib/guide-events' import { runRedo, runUndo } from '../lib/history' import { isActive } from '../lib/interaction/scope' -import { - copySelectedNodesToEditorClipboard, - pasteEditorClipboardToLevel, -} from '../lib/scene-clipboard' -import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus' +import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard' +import { sfxEmitter } from '../lib/sfx-bus' import { toggleWindowOpenState } from '../lib/window-interaction' +import useDeleteConfirmation from '../store/use-delete-confirmation' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor' import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope' @@ -210,6 +213,10 @@ export const useKeyboard = ({ return } + if (useDeleteConfirmation.getState().request) { + 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 @@ -361,13 +368,14 @@ export const useKeyboard = ({ if (isVersionPreviewMode) return e.preventDefault() copySelectedNodesToEditorClipboard() + } else if (e.key === 'x' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + if (isVersionPreviewMode) return + e.preventDefault() + cutSelectionToEditorClipboard() } 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') - } + void pasteSelectionAndPickUp() } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() @@ -621,27 +629,7 @@ export const useKeyboard = ({ } } - 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) + if (deleteSelection()) { return } diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts index c203a4c71..9117e4857 100644 --- a/packages/editor/src/lib/scene-clipboard.test.ts +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'bun:test' +import { beforeEach, describe, expect, mock, test } from 'bun:test' import { type AnyNode, type AnyNodeId, @@ -8,14 +8,18 @@ import { type CabinetNode as CabinetNodeType, type LevelNode, MeasurementNode, + SceneMaterial, + type SceneMaterialId, useScene, WallNode, + WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { copySelectedNodesToEditorClipboard, getEditorClipboardSnapshot, pasteEditorClipboardToLevel, + pasteSystemEditorClipboardToLevel, } from './scene-clipboard' const sourceLevelId = 'level_clipboard-source' as LevelNode['id'] @@ -77,6 +81,7 @@ function seedCabinetRun() { [leftModule.id]: leftModule as AnyNode, [rightModule.id]: rightModule as AnyNode, }, + materials: {}, rootNodeIds: [sourceLevel.id, targetLevel.id], } as never) useViewer.getState().setSelection({ @@ -193,4 +198,128 @@ describe('scene clipboard', () => { expect(Array.isArray(anchor)).toBe(false) if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id) }) + + test('detaches a standalone copied opening so its move tool can rehost it', () => { + const wall = WallNode.parse({ + id: 'wall_clipboard-window-host', + parentId: sourceLevelId, + start: [0, 0], + end: [3, 0], + }) + const window = WindowNode.parse({ + id: 'window_clipboard-standalone', + parentId: wall.id, + wallId: wall.id, + position: [1.5, 1.2, 0], + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [sourceLevelId]: makeLevel(sourceLevelId, [wall.id]), + [wall.id]: { ...wall, children: [window.id] }, + [window.id]: window, + }, + })) + + expect(copySelectedNodesToEditorClipboard([window.id])).toBe(true) + const result = pasteEditorClipboardToLevel(targetLevelId) + expect(result?.pastedIds).toHaveLength(1) + + const pastedWindow = result?.pastedIds[0] + ? useScene.getState().nodes[result.pastedIds[0]] + : undefined + expect(pastedWindow?.type).toBe('window') + if (pastedWindow?.type === 'window') { + expect(pastedWindow.parentId).toBe(targetLevelId) + expect(pastedWindow.wallId).toBeUndefined() + expect(pastedWindow.roofSegmentId).toBeUndefined() + } + }) + + test('round-trips nodes and custom scene materials through the browser clipboard', async () => { + let systemClipboardText = '' + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText: async () => systemClipboardText, + writeText: async (text: string) => { + systemClipboardText = text + }, + }, + }) + + const materialId = 'mat_clipboard-blue' as SceneMaterialId + const material = SceneMaterial.parse({ + id: materialId, + name: 'Clipboard blue', + material: { properties: { color: '#2266dd' } }, + }) + const wall = WallNode.parse({ + id: 'wall_clipboard-material', + parentId: sourceLevelId, + start: [0, 0], + end: [3, 0], + slots: { exterior: `scene:${materialId}` }, + }) + useScene.setState((state) => ({ + materials: { [materialId]: material }, + nodes: { + ...state.nodes, + [sourceLevelId]: makeLevel(sourceLevelId, [wall.id]), + [wall.id]: wall, + }, + })) + + expect(copySelectedNodesToEditorClipboard([wall.id])).toBe(true) + expect(systemClipboardText).toContain('pascal.scene-nodes') + + useScene.setState({ + materials: {}, + nodes: { + [targetLevelId]: makeLevel(targetLevelId), + }, + rootNodeIds: [targetLevelId], + } as never) + useViewer.getState().setSelection({ levelId: targetLevelId, selectedIds: [] }) + + const result = await pasteSystemEditorClipboardToLevel() + expect(result?.pastedIds).toHaveLength(1) + expect(result?.createdMaterialIds).toEqual([materialId]) + expect(useScene.getState().materials[materialId]).toEqual(material) + + const pastedWall = Object.values(useScene.getState().nodes).find((node) => node.type === 'wall') + expect(pastedWall?.type).toBe('wall') + if (pastedWall?.type === 'wall') { + expect(pastedWall.slots?.exterior).toBe(`scene:${materialId}`) + } + }) + + test('waits for an in-flight copy before reading the browser clipboard', async () => { + let systemClipboardText = 'older clipboard contents' + let finishWrite!: () => void + const readText = mock(async () => systemClipboardText) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText, + writeText: (text: string) => + new Promise((resolve) => { + finishWrite = () => { + systemClipboardText = text + resolve() + } + }), + }, + }) + + expect(copySelectedNodesToEditorClipboard([runId])).toBe(true) + const paste = pasteSystemEditorClipboardToLevel(targetLevelId) + await Promise.resolve() + expect(readText).not.toHaveBeenCalled() + + finishWrite() + const result = await paste + expect(readText).toHaveBeenCalledTimes(1) + expect(result?.pastedIds).toHaveLength(1) + }) }) diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index 5b67ba7cd..081836c33 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -2,8 +2,12 @@ import { AnyNode, type AnyNodeId, generateId, + generateSceneMaterialId, type LevelNode, + nodeRegistry, remapMeasurementReferences, + SceneMaterial, + type SceneMaterialId, type StairNode, useScene, } from '@pascal-app/core' @@ -11,18 +15,25 @@ import { useViewer } from '@pascal-app/viewer' type ClipboardPayload = { copiedAt: number + materials: SceneMaterial[] nodes: AnyNode[] rootIds: AnyNodeId[] } -type PasteResult = { +export type PasteResult = { + createdMaterialIds: SceneMaterialId[] pastedIds: AnyNodeId[] skippedIds: AnyNodeId[] } +const SYSTEM_CLIPBOARD_KIND = 'pascal.scene-nodes' +const SYSTEM_CLIPBOARD_VERSION = 1 + const COPYABLE_ROOT_TYPES = new Set([ 'wall', 'fence', + 'door', + 'window', 'column', 'item', 'slab', @@ -37,6 +48,7 @@ const COPYABLE_ROOT_TYPES = new Set([ ]) let clipboardPayload: ClipboardPayload | null = null +let pendingSystemClipboardWrite: Promise | null = null const subscribers = new Set<() => void>() function notifySubscribers() { @@ -97,10 +109,29 @@ function hasSelectedAncestor( return false } -function isLevelChildRoot(nodes: Record, node: AnyNode) { +function isClipboardRoot( + nodes: Record, + node: AnyNode, + allowHostedOpening: boolean, +) { const parentId = node.parentId as AnyNodeId | null if (!parentId) return true - return nodes[parentId]?.type === 'level' + const parent = nodes[parentId] + if (parent?.type === 'level') return true + if ( + allowHostedOpening && + (node.type === 'door' || node.type === 'window') && + (parent?.type === 'wall' || parent?.type === 'roof-segment') + ) { + return true + } + return parent?.type === 'building' && nodeRegistry.get(node.type)?.floorplanScope === 'building' +} + +function isCopyableRootType(node: AnyNode) { + if (COPYABLE_ROOT_TYPES.has(node.type)) return true + const definition = nodeRegistry.get(node.type) + return !!definition && definition.capabilities?.duplicable !== false } function getPromotedCabinetRunId( @@ -160,14 +191,28 @@ function remapNodeReferences( oldId: AnyNodeId, targetLevel: LevelNode, idMap: Map, + materialIdMap: Map, rootIds: Set, nodes: Record, ) { - const clone = JSON.parse(JSON.stringify(node)) as AnyNode + const clone = remapSceneMaterialReferences( + JSON.parse(JSON.stringify(node)), + materialIdMap, + ) as AnyNode ;(clone as Record).id = idMap.get(oldId) if (rootIds.has(oldId)) { - clone.parentId = targetLevel.id + const buildingId = targetLevel.parentId as AnyNodeId | null + clone.parentId = + nodeRegistry.get(node.type)?.floorplanScope === 'building' && + buildingId && + nodes[buildingId]?.type === 'building' + ? buildingId + : targetLevel.id + if (clone.type === 'door' || clone.type === 'window') { + delete clone.roofSegmentId + delete clone.roofFace + } } else if (clone.parentId && typeof clone.parentId === 'string') { clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId } @@ -208,9 +253,47 @@ function remapNodeReferences( return AnyNode.parse(clone) } +function remapSceneMaterialReferences( + value: unknown, + materialIdMap: Map, +): unknown { + if (typeof value === 'string' && value.startsWith('scene:')) { + const oldId = value.slice('scene:'.length) as SceneMaterialId + const nextId = materialIdMap.get(oldId) + return nextId ? `scene:${nextId}` : value + } + if (Array.isArray(value)) { + return value.map((entry) => remapSceneMaterialReferences(entry, materialIdMap)) + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + remapSceneMaterialReferences(entry, materialIdMap), + ]), + ) + } + return value +} + +function collectReferencedSceneMaterialIds(value: unknown, ids: Set) { + if (typeof value === 'string' && value.startsWith('scene:')) { + ids.add(value.slice('scene:'.length) as SceneMaterialId) + return + } + if (Array.isArray(value)) { + for (const entry of value) collectReferencedSceneMaterialIds(entry, ids) + return + } + if (value && typeof value === 'object') { + for (const entry of Object.values(value)) collectReferencedSceneMaterialIds(entry, ids) + } +} + function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null { const scene = useScene.getState() const selectedIdSet = new Set(ids) + const allowHostedOpening = ids.length === 1 const promotedIds = ids.map((id) => { const node = scene.nodes[id] return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : id @@ -219,8 +302,8 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null { const node = scene.nodes[id] return ( node && - COPYABLE_ROOT_TYPES.has(node.type) && - isLevelChildRoot(scene.nodes, node) && + isCopyableRootType(node) && + isClipboardRoot(scene.nodes, node, allowHostedOpening) && !hasSelectedAncestor(scene.nodes, id, selectedIdSet) ) }) @@ -234,16 +317,99 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null { collectSubtreeIds(scene.nodes, rootId, subtreeIds) } + const copiedNodes = [...subtreeIds] + .map((id) => scene.nodes[id]) + .filter((node): node is AnyNode => !!node) + .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode) + const materialIds = new Set() + collectReferencedSceneMaterialIds(copiedNodes, materialIds) + return { copiedAt: Date.now(), - nodes: [...subtreeIds] - .map((id) => scene.nodes[id]) - .filter((node): node is AnyNode => !!node) - .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode), + materials: [...materialIds] + .map((id) => scene.materials[id]) + .filter((material): material is SceneMaterial => !!material) + .map((material) => JSON.parse(JSON.stringify(material)) as SceneMaterial), + nodes: copiedNodes, rootIds, } } +function serializeClipboardPayload(payload: ClipboardPayload) { + return JSON.stringify({ + kind: SYSTEM_CLIPBOARD_KIND, + version: SYSTEM_CLIPBOARD_VERSION, + payload, + }) +} + +function parseClipboardPayload(text: string): ClipboardPayload | null { + try { + const envelope = JSON.parse(text) as { + kind?: unknown + payload?: unknown + version?: unknown + } + if ( + envelope.kind !== SYSTEM_CLIPBOARD_KIND || + envelope.version !== SYSTEM_CLIPBOARD_VERSION || + !envelope.payload || + typeof envelope.payload !== 'object' + ) { + return null + } + + const candidate = envelope.payload as { + copiedAt?: unknown + materials?: unknown + nodes?: unknown + rootIds?: unknown + } + if ( + typeof candidate.copiedAt !== 'number' || + !Array.isArray(candidate.nodes) || + !Array.isArray(candidate.rootIds) || + !candidate.rootIds.every((id) => typeof id === 'string') + ) { + return null + } + + const nodes = candidate.nodes.map((node) => AnyNode.safeParse(node)) + if (nodes.some((result) => !result.success)) return null + const materials = Array.isArray(candidate.materials) + ? candidate.materials.map((material) => SceneMaterial.safeParse(material)) + : [] + if (materials.some((result) => !result.success)) return null + + const parsedNodes = nodes.filter((result) => result.success).map((result) => result.data) + const nodeIds = new Set(parsedNodes.map((node) => node.id)) + const rootIds = candidate.rootIds as AnyNodeId[] + if (rootIds.length === 0 || rootIds.some((id) => !nodeIds.has(id))) return null + + return { + copiedAt: candidate.copiedAt, + materials: materials.filter((result) => result.success).map((result) => result.data), + nodes: parsedNodes, + rootIds, + } + } catch { + return null + } +} + +function writeSystemClipboard(payload: ClipboardPayload) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + pendingSystemClipboardWrite = null + return + } + pendingSystemClipboardWrite = navigator.clipboard + .writeText(serializeClipboardPayload(payload)) + .then( + () => true, + () => false, + ) +} + export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[]) const payload = buildClipboardPayload(ids) @@ -251,7 +417,33 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { clipboardPayload = payload notifySubscribers() + writeSystemClipboard(payload) + + return true +} + +export async function readEditorClipboardFromSystem() { + const pendingWrite = pendingSystemClipboardWrite + pendingSystemClipboardWrite = null + if (pendingWrite && !(await pendingWrite)) { + return hasEditorClipboard() + } + + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) { + return hasEditorClipboard() + } + let text: string + try { + text = await navigator.clipboard.readText() + } catch { + return hasEditorClipboard() + } + + const payload = parseClipboardPayload(text) + if (!payload) return false + clipboardPayload = payload + notifySubscribers() return true } @@ -275,6 +467,13 @@ export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteRes return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId) } +export async function pasteSystemEditorClipboardToLevel( + targetLevelId?: AnyNodeId, +): Promise { + if (!(await readEditorClipboardFromSystem())) return null + return pasteEditorClipboardToLevel(targetLevelId) +} + function applyClipboardPayloadToLevel( payload: ClipboardPayload, targetLevelId?: AnyNodeId, @@ -284,10 +483,23 @@ function applyClipboardPayloadToLevel( const scene = useScene.getState() const idMap = new Map() + const materialIdMap = new Map() + const materialsToCreate: SceneMaterial[] = [] for (const node of payload.nodes) { idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId) } + for (const material of payload.materials) { + const oldId = material.id as SceneMaterialId + const existing = scene.materials[oldId] + if (existing && JSON.stringify(existing) === JSON.stringify(material)) { + materialIdMap.set(oldId, oldId) + continue + } + const nextId = existing ? generateSceneMaterialId() : oldId + materialIdMap.set(oldId, nextId) + materialsToCreate.push({ ...material, id: nextId }) + } const rootIdSet = new Set(payload.rootIds) const pastedNodes: AnyNode[] = [] @@ -296,7 +508,15 @@ function applyClipboardPayloadToLevel( for (const node of payload.nodes) { try { pastedNodes.push( - remapNodeReferences(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes), + remapNodeReferences( + node, + node.id as AnyNodeId, + targetLevel, + idMap, + materialIdMap, + rootIdSet, + scene.nodes, + ), ) } catch (error) { console.error('Failed to paste copied node', node.id, error) @@ -305,9 +525,12 @@ function applyClipboardPayloadToLevel( } if (pastedNodes.length === 0) { - return { pastedIds: [], skippedIds } + return { createdMaterialIds: [], pastedIds: [], skippedIds } } + for (const material of materialsToCreate) { + scene.addSceneMaterial(material) + } scene.createNodes( pastedNodes.map((node) => ({ node, @@ -326,6 +549,7 @@ function applyClipboardPayloadToLevel( }) return { + createdMaterialIds: materialsToCreate.map((material) => material.id as SceneMaterialId), pastedIds: pastedRootIds, skippedIds, } diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index e034681c8..0682f5582 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core' +import { type AnyNode, emitter, nodeRegistry, registerNode } from '@pascal-app/core' import { z } from 'zod' import { + emitCanvasNodeSelection, resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, @@ -47,6 +48,20 @@ describe('resolveSelectedIdsForNodeClick', () => { }) }) +describe('emitCanvasNodeSelection', () => { + test('publishes the accepted canvas node once', () => { + const node = { id: 'wall_1', type: 'wall' } as unknown as AnyNode + const received: AnyNode[] = [] + const onSelection = (selectedNode: AnyNode) => received.push(selectedNode) + emitter.on('selection:canvas-node-click', onSelection) + + emitCanvasNodeSelection(node) + + emitter.off('selection:canvas-node-click', onSelection) + expect(received).toEqual([node]) + }) +}) + describe('selectionModifiersFromEvent', () => { test('falls back to tracked modifier state when the click event omits keys', () => { expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({ diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index f7b877361..04954c7d4 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -1,5 +1,6 @@ import { type AnyNode, + emitter, type ItemNode, nodeRegistry, resolveSelectionProxyId, @@ -16,6 +17,10 @@ export type NodeSelectionTarget = { structureLayer?: 'zones' | 'elements' } +export function emitCanvasNodeSelection(node: AnyNode): void { + emitter.emit('selection:canvas-node-click', node) +} + function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean { if (node.id === target.id) return false // Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind diff --git a/packages/editor/src/store/use-delete-confirmation.ts b/packages/editor/src/store/use-delete-confirmation.ts new file mode 100644 index 000000000..bb88b22cb --- /dev/null +++ b/packages/editor/src/store/use-delete-confirmation.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' + +type DeleteConfirmationRequest = { + count: number + onConfirm: () => void +} + +type DeleteConfirmationStore = { + request: DeleteConfirmationRequest | null + cancel: () => void + confirm: () => void + requestConfirmation: (request: DeleteConfirmationRequest) => void +} + +const useDeleteConfirmation = create((set, get) => ({ + request: null, + cancel: () => set({ request: null }), + confirm: () => { + const request = get().request + if (!request) return + set({ request: null }) + request.onConfirm() + }, + requestConfirmation: (request) => set({ request }), +})) + +export default useDeleteConfirmation diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts index 7c919ea40..fdd717351 100644 --- a/packages/nodes/src/spawn/__tests__/parity.test.ts +++ b/packages/nodes/src/spawn/__tests__/parity.test.ts @@ -6,6 +6,7 @@ import { } from '@pascal-app/core' import { spawnDefinition } from '../definition' import { buildSpawnFloorplan } from '../floorplan' +import { SpawnPreview } from '../renderer' import { SpawnNode } from '../schema' /** @@ -127,6 +128,14 @@ describe('spawn definition', () => { expect(typeof spawnDefinition.tool).toBe('function') }) + test('placement exposes the shared spawn model preview and rotation hint', () => { + expect(typeof SpawnPreview).toBe('function') + expect(spawnDefinition.toolHints).toContainEqual({ + key: 'R / T', + label: 'Rotate spawn point', + }) + }) + test('mcp description is set so AI surfaces describe the kind', () => { expect(spawnDefinition.mcp?.description).toBeDefined() expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0) diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 08aedf694..94facff03 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -101,6 +101,7 @@ export const spawnDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place spawn point' }, + { key: 'R / T', label: 'Rotate spawn point' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/spawn/renderer.tsx b/packages/nodes/src/spawn/renderer.tsx index 4203655d4..319070fff 100644 --- a/packages/nodes/src/spawn/renderer.tsx +++ b/packages/nodes/src/spawn/renderer.tsx @@ -12,31 +12,24 @@ import { useMemo, useRef } from 'react' import { Color, type Group, Shape } from 'three' const SPAWN_COLOR = new Color('#818cf8') +const disableRaycast = () => {} /** - * Registry-driven spawn renderer. Behaviorally identical to the legacy - * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same - * geometry and event surface. When the spawn definition lands - * in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer - * here and the legacy one is short-circuited. - * - * Lives in `@pascal-app/nodes` (not viewer) so the kind owns its own render - * code. Phase 5's batch migration applies the same pattern to every node. + * Shared visible spawn model. Placement uses the same four meshes as the + * committed renderer so the orientation shown before click is authoritative. */ -const SpawnRenderer = ({ node }: { node: SpawnNode }) => { - const ref = useRef(null!) +const SpawnVisual = ({ + ghost = false, + layers, + node, +}: { + ghost?: boolean + layers?: number + node: SpawnNode +}) => { const handlers = useNodeEvents(node, 'spawn') - const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId)) - const effectiveNode = useMemo( - () => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node), - [node, liveOverride], - ) - const liveTransform = useLiveTransforms((state) => state.get(node.id)) - const walkthroughMode = useViewer((state) => state.walkthroughMode) const shading = useViewer((state) => state.shading) - useRegistry(node.id, 'spawn', ref) - const material = useMemo(() => { const next = createDefaultMaterial('#818cf8', 0.42, shading) as ReturnType< typeof createDefaultMaterial @@ -48,9 +41,14 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => { next.emissive?.copy(SPAWN_COLOR) if ('emissiveIntensity' in next) next.emissiveIntensity = 0.08 if ('metalness' in next) next.metalness = 0.03 + if (ghost) { + next.transparent = true + next.opacity = 0.5 + next.depthWrite = false + } next.needsUpdate = true return next - }, [shading]) + }, [ghost, shading]) const arrowShape = useMemo(() => { const shape = new Shape() @@ -62,31 +60,86 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => { }, []) return ( - - + <> + - + - + - + + + ) +} + +export const SpawnPreview = ({ layers, node }: { layers?: number; node: SpawnNode }) => ( + +) + +/** + * Registry-driven spawn renderer. Behaviorally identical to the legacy + * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same + * geometry and event surface. When the spawn definition lands + * in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer + * here and the legacy one is short-circuited. + * + * Lives in `@pascal-app/nodes` (not viewer) so the kind owns its own render + * code. Phase 5's batch migration applies the same pattern to every node. + */ +const SpawnRenderer = ({ node }: { node: SpawnNode }) => { + const ref = useRef(null!) + const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId)) + const effectiveNode = useMemo( + () => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node), + [node, liveOverride], + ) + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const walkthroughMode = useViewer((state) => state.walkthroughMode) + + useRegistry(node.id, 'spawn', ref) + + return ( + + ) } diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index ce196c29d..41d17da4f 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -9,7 +9,7 @@ import { useScene, } from '@pascal-app/core' import { - CursorSphere, + EDITOR_LAYER, getFloorStackPreviewPosition, isAlignmentGuideActive, isGridSnapActive, @@ -18,14 +18,18 @@ import { triggerSFX, useAlignmentGuides, useEditor, + usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { Group } from 'three' import { getLevelLocalSnappedPosition, resolveAlignedFloorPlacement, } from '../shared/floor-placement' +import { SpawnPreview } from './renderer' + +const ROTATION_STEP = Math.PI / 4 function getExistingSpawnIds() { const nodes = useScene.getState().nodes @@ -38,14 +42,17 @@ function getExistingSpawnIds() { /** * Registry-driven spawn placement tool. Reads `activeLevelId` from useViewer * directly (no props), broadcasts placement via store updates + SFX, and - * uses the shared CursorSphere from @pascal-app/editor for visual parity - * with legacy placement tools. Snapping is mode-driven (grid + Figma-style + * shows the real spawn model as a translucent placement ghost, and supports + * R/T yaw before commit. Snapping is mode-driven (grid + Figma-style * alignment "lines"), matching the shelf / column build tools. */ const SpawnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) const previousSnapRef = useRef(null) + const rotationRef = useRef(0) + const cursorVisibleRef = useRef(false) + const [cursorVisible, setCursorVisible] = useState(false) // Default spawn for the footprint anchors the alignment solver reads. const previewNode = useMemo( @@ -56,10 +63,19 @@ const SpawnTool = () => { useEffect(() => { if (!activeLevelId) return previousSnapRef.current = null + rotationRef.current = 0 + cursorRef.current?.rotation.set(0, 0, 0) + cursorVisibleRef.current = false + setCursorVisible(false) const lastCursorRef: { current: [number, number, number] | null } = { current: null } let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) const onGridMove = (event: GridEvent) => { + if (!cursorVisibleRef.current) { + cursorVisibleRef.current = true + setCursorVisible(true) + } + const { position, guides } = resolveAlignedFloorPlacement({ node: previewNode, rawX: event.localPosition[0], @@ -75,11 +91,16 @@ const SpawnTool = () => { const visualPosition = getFloorStackPreviewPosition({ node: previewNode, position, - rotation: 0, + rotation: rotationRef.current, levelId: activeLevelId, }) cursorRef.current?.position.set(...visualPosition) lastCursorRef.current = position + usePlacementPreview.getState().set({ + ...previewNode, + position, + rotation: rotationRef.current, + }) const nextSnapKey = movementSfxStepKey({ coords: [position[0], position[2]], @@ -111,12 +132,12 @@ const SpawnTool = () => { ...live, parentId: activeLevelId, position: next, - rotation: 0, + rotation: rotationRef.current, }) useScene.getState().updateNode(existingSpawnId, { parentId: activeLevelId, position: next, - rotation: 0, + rotation: rotationRef.current, ...resolveSupportSlabPatch(effectiveSpawn, useScene.getState().nodes), }) if (duplicates.length > 0) { @@ -127,7 +148,7 @@ const SpawnTool = () => { const spawn = SpawnNode.parse({ name: 'Spawn Point', position: next, - rotation: 0, + rotation: rotationRef.current, parentId: activeLevelId, }) const committedSpawn = SpawnNode.parse({ @@ -142,23 +163,63 @@ const SpawnTool = () => { triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) useAlignmentGuides.getState().clear() + usePlacementPreview.getState().clear() useEditor.getState().setTool(null) useEditor.getState().setMode('select') } + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + if ( + event.repeat || + event.metaKey || + event.ctrlKey || + event.altKey || + target?.tagName === 'INPUT' || + target?.tagName === 'TEXTAREA' || + target?.isContentEditable + ) { + return + } + let delta = 0 + if (event.key === 'r' || event.key === 'R') delta = ROTATION_STEP + else if (event.key === 't' || event.key === 'T') delta = -ROTATION_STEP + else return + + event.preventDefault() + event.stopPropagation() + rotationRef.current += delta + if (cursorRef.current) cursorRef.current.rotation.y = rotationRef.current + if (lastCursorRef.current) { + usePlacementPreview.getState().set({ + ...previewNode, + position: lastCursorRef.current, + rotation: rotationRef.current, + }) + } + triggerSFX('sfx:item-rotate') + } + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) + window.addEventListener('keydown', onKeyDown, true) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) + window.removeEventListener('keydown', onKeyDown, true) useAlignmentGuides.getState().clear() + usePlacementPreview.getState().clear() } }, [activeLevelId, previewNode]) if (!activeLevelId) return null - return + return ( + + + + ) } export default SpawnTool diff --git a/wiki/architecture/events.md b/wiki/architecture/events.md index 232de3d0b..bba5fa238 100644 --- a/wiki/architecture/events.md +++ b/wiki/architecture/events.md @@ -41,6 +41,17 @@ interface NodeEvent { Grid events only carry `position` and `nativeEvent` (no `node`). +## Selection Intent Events + +`selection:canvas-node-click` fires after the editor accepts a 2D or 3D node +click and resolves the node that selection actually targets. Hosts can use it +for contextual navigation without reacting to programmatic `setSelection` +calls. The payload is the resolved `AnyNode`. + +`selection:find-node` is the explicit reveal intent emitted by the node action +menu. Hosts and plugins that own catalogs or panels listen to it and reveal the +node's related controls or presets. + ## Emitting Renderers emit via `useNodeEvents` — never call `emitter.emit` directly in a renderer: