duplicate item

This commit is contained in:
wass08
2026-02-20 18:06:59 +09:00
parent 96865a8ce7
commit 33c81045bb
2 changed files with 46 additions and 5 deletions
@@ -24,13 +24,28 @@ function getInitialState(node: {
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
const draftNode = useDraftNode() const draftNode = useDraftNode()
const meta = (typeof movingNode.metadata === 'object' && movingNode.metadata !== null)
? movingNode.metadata as Record<string, unknown>
: {}
const isNew = !!meta.isNew
const cursor = usePlacementCoordinator({ const cursor = usePlacementCoordinator({
asset: movingNode.asset, asset: movingNode.asset,
draftNode, 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) => { initDraft: (gridPosition) => {
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) draftNode.adopt(movingNode)
gridPosition.copy(new Vector3(...movingNode.position)) gridPosition.copy(new Vector3(...movingNode.position))
}
}, },
onCommitted: () => { onCommitted: () => {
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
@@ -1,8 +1,8 @@
'use client' '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 { 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 Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
@@ -51,6 +51,24 @@ export function ItemPanel() {
} }
}, [node, setMovingNode, setSelection]) }, [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(() => { const handleDelete = useCallback(() => {
if (!selectedId) return if (!selectedId) return
sfxEmitter.emit('sfx:item-delete') sfxEmitter.emit('sfx:item-delete')
@@ -193,6 +211,14 @@ export function ItemPanel() {
<Move className="h-3.5 w-3.5" /> <Move className="h-3.5 w-3.5" />
<span>Move</span> <span>Move</span>
</button> </button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
<span>Duplicate</span>
</button>
<button <button
type="button" type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer" className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"