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>
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeType,
|
|
type EventSuffix,
|
|
emitter,
|
|
type NodeEvent,
|
|
} from '@pascal-app/core'
|
|
import type { ThreeEvent } from '@react-three/fiber'
|
|
import useViewer from '../store/use-viewer'
|
|
|
|
// Derive `{ node, event }` per kind directly from the `AnyNode`
|
|
// discriminated union — no hand-maintained kind→type map. Adding a new
|
|
// kind to `AnyNode` automatically makes it valid here; removing one
|
|
// removes its overload. `Extract<AnyNode, { type: K }>` picks the node
|
|
// shape, `NodeEvent<T>` adapts the bus payload to that shape.
|
|
type NodeByKind<K extends AnyNodeType> = Extract<AnyNode, { type: K }>
|
|
|
|
export function useNodeEvents<K extends AnyNodeType>(node: NodeByKind<K>, type: K) {
|
|
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
|
|
const eventKey = `${type}:${suffix}` as `${K}:${EventSuffix}`
|
|
const localPoint = e.object.worldToLocal(e.point.clone())
|
|
const payload: NodeEvent<NodeByKind<K>> = {
|
|
node,
|
|
position: [e.point.x, e.point.y, e.point.z],
|
|
localPosition: [localPoint.x, localPoint.y, localPoint.z],
|
|
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
|
|
faceIndex: e.faceIndex ?? undefined,
|
|
object: e.object,
|
|
stopPropagation: () => e.stopPropagation(),
|
|
nativeEvent: e,
|
|
}
|
|
|
|
// `emitter.emit` is typed over a fixed union of `${kind}:${suffix}`
|
|
// keys; the `as never` cast lets us emit a kind-specific payload
|
|
// through that generic surface without enumerating every kind.
|
|
emitter.emit(eventKey, payload as never)
|
|
}
|
|
|
|
// 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 (selectionSuppressed()) return
|
|
if (e.button !== 0) return
|
|
emit('pointerdown', e)
|
|
},
|
|
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
|
|
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>) => {
|
|
// Disable default R3F click since we synthesize it on pointerup
|
|
// This prevents double-clicks from firing twice.
|
|
},
|
|
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
|
|
if (spatialSuppressed()) return
|
|
emit('enter', e)
|
|
},
|
|
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
|
|
if (spatialSuppressed()) return
|
|
emit('leave', e)
|
|
},
|
|
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
|
|
if (spatialSuppressed()) return
|
|
emit('move', e)
|
|
},
|
|
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
|
|
if (selectionSuppressed()) return
|
|
emit('double-click', e)
|
|
},
|
|
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
|
|
if (selectionSuppressed()) return
|
|
emit('context-menu', e)
|
|
},
|
|
}
|
|
}
|