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
+28 -2
View File
@@ -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