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
@@ -12,12 +12,27 @@ import {
DoubleSide,
ExtrudeGeometry,
type Group,
type Intersection,
Mesh,
type Raycaster,
Shape,
TorusGeometry,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import useEditor from '../../../store/use-editor'
// While a press-drag move is in flight (`placementDragMode`), the move tool
// owns the pointer and the handle rig rides the moving node — so a handle hit
// area would sit under the cursor and starve the tool's surface raycast
// (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
// Make every handle hit area inert for the duration; the indicator mesh still
// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
if (useEditor.getState().placementDragMode) return
Mesh.prototype.raycast.call(this, raycaster, intersects)
}
export const ARROW_SCALE = 0.65
export const ARROW_COLOR = '#8381ed'
@@ -382,6 +397,7 @@ export function InvisibleHandleHitArea({
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
raycast={hitAreaRaycast}
renderOrder={HIT_AREA_RENDER_ORDER}
scale={scale}
/>
@@ -70,6 +70,10 @@ const _resizePositionW = new Vector3()
const _resizeRay = new Ray()
const _resizeRayW = new Vector3()
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0]
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
target.set(0, 0, 0)
if (axis === 'x') target.x = 1
@@ -1230,7 +1234,7 @@ function TranslateArrow({
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
// it lies in the item-local XY plane (= the wall face).
const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0]
const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0]
return (
<HandleArrow
@@ -1288,16 +1292,20 @@ function TapActionArrow({
)
}
// Default 'arrow' shape — the standard chevron.
const baseScale = zoom * ARROW_SCALE
// A `move-cross` with `plane: 'node-normal'` stands up into the node's facing
// plane (a wall face) like the door / window / wall-item move grips; other
// tap-actions keep their in-plane `rotationY`.
const rotation: [number, number, number] =
descriptor.plane === 'node-normal' ? NODE_NORMAL_TILT : [0, rotationY, 0]
return (
<HandleArrow
cursor={cursor}
hover={isHovered}
onHoverChange={setIsHovered}
onPointerDown={onActivate}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="chevron"
placement={{ position, rotation, baseScale }}
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
/>
)
}
@@ -1101,7 +1101,11 @@ export const SelectionManager = () => {
}
const onEnter = (event: NodeEvent) => {
if (boxSelectHandled) return
// A host-driven drag (handle resize/rotate) sets `inputDragging`.
// useNodeEvents now emits hover events during such a drag so surface
// move tools keep tracking the cursor — but paint preview must not fire
// mid-drag, so gate on `inputDragging` here too.
if (boxSelectHandled || useViewer.getState().inputDragging) return
const interaction = getPaintInteraction(event)
if (!interaction) return
@@ -1665,6 +1669,11 @@ export const SelectionManager = () => {
if (movingNode || curvingWall || curvingFence) return
const onEnter = (event: NodeEvent) => {
// A host-driven drag (handle resize/rotate, box-select) sets
// `inputDragging`. useNodeEvents still emits hover events during it so
// surface move tools keep tracking — but the select-hover outline must
// stay put, so don't repaint under the cursor mid-drag.
if (useViewer.getState().inputDragging) return
const node = event.node
const currentPhase = useEditor.getState().phase
@@ -1692,6 +1701,7 @@ export const SelectionManager = () => {
}
const onLeave = (event: NodeEvent) => {
if (useViewer.getState().inputDragging) return
const nodeId = event?.node?.id
if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
@@ -62,14 +62,16 @@ describe('resolveDirectRotationDragDelta', () => {
})
describe('canDirectMoveNode', () => {
test('excludes floorplan-only move targets from 3D direct move', () => {
// Accepts kinds with a 3D-mountable move tool (`movable` or
// `affordanceTools.move`); floorplan-only movers (zone) are excluded.
test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
const kind = 'direct-move-floorplan-only-test'
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
})
test('excludes bespoke move tools from 3D direct move', () => {
test('accepts kinds with a bespoke move tool', () => {
const kind = 'direct-move-bespoke-tool-test'
registerTestDefinition(kind, {
affordanceTools: {
@@ -77,7 +79,7 @@ describe('canDirectMoveNode', () => {
} as never,
})
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
})
test('accepts nodes with the generic movable capability', () => {
@@ -90,4 +92,11 @@ describe('canDirectMoveNode', () => {
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
})
test('rejects kinds with no registered move path', () => {
const kind = 'direct-move-none-test'
registerTestDefinition(kind, {})
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
})
})
@@ -4,6 +4,7 @@ import {
createSceneApi,
DEFAULT_ANGLE_STEP,
type HandleDescriptor,
hasRegistry3DMoveTool,
nodeRegistry,
type SceneApi,
useScene,
@@ -34,7 +35,10 @@ export function canDirectRotateNode(node: AnyNode): boolean {
}
export function canDirectMoveNode(node: AnyNode): boolean {
return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
// floorplan-only movers (zone) for the 2D plan.
return hasRegistry3DMoveTool(node.type)
}
export function snapDirectRotationDelta(delta: number, free: boolean): number {