Move + duplicate for registry kinds via MoveRegistryNodeTool

Shelf had the floating action menu (move/delete) showing thanks to
the previous registry-driven selection commit, but clicking move
did nothing and duplicate silently failed. Two hardcoded chains:

1) FloatingActionMenu.handleMove guarded `setMovingNode` behind a
   hardcoded `node.type === 'item' || ... || node.type === 'spawn'`
   chain. Added `|| isRegistrySelectable(node.type)` so any
   registry kind triggers the move flow.

2) MoveTool dispatched per-kind components (MoveItemContent,
   MoveColumnTool, MoveWallTool, ...). The default fallback
   mounted MoveItemContent, which assumes the node is an ItemNode
   with asset/scale/metadata — crashes for shelf. Added a generic
   MoveRegistryNodeTool (kind-agnostic clone of MoveColumnTool):
   pure position+rotation drag with grid snap, re-parses orphan
   re-creates via `nodeRegistry.get(kind).schema.parse(...)`.
   MoveTool dispatches to it for any `nodeRegistry.has(movingNode.type)`
   before the MoveItemContent fallback.

3) FloatingActionMenu.handleDuplicate had a hardcoded
   `node.type === 'door' ? DoorNode.parse(...) : ...` chain. Added
   a registry-driven fallback after it:
   `const def = nodeRegistry.get(node.type); duplicate = def.schema.parse(duplicateInfo)`.
   Then the createNode + setMovingNode branches also augment with
   `nodeRegistry.has(duplicate.type)` so the new shelf gets
   created in the scene and handed off to the move tool for
   placement.

After this:
- Click shelf → move icon in floating menu → cursor follows mouse,
  click to place at new position.
- Click shelf → duplicate icon → new shelf appears, offset by (1,0,1),
  handed to move tool so the user can position it.

Phase 4 will collapse MoveRegistryNodeTool with the per-kind movers
once they all reduce to the same position+rotation shape, and read
`capabilities.movable` to gate handleMove instead of the OR chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-14 15:39:36 -04:00
co-authored by Claude Opus 4.7
parent 6d97a87547
commit a090c22f42
3 changed files with 175 additions and 2 deletions
@@ -11,6 +11,7 @@ import {
generateId, generateId,
ItemNode, ItemNode,
isRegistrySelectable, isRegistrySelectable,
nodeRegistry,
RoofSegmentNode, RoofSegmentNode,
type SlabNode, type SlabNode,
SpawnNode, SpawnNode,
@@ -201,7 +202,11 @@ export function FloatingActionMenu() {
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' || node.type === 'roof-segment' ||
node.type === 'stair' || node.type === 'stair' ||
node.type === 'stair-segment' node.type === 'stair-segment' ||
// Registry-driven kinds default to movable; MoveTool dispatches them
// to MoveRegistryNodeTool. Phase 4 reads `capabilities.movable` to
// gate this instead of the unconditional OR.
isRegistrySelectable(node.type)
) { ) {
setMovingNode(node as any) setMovingNode(node as any)
} }
@@ -295,6 +300,16 @@ export function FloatingActionMenu() {
} else if (node.type === 'spawn') { } else if (node.type === 'spawn') {
duplicate = SpawnNode.parse(duplicateInfo) duplicate = SpawnNode.parse(duplicateInfo)
} }
// Registry-driven fallback: any kind with a NodeDefinition can be
// duplicated through its schema's parse(). Future built-in kinds
// get duplicate for free.
if (!duplicate) {
const def = nodeRegistry.get(node.type)
if (def) {
duplicate = def.schema.parse(duplicateInfo) as AnyNode
}
}
} catch (error) { } catch (error) {
console.error('Failed to parse duplicate', error) console.error('Failed to parse duplicate', error)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -337,6 +352,19 @@ export function FloatingActionMenu() {
} }
// Duplicate children for stair nodes // Duplicate children for stair nodes
} else if (nodeRegistry.has(duplicate.type)) {
// Registry-driven kinds: offset the position slightly so the
// duplicate doesn't overlap exactly, then create + hand to the
// move tool. Mirrors the roof-segment / stair-segment behavior.
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
const pos = (duplicate as { position: [number, number, number] }).position
;(duplicate as { position: [number, number, number] }).position = [
pos[0] + 1,
pos[1],
pos[2] + 1,
]
}
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} }
if ( if (
duplicate.type === 'item' || duplicate.type === 'item' ||
@@ -348,7 +376,10 @@ export function FloatingActionMenu() {
duplicate.type === 'door' || duplicate.type === 'door' ||
duplicate.type === 'roof-segment' || duplicate.type === 'roof-segment' ||
duplicate.type === 'spawn' || duplicate.type === 'spawn' ||
duplicate.type === 'stair-segment' duplicate.type === 'stair-segment' ||
// Registry-driven kinds get picked up by MoveTool's generic
// fallback (MoveRegistryNodeTool) so the user can reposition.
nodeRegistry.has(duplicate.type)
) { ) {
setMovingNode(duplicate as any) setMovingNode(duplicate as any)
} else if (duplicate.type === 'stair') { } else if (duplicate.type === 'stair') {
@@ -16,6 +16,7 @@ import type {
WallNode, WallNode,
WindowNode, WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { nodeRegistry } from '@pascal-app/core'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -25,6 +26,7 @@ import { MoveColumnTool } from '../column/move-column-tool'
import { MoveDoorTool } from '../door/move-door-tool' import { MoveDoorTool } from '../door/move-door-tool'
import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool' import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveSpawnTool } from '../spawn/move-spawn-tool' import { MoveSpawnTool } from '../spawn/move-spawn-tool'
@@ -117,5 +119,13 @@ export const MoveTool: React.FC<{
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} /> return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} /> return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
// Registry-driven kinds (any NodeDefinition with `capabilities.movable`)
// get a generic position+rotation mover. Phase 4 may consolidate this
// with the per-kind movers above when they all collapse to the same
// shape. Must come BEFORE the MoveItemContent fallback because that
// assumes the node is an ItemNode.
if (nodeRegistry.has(movingNode.type)) {
return <MoveRegistryNodeTool node={movingNode} />
}
return <MoveItemContent movingNode={movingNode as ItemNode} /> return <MoveItemContent movingNode={movingNode as ItemNode} />
} }
@@ -0,0 +1,132 @@
'use client'
import '../../../three-types'
import {
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
nodeRegistry,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
/**
* Generic move tool for any registry-backed kind. Mirrors MoveColumnTool's
* shape but parses re-creation through `nodeRegistry.get(kind).schema`
* instead of a hardcoded schema reference. Used as the fallback in
* `<MoveTool>` for kinds without a bespoke mover.
*
* Phase 4 may consolidate this with the per-kind movers if they all
* collapse to the same position+rotation shape — until then they live
* side by side.
*/
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const initialPosition: [number, number, number] =
'position' in node && Array.isArray((node as { position?: unknown }).position)
? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
: [0, 0, 0]
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(initialPosition)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
const applyPreview = (position: [number, number, number]) => {
setPreviewPosition(position)
useLiveTransforms.getState().set(node.id, {
position,
rotation: 'rotation' in node ? ((node as { rotation?: number }).rotation ?? 0) : 0,
})
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
}
const onGridMove = (event: GridEvent) => {
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
}
const onGridClick = (event: GridEvent) => {
const position: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
const nodeId = node.id
if (nodeId && useScene.getState().nodes[nodeId]) {
// Existing node — just update its position.
committed = true
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { position } as Partial<AnyNode>)
} else if (node.parentId) {
// Orphan re-create path — re-parse the node fresh via the kind's
// schema in the registry. Mirrors MoveColumnTool's behavior for
// registry-supplied kinds.
const def = nodeRegistry.get(node.type)
if (def) {
const reparsed = def.schema.parse({
...(node as Record<string, unknown>),
id: undefined,
metadata: {},
position,
})
committed = true
useScene.temporal.getState().resume()
useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
}
}
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
sceneRegistry.nodes
.get(node.id)
?.position.set(initialPosition[0], initialPosition[1], initialPosition[2])
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
sceneRegistry.nodes
.get(node.id)
?.position.set(initialPosition[0], initialPosition[1], initialPosition[2])
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, initialPosition, node])
// Cursor color from the def's presentation if available, else a neutral fallback.
const def = nodeRegistry.get(node.type)
const cursorColor = def?.presentation?.icon.kind === 'iconify' ? '#a78bfa' : '#a78bfa'
return <CursorSphere color={cursorColor} height={2.5} position={previewPosition} />
}