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:
co-authored by
Claude Opus 4.8
parent
8eab9979ab
commit
4b7e0eca76
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user