163 lines
5.0 KiB
TypeScript

'use client'
import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
import {
type PlacementState,
triggerSFX,
useDraftNode,
useEditor,
usePlacementCoordinator,
} from '@pascal-app/editor'
import { Vector3 } from 'three'
/**
* Phase 5 Stage D — item's registry-driven 3D move affordance.
*
* Replaces the legacy `MoveItemContent` in `editor/src/components/tools/
* item/move-tool.tsx`. Behaviour is identical: it adopts the moving node
* (or creates a draft for duplicates flagged `isNew`), runs the placement
* coordinator with surface strategies for floor / wall / ceiling / item-
* surface, and commits via `useScene.updateNode` on click.
*
* Registered via `def.affordanceTools.move`. The editor's
* `MoveTool` dispatcher picks this up through `getRegistryAffordance
* Tool('item', 'move')` before its legacy chain reaches `<MoveItemContent>`
* — so the legacy fallback can now go away.
*
* Closes the 2D ↔ 3D coexistence bugs from last session: when both
* paths mounted, the legacy mover's `destroy()` would clobber the 2D
* commit; with this tool owning the move, only one path is alive at a
* time.
*
* Placement primitives (`useDraftNode`, `usePlacementCoordinator`,
* `PlacementState`) are re-exported from `@pascal-app/editor` — same
* hooks the legacy code used. When `ItemTool` (item placement, not
* move) also ports to `def.tool`, the primitives can be inlined here
* and dropped from editor.
*/
function getInitialState(node: ItemNode): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
if (node.roofSegmentId) {
return {
surface: 'roof-wall',
wallId: null,
roofSegmentId: node.roofSegmentId,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
}
return {
surface: 'wall',
wallId: node.parentId,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
}
if (attachTo === 'ceiling') {
return {
surface: 'ceiling',
wallId: null,
roofSegmentId: null,
ceilingId: node.parentId,
surfaceItemId: null,
shelfId: null,
}
}
// A floor item resting on a host surface (table / counter / shelf) starts in
// that surface, not 'floor', so the first pointer move runs the surface move
// handler — which preserves the grab offset — instead of a fresh `enter()`
// that snaps the item's origin under the cursor. Without this the item
// teleports the instant it's grabbed.
const parent = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : undefined
if (parent?.type === 'item') {
return {
surface: 'item-surface',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: node.parentId,
shelfId: null,
}
}
if (parent?.type === 'shelf') {
return {
surface: 'shelf-surface',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: node.parentId,
}
}
return {
surface: 'floor',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
}
export function MoveItemTool({ node }: { node: ItemNode }) {
const draftNode = useDraftNode()
const meta =
typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
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
? {
surface: 'floor',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
: getInitialState(node),
// Preserve the original item's scale so Y-position calculations use the correct height.
defaultScale: isNew ? node.scale : undefined,
preserveDragOffset: true,
initDraft: (gridPosition) => {
if (isNew) {
// Duplicate: floor items get a draft immediately; wall/ceiling
// 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, node.slots)
}
} else {
draftNode.adopt(node)
gridPosition.copy(new Vector3(...node.position))
}
},
onCommitted: () => {
triggerSFX('sfx:item-place')
useEditor.getState().setMovingNode(null)
return false
},
onCancel: () => {
draftNode.destroy()
useEditor.getState().setMovingNode(null)
},
})
return <>{cursor}</>
}
export default MoveItemTool