From a090c22f4281f3446a2eb629cade1df8b020ea2a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 14 May 2026 15:39:36 -0400 Subject: [PATCH] Move + duplicate for registry kinds via MoveRegistryNodeTool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../editor/floating-action-menu.tsx | 35 ++++- .../src/components/tools/item/move-tool.tsx | 10 ++ .../registry/move-registry-node-tool.tsx | 132 ++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/components/tools/registry/move-registry-node-tool.tsx 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 +}