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:
co-authored by
Claude Opus 4.7
parent
6d97a87547
commit
a090c22f42
@@ -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} />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user