From 115a142d839f6075c3f98a9a474b4a99f2391b0f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 19 Jun 2026 11:17:30 -0400 Subject: [PATCH] fix(item): duplicated items keep their painted slot materials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicating an item drops to the catalog placement flow, which rebuilds the draft from the asset + transform and never carried node.slots — so the copy lost every painted slot override. Thread slots through the draft create path: useDraftNode.create seeds it onto the draft and commit() forwards it to the final node, the placement coordinator passes it to its lazy wall/ceiling draft creates, and the item move tool supplies node.slots for both the floor (direct create) and wall/ceiling (coordinator) duplicate paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/tools/item/use-draft-node.ts | 8 +++++++- .../tools/item/use-placement-coordinator.tsx | 19 +++++++++++++++++-- packages/nodes/src/item/move-tool.tsx | 5 ++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index dfd752fb..a53a47b9 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -27,12 +27,14 @@ export interface DraftNodeHandle { readonly current: ItemNode | null /** Whether the current draft was adopted (move mode) vs created (create mode) */ readonly isAdopted: boolean - /** Create a new draft item at the given position. Returns the created node or null. */ + /** Create a new draft item at the given position. Returns the created node or null. + * `slots` seeds painted slot overrides so duplicates keep their materials. */ create: ( gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number], + slots?: ItemNode['slots'], ) => ItemNode | null /** Take ownership of an existing scene node as the draft (for move mode). */ adopt: (node: ItemNode) => void @@ -61,6 +63,7 @@ export function useDraftNode(): DraftNodeHandle { asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number], + slots?: ItemNode['slots'], ): ItemNode | null => { const currentLevelId = useViewer.getState().selection.levelId if (!currentLevelId) return null @@ -73,6 +76,7 @@ export function useDraftNode(): DraftNodeHandle { asset, parentId: currentLevelId, metadata: { isTransient: true }, + ...(slots ? { slots } : {}), }) useScene.getState().createNode(node, currentLevelId) @@ -180,6 +184,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: updateProps.rotation ?? draft.rotation, scale: updateProps.scale ?? draft.scale, side: updateProps.side ?? draft.side, + // Carry painted slot overrides so a duplicated item keeps its materials. + ...(draft.slots ? { slots: draft.slots } : {}), // Roof host — see the move-mode commit above for why this must be // forwarded explicitly. roofSegmentId: updateProps.roofSegmentId, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index dbe35414..4384da2a 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -192,6 +192,9 @@ export interface PlacementCoordinatorConfig { initialState?: PlacementState /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ defaultScale?: [number, number, number] + /** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling + * duplicates) so the duplicate keeps its materials. */ + slots?: ItemNode['slots'] /** Move-mode sessions keep the grabbed item offset from the first surface hit * (floor / wall / ceiling / item-surface / shelf) instead of snapping the * item's origin under the cursor. */ @@ -512,7 +515,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea 0, ] - draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale) + draftNode.create( + gridPosition.current, + asset, + initRotation, + configRef.current.defaultScale, + configRef.current.slots, + ) const draft = draftNode.current if (draft) { @@ -857,7 +866,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { - draftNode.create(gridPosition.current, asset, currentRotation) + draftNode.create( + gridPosition.current, + asset, + currentRotation, + configRef.current.defaultScale, + configRef.current.slots, + ) const previewBounds = expandBoundsToGrid( getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo), asset.attachTo, diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index f32c7ce4..852cd6c4 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -115,6 +115,9 @@ export function MoveItemTool({ node }: { node: ItemNode }) { const cursor = usePlacementCoordinator({ asset: node.asset, draftNode, + // Carry painted slot overrides onto the duplicate's draft (wall/ceiling + // items create their draft lazily inside the coordinator). + slots: node.slots, // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft. initialState: isNew ? { @@ -135,7 +138,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { // items are created lazily on surface entry. gridPosition.copy(new Vector3(...node.position)) if (!node.asset.attachTo) { - draftNode.create(gridPosition, node.asset, node.rotation, node.scale) + draftNode.create(gridPosition, node.asset, node.rotation, node.scale, node.slots) } } else { draftNode.adopt(node)