fix(editor): preserve grab offset when moving items (no teleport-to-cursor) (#416)

* fix(editor): preserve grab offset when moving items (no teleport-to-cursor)

Grabbing a placed item to move it (move-cross handle or the Move
affordance) snapped the item's origin to the cursor instead of keeping
the offset between the grab point and the origin.

- Floor (regression): the grab-offset rewrite patched event.localPosition,
  but floorStrategy.move reads event.position (the world cursor) for its
  world-grid snap on the default non-Shift path -- a read added after the
  offset fix landed. Now also correct event.position, derived from the
  corrected building-local point via buildingLocalToWorld.
- Wall / ceiling / item-surface / shelf: the shared surface strategies
  snapped the origin straight to the cursor with no grab offset. Added
  per-surface grab anchors (start + (raw - anchor)) seeded on the first
  move per host and reset on leave/detach; host-resting items start in
  their surface, and the level-reparent effect skips intentionally-hosted
  drafts so the dragged mesh stays on its host.

Rename preserveFloorDragOffset -> preserveDragOffset (it now governs all
surfaces). 2D move paths already preserved the grab offset, so no sibling
change is needed -- this brings 3D into parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(editor): tidy grab-offset floor/wall paths (quality pass)

Behaviour-preserving readability pass before shipping:
- Extract the floor grab-offset IIFE into a named `applyFloorGrabOffset`
  helper (parity with `resolveHostSurfaceWorld`); drop redundant tuple
  casts via the return annotation and tighten the comment.
- Wall move: always set `position` (raw hit when the wall mesh is absent)
  instead of a conditional object spread.

A reviewed dedup of the three per-surface anchors was deliberately NOT
taken — the wall (X/Y) / ceiling (X/Z) / host-surface frames are
heterogeneous enough that unifying them adds more indirection than it
removes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-16 18:53:00 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8eab9979ab
commit 4b7e0eca76
2 changed files with 217 additions and 32 deletions
@@ -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])