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 e0dac1b92..dbe354146 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -192,8 +192,10 @@ 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] - /** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */ - preserveFloorDragOffset?: boolean + /** 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. */ + preserveDragOffset?: boolean } export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { @@ -461,6 +463,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z) } + const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => { + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z) + } + const applyTransition = (result: TransitionResult) => { // Alignment guides are floor-only; clear them when the cursor moves // onto a wall / ceiling / item surface (only those paths call this). @@ -528,11 +536,95 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) - const preserveFloorDragOffset = - configRef.current.preserveFloorDragOffset === true && - placementState.current.surface === 'floor' && - !asset.attachTo - const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null + const preserveDragOffset = configRef.current.preserveDragOffset === true + const relativeFloorStart = + preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo + ? gridPosition.current.clone() + : null + + // Grab anchors for the non-floor surfaces. Each captures the cursor's + // surface-local position and the item's stored position on the first move + // for a given host, then offsets every later move by + // `start + (raw - anchor)` — mirroring the floor path and the door/window + // move tools so the item tracks the grabbed point instead of teleporting + // its origin under the cursor. Reset on host change (re-seeded from the + // item's then-current position) by the surface leave handlers. + let wallDragAnchor: { + wallId: string + rawX: number + rawY: number + startX: number + startY: number + } | null = null + let ceilingDragAnchor: { + ceilingId: string + rawX: number + rawZ: number + startX: number + startZ: number + } | null = null + let hostSurfaceDragAnchor: { + hostId: string + rawX: number + rawZ: number + startX: number + startZ: number + } | null = null + + // Item-surface / shelf moves snap from a WORLD cursor hit projected into the + // host's local frame. Re-project the offset-corrected local point back to + // world so the strategy (which re-derives both the stored position and the + // visual cursor from `event.position`) stays self-consistent. + const resolveHostSurfaceWorld = ( + hostId: string, + worldPos: readonly [number, number, number], + ): [number, number, number] | null => { + const draft = draftNode.current + const hostMesh = sceneRegistry.nodes.get(hostId) + if (!(preserveDragOffset && draft && hostMesh)) return null + const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2])) + if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) { + hostSurfaceDragAnchor = { + hostId, + rawX: rawLocal.x, + rawZ: rawLocal.z, + startX: draft.position[0], + startZ: draft.position[2], + } + } + const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX) + const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ) + const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ)) + return [world.x, world.y, world.z] + } + + // Floor grab-offset: the item tracks the grabbed point instead of snapping + // its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid + // (`event.position`) on its default path and only reads `event.localPosition` + // under Shift, so both frames must carry the offset; the world point is + // derived from the corrected local one so the two stay consistent. + const applyFloorGrabOffset = (event: GridEvent): GridEvent => { + if (relativeFloorStart === null) return event + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + const anchor = floorDragAnchor ?? [rawX, rawZ] + floorDragAnchor = anchor + const correctedLocal: [number, number, number] = [ + relativeFloorStart.x + (rawX - anchor[0]), + event.localPosition[1], + relativeFloorStart.z + (rawZ - anchor[1]), + ] + const correctedWorld = buildingLocalToWorld( + correctedLocal[0], + correctedLocal[1], + correctedLocal[2], + ) + return { + ...event, + position: [correctedWorld.x, event.position[1], correctedWorld.z], + localPosition: correctedLocal, + } + } // Sync cursor to the draft mesh's world position and rotation if (draftNode.current) { @@ -656,23 +748,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea detachItemSurfaceToFloor(event as unknown as ItemEvent) } - const floorEvent = - relativeFloorStart !== null - ? (() => { - const rawX = event.localPosition[0] - const rawZ = event.localPosition[2] - const anchor = floorDragAnchor ?? [rawX, rawZ] - floorDragAnchor = anchor - return { - ...event, - localPosition: [ - relativeFloorStart.x + (rawX - anchor[0]), - event.localPosition[1], - relativeFloorStart.z + (rawZ - anchor[1]), - ] as [number, number, number], - } - })() - : event + const floorEvent = applyFloorGrabOffset(event) lastRawPos.current.set( floorEvent.localPosition[0], @@ -865,7 +941,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - const result = wallStrategy.move(ctx, event, getActiveValidators()) + let wallMoveEvent = event + if (preserveDragOffset && draftNode.current) { + const rawX = event.localPosition[0] + const rawY = event.localPosition[1] + if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) { + wallDragAnchor = { + wallId: event.node.id, + rawX, + rawY, + startX: draftNode.current.position[0], + startY: draftNode.current.position[1], + } + } + const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX) + const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY) + const wallMesh = sceneRegistry.nodes.get(event.node.id) + // Derive the world cursor from the corrected wall-local point so the + // visual cursor (world) and the stored position (wall-local) agree; if + // the wall mesh is somehow absent, keep the raw world hit unchanged. + const correctedWorld = wallMesh + ? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2])) + : null + wallMoveEvent = { + ...event, + localPosition: [correctedX, correctedY, event.localPosition[2]], + position: correctedWorld + ? [correctedWorld.x, correctedWorld.y, correctedWorld.z] + : event.position, + } + } + const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators()) if (!result) return event.stopPropagation() @@ -962,6 +1068,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallLeave = (event: WallEvent) => { + wallDragAnchor = null const result = wallStrategy.leave(getContext()) if (!result) return @@ -1133,6 +1240,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Item Surface Handlers ---- const detachItemSurfaceToFloor = (event: ItemEvent) => { + hostSurfaceDragAnchor = null const buildingLocalPoint = worldToBuildingLocal( event.position[0], event.position[1], @@ -1233,8 +1341,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - lastRawPos.current.set(event.position[0], event.position[1], event.position[2]) - const result = itemSurfaceStrategy.move(ctx, event) + const surfaceWorld = + ctx.state.surfaceItemId !== null + ? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position) + : null + const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event + lastRawPos.current.set( + itemMoveEvent.position[0], + itemMoveEvent.position[1], + itemMoveEvent.position[2], + ) + const result = itemSurfaceStrategy.move(ctx, itemMoveEvent) if (!result) return event.stopPropagation() @@ -1428,8 +1545,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) - const result = ceilingStrategy.move(getContext(), event) + let ceilingMoveEvent = event + if (preserveDragOffset && draftNode.current) { + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) { + ceilingDragAnchor = { + ceilingId: event.node.id, + rawX, + rawZ, + startX: draftNode.current.position[0], + startZ: draftNode.current.position[2], + } + } + ceilingMoveEvent = { + ...event, + localPosition: [ + ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX), + event.localPosition[1], + ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ), + ], + } + } + lastRawPos.current.set( + ceilingMoveEvent.localPosition[0], + ceilingMoveEvent.localPosition[1], + ceilingMoveEvent.localPosition[2], + ) + const result = ceilingStrategy.move(getContext(), ceilingMoveEvent) if (!result) return event.stopPropagation() @@ -1493,6 +1636,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingLeave = (event: CeilingEvent) => { + ceilingDragAnchor = null const result = ceilingStrategy.leave(getContext()) if (!result) return @@ -1566,7 +1710,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } return } - const result = shelfSurfaceStrategy.move(ctx, event) + const shelfWorld = + ctx.state.shelfId !== null + ? resolveHostSurfaceWorld(ctx.state.shelfId, event.position) + : null + const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event + const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent) if (!result) return event.stopPropagation() @@ -1905,6 +2054,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current if (!(draft && viewerLevelId) || asset.attachTo) return if (draft.parentId === viewerLevelId) return + // A non-attach item resting on a host surface (table / counter / shelf) is + // intentionally parented to that host while it's moved — the surface move + // handlers keep it hosted and the commit writes the host parent back. Only + // free floor items get re-homed to the level here; yanking a hosted item + // onto the level would re-interpret its host-local position in level space + // and float the dragged mesh off the host toward the building origin. + const draftParent = draft.parentId + ? useScene.getState().nodes[draft.parentId as AnyNodeId] + : undefined + if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return draft.parentId = viewerLevelId useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId }) }, [viewerLevelId, draftNode, asset]) diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index 1e7cfd3b6..f32c7ce43 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ItemNode } from '@pascal-app/core' +import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core' import { type PlacementState, triggerSFX, @@ -67,6 +67,32 @@ function getInitialState(node: ItemNode): PlacementState { 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, @@ -102,7 +128,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { : getInitialState(node), // Preserve the original item's scale so Y-position calculations use the correct height. defaultScale: isNew ? node.scale : undefined, - preserveFloorDragOffset: true, + preserveDragOffset: true, initDraft: (gridPosition) => { if (isNew) { // Duplicate: floor items get a draft immediately; wall/ceiling