diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx
index 88962699..20ac4b20 100644
--- a/packages/editor/src/components/editor/floating-action-menu.tsx
+++ b/packages/editor/src/components/editor/floating-action-menu.tsx
@@ -11,6 +11,7 @@ import {
generateId,
ItemNode,
isRegistrySelectable,
+ nodeRegistry,
RoofSegmentNode,
type SlabNode,
SpawnNode,
@@ -201,7 +202,11 @@ export function FloatingActionMenu() {
node.type === 'roof' ||
node.type === 'roof-segment' ||
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)
}
@@ -295,6 +300,16 @@ export function FloatingActionMenu() {
} else if (node.type === 'spawn') {
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) {
console.error('Failed to parse duplicate', error)
useScene.temporal.getState().resume()
@@ -337,6 +352,19 @@ export function FloatingActionMenu() {
}
// 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 (
duplicate.type === 'item' ||
@@ -348,7 +376,10 @@ export function FloatingActionMenu() {
duplicate.type === 'door' ||
duplicate.type === 'roof-segment' ||
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)
} else if (duplicate.type === 'stair') {
diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx
index 5b017ed2..193df651 100644
--- a/packages/editor/src/components/tools/item/move-tool.tsx
+++ b/packages/editor/src/components/tools/item/move-tool.tsx
@@ -16,6 +16,7 @@ import type {
WallNode,
WindowNode,
} from '@pascal-app/core'
+import { nodeRegistry } from '@pascal-app/core'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
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 { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
+import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
@@ -117,5 +119,13 @@ export const MoveTool: React.FC<{
return
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return
+ // 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
+ }
return
}
diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
new file mode 100644
index 00000000..dc8f438a
--- /dev/null
+++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
@@ -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
+ * `` 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)
+ } 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),
+ 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
+}