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 = { 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 * "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 * selected node; whoever owns the node's catalog/panel (host browser, a
@@ -21,6 +21,7 @@ import {
computeAffectedSiblingIds, computeAffectedSiblingIds,
floorplanHandleDoubleClickAffordance, floorplanHandleDoubleClickAffordance,
InteractiveGeometry, InteractiveGeometry,
isFloorplanOpeningPlacementState,
splitFloorplanOverlay, splitFloorplanOverlay,
subscribeFloorplanAffordanceToolCancel, subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer' } 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', () => { describe('floorplan vertex double-click routing', () => {
test('routes polygon vertex handles to the kind-owned delete affordance', () => { test('routes polygon vertex handles to the kind-owned delete affordance', () => {
expect( expect(
@@ -69,6 +69,7 @@ import {
isIdle, isIdle,
tangentReshapeScope, tangentReshapeScope,
} from '../../../lib/interaction/scope' } from '../../../lib/interaction/scope'
import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' 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 useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
import useFloorplanPreflight from '../../../store/use-floorplan-preflight' import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
import useInteractionScope, { import useInteractionScope, {
getMovingNode,
useEndpointReshape, useEndpointReshape,
useMovingNode, useMovingNode,
} from '../../../store/use-interaction-scope' } 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 MOVE_CURSOR_STYLE = { cursor: 'move' } as const
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } 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 { function snapshotNode(node: AnyNode): NodeSnapshot {
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
// deep-cloned to detach from the live store reference. // 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 // wall's registry entry would otherwise swallow the click via
// `handleClickStop` / `handleSelect`, so the placement never fires. // `handleClickStop` / `handleSelect`, so the placement never fires.
// Pass clicks through in that case. // Pass clicks through in that case.
const editorPhase = useEditor((s) => s.phase)
const editorMode = useEditor((s) => s.mode) const editorMode = useEditor((s) => s.mode)
const editorTool = useEditor((s) => s.tool)
const structureLayer = useEditor((s) => s.structureLayer) const structureLayer = useEditor((s) => s.structureLayer)
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
const endpointReshape = useEndpointReshape() const endpointReshape = useEndpointReshape()
const isOpeningPlacementActive =
(editorPhase === 'structure' &&
editorMode === 'build' &&
(editorTool === 'door' || editorTool === 'window')) ||
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
const isMarqueeSelectionActive = const isMarqueeSelectionActive =
editorMode === 'select' && editorMode === 'select' &&
floorplanSelectionTool === 'marquee' && floorplanSelectionTool === 'marquee' &&
@@ -557,13 +581,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const applyEntrySelection = useCallback( const applyEntrySelection = useCallback(
(id: AnyNodeId, shouldToggle: boolean) => { (id: AnyNodeId, shouldToggle: boolean) => {
const currentSelectedIds = useViewer.getState().selection.selectedIds const currentSelectedIds = useViewer.getState().selection.selectedIds
setSelection({ const nextSelectedIds = shouldToggle
selectedIds: shouldToggle ? currentSelectedIds.includes(id)
? currentSelectedIds.includes(id) ? currentSelectedIds.filter((selectedId) => selectedId !== id)
? currentSelectedIds.filter((selectedId) => selectedId !== id) : [...currentSelectedIds, 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 // Setting selection re-renders the entry — the overlay pass mounts
// (endpoint handles, etc.), reshuffling DOM under the cursor between // (endpoint handles, etc.), reshuffling DOM under the cursor between
// pointerdown and click. If the click target ends up on the SVG // 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>) => { const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
if (isFloorplanOpeningPlacementActiveNow()) return
event.stopPropagation() event.stopPropagation()
}, []) }, [])
@@ -829,6 +857,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const handleEntryPointerDown = useCallback( const handleEntryPointerDown = useCallback(
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => { (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 (startDirectMoveDrag(id, event)) return
if (startDirectRotateDrag(id, event)) return if (startDirectRotateDrag(id, event)) return
if (startGroupMoveDrag(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. // still propagate normally inside the registry tree.
<g <g
className="floorplan-registry-layer" className="floorplan-registry-layer"
onClick={isOpeningPlacementActive ? undefined : handleClickStop} onClick={handleClickStop}
opacity={isAmbient ? 0.3 : undefined} opacity={isAmbient ? 0.3 : undefined}
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined} style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
> >
@@ -1347,7 +1380,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)} hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
interactiveElevators={interactiveElevators} interactiveElevators={interactiveElevators}
isMarqueeSelectionActive={isMarqueeSelectionActive} isMarqueeSelectionActive={isMarqueeSelectionActive}
isOpeningPlacementActive={isOpeningPlacementActive}
key={`base-${entry.id}`} key={`base-${entry.id}`}
levelDataCacheRef={levelDataCacheRef} levelDataCacheRef={levelDataCacheRef}
levelNodeIdsByType={floorplanData.levelNodeIdsByType} levelNodeIdsByType={floorplanData.levelNodeIdsByType}
@@ -1401,7 +1433,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)} hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
interactiveElevators={interactiveElevators} interactiveElevators={interactiveElevators}
isMarqueeSelectionActive={isMarqueeSelectionActive} isMarqueeSelectionActive={isMarqueeSelectionActive}
isOpeningPlacementActive={isOpeningPlacementActive}
key={`overlay-${entry.id}`} key={`overlay-${entry.id}`}
levelDataCacheRef={levelDataCacheRef} levelDataCacheRef={levelDataCacheRef}
levelNodeIdsByType={floorplanData.levelNodeIdsByType} levelNodeIdsByType={floorplanData.levelNodeIdsByType}
@@ -1735,7 +1766,6 @@ type FloorplanRegistryEntryProps = {
hoveredHandleId: string | null hoveredHandleId: string | null
interactiveElevators: unknown interactiveElevators: unknown
isMarqueeSelectionActive: boolean isMarqueeSelectionActive: boolean
isOpeningPlacementActive: boolean
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> } levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]> levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
moving: boolean moving: boolean
@@ -1792,7 +1822,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
hoveredHandleId, hoveredHandleId,
interactiveElevators, interactiveElevators,
isMarqueeSelectionActive, isMarqueeSelectionActive,
isOpeningPlacementActive,
levelDataCacheRef, levelDataCacheRef,
levelNodeIdsByType, levelNodeIdsByType,
moving, moving,
@@ -1941,9 +1970,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
: visibleGeometry : visibleGeometry
if (!geometry) return null if (!geometry) return null
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop const entryClick = isMarqueeSelectionActive ? undefined : onClickStop
const entryPointerDown = const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown
isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
return ( return (
<g <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, type AnyNodeId,
bboxCornerAnchors, bboxCornerAnchors,
collectAlignmentAnchors, collectAlignmentAnchors,
emitter,
pauseSceneHistory, pauseSceneHistory,
pauseSpaceDetection, pauseSpaceDetection,
resolveAlignment, resolveAlignment,
resumeSceneHistory, resumeSceneHistory,
resumeSpaceDetection, resumeSpaceDetection,
type SceneMaterialId,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
@@ -16,9 +18,15 @@ import { useViewer } from '@pascal-app/viewer'
import { Plane, Vector2, Vector3 } from 'three' import { Plane, Vector2, Vector3 } from 'three'
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help' import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
import { clientToPlan } from '../../lib/floorplan/plan-coords' import { clientToPlan } from '../../lib/floorplan/plan-coords'
import { duplicateNodesToLevel } from '../../lib/scene-clipboard' import {
import { sfxEmitter } from '../../lib/sfx-bus' copySelectedNodesToEditorClipboard,
duplicateNodesToLevel,
getEditorClipboardSnapshot,
pasteSystemEditorClipboardToLevel,
} from '../../lib/scene-clipboard'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides' import useAlignmentGuides from '../../store/use-alignment-guides'
import useDeleteConfirmation from '../../store/use-delete-confirmation'
import useEditor, { import useEditor, {
isAlignmentGuideActive, isAlignmentGuideActive,
isGridSnapActive, isGridSnapActive,
@@ -79,7 +87,7 @@ export function canGroupPickUp(): boolean {
* and drag them along with the copies. * and drag them along with the copies.
*/ */
export function startGroupPickUp( export function startGroupPickUp(
opts: { onCancel?: () => void; scopeToSelection?: boolean } = {}, opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
): boolean { ): boolean {
const { selectedIds, levelId } = useViewer.getState().selection const { selectedIds, levelId } = useViewer.getState().selection
const participantIds = groupParticipantIds() const participantIds = groupParticipantIds()
@@ -202,16 +210,18 @@ export function startGroupPickUp(
const applyMove = (e: PointerEvent) => { const applyMove = (e: PointerEvent) => {
const plan = resolvePlanPoint(e) const plan = resolvePlanPoint(e)
if (!plan) return if (!plan) return
// Delta-relative to where tracking starts so the group never teleports // Ordinary moves are delta-relative so the group never teleports. A
// to the cursor. // pasted selection instead arrives centered under the cursor.
if (!startPlan) { if (!opts.positionAtCursor && !startPlan) {
startPlan = plan startPlan = plan
return return
} }
const step = useEditor.getState().gridSnapStep const step = useEditor.getState().gridSnapStep
const snap = isGridSnapActive() && step > 0 const snap = isGridSnapActive() && step > 0
let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0] const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1] 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) { if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
@@ -361,6 +371,7 @@ export function startGroupPickUp(
// Only a press over a tracked surface commits; a click on side panels or // Only a press over a tracked surface commits; a click on side panels or
// the toolbar keeps the pick-up alive. // the toolbar keeps the pick-up alive.
if (!resolvePlanPoint(e)) return if (!resolvePlanPoint(e)) return
if (opts.positionAtCursor && !lastDelta) applyMove(e)
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
commitPointerId = e.pointerId commitPointerId = e.pointerId
@@ -380,6 +391,17 @@ export function startGroupPickUp(
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
const key = e.key.toLowerCase() 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) { if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@@ -437,6 +459,103 @@ export function duplicateSelectionAndPickUp(): boolean {
return true 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, * Delete every selected node — same semantics as the keyboard Delete arm,
* including the accidental-bulk-delete confirm. * including the accidental-bulk-delete confirm.
@@ -444,14 +563,25 @@ export function duplicateSelectionAndPickUp(): boolean {
export function deleteSelection(): boolean { export function deleteSelection(): boolean {
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[] const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedIds.length === 0) return false if (selectedIds.length === 0) return false
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
const confirmed = window.confirm( const commitDelete = () => {
`Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`, if (selectedIds.length === 1) {
) emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
if (!confirmed) return false } else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNodes(selectedIds)
useViewer.getState().setSelection({ selectedIds: [] })
} }
sfxEmitter.emit('sfx:structure-delete')
useScene.getState().deleteNodes(selectedIds) if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
useViewer.getState().setSelection({ selectedIds: [] }) useDeleteConfirmation.getState().requestConfirmation({
count: selectedIds.length,
onConfirm: commitDelete,
})
return true
}
commitDelete()
return true 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 type { SidebarTab } from '../ui/sidebar/tab-bar'
import { useHostPanels } from '../ui/sidebar/use-plugin-panels' import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
import { CustomCameraControls } from './custom-camera-controls' import { CustomCameraControls } from './custom-camera-controls'
import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
import { EditorLayoutV2 } from './editor-layout-v2' import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager' import { ExportManager } from './export-manager'
import { FenceTangentLines3D } from './fence-tangent-lines-3d' 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 / 3d / split alike) can anchor to this container's bottom-left. */}
<div className="relative flex h-full" ref={setViewerAreaNode}> <div className="relative flex h-full" ref={setViewerAreaNode}>
<QuickMeasurementHud /> <QuickMeasurementHud />
<DeleteConfirmationDialog />
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */} {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
<div <div
className="relative h-full flex-shrink-0" className="relative h-full flex-shrink-0"
@@ -60,6 +60,7 @@ import {
} from '../../lib/paint-scope' } from '../../lib/paint-scope'
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
import { import {
emitCanvasNodeSelection,
resolveCanvasSelectionNode, resolveCanvasSelectionNode,
resolveNodeSelectionTarget, resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick, resolveSelectedIdsForNodeClick,
@@ -1365,7 +1366,7 @@ export const SelectionManager = () => {
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
let owns = false let owns = false
let prevKey = '' let prevKey = '\0'
const applyCursor = () => { const applyCursor = () => {
const { selection, hoveredId } = useViewer.getState() const { selection, hoveredId } = useViewer.getState()
const selectedIds = selection.selectedIds const selectedIds = selection.selectedIds
@@ -1648,6 +1649,7 @@ export const SelectionManager = () => {
modifierKeysRef.current, modifierKeysRef.current,
selectedIdsBeforeRouting, selectedIdsBeforeRouting,
) )
emitCanvasNodeSelection(nodeToSelect)
let nextMaterialTargetHandled = false let nextMaterialTargetHandled = false
@@ -36,21 +36,15 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react' } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { pasteSelectionAndPickUp } from '../editor/group-actions'
import { import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from '../../lib/level-duplication' } from '../../lib/level-duplication'
import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core' import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' 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 { useLinearDisplay } from '../../lib/use-linear-display'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { ActionButton } from './controls/action-button' import { ActionButton } from './controls/action-button'
@@ -399,11 +393,6 @@ export function FloatingLevelSelector() {
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null) const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null) const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
const clipboardSnapshot = useSyncExternalStore(
subscribeEditorClipboard,
getEditorClipboardSnapshot,
getEditorClipboardSnapshot,
)
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 4 }, activationConstraint: { distance: 4 },
@@ -522,10 +511,7 @@ export function FloatingLevelSelector() {
) )
const handlePasteToLevel = useCallback((level: LevelNode) => { const handlePasteToLevel = useCallback((level: LevelNode) => {
const result = pasteEditorClipboardToLevel(level.id) void pasteSelectionAndPickUp(level.id)
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
}, []) }, [])
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -627,9 +613,7 @@ export function FloatingLevelSelector() {
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)} onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onPaste={ onPaste={() => handlePasteToLevel(level)}
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
}
onRequestDelete={() => setDeletingLevel(level)} onRequestDelete={() => setDeletingLevel(level)}
onSelect={() => onSelect={() =>
setSelection( setSelection(
@@ -69,6 +69,22 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{ {
title: 'Selection', title: 'Selection',
shortcuts: [ 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'], keys: ['Cmd/Ctrl', 'Left click'],
action: 'Add or remove an object from multi-selection', 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 { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import {
cutSelectionToEditorClipboard,
deleteSelection,
pasteSelectionAndPickUp,
} from '../components/editor/group-actions'
import { import {
classifyParticipant, classifyParticipant,
collectParticipants, collectParticipants,
@@ -24,12 +29,10 @@ import { toggleDoorOpenState } from '../lib/door-interaction'
import { guideEmitter } from '../lib/guide-events' import { guideEmitter } from '../lib/guide-events'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { isActive } from '../lib/interaction/scope' import { isActive } from '../lib/interaction/scope'
import { import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
copySelectedNodesToEditorClipboard, import { sfxEmitter } from '../lib/sfx-bus'
pasteEditorClipboardToLevel,
} from '../lib/scene-clipboard'
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
import { toggleWindowOpenState } from '../lib/window-interaction' import { toggleWindowOpenState } from '../lib/window-interaction'
import useDeleteConfirmation from '../store/use-delete-confirmation'
import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope' import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
@@ -210,6 +213,10 @@ export const useKeyboard = ({
return return
} }
if (useDeleteConfirmation.getState().request) {
return
}
if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') { if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') {
// In paint mode Shift cycles the application scope (this surface → // In paint mode Shift cycles the application scope (this surface →
// whole item / all matching / room) — the paint-mode analogue of the // whole item / all matching / room) — the paint-mode analogue of the
@@ -361,13 +368,14 @@ export const useKeyboard = ({
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
copySelectedNodesToEditorClipboard() 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) { } else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
const result = pasteEditorClipboardToLevel() void pasteSelectionAndPickUp()
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
} else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
@@ -621,27 +629,7 @@ export const useKeyboard = ({
} }
} }
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (deleteSelection()) {
if (selectedNodeIds.length > 0) {
// Guard against accidental bulk deletion (e.g. box-select all + Delete)
const BULK_DELETE_THRESHOLD = 10
if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
const confirmed = window.confirm(
`Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
)
if (!confirmed) return
}
// Play appropriate SFX based on what's being deleted
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
emitDeleteSFX(node?.type)
} else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNodes(selectedNodeIds)
return 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 { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
@@ -8,14 +8,18 @@ import {
type CabinetNode as CabinetNodeType, type CabinetNode as CabinetNodeType,
type LevelNode, type LevelNode,
MeasurementNode, MeasurementNode,
SceneMaterial,
type SceneMaterialId,
useScene, useScene,
WallNode, WallNode,
WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { import {
copySelectedNodesToEditorClipboard, copySelectedNodesToEditorClipboard,
getEditorClipboardSnapshot, getEditorClipboardSnapshot,
pasteEditorClipboardToLevel, pasteEditorClipboardToLevel,
pasteSystemEditorClipboardToLevel,
} from './scene-clipboard' } from './scene-clipboard'
const sourceLevelId = 'level_clipboard-source' as LevelNode['id'] const sourceLevelId = 'level_clipboard-source' as LevelNode['id']
@@ -77,6 +81,7 @@ function seedCabinetRun() {
[leftModule.id]: leftModule as AnyNode, [leftModule.id]: leftModule as AnyNode,
[rightModule.id]: rightModule as AnyNode, [rightModule.id]: rightModule as AnyNode,
}, },
materials: {},
rootNodeIds: [sourceLevel.id, targetLevel.id], rootNodeIds: [sourceLevel.id, targetLevel.id],
} as never) } as never)
useViewer.getState().setSelection({ useViewer.getState().setSelection({
@@ -193,4 +198,128 @@ describe('scene clipboard', () => {
expect(Array.isArray(anchor)).toBe(false) expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id) 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, AnyNode,
type AnyNodeId, type AnyNodeId,
generateId, generateId,
generateSceneMaterialId,
type LevelNode, type LevelNode,
nodeRegistry,
remapMeasurementReferences, remapMeasurementReferences,
SceneMaterial,
type SceneMaterialId,
type StairNode, type StairNode,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -11,18 +15,25 @@ import { useViewer } from '@pascal-app/viewer'
type ClipboardPayload = { type ClipboardPayload = {
copiedAt: number copiedAt: number
materials: SceneMaterial[]
nodes: AnyNode[] nodes: AnyNode[]
rootIds: AnyNodeId[] rootIds: AnyNodeId[]
} }
type PasteResult = { export type PasteResult = {
createdMaterialIds: SceneMaterialId[]
pastedIds: AnyNodeId[] pastedIds: AnyNodeId[]
skippedIds: AnyNodeId[] skippedIds: AnyNodeId[]
} }
const SYSTEM_CLIPBOARD_KIND = 'pascal.scene-nodes'
const SYSTEM_CLIPBOARD_VERSION = 1
const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
'wall', 'wall',
'fence', 'fence',
'door',
'window',
'column', 'column',
'item', 'item',
'slab', 'slab',
@@ -37,6 +48,7 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
]) ])
let clipboardPayload: ClipboardPayload | null = null let clipboardPayload: ClipboardPayload | null = null
let pendingSystemClipboardWrite: Promise<boolean> | null = null
const subscribers = new Set<() => void>() const subscribers = new Set<() => void>()
function notifySubscribers() { function notifySubscribers() {
@@ -97,10 +109,29 @@ function hasSelectedAncestor(
return false 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 const parentId = node.parentId as AnyNodeId | null
if (!parentId) return true 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( function getPromotedCabinetRunId(
@@ -160,14 +191,28 @@ function remapNodeReferences(
oldId: AnyNodeId, oldId: AnyNodeId,
targetLevel: LevelNode, targetLevel: LevelNode,
idMap: Map<AnyNodeId, AnyNodeId>, idMap: Map<AnyNodeId, AnyNodeId>,
materialIdMap: Map<SceneMaterialId, SceneMaterialId>,
rootIds: Set<AnyNodeId>, rootIds: Set<AnyNodeId>,
nodes: Record<AnyNodeId, AnyNode>, 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) ;(clone as Record<string, unknown>).id = idMap.get(oldId)
if (rootIds.has(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') { } else if (clone.parentId && typeof clone.parentId === 'string') {
clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
} }
@@ -208,9 +253,47 @@ function remapNodeReferences(
return AnyNode.parse(clone) 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 { function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
const scene = useScene.getState() const scene = useScene.getState()
const selectedIdSet = new Set(ids) const selectedIdSet = new Set(ids)
const allowHostedOpening = ids.length === 1
const promotedIds = ids.map((id) => { const promotedIds = ids.map((id) => {
const node = scene.nodes[id] const node = scene.nodes[id]
return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : 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] const node = scene.nodes[id]
return ( return (
node && node &&
COPYABLE_ROOT_TYPES.has(node.type) && isCopyableRootType(node) &&
isLevelChildRoot(scene.nodes, node) && isClipboardRoot(scene.nodes, node, allowHostedOpening) &&
!hasSelectedAncestor(scene.nodes, id, selectedIdSet) !hasSelectedAncestor(scene.nodes, id, selectedIdSet)
) )
}) })
@@ -234,16 +317,99 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
collectSubtreeIds(scene.nodes, rootId, subtreeIds) 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<SceneMaterialId>()
collectReferencedSceneMaterialIds(copiedNodes, materialIds)
return { return {
copiedAt: Date.now(), copiedAt: Date.now(),
nodes: [...subtreeIds] materials: [...materialIds]
.map((id) => scene.nodes[id]) .map((id) => scene.materials[id])
.filter((node): node is AnyNode => !!node) .filter((material): material is SceneMaterial => !!material)
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode), .map((material) => JSON.parse(JSON.stringify(material)) as SceneMaterial),
nodes: copiedNodes,
rootIds, 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[]) { export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[]) const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
const payload = buildClipboardPayload(ids) const payload = buildClipboardPayload(ids)
@@ -251,10 +417,36 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
clipboardPayload = payload clipboardPayload = payload
notifySubscribers() notifySubscribers()
writeSystemClipboard(payload)
return true 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 / * Clone the given nodes (subtrees included, ids remapped) onto the target /
* active level in place — the same copy + paste pipeline in one step, WITHOUT * 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) return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId)
} }
export async function pasteSystemEditorClipboardToLevel(
targetLevelId?: AnyNodeId,
): Promise<PasteResult | null> {
if (!(await readEditorClipboardFromSystem())) return null
return pasteEditorClipboardToLevel(targetLevelId)
}
function applyClipboardPayloadToLevel( function applyClipboardPayloadToLevel(
payload: ClipboardPayload, payload: ClipboardPayload,
targetLevelId?: AnyNodeId, targetLevelId?: AnyNodeId,
@@ -284,10 +483,23 @@ function applyClipboardPayloadToLevel(
const scene = useScene.getState() const scene = useScene.getState()
const idMap = new Map<AnyNodeId, AnyNodeId>() const idMap = new Map<AnyNodeId, AnyNodeId>()
const materialIdMap = new Map<SceneMaterialId, SceneMaterialId>()
const materialsToCreate: SceneMaterial[] = []
for (const node of payload.nodes) { for (const node of payload.nodes) {
idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId) 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 rootIdSet = new Set(payload.rootIds)
const pastedNodes: AnyNode[] = [] const pastedNodes: AnyNode[] = []
@@ -296,7 +508,15 @@ function applyClipboardPayloadToLevel(
for (const node of payload.nodes) { for (const node of payload.nodes) {
try { try {
pastedNodes.push( 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) { } catch (error) {
console.error('Failed to paste copied node', node.id, error) console.error('Failed to paste copied node', node.id, error)
@@ -305,9 +525,12 @@ function applyClipboardPayloadToLevel(
} }
if (pastedNodes.length === 0) { if (pastedNodes.length === 0) {
return { pastedIds: [], skippedIds } return { createdMaterialIds: [], pastedIds: [], skippedIds }
} }
for (const material of materialsToCreate) {
scene.addSceneMaterial(material)
}
scene.createNodes( scene.createNodes(
pastedNodes.map((node) => ({ pastedNodes.map((node) => ({
node, node,
@@ -326,6 +549,7 @@ function applyClipboardPayloadToLevel(
}) })
return { return {
createdMaterialIds: materialsToCreate.map((material) => material.id as SceneMaterialId),
pastedIds: pastedRootIds, pastedIds: pastedRootIds,
skippedIds, skippedIds,
} }
@@ -1,7 +1,8 @@
import { describe, expect, test } from 'bun:test' 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 { z } from 'zod'
import { import {
emitCanvasNodeSelection,
resolveCanvasSelectionNode, resolveCanvasSelectionNode,
resolveNodeSelectionTarget, resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick, 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', () => { describe('selectionModifiersFromEvent', () => {
test('falls back to tracked modifier state when the click event omits keys', () => { test('falls back to tracked modifier state when the click event omits keys', () => {
expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({ expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({
@@ -1,5 +1,6 @@
import { import {
type AnyNode, type AnyNode,
emitter,
type ItemNode, type ItemNode,
nodeRegistry, nodeRegistry,
resolveSelectionProxyId, resolveSelectionProxyId,
@@ -16,6 +17,10 @@ export type NodeSelectionTarget = {
structureLayer?: 'zones' | 'elements' structureLayer?: 'zones' | 'elements'
} }
export function emitCanvasNodeSelection(node: AnyNode): void {
emitter.emit('selection:canvas-node-click', node)
}
function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean { function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean {
if (node.id === target.id) return false if (node.id === target.id) return false
// Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind // 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' } from '@pascal-app/core'
import { spawnDefinition } from '../definition' import { spawnDefinition } from '../definition'
import { buildSpawnFloorplan } from '../floorplan' import { buildSpawnFloorplan } from '../floorplan'
import { SpawnPreview } from '../renderer'
import { SpawnNode } from '../schema' import { SpawnNode } from '../schema'
/** /**
@@ -127,6 +128,14 @@ describe('spawn definition', () => {
expect(typeof spawnDefinition.tool).toBe('function') 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', () => { test('mcp description is set so AI surfaces describe the kind', () => {
expect(spawnDefinition.mcp?.description).toBeDefined() expect(spawnDefinition.mcp?.description).toBeDefined()
expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0) expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0)
+1
View File
@@ -101,6 +101,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place spawn point' }, { key: 'Left click', label: 'Place spawn point' },
{ key: 'R / T', label: 'Rotate spawn point' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+98 -45
View File
@@ -12,6 +12,103 @@ import { useMemo, useRef } from 'react'
import { Color, type Group, Shape } from 'three' import { Color, type Group, Shape } from 'three'
const SPAWN_COLOR = new Color('#818cf8') 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 * 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 SpawnRenderer = ({ node }: { node: SpawnNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
const handlers = useNodeEvents(node, 'spawn')
const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId)) const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId))
const effectiveNode = useMemo( const effectiveNode = useMemo(
() => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node), () => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node),
@@ -33,34 +129,9 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
) )
const liveTransform = useLiveTransforms((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id))
const walkthroughMode = useViewer((state) => state.walkthroughMode) const walkthroughMode = useViewer((state) => state.walkthroughMode)
const shading = useViewer((state) => state.shading)
useRegistry(node.id, 'spawn', ref) 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 ( return (
<group <group
position={liveTransform?.position ?? effectiveNode.position} position={liveTransform?.position ?? effectiveNode.position}
@@ -68,25 +139,7 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
rotation={[0, liveTransform?.rotation ?? effectiveNode.rotation, 0]} rotation={[0, liveTransform?.rotation ?? effectiveNode.rotation, 0]}
visible={!walkthroughMode && effectiveNode.visible !== false} visible={!walkthroughMode && effectiveNode.visible !== false}
> >
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}> <SpawnVisual node={effectiveNode} />
<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>
</group> </group>
) )
} }
+70 -9
View File
@@ -9,7 +9,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, EDITOR_LAYER,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
isAlignmentGuideActive, isAlignmentGuideActive,
isGridSnapActive, isGridSnapActive,
@@ -18,14 +18,18 @@ import {
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
usePlacementPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' 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 type { Group } from 'three'
import { import {
getLevelLocalSnappedPosition, getLevelLocalSnappedPosition,
resolveAlignedFloorPlacement, resolveAlignedFloorPlacement,
} from '../shared/floor-placement' } from '../shared/floor-placement'
import { SpawnPreview } from './renderer'
const ROTATION_STEP = Math.PI / 4
function getExistingSpawnIds() { function getExistingSpawnIds() {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
@@ -38,14 +42,17 @@ function getExistingSpawnIds() {
/** /**
* Registry-driven spawn placement tool. Reads `activeLevelId` from useViewer * Registry-driven spawn placement tool. Reads `activeLevelId` from useViewer
* directly (no props), broadcasts placement via store updates + SFX, and * directly (no props), broadcasts placement via store updates + SFX, and
* uses the shared CursorSphere from @pascal-app/editor for visual parity * shows the real spawn model as a translucent placement ghost, and supports
* with legacy placement tools. Snapping is mode-driven (grid + Figma-style * R/T yaw before commit. Snapping is mode-driven (grid + Figma-style
* alignment "lines"), matching the shelf / column build tools. * alignment "lines"), matching the shelf / column build tools.
*/ */
const SpawnTool = () => { const SpawnTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<string | null>(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. // Default spawn for the footprint anchors the alignment solver reads.
const previewNode = useMemo( const previewNode = useMemo(
@@ -56,10 +63,19 @@ const SpawnTool = () => {
useEffect(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
previousSnapRef.current = null 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 } const lastCursorRef: { current: [number, number, number] | null } = { current: null }
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorVisibleRef.current) {
cursorVisibleRef.current = true
setCursorVisible(true)
}
const { position, guides } = resolveAlignedFloorPlacement({ const { position, guides } = resolveAlignedFloorPlacement({
node: previewNode, node: previewNode,
rawX: event.localPosition[0], rawX: event.localPosition[0],
@@ -75,11 +91,16 @@ const SpawnTool = () => {
const visualPosition = getFloorStackPreviewPosition({ const visualPosition = getFloorStackPreviewPosition({
node: previewNode, node: previewNode,
position, position,
rotation: 0, rotation: rotationRef.current,
levelId: activeLevelId, levelId: activeLevelId,
}) })
cursorRef.current?.position.set(...visualPosition) cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position lastCursorRef.current = position
usePlacementPreview.getState().set({
...previewNode,
position,
rotation: rotationRef.current,
})
const nextSnapKey = movementSfxStepKey({ const nextSnapKey = movementSfxStepKey({
coords: [position[0], position[2]], coords: [position[0], position[2]],
@@ -111,12 +132,12 @@ const SpawnTool = () => {
...live, ...live,
parentId: activeLevelId, parentId: activeLevelId,
position: next, position: next,
rotation: 0, rotation: rotationRef.current,
}) })
useScene.getState().updateNode(existingSpawnId, { useScene.getState().updateNode(existingSpawnId, {
parentId: activeLevelId, parentId: activeLevelId,
position: next, position: next,
rotation: 0, rotation: rotationRef.current,
...resolveSupportSlabPatch(effectiveSpawn, useScene.getState().nodes), ...resolveSupportSlabPatch(effectiveSpawn, useScene.getState().nodes),
}) })
if (duplicates.length > 0) { if (duplicates.length > 0) {
@@ -127,7 +148,7 @@ const SpawnTool = () => {
const spawn = SpawnNode.parse({ const spawn = SpawnNode.parse({
name: 'Spawn Point', name: 'Spawn Point',
position: next, position: next,
rotation: 0, rotation: rotationRef.current,
parentId: activeLevelId, parentId: activeLevelId,
}) })
const committedSpawn = SpawnNode.parse({ const committedSpawn = SpawnNode.parse({
@@ -142,23 +163,63 @@ const SpawnTool = () => {
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
useEditor.getState().setTool(null) useEditor.getState().setTool(null)
useEditor.getState().setMode('select') 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:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
window.addEventListener('keydown', onKeyDown, true)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
window.removeEventListener('keydown', onKeyDown, true)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
} }
}, [activeLevelId, previewNode]) }, [activeLevelId, previewNode])
if (!activeLevelId) return null 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 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`). 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 ## Emitting
Renderers emit via `useNodeEvents` — never call `emitter.emit` directly in a renderer: Renderers emit via `useNodeEvents` — never call `emitter.emit` directly in a renderer: