Skip to content

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

Merged
Aymericr merged 2 commits into
mainfrom
fix/move-grab-offset-teleport
Jun 16, 2026
Merged

fix(editor): preserve grab offset when moving items (no teleport-to-cursor)#416
Aymericr merged 2 commits into
mainfrom
fix/move-grab-offset-teleport

Conversation

@Aymericr

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the move-grab "teleport": grabbing an already-placed item to move it (the move-cross handle, or the Move affordance) snapped the item's origin to the cursor instead of preserving the offset between where you grabbed it and the item's origin. Grabbing a sofa by its left edge and nudging it shouldn't recenter it under the pointer.

Root cause was two distinct problems:

  • Floor items — a regression. The grab-offset correction rewrote event.localPosition, but floorStrategy.move reads event.position (the raw world cursor) for its world-grid snap on the default (non-Shift) path — that read was introduced when world-grid alignment landed (editor: building rotation, alt-click single wall, world-grid alignment, tap-to-engage move #388), after the offset fix (be0f491b). So the correction was silently bypassed and the item snapped its origin to the grid-snapped cursor. The correction now also rewrites event.position, derived from the corrected building-local point via a new buildingLocalToWorld (the exact inverse of the transform that produced localPosition; buildings rotate only about Y, so the affine identity holds exactly).

  • Wall / ceiling / item-surface / shelf items — pre-existing. The shared surface strategies snap the item's origin straight to the cursor hit with no grab offset. Added per-surface grab anchors (seeded on the first move per host, reset on leave/detach) that offset every move by start + (raw − anchor) in the surface's own frame — mirroring the floor path and the existing door/window move tools, with the world point re-derived so the visual cursor stays synced with the mesh. Items resting on a table/shelf now start in their host surface so the first move preserves the offset instead of snapping via enter(), and the level-reparent effect skips intentionally-hosted items so the dragged mesh stays on its host.

Renamed the coordinator's preserveFloorDragOffsetpreserveDragOffset (it now governs all surfaces).

2D parity: the 2D move paths already preserve the grab offset (relative-mode resolvePlanarCursorPosition), so no 2D sibling change is needed — this brings 3D into parity with 2D.

Out of scope: roof-wall-mounted items (rare; the face-UV frame needs separate handling). 2D door/window keep mode:'absolute' on purpose (a wall opening snaps to the wall nearest the cursor — documented).

How to test

  1. Place an item on the floor. Grab its move-cross handle (or use Move) at a point offset from its center and drag — it should follow the grabbed point, not jump its origin under the cursor. (Previously it teleported on the first move.)
  2. Repeat for a wall-mounted item (art / light), a ceiling fixture, and an item resting on a table or shelf — all should preserve the grab offset and stay on their surface.
  3. Hold Shift while dragging a floor item (free / off-grid) — offset still preserved.
  4. Press R / T to rotate mid-drag — the item stays at the grab-offset position.
  5. On a rotated building, floor moves should still track the cursor by the grabbed point.

Notes for reviewers

Diagnosed and adversarially reviewed with an independent multi-agent review and Codex; both converged. The floor world↔local math was verified correct; one host-reparent issue (host-resting items being ejected to the level mid-drag) was found by both reviewers and fixed by gating the reparent effect.

Checklist

  • My code follows the existing code style (bun check clean on the changed files)
  • Typechecks (@pascal-app/editor + @pascal-app/nodes)
  • Manual verification in the running editor (author will verify)
  • This PR targets the main branch

…ursor)

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>
@mintlify

mintlify Bot commented Jun 16, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
pascal 🔴 Failed Jun 16, 2026, 7:13 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

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>
@Aymericr Aymericr merged commit 4b7e0ec into main Jun 16, 2026
1 of 2 checks passed
@Aymericr Aymericr deleted the fix/move-grab-offset-teleport branch June 16, 2026 22:53
Aymericr added a commit that referenced this pull request Jun 16, 2026
…ursor) (#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>
(cherry picked from commit 4b7e0ec)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant