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,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