feat(editor): draggable move handle for wall-hosted doors & windows

Doors and windows could only be moved via the floating action menu — their
3D handle rig declared width/height resize arrows but no move grip, and
Ctrl/Meta-drag was a no-op for them. Add a press-drag move cross and make
direct-drag work for every bespoke-mover kind.

- door/window: add a `tap-action` `move-cross` handle (plane node-normal,
  portal grandparent, `engageMoveDrag`) mirroring the item wall grip. It
  routes through the existing per-kind move tool (3D `affordanceTools.move`,
  2D `floorplanMoveTarget`) — wall-bound slide + re-host onto another wall —
  so the grip, the floating Move button, and the 2D plan's move dot share one
  pipeline. Grab-drag-release commits without a second click.

- canDirectMoveNode: gate Ctrl/Meta-drag on `movable || affordanceTools.move`
  (the 3D-mountable move paths) instead of `movable` only, so doors/windows/
  walls/slabs/stairs/… are draggable in 3D as they already are in 2D.
  Floorplan-only movers (zone) stay excluded — no 3D tool mounts. The
  floating helper auto-syncs (it reads canDirectMoveNode).

- TapActionArrow: honor `plane: 'node-normal'` by tilting the move cross
  [π/2,0,0] into the wall face — previously ignored, so the item wall grip
  rendered flat too. Now door/window/wall-item crosses lie in the wall.

- use-node-events: split the drag-suppression gate. `inputDragging` still
  suppresses SELECTION events (the synthesized release-click would re-select),
  but no longer suppresses SPATIAL events (enter/move/leave) — a
  surface-following move tool runs with `inputDragging` set and needs
  wall:move to track the cursor. General consumers that must ignore drags
  (viewer hover, box-select) already self-gate on `inputDragging`; the
  editor's select-hover and paint-preview enter handlers now gate on it too.

- handle-arrow: make handle hit areas inert while `placementDragMode` is set,
  so a move grip riding the dragged node can't intercept the ray and starve
  the move tool's surface raycast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-14 09:51:37 -04:00
co-authored by Claude Opus 4.8
parent cf24b62c44
commit 1f829d52ed
10 changed files with 137 additions and 23 deletions
+22 -14
View File
@@ -36,52 +36,60 @@ export function useNodeEvents<K extends AnyNodeType>(node: NodeByKind<K>, type:
emitter.emit(eventKey, payload as never)
}
// Suppress node pointer events while an interaction drag is in
// progress. `cameraDragging` covers orbit/pan/dolly; `inputDragging`
// covers host-driven drags (editor handle arrows etc.). Without
// this, the synthesized click on pointerup would reroute selection
// to whatever mesh the cursor lands on at release.
const isInteractionActive = () => {
// Camera drags (orbit / pan / dolly) suppress ALL node pointer events.
//
// `inputDragging` (host-driven drags: handle arrows, press-drag moves)
// additionally suppresses the SELECTION events — without it the click
// synthesized on pointer-release would reroute selection to whatever mesh
// sits under the cursor at release. It must NOT suppress the SPATIAL events
// (`enter` / `move` / `leave`): a surface-following move tool — a door /
// window sliding along a wall — runs WITH `inputDragging` set and depends on
// those events to track the cursor. Consumers that should ignore drag-time
// spatial events gate on `inputDragging` themselves (the editor's hover and
// paint paths, box-select), so emitting them during a drag only reaches the
// active move tool that wants them.
const spatialSuppressed = () => useViewer.getState().cameraDragging
const selectionSuppressed = () => {
const s = useViewer.getState()
return s.cameraDragging || s.inputDragging
}
return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
if (e.button !== 0) return
emit('pointerdown', e)
},
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
if (e.button !== 0) return
emit('pointerup', e)
// Synthesize a click event on pointer up to be more forgiving than R3F's default onClick
// which often fails if the mouse moves even 1 pixel.
emit('click', e)
},
onClick: (e: ThreeEvent<PointerEvent>) => {
onClick: (_e: ThreeEvent<PointerEvent>) => {
// Disable default R3F click since we synthesize it on pointerup
// This prevents double-clicks from firing twice.
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (spatialSuppressed()) return
emit('enter', e)
},
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (spatialSuppressed()) return
emit('leave', e)
},
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (spatialSuppressed()) return
emit('move', e)
},
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
emit('double-click', e)
},
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
emit('context-menu', e)
},
}