Improve editor placement and selection workflows (#543)

* feat: expose accepted canvas node selections

* feat(editor): replace bulk delete alert with dialog

* fix(editor): allow immediate selection after opening placement

* feat(spawn): preview model during placement

* feat(editor): add cross-project selection clipboard

* fix(editor): harden placement and clipboard previews

* fix(editor): preserve carried clipboard state

* fix(editor): replace active paste drafts safely
This commit is contained in:
Aymeric Rabot
2026-07-24 15:35:10 +02:00
committed by GitHub
parent b95d737eb6
commit 99812cd4cb
20 changed files with 932 additions and 157 deletions
+6
View File
@@ -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
@@ -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(
@@ -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
const nextSelectedIds = shouldToggle
? currentSelectedIds.includes(id)
? currentSelectedIds.filter((selectedId) => selectedId !== id)
: [...currentSelectedIds, id]
: [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<SVGGElement>) => {
if (isFloorplanOpeningPlacementActiveNow()) return
event.stopPropagation()
}, [])
@@ -829,6 +857,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const handleEntryPointerDown = useCallback(
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
// 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.
<g
className="floorplan-registry-layer"
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
onClick={handleClickStop}
opacity={isAmbient ? 0.3 : undefined}
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
>
@@ -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<string, LevelDataCacheEntry> }
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
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 (
<g
@@ -0,0 +1,54 @@
'use client'
import { useEffect } from 'react'
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)
useEffect(() => cancel, [cancel])
return (
<Dialog onOpenChange={(open) => !open && cancel()} open={request !== null}>
<DialogContent
className="border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl sm:max-w-md"
data-delete-confirmation-dialog
showCloseButton={false}
>
<DialogHeader>
<DialogTitle>Delete {request?.count ?? 0} elements?</DialogTitle>
<DialogDescription>
This removes every selected element. You can undo the deletion while it remains in the
editor history.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
onClick={cancel}
type="button"
>
Cancel
</button>
<button
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
onClick={confirm}
type="button"
>
Delete
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<boolean> {
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<string, unknown>)
: {}
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
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
}
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) {
useDeleteConfirmation.getState().requestConfirmation({
count: selectedIds.length,
onConfirm: commitDelete,
})
return true
}
commitDelete()
return true
}
@@ -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. */}
<div className="relative flex h-full" ref={setViewerAreaNode}>
<QuickMeasurementHud />
<DeleteConfirmationDialog />
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
<div
className="relative h-full flex-shrink-0"
@@ -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
@@ -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<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(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(
@@ -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',
+18 -30
View File
@@ -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
}
+130 -1
View File
@@ -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<void>((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)
})
})
+237 -13
View File
@@ -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<AnyNode['type']>([
'wall',
'fence',
'door',
'window',
'column',
'item',
'slab',
@@ -37,6 +48,7 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
])
let clipboardPayload: ClipboardPayload | null = null
let pendingSystemClipboardWrite: Promise<boolean> | null = null
const subscribers = new Set<() => void>()
function notifySubscribers() {
@@ -97,10 +109,29 @@ function hasSelectedAncestor(
return false
}
function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
function isClipboardRoot(
nodes: Record<AnyNodeId, AnyNode>,
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<AnyNodeId, AnyNodeId>,
materialIdMap: Map<SceneMaterialId, SceneMaterialId>,
rootIds: Set<AnyNodeId>,
nodes: Record<AnyNodeId, AnyNode>,
) {
const clone = JSON.parse(JSON.stringify(node)) as AnyNode
const clone = remapSceneMaterialReferences(
JSON.parse(JSON.stringify(node)),
materialIdMap,
) as AnyNode
;(clone as Record<string, unknown>).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<SceneMaterialId, SceneMaterialId>,
): 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<SceneMaterialId>) {
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)
}
return {
copiedAt: Date.now(),
nodes: [...subtreeIds]
const copiedNodes = [...subtreeIds]
.map((id) => scene.nodes[id])
.filter((node): node is AnyNode => !!node)
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode)
const materialIds = new Set<SceneMaterialId>()
collectReferencedSceneMaterialIds(copiedNodes, materialIds)
return {
copiedAt: Date.now(),
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,10 +417,36 @@ 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
}
/**
* Clone the given nodes (subtrees included, ids remapped) onto the target /
* active level in place — the same copy + paste pipeline in one step, WITHOUT
@@ -275,6 +467,13 @@ export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteRes
return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId)
}
export async function pasteSystemEditorClipboardToLevel(
targetLevelId?: AnyNodeId,
): Promise<PasteResult | null> {
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<AnyNodeId, AnyNodeId>()
const materialIdMap = new Map<SceneMaterialId, SceneMaterialId>()
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,
}
@@ -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({
@@ -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
@@ -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<DeleteConfirmationStore>((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
@@ -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)
+1
View File
@@ -101,6 +101,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place spawn point' },
{ key: 'R / T', label: 'Rotate spawn point' },
{ key: 'Esc', label: 'Cancel' },
],
+98 -45
View File
@@ -12,6 +12,103 @@ import { useMemo, useRef } from 'react'
import { Color, type Group, Shape } from 'three'
const SPAWN_COLOR = new Color('#818cf8')
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,
layers,
node,
}: {
ghost?: boolean
layers?: number
node: SpawnNode
}) => {
const handlers = useNodeEvents(node, 'spawn')
const shading = useViewer((state) => state.shading)
const material = useMemo(() => {
const next = createDefaultMaterial('#818cf8', 0.42, shading) as ReturnType<
typeof createDefaultMaterial
> & {
emissive?: Color
emissiveIntensity?: number
metalness?: number
}
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
}, [ghost, shading])
const arrowShape = useMemo(() => {
const shape = new Shape()
shape.moveTo(0, 0.24)
shape.lineTo(-0.18, -0.14)
shape.lineTo(0.18, -0.14)
shape.closePath()
return shape
}, [])
return (
<>
<mesh
layers={layers}
position={[0, 0.09, 0]}
raycast={ghost ? disableRaycast : undefined}
rotation={[-Math.PI / 2, 0, 0]}
{...(ghost ? {} : handlers)}
>
<ringGeometry args={[0.34, 0.48, 48]} />
<primitive attach="material" object={material} />
</mesh>
<mesh
layers={layers}
position={[0, 0.1, -0.52]}
raycast={ghost ? disableRaycast : undefined}
rotation={[-Math.PI / 2, 0, 0]}
{...(ghost ? {} : handlers)}
>
<shapeGeometry args={[arrowShape]} />
<primitive attach="material" object={material} />
</mesh>
<mesh
layers={layers}
position={[0, 0.41, 0]}
raycast={ghost ? disableRaycast : undefined}
{...(ghost ? {} : handlers)}
>
<boxGeometry args={[0.3, 0.54, 0.16]} />
<primitive attach="material" object={material} />
</mesh>
<mesh
layers={layers}
position={[0, 0.83, 0]}
raycast={ghost ? disableRaycast : undefined}
{...(ghost ? {} : handlers)}
>
<boxGeometry args={[0.18, 0.18, 0.18]} />
<primitive attach="material" object={material} />
</mesh>
</>
)
}
export const SpawnPreview = ({ layers, node }: { layers?: number; node: SpawnNode }) => (
<SpawnVisual ghost layers={layers} node={node} />
)
/**
* Registry-driven spawn renderer. Behaviorally identical to the legacy
@@ -25,7 +122,6 @@ const SPAWN_COLOR = new Color('#818cf8')
*/
const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
const ref = useRef<Group>(null!)
const handlers = useNodeEvents(node, 'spawn')
const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId))
const effectiveNode = useMemo(
() => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node),
@@ -33,34 +129,9 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
)
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
> & {
emissive?: Color
emissiveIntensity?: number
metalness?: number
}
next.emissive?.copy(SPAWN_COLOR)
if ('emissiveIntensity' in next) next.emissiveIntensity = 0.08
if ('metalness' in next) next.metalness = 0.03
next.needsUpdate = true
return next
}, [shading])
const arrowShape = useMemo(() => {
const shape = new Shape()
shape.moveTo(0, 0.24)
shape.lineTo(-0.18, -0.14)
shape.lineTo(0.18, -0.14)
shape.closePath()
return shape
}, [])
return (
<group
position={liveTransform?.position ?? effectiveNode.position}
@@ -68,25 +139,7 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
rotation={[0, liveTransform?.rotation ?? effectiveNode.rotation, 0]}
visible={!walkthroughMode && effectiveNode.visible !== false}
>
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<ringGeometry args={[0.34, 0.48, 48]} />
<primitive attach="material" object={material} />
</mesh>
<mesh position={[0, 0.1, -0.52]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<shapeGeometry args={[arrowShape]} />
<primitive attach="material" object={material} />
</mesh>
<mesh position={[0, 0.41, 0]} {...handlers}>
<boxGeometry args={[0.3, 0.54, 0.16]} />
<primitive attach="material" object={material} />
</mesh>
<mesh position={[0, 0.83, 0]} {...handlers}>
<boxGeometry args={[0.18, 0.18, 0.18]} />
<primitive attach="material" object={material} />
</mesh>
<SpawnVisual node={effectiveNode} />
</group>
)
}
+70 -9
View File
@@ -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<Group>(null)
const previousSnapRef = useRef<string | null>(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 <CursorSphere color="#818cf8" height={2.2} ref={cursorRef} />
return (
<group ref={cursorRef} visible={cursorVisible}>
<SpawnPreview layers={EDITOR_LAYER} node={previewNode} />
</group>
)
}
export default SpawnTool
+11
View File
@@ -41,6 +41,17 @@ interface NodeEvent<T extends AnyNode = AnyNode> {
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: