From 7f917ad715d2d5ee8a04e680a0d6401471ad7a36 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 24 Jul 2026 11:04:53 +0200 Subject: [PATCH 1/8] feat: expose accepted canvas node selections --- packages/core/src/events/bus.ts | 6 ++++++ .../renderers/floorplan-registry-layer.tsx | 18 +++++++++++------- .../components/editor/selection-manager.tsx | 4 +++- .../editor/src/lib/selection-routing.test.ts | 17 ++++++++++++++++- packages/editor/src/lib/selection-routing.ts | 5 +++++ wiki/architecture/events.md | 11 +++++++++++ 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 25aed8e77d..1a9b079d37 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -277,6 +277,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.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 0bb2589991..1621b28081 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' @@ -553,13 +554,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 diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 898c59697f..358bfceba8 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -60,6 +60,7 @@ import { } from '../../lib/paint-scope' import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { + emitCanvasNodeSelection, resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, @@ -1365,7 +1366,7 @@ export const SelectionManager = () => { 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/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index e034681c8b..0682f5582c 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 f7b8773613..04954c7d48 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/wiki/architecture/events.md b/wiki/architecture/events.md index 232de3d0b3..bba5fa2381 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: From fb06b5cfe0c7055224bf9ae69e171d33c668dc3b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 24 Jul 2026 11:51:18 +0200 Subject: [PATCH 2/8] feat(editor): replace bulk delete alert with dialog --- .../editor/delete-confirmation-dialog.tsx | 51 +++++++++++++++++++ .../src/components/editor/group-actions.ts | 28 +++++++--- .../editor/src/components/editor/index.tsx | 2 + packages/editor/src/hooks/use-keyboard.ts | 30 +++-------- .../src/store/use-delete-confirmation.ts | 27 ++++++++++ 5 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 packages/editor/src/components/editor/delete-confirmation-dialog.tsx create mode 100644 packages/editor/src/store/use-delete-confirmation.ts diff --git a/packages/editor/src/components/editor/delete-confirmation-dialog.tsx b/packages/editor/src/components/editor/delete-confirmation-dialog.tsx new file mode 100644 index 0000000000..a1ae220b59 --- /dev/null +++ b/packages/editor/src/components/editor/delete-confirmation-dialog.tsx @@ -0,0 +1,51 @@ +'use client' + +import useDeleteConfirmation from '../../store/use-delete-confirmation' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/primitives/dialog' + +export function DeleteConfirmationDialog() { + const request = useDeleteConfirmation((state) => state.request) + const cancel = useDeleteConfirmation((state) => state.cancel) + const confirm = useDeleteConfirmation((state) => state.confirm) + + 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 983ff5d367..a97cbec8c9 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -17,8 +17,9 @@ 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 { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import useAlignmentGuides from '../../store/use-alignment-guides' +import useDeleteConfirmation from '../../store/use-delete-confirmation' import useEditor, { isAlignmentGuideActive, isGridSnapActive, @@ -444,14 +445,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 78c0f25d68..a03b9ac7b8 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 */}
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/store/use-delete-confirmation.ts b/packages/editor/src/store/use-delete-confirmation.ts new file mode 100644 index 0000000000..bb88b22cbe --- /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 From ed82dcd176e1edd2622c0992b54e6b18995db5ef Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 24 Jul 2026 12:09:13 +0200 Subject: [PATCH 3/8] fix(editor): allow immediate selection after opening placement --- .../floorplan-registry-layer.test.ts | 30 +++++++++++ .../renderers/floorplan-registry-layer.tsx | 54 +++++++++++++------ 2 files changed, 69 insertions(+), 15 deletions(-) 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 c8ef7df1f0..2502bf1e29 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 1621b28081..1d53e03849 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 @@ -78,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' @@ -346,6 +347,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. @@ -428,17 +458,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' && @@ -587,6 +610,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) const handleClickStop = useCallback((event: React.MouseEvent) => { + if (isFloorplanOpeningPlacementActiveNow()) return event.stopPropagation() }, []) @@ -829,6 +853,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 +1354,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // still propagate normally inside the registry tree. @@ -1347,7 +1376,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 +1429,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} @@ -1659,7 +1686,6 @@ type FloorplanRegistryEntryProps = { hoveredHandleId: string | null interactiveElevators: unknown isMarqueeSelectionActive: boolean - isOpeningPlacementActive: boolean levelDataCacheRef: { current: Map } levelNodeIdsByType: ReadonlyMap moving: boolean @@ -1716,7 +1742,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ hoveredHandleId, interactiveElevators, isMarqueeSelectionActive, - isOpeningPlacementActive, levelDataCacheRef, levelNodeIdsByType, moving, @@ -1865,9 +1890,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 ( Date: Fri, 24 Jul 2026 12:17:35 +0200 Subject: [PATCH 4/8] feat(spawn): preview model during placement --- .../nodes/src/spawn/__tests__/parity.test.ts | 9 ++ packages/nodes/src/spawn/definition.ts | 1 + packages/nodes/src/spawn/renderer.tsx | 99 +++++++++++++------ packages/nodes/src/spawn/tool.tsx | 77 +++++++++++++-- 4 files changed, 147 insertions(+), 39 deletions(-) diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts index 7c919ea408..fdd717351e 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 08aedf6940..94facff033 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 4203655d4f..e60b059b01 100644 --- a/packages/nodes/src/spawn/renderer.tsx +++ b/packages/nodes/src/spawn/renderer.tsx @@ -12,31 +12,16 @@ 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, node }: { ghost?: boolean; 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 +33,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 +52,80 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => { }, []) return ( - - + <> + - + - + - + + + ) +} + +export const SpawnPreview = ({ node }: { 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 ce196c29d0..471d09d7e3 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -9,7 +9,6 @@ import { useScene, } from '@pascal-app/core' import { - CursorSphere, getFloorStackPreviewPosition, isAlignmentGuideActive, isGridSnapActive, @@ -18,14 +17,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 +41,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 +62,18 @@ const SpawnTool = () => { useEffect(() => { if (!activeLevelId) return previousSnapRef.current = null + rotationRef.current = 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 +89,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 +130,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 +146,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 +161,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 From 0fa7d83fb33c69f4738d068456bf84eab7e4b097 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 24 Jul 2026 13:17:59 +0200 Subject: [PATCH 5/8] feat(editor): add cross-project selection clipboard --- .../src/components/editor/group-actions.ts | 121 ++++++++- .../components/ui/floating-level-selector.tsx | 22 +- .../keyboard-shortcuts-dialog.tsx | 16 ++ packages/editor/src/hooks/use-keyboard.ts | 20 +- .../editor/src/lib/scene-clipboard.test.ts | 100 ++++++++ packages/editor/src/lib/scene-clipboard.ts | 238 +++++++++++++++++- 6 files changed, 469 insertions(+), 48 deletions(-) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index a97cbec8c9..2f5172907b 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -8,6 +8,7 @@ import { resolveAlignment, resumeSceneHistory, resumeSpaceDetection, + type SceneMaterialId, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -16,7 +17,12 @@ 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 { + 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' @@ -80,7 +86,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() @@ -203,16 +209,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({ @@ -362,6 +370,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 @@ -381,6 +390,12 @@ 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. + cancel() + return + } if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { e.preventDefault() e.stopPropagation() @@ -438,6 +453,98 @@ 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 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) discardPaste() + return started +} + +/** + * 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. diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 591902c4ab..b5300752dc 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 94da6539c0..09385f0601 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 a06a09480b..1eaafd691a 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -10,7 +10,11 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { Vector3 } from 'three' -import { deleteSelection } from '../components/editor/group-actions' +import { + cutSelectionToEditorClipboard, + deleteSelection, + pasteSelectionAndPickUp, +} from '../components/editor/group-actions' import { classifyParticipant, collectParticipants, @@ -25,10 +29,7 @@ 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 { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard' import { sfxEmitter } from '../lib/sfx-bus' import { toggleWindowOpenState } from '../lib/window-interaction' import useDeleteConfirmation from '../store/use-delete-confirmation' @@ -367,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() diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts index c203a4c71b..d421437958 100644 --- a/packages/editor/src/lib/scene-clipboard.test.ts +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -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,99 @@ 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}`) + } + }) }) diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index 5b67ba7cd9..5e3aa7354c 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', @@ -97,10 +108,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 +190,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 +252,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 +301,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 +316,94 @@ 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) return + void navigator.clipboard.writeText(serializeClipboardPayload(payload)).catch(() => { + // The in-memory clipboard remains available when browser permissions deny + // system clipboard access. + }) +} + export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[]) const payload = buildClipboardPayload(ids) @@ -251,7 +411,27 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { clipboardPayload = payload notifySubscribers() + writeSystemClipboard(payload) + + return true +} +export async function readEditorClipboardFromSystem() { + 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 +455,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 +471,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 +496,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 +513,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 +537,7 @@ function applyClipboardPayloadToLevel( }) return { + createdMaterialIds: materialsToCreate.map((material) => material.id as SceneMaterialId), pastedIds: pastedRootIds, skippedIds, } From d31de463c9ce6f2ee45a63fa2ac7b38dc8f08f8c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 24 Jul 2026 15:10:48 +0200 Subject: [PATCH 6/8] fix(editor): harden placement and clipboard previews --- .../editor/delete-confirmation-dialog.tsx | 3 ++ .../editor/src/lib/scene-clipboard.test.ts | 31 ++++++++++++++++++- packages/editor/src/lib/scene-clipboard.ts | 22 ++++++++++--- packages/nodes/src/spawn/renderer.tsx | 18 +++++++++-- packages/nodes/src/spawn/tool.tsx | 3 +- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/editor/delete-confirmation-dialog.tsx b/packages/editor/src/components/editor/delete-confirmation-dialog.tsx index a1ae220b59..05194a00bc 100644 --- a/packages/editor/src/components/editor/delete-confirmation-dialog.tsx +++ b/packages/editor/src/components/editor/delete-confirmation-dialog.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect } from 'react' import useDeleteConfirmation from '../../store/use-delete-confirmation' import { Dialog, @@ -15,6 +16,8 @@ export function DeleteConfirmationDialog() { const cancel = useDeleteConfirmation((state) => state.cancel) const confirm = useDeleteConfirmation((state) => state.confirm) + useEffect(() => cancel, [cancel]) + return ( !open && cancel()} open={request !== null}> { 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 5e3aa7354c..081836c333 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -48,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() { @@ -397,11 +398,16 @@ function parseClipboardPayload(text: string): ClipboardPayload | null { } function writeSystemClipboard(payload: ClipboardPayload) { - if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return - void navigator.clipboard.writeText(serializeClipboardPayload(payload)).catch(() => { - // The in-memory clipboard remains available when browser permissions deny - // system clipboard access. - }) + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + pendingSystemClipboardWrite = null + return + } + pendingSystemClipboardWrite = navigator.clipboard + .writeText(serializeClipboardPayload(payload)) + .then( + () => true, + () => false, + ) } export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { @@ -417,6 +423,12 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { } export async function readEditorClipboardFromSystem() { + const pendingWrite = pendingSystemClipboardWrite + pendingSystemClipboardWrite = null + if (pendingWrite && !(await pendingWrite)) { + return hasEditorClipboard() + } + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) { return hasEditorClipboard() } diff --git a/packages/nodes/src/spawn/renderer.tsx b/packages/nodes/src/spawn/renderer.tsx index e60b059b01..319070fff2 100644 --- a/packages/nodes/src/spawn/renderer.tsx +++ b/packages/nodes/src/spawn/renderer.tsx @@ -18,7 +18,15 @@ const disableRaycast = () => {} * Shared visible spawn model. Placement uses the same four meshes as the * committed renderer so the orientation shown before click is authoritative. */ -const SpawnVisual = ({ ghost = false, node }: { ghost?: boolean; node: SpawnNode }) => { +const SpawnVisual = ({ + ghost = false, + layers, + node, +}: { + ghost?: boolean + layers?: number + node: SpawnNode +}) => { const handlers = useNodeEvents(node, 'spawn') const shading = useViewer((state) => state.shading) @@ -54,6 +62,7 @@ const SpawnVisual = ({ ghost = false, node }: { ghost?: boolean; node: SpawnNode return ( <> +export const SpawnPreview = ({ layers, node }: { layers?: number; node: SpawnNode }) => ( + +) /** * Registry-driven spawn renderer. Behaviorally identical to the legacy diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 471d09d7e3..394cdc4e06 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -9,6 +9,7 @@ import { useScene, } from '@pascal-app/core' import { + EDITOR_LAYER, getFloorStackPreviewPosition, isAlignmentGuideActive, isGridSnapActive, @@ -215,7 +216,7 @@ const SpawnTool = () => { return ( - + ) } From 9f220c836749e3adc9b725c3fe2fd1acb6bc995f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 24 Jul 2026 15:23:39 +0200 Subject: [PATCH 7/8] fix(editor): preserve carried clipboard state --- packages/editor/src/components/editor/group-actions.ts | 7 ++++++- packages/nodes/src/spawn/tool.tsx | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 2f5172907b..2da011db55 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -392,7 +392,12 @@ export function startGroupPickUp( 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. + // 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 } diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 394cdc4e06..41d17da4f6 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -64,6 +64,7 @@ const SpawnTool = () => { 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 } From e501ddb267b8b93f1293de87371b1b9ee978c23f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 24 Jul 2026 15:30:26 +0200 Subject: [PATCH 8/8] fix(editor): replace active paste drafts safely --- packages/editor/src/components/editor/group-actions.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 2da011db55..9572367db4 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -3,6 +3,7 @@ import { type AnyNodeId, bboxCornerAnchors, collectAlignmentAnchors, + emitter, pauseSceneHistory, pauseSpaceDetection, resolveAlignment, @@ -485,6 +486,11 @@ function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) { * 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 @@ -526,8 +532,8 @@ export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promis scopeToSelection: true, onCancel: discardPaste, }) - if (!started) discardPaste() - return started + if (!started) sfxEmitter.emit('sfx:item-place') + return true } /**