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 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]
/** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */ /** Move-mode sessions keep the grabbed item offset from the first surface hit
preserveFloorDragOffset?: boolean * (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 { 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) 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) => { const applyTransition = (result: TransitionResult) => {
// Alignment guides are floor-only; clear them when the cursor moves // Alignment guides are floor-only; clear them when the cursor moves
// onto a wall / ceiling / item surface (only those paths call this). // onto a wall / ceiling / item surface (only those paths call this).
@@ -528,11 +536,95 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Init draft ---- // ---- Init draft ----
configRef.current.initDraft(gridPosition.current) configRef.current.initDraft(gridPosition.current)
const preserveFloorDragOffset = const preserveDragOffset = configRef.current.preserveDragOffset === true
configRef.current.preserveFloorDragOffset === true && const relativeFloorStart =
placementState.current.surface === 'floor' && preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
!asset.attachTo ? gridPosition.current.clone()
const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null : 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 // Sync cursor to the draft mesh's world position and rotation
if (draftNode.current) { if (draftNode.current) {
@@ -656,23 +748,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
detachItemSurfaceToFloor(event as unknown as ItemEvent) detachItemSurfaceToFloor(event as unknown as ItemEvent)
} }
const floorEvent = const floorEvent = applyFloorGrabOffset(event)
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
lastRawPos.current.set( lastRawPos.current.set(
floorEvent.localPosition[0], floorEvent.localPosition[0],
@@ -865,7 +941,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return 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 if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -962,6 +1068,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onWallLeave = (event: WallEvent) => { const onWallLeave = (event: WallEvent) => {
wallDragAnchor = null
const result = wallStrategy.leave(getContext()) const result = wallStrategy.leave(getContext())
if (!result) return if (!result) return
@@ -1133,6 +1240,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Item Surface Handlers ---- // ---- Item Surface Handlers ----
const detachItemSurfaceToFloor = (event: ItemEvent) => { const detachItemSurfaceToFloor = (event: ItemEvent) => {
hostSurfaceDragAnchor = null
const buildingLocalPoint = worldToBuildingLocal( const buildingLocalPoint = worldToBuildingLocal(
event.position[0], event.position[0],
event.position[1], event.position[1],
@@ -1233,8 +1341,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
lastRawPos.current.set(event.position[0], event.position[1], event.position[2]) const surfaceWorld =
const result = itemSurfaceStrategy.move(ctx, event) 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 if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -1428,8 +1545,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) let ceilingMoveEvent = event
const result = ceilingStrategy.move(getContext(), 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 if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -1493,6 +1636,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onCeilingLeave = (event: CeilingEvent) => { const onCeilingLeave = (event: CeilingEvent) => {
ceilingDragAnchor = null
const result = ceilingStrategy.leave(getContext()) const result = ceilingStrategy.leave(getContext())
if (!result) return if (!result) return
@@ -1566,7 +1710,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
return 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 if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -1905,6 +2054,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current const draft = draftNode.current
if (!(draft && viewerLevelId) || asset.attachTo) return if (!(draft && viewerLevelId) || asset.attachTo) return
if (draft.parentId === viewerLevelId) 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 draft.parentId = viewerLevelId
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId }) useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
}, [viewerLevelId, draftNode, asset]) }, [viewerLevelId, draftNode, asset])
+28 -2
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import type { ItemNode } from '@pascal-app/core' import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
import { import {
type PlacementState, type PlacementState,
triggerSFX, triggerSFX,
@@ -67,6 +67,32 @@ function getInitialState(node: ItemNode): PlacementState {
shelfId: 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 { return {
surface: 'floor', surface: 'floor',
wallId: null, wallId: null,
@@ -102,7 +128,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
: getInitialState(node), : getInitialState(node),
// Preserve the original item's scale so Y-position calculations use the correct height. // Preserve the original item's scale so Y-position calculations use the correct height.
defaultScale: isNew ? node.scale : undefined, defaultScale: isNew ? node.scale : undefined,
preserveFloorDragOffset: true, preserveDragOffset: true,
initDraft: (gridPosition) => { initDraft: (gridPosition) => {
if (isNew) { if (isNew) {
// Duplicate: floor items get a draft immediately; wall/ceiling // Duplicate: floor items get a draft immediately; wall/ceiling