fix(item): duplicated items keep their painted slot materials

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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-19 11:17:30 -04:00
co-authored by Claude Opus 4.8
parent bf9246cd36
commit 115a142d83
3 changed files with 28 additions and 4 deletions
@@ -27,12 +27,14 @@ export interface DraftNodeHandle {
readonly current: ItemNode | null readonly current: ItemNode | null
/** Whether the current draft was adopted (move mode) vs created (create mode) */ /** Whether the current draft was adopted (move mode) vs created (create mode) */
readonly isAdopted: boolean 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: ( create: (
gridPosition: Vector3, gridPosition: Vector3,
asset: AssetInput, asset: AssetInput,
rotation?: [number, number, number], rotation?: [number, number, number],
scale?: [number, number, number], scale?: [number, number, number],
slots?: ItemNode['slots'],
) => ItemNode | null ) => ItemNode | null
/** Take ownership of an existing scene node as the draft (for move mode). */ /** Take ownership of an existing scene node as the draft (for move mode). */
adopt: (node: ItemNode) => void adopt: (node: ItemNode) => void
@@ -61,6 +63,7 @@ export function useDraftNode(): DraftNodeHandle {
asset: AssetInput, asset: AssetInput,
rotation?: [number, number, number], rotation?: [number, number, number],
scale?: [number, number, number], scale?: [number, number, number],
slots?: ItemNode['slots'],
): ItemNode | null => { ): ItemNode | null => {
const currentLevelId = useViewer.getState().selection.levelId const currentLevelId = useViewer.getState().selection.levelId
if (!currentLevelId) return null if (!currentLevelId) return null
@@ -73,6 +76,7 @@ export function useDraftNode(): DraftNodeHandle {
asset, asset,
parentId: currentLevelId, parentId: currentLevelId,
metadata: { isTransient: true }, metadata: { isTransient: true },
...(slots ? { slots } : {}),
}) })
useScene.getState().createNode(node, currentLevelId) useScene.getState().createNode(node, currentLevelId)
@@ -180,6 +184,8 @@ export function useDraftNode(): DraftNodeHandle {
rotation: updateProps.rotation ?? draft.rotation, rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale, scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side, 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 // Roof host — see the move-mode commit above for why this must be
// forwarded explicitly. // forwarded explicitly.
roofSegmentId: updateProps.roofSegmentId, roofSegmentId: updateProps.roofSegmentId,
@@ -192,6 +192,9 @@ export interface PlacementCoordinatorConfig {
initialState?: PlacementState initialState?: PlacementState
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
defaultScale?: [number, number, number] 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 /** Move-mode sessions keep the grabbed item offset from the first surface hit
* (floor / wall / ceiling / item-surface / shelf) instead of snapping the * (floor / wall / ceiling / item-surface / shelf) instead of snapping the
* item's origin under the cursor. */ * item's origin under the cursor. */
@@ -512,7 +515,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
0, 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 const draft = draftNode.current
if (draft) { if (draft) {
@@ -857,7 +866,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draftNode.commit(result.nodeUpdate) draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { 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( const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo), getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
asset.attachTo, asset.attachTo,
+4 -1
View File
@@ -115,6 +115,9 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
const cursor = usePlacementCoordinator({ const cursor = usePlacementCoordinator({
asset: node.asset, asset: node.asset,
draftNode, 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. // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft.
initialState: isNew initialState: isNew
? { ? {
@@ -135,7 +138,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
// items are created lazily on surface entry. // items are created lazily on surface entry.
gridPosition.copy(new Vector3(...node.position)) gridPosition.copy(new Vector3(...node.position))
if (!node.asset.attachTo) { 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 { } else {
draftNode.adopt(node) draftNode.adopt(node)