Files
editor/wiki/architecture/events.md
T
Aymeric RabotandGitHub 99812cd4cb Improve editor placement and selection workflows (#543)
* feat: expose accepted canvas node selections

* feat(editor): replace bulk delete alert with dialog

* fix(editor): allow immediate selection after opening placement

* feat(spawn): preview model during placement

* feat(editor): add cross-project selection clipboard

* fix(editor): harden placement and clipboard previews

* fix(editor): preserve carried clipboard state

* fix(editor): replace active paste drafts safely
2026-07-24 15:35:10 +02:00

3.8 KiB

Events

Typed event bus — emitting and listening to node and grid events.

Applies to: packages/core/src/events/**, packages/viewer/**, apps/editor/**.

The event bus (emitter) is a global mitt instance typed with EditorEvents. It decouples renderers (which emit) from selection managers and tools (which listen).

Source: packages/core/src/events/bus.ts

Event Key Format

<nodeType>:<suffix>

Example keys: wall:click, item:enter, door:double-click, grid:pointerdown

Node Types

wall item site building level zone slab ceiling roof window door

Suffixes

'click' | 'move' | 'enter' | 'leave' | 'pointerdown' | 'pointerup' | 'context-menu' | 'double-click'

The grid:* events fire when the user interacts with empty space (no node hit). They are not emitted by a mesh — useGridEvents(gridY) (apps/editor/hooks/use-grid-events.ts) manually raycasts against a ground plane and calls emitter.emit('grid:click', …). Mount it in any tool or editor component that needs empty-space interactions.

NodeEvent Shape

interface NodeEvent<T extends AnyNode = AnyNode> {
  node: T                                  // typed node that triggered the event
  position: [number, number, number]       // world-space hit position
  localPosition: [number, number, number]  // object-local hit position
  normal?: [number, number, number]        // face normal, if available
  stopPropagation: () => void
  nativeEvent: ThreeEvent<PointerEvent>
}

Grid events only carry position and nativeEvent (no node).

Selection Intent Events

selection:canvas-node-click fires after the editor accepts a 2D or 3D node click and resolves the node that selection actually targets. Hosts can use it for contextual navigation without reacting to programmatic setSelection calls. The payload is the resolved AnyNode.

selection:find-node is the explicit reveal intent emitted by the node action menu. Hosts and plugins that own catalogs or panels listen to it and reveal the node's related controls or presets.

Emitting

Renderers emit via useNodeEvents — never call emitter.emit directly in a renderer:

// packages/viewer/src/hooks/use-node-events.ts
const events = useNodeEvents(node, 'wall')
return <mesh ref={ref} {...events} />

useNodeEvents converts R3F ThreeEvent into a NodeEvent and emits wall:click, wall:enter, etc. It suppresses events while the camera is dragging.

Listening

Listen in a useEffect. Always clean up with emitter.off using the same function reference:

// Single event
useEffect(() => {
  const handler = (e: WallEvent) => { /* … */ }
  emitter.on('wall:click', handler)
  return () => emitter.off('wall:click', handler)
}, [])

// Multiple node types, same handler
useEffect(() => {
  const types = ['wall', 'slab', 'door'] as const
  const handler = (e: NodeEvent) => { /* … */ }
  types.forEach(t => emitter.on(`${t}:click`, handler as any))
  return () => types.forEach(t => emitter.off(`${t}:click`, handler as any))
}, [])

See apps/editor/components/editor/selection-manager.tsx for a full multi-type listener example.

Rules

  • Renderers only emit, never listen. Listening belongs in selection managers, tools, or systems.
  • Always clean up. Forgetting emitter.off causes duplicate handlers and memory leaks.
  • Use the same function reference for on and off. Anonymous functions inside useEffect are fine as long as the ref is captured in the same scope.
  • Don't use emitter for state. It's for one-shot interaction events. Persistent state goes in useScene, useViewer, or useEditor.
  • stopPropagation prevents the event from being handled by overlapping listeners (e.g. a door on a wall). Call it when a handler should be the final consumer.