diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx index 8c9018be..7d73c926 100644 --- a/apps/editor/components/tools/item/move-tool.tsx +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -24,13 +24,28 @@ function getInitialState(node: { function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { const draftNode = useDraftNode() + const meta = (typeof movingNode.metadata === 'object' && movingNode.metadata !== null) + ? movingNode.metadata as Record + : {} + const isNew = !!meta.isNew + const cursor = usePlacementCoordinator({ asset: movingNode.asset, draftNode, - initialState: getInitialState(movingNode), + // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft + initialState: isNew ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } : getInitialState(movingNode), initDraft: (gridPosition) => { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) + if (isNew) { + // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. + // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. + gridPosition.copy(new Vector3(...movingNode.position)) + if (!movingNode.asset.attachTo) { + draftNode.create(gridPosition, movingNode.asset, movingNode.rotation) + } + } else { + draftNode.adopt(movingNode) + gridPosition.copy(new Vector3(...movingNode.position)) + } }, onCommitted: () => { sfxEmitter.emit('sfx:item-place') diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index e9360923..655fc19d 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -1,8 +1,8 @@ 'use client' -import { type AnyNode, type ItemNode, useScene } from '@pascal-app/core' +import { type AnyNode, ItemNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Move, Trash2, X } from 'lucide-react' +import { Copy, Move, Trash2, X } from 'lucide-react' import Image from 'next/image' import { useCallback } from 'react' import useEditor from '@/store/use-editor' @@ -51,6 +51,24 @@ export function ItemPanel() { } }, [node, setMovingNode, setSelection]) + const handleDuplicate = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + // Create a proto node (not added to scene) as a carrier for asset/position info. + // MoveItemContent detects metadata.isNew and uses draftNode.create() so ghost rendering works correctly. + const proto = ItemNode.parse({ + position: [...node.position] as [number, number, number], + rotation: [...node.rotation] as [number, number, number], + name: node.name, + asset: node.asset, + parentId: node.parentId, + side: node.side, + metadata: { isNew: true }, + }) + setMovingNode(proto) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + const handleDelete = useCallback(() => { if (!selectedId) return sfxEmitter.emit('sfx:item-delete') @@ -193,6 +211,14 @@ export function ItemPanel() { Move +