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:
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user