From 1f829d52ed3c8e066f00e0fc8deec722c74015e1 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 14 Jun 2026 01:54:45 -0400 Subject: [PATCH] feat(editor): draggable move handle for wall-hosted doors & windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/registry.ts | 14 ++++++++ .../editor/handles/handle-arrow.tsx | 16 +++++++++ .../components/editor/node-arrow-handles.tsx | 16 ++++++--- .../components/editor/selection-manager.tsx | 12 ++++++- .../src/lib/direct-manipulation.test.ts | 15 ++++++-- .../editor/src/lib/direct-manipulation.ts | 6 +++- packages/nodes/src/door/definition.ts | 22 ++++++++++++ packages/nodes/src/window/definition.ts | 22 ++++++++++++ packages/viewer/src/hooks/use-node-events.ts | 36 +++++++++++-------- 10 files changed, 137 insertions(+), 23 deletions(-) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 8dcfc58f..a0b58e53 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -18,6 +18,7 @@ export { discoverPlugins, getHostRefFields, getSelectableKinds, + hasRegistry3DMoveTool, isDrawnViaTool, isDrawnViaToolKind, isPresettable, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 051176fc..57aaabcf 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -146,6 +146,20 @@ export function isRegistryMovable(kind: string): boolean { return false } +/** + * Whether the kind has a move tool that MOUNTS in the 3D viewport — the + * generic `capabilities.movable` mover or a bespoke `affordanceTools.move`. + * Narrower than {@link isRegistryMovable}, which also accepts floorplan-only + * movers (e.g. zone) that have no 3D tool. Gates 3D direct move: Ctrl/Meta-drag + * and the move-cross grip. Kept beside `isRegistryMovable` so the 2D and 3D + * movability predicates can't drift apart. + */ +export function hasRegistry3DMoveTool(kind: string): boolean { + const def = nodeRegistry.get(kind) + if (!def) return false + return def.capabilities.movable !== undefined || def.affordanceTools?.move !== undefined +} + /** * Whether the kind can be saved as a reusable preset. Default: an * explicit `capabilities.presettable` boolean wins; otherwise the kind diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 0a91fc9f..caf71ffe 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -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} /> diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 07fd9fe1..5dba030f 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -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 ( ) } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index ace508cc..71d32281 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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 }) diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 377d49cd..61297d99 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -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) + }) }) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index 54564e57..db2649a7 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -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 { diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index f006e263..ba1b1d16 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -19,6 +19,9 @@ const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 const MIN_DOOR_HEIGHT = 0.5 const MIN_DOOR_WIDTH = 0.3 +// How far the move cross floats off the wall face (+Z, the door's facing +// normal) so it's grabbable instead of buried in the leaf/frame. +const MOVE_HANDLE_LIFT = 0.12 function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { if (!door.wallId) return Number.POSITIVE_INFINITY @@ -112,7 +115,26 @@ function doorHeightHandle(): HandleDescriptor { } } +// Press-drag move grip at the door centre, standing in the wall face. Routes +// through the same move tool as the floating Move button (3D +// `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall slide + re-host onto +// another wall — but `engageMoveDrag` commits on release, with no second click. +function doorMoveHandle(): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'move-cross', + plane: 'node-normal', + portal: 'grandparent', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), + placement: { + position: () => [0, 0, MOVE_HANDLE_LIFT], + }, + } +} + const doorHandles: HandleDescriptor[] = [ + doorMoveHandle(), doorWidthHandle('left'), doorWidthHandle('right'), doorHeightHandle(), diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 96f8b11c..1e32a160 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -18,6 +18,9 @@ const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 const MIN_WINDOW_HEIGHT = 0.3 const MIN_WINDOW_WIDTH = 0.3 +// How far the move cross floats off the wall face (+Z, the window's facing +// normal) so it's grabbable instead of buried in the sash/frame. +const MOVE_HANDLE_LIFT = 0.12 function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { if (!w.wallId) return Number.POSITIVE_INFINITY @@ -113,7 +116,26 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'move-cross', + plane: 'node-normal', + portal: 'grandparent', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), + placement: { + position: () => [0, 0, MOVE_HANDLE_LIFT], + }, + } +} + const windowHandles: HandleDescriptor[] = [ + windowMoveHandle(), windowWidthHandle('left'), windowWidthHandle('right'), windowHeightHandle('top'), diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index a33beab3..98fd5d04 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -36,52 +36,60 @@ export function useNodeEvents(node: NodeByKind, 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) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return if (e.button !== 0) return emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { - 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) => { + onClick: (_e: ThreeEvent) => { // Disable default R3F click since we synthesize it on pointerup // This prevents double-clicks from firing twice. }, onPointerEnter: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('enter', e) }, onPointerLeave: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('leave', e) }, onPointerMove: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('move', e) }, onDoubleClick: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return emit('double-click', e) }, onContextMenu: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return emit('context-menu', e) }, }