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)
},
}