Replace per-tool rule trees (.cursor/rules, .claude/rules, .codex/rules) with a single wiki/architecture/ source — 11 pages + README — readable as plain markdown by any agent. Canonical skills live in .agents/skills/; .claude/skills, .cursor/skills, .codex/skills are directory symlinks. AGENTS.md is the entrypoint (rewritten as a lean overview, no per-tool path lists). CLAUDE.md, GEMINI.md, and .github/copilot-instructions.md all point to it. Add open-pr skill (uses .github/pull_request_template.md as the source of truth for the PR body) and remove the dangling .claude/CLAUDE.md relative symlink. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3.3 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).
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.offcauses duplicate handlers and memory leaks. - Use the same function reference for
onandoff. Anonymous functions insideuseEffectare 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, oruseEditor. stopPropagationprevents 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.