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:
co-authored by
Claude Opus 4.8
parent
cf24b62c44
commit
1f829d52ed
@@ -18,6 +18,7 @@ export {
|
||||
discoverPlugins,
|
||||
getHostRefFields,
|
||||
getSelectableKinds,
|
||||
hasRegistry3DMoveTool,
|
||||
isDrawnViaTool,
|
||||
isDrawnViaToolKind,
|
||||
isPresettable,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<DoorNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<DoorNodeType> {
|
||||
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<DoorNodeType>[] = [
|
||||
doorMoveHandle(),
|
||||
doorWidthHandle('left'),
|
||||
doorWidthHandle('right'),
|
||||
doorHeightHandle(),
|
||||
|
||||
@@ -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<WindowNode
|
||||
}
|
||||
}
|
||||
|
||||
// Press-drag move grip at the window centre, standing in the wall face. Routes
|
||||
// through the same move tool as the floating Move button (3D
|
||||
// `affordanceTools.move`, 2D `floorplanMoveTarget`) — slide within the wall
|
||||
// plane + re-host onto another wall — committing on release, no second click.
|
||||
function windowMoveHandle(): HandleDescriptor<WindowNodeType> {
|
||||
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<WindowNodeType>[] = [
|
||||
windowMoveHandle(),
|
||||
windowWidthHandle('left'),
|
||||
windowWidthHandle('right'),
|
||||
windowHeightHandle('top'),
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user