The Pascal Editor SDK provides a comprehensive event system for handling user interactions with 3D objects. Events are emitted through a centralized event bus, making it easy to build interactive tools and features.
Understanding the Event System
Events in Pascal Editor follow a predictable pattern:
Event names combine the node type with an action: wall:click, item:enter, grid:move
Event data includes the node, position, and native Three.js event
Event propagation can be stopped to prevent parent handlers from firing
All events are typed, providing autocomplete and type safety when subscribing to events.
Available Events
Every node type supports these interaction events:
click - User clicked on the object
double-click - User double-clicked on the object
move - Mouse moved over the object
enter - Mouse entered the object bounds
leave - Mouse left the object bounds
pointerdown - Mouse button pressed on the object
pointerup - Mouse button released on the object
context-menu - Right-click on the object
Event Types by Node
// Node events
'wall:click' | 'wall:move' | 'wall:enter' | 'wall:leave' | ...
'item:click' | 'item:move' | 'item:enter' | 'item:leave' | ...
'slab:click' | 'slab:move' | 'slab:enter' | 'slab:leave' | ...
'zone:click' | 'zone:move' | 'zone:enter' | 'zone:leave' | ...
'ceiling:click' | 'ceiling:move' | 'ceiling:enter' | ...
'roof:click' | 'roof:move' | 'roof:enter' | ...
'window:click' | 'window:move' | 'window:enter' | ...
'door:click' | 'door:move' | 'door:enter' | ...
// Grid events (when clicking on empty space)
'grid:click' | 'grid:move' | 'grid:enter' | ...
// Camera control events
'camera-controls:view' | 'camera-controls:capture' | ...
// Tool events
'tool:cancel'
Subscribing to Events
Import the event emitter
import { emitter } from '@pascal-app/core'
Subscribe to an event
Use emitter.on() to listen for events: import { emitter , type WallEvent } from '@pascal-app/core'
// Subscribe to wall clicks
const handleWallClick = ( event : WallEvent ) => {
console . log ( 'Wall clicked:' , event . node . id )
console . log ( 'Click position:' , event . position )
}
emitter . on ( 'wall:click' , handleWallClick )
Clean up subscriptions
Always unsubscribe when your component unmounts: import { useEffect } from 'react'
useEffect (() => {
const handleWallClick = ( event : WallEvent ) => {
// Handle event
}
emitter . on ( 'wall:click' , handleWallClick )
// Cleanup function
return () => {
emitter . off ( 'wall:click' , handleWallClick )
}
}, [])
Event Data Structure
Each event provides rich contextual information:
NodeEvent Structure
interface NodeEvent < T extends AnyNode = AnyNode > {
node : T // The node that was interacted with
position : [ number , number , number ] // World-space click position
localPosition : [ number , number , number ] // Local-space position relative to node
normal ?: [ number , number , number ] // Surface normal at click point
stopPropagation : () => void // Prevent parent handlers from firing
nativeEvent : ThreeEvent < PointerEvent > // Original Three.js event
}
GridEvent Structure
interface GridEvent {
position : [ number , number , number ] // Grid click position
nativeEvent : ThreeEvent < PointerEvent >
}
Common Event Patterns
Selecting Items on Click
import { emitter , type ItemEvent } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
function setupItemSelection () {
const handleItemClick = ( event : ItemEvent ) => {
// Set the selected item in the viewer state
useViewer . getState (). setSelection ({
nodeId: event . node . id ,
type: 'item' ,
})
// Prevent event from bubbling to grid click
event . stopPropagation ()
}
emitter . on ( 'item:click' , handleItemClick )
return () => emitter . off ( 'item:click' , handleItemClick )
}
Hover Highlighting
import { emitter , type WallEvent } from '@pascal-app/core'
import { sceneRegistry } from '@pascal-app/core'
import type * as THREE from 'three'
function setupWallHover () {
const handleWallEnter = ( event : WallEvent ) => {
const mesh = sceneRegistry . nodes . get ( event . node . id ) as THREE . Mesh
if ( ! mesh ) return
// Highlight the wall
mesh . material . color . setHex ( 0x4080ff )
}
const handleWallLeave = ( event : WallEvent ) => {
const mesh = sceneRegistry . nodes . get ( event . node . id ) as THREE . Mesh
if ( ! mesh ) return
// Remove highlight
mesh . material . color . setHex ( 0xffffff )
}
emitter . on ( 'wall:enter' , handleWallEnter )
emitter . on ( 'wall:leave' , handleWallLeave )
return () => {
emitter . off ( 'wall:enter' , handleWallEnter )
emitter . off ( 'wall:leave' , handleWallLeave )
}
}
import { emitter , type ItemEvent } from '@pascal-app/core'
function setupContextMenu () {
const handleContextMenu = ( event : ItemEvent ) => {
// Prevent default browser context menu
event . nativeEvent . nativeEvent . preventDefault ()
// Show custom context menu
showContextMenu ({
x: event . nativeEvent . nativeEvent . clientX ,
y: event . nativeEvent . nativeEvent . clientY ,
options: [
{ label: 'Delete' , action : () => deleteItem ( event . node . id ) },
{ label: 'Duplicate' , action : () => duplicateItem ( event . node . id ) },
{ label: 'Properties' , action : () => showProperties ( event . node . id ) },
],
})
event . stopPropagation ()
}
emitter . on ( 'item:context-menu' , handleContextMenu )
return () => emitter . off ( 'item:context-menu' , handleContextMenu )
}
Grid Click for Placement
import { emitter , type GridEvent } from '@pascal-app/core'
import { useScene , ItemNode } from '@pascal-app/core'
function setupItemPlacement ( asset : Asset ) {
const handleGridClick = ( event : GridEvent ) => {
const [ x , y , z ] = event . position
// Create item at click position
const item = ItemNode . parse ({
position: [ x , y , z ],
rotation: [ 0 , 0 , 0 ],
asset: asset ,
})
const currentLevelId = useViewer . getState (). selection . levelId
useScene . getState (). createNode ( item , currentLevelId )
}
emitter . on ( 'grid:click' , handleGridClick )
return () => emitter . off ( 'grid:click' , handleGridClick )
}
Stopping Event Propagation
Use stopPropagation() to prevent events from bubbling up:
import { emitter } from '@pascal-app/core'
// Handle item clicks
emitter . on ( 'item:click' , ( event ) => {
console . log ( 'Item clicked:' , event . node . id )
event . stopPropagation () // Don't trigger grid:click
})
// This won't fire when clicking items
emitter . on ( 'grid:click' , ( event ) => {
console . log ( 'Grid clicked:' , event . position )
})
Always call stopPropagation() when handling object-specific events to prevent unintended grid interactions.
Build interactive tools using event subscriptions:
import { emitter } from '@pascal-app/core'
import { useEffect , useState } from 'react'
function useWallDrawingTool () {
const [ startPoint , setStartPoint ] = useState <[ number , number , number ] | null >( null )
useEffect (() => {
const handleGridClick = ( event : GridEvent ) => {
const [ x , , z ] = event . position
if ( ! startPoint ) {
// First click: set start point
setStartPoint ([ x , 0 , z ])
} else {
// Second click: create wall
const wall = WallNode . parse ({
start: [ startPoint [ 0 ], startPoint [ 2 ]],
end: [ x , z ],
thickness: 0.2 ,
height: 2.7 ,
})
const currentLevelId = useViewer . getState (). selection . levelId
useScene . getState (). createNode ( wall , currentLevelId )
// Reset for next wall
setStartPoint ( null )
}
}
const handleCancel = () => {
setStartPoint ( null )
}
emitter . on ( 'grid:click' , handleGridClick )
emitter . on ( 'tool:cancel' , handleCancel )
return () => {
emitter . off ( 'grid:click' , handleGridClick )
emitter . off ( 'tool:cancel' , handleCancel )
}
}, [ startPoint ])
return { isDrawing: startPoint !== null }
}
Camera Control Events
Control the camera programmatically:
import { emitter } from '@pascal-app/core'
// Focus camera on a specific node
emitter . emit ( 'camera-controls:view' , { nodeId: wallId })
// Switch to top-down view
emitter . emit ( 'camera-controls:top-view' , undefined )
// Orbit camera
emitter . emit ( 'camera-controls:orbit-cw' , undefined ) // Clockwise
emitter . emit ( 'camera-controls:orbit-ccw' , undefined ) // Counter-clockwise
// Generate thumbnail
emitter . emit ( 'camera-controls:generate-thumbnail' , { projectId: 'proj_123' })
Typed Event Handlers
TypeScript provides full type safety for event handlers:
import { emitter , type WallEvent , type ItemEvent , type GridEvent } from '@pascal-app/core'
// TypeScript knows the exact shape of each event
emitter . on ( 'wall:click' , ( event : WallEvent ) => {
event . node // WallNode
event . node . start // [number, number]
event . node . thickness // number | undefined
})
emitter . on ( 'item:click' , ( event : ItemEvent ) => {
event . node // ItemNode
event . node . asset // Asset
event . node . position // [number, number, number]
})
emitter . on ( 'grid:click' , ( event : GridEvent ) => {
event . position // [number, number, number]
// event.node - TypeScript error: GridEvent has no 'node' property
})
Best Practices
Always Unsubscribe
useEffect (() => {
const handler = ( event ) => { /* ... */ }
emitter . on ( 'item:click' , handler )
return () => emitter . off ( 'item:click' , handler ) // Critical!
}, [])
Failing to unsubscribe causes memory leaks and duplicate event handlers.
Use stopPropagation Wisely
// Good: Stop propagation for specific object interactions
emitter . on ( 'wall:click' , ( event ) => {
selectWall ( event . node . id )
event . stopPropagation () // Don't trigger grid click
})
// Bad: Stopping propagation unnecessarily
emitter . on ( 'item:move' , ( event ) => {
updateHoverState ( event . node . id )
event . stopPropagation () // Usually not needed for hover
})
Debounce High-Frequency Events
import { debounce } from 'lodash'
const handleMouseMove = debounce (( event : ItemEvent ) => {
// Expensive operation
updateTooltip ( event . node )
}, 100 )
emitter . on ( 'item:move' , handleMouseMove )
Next Steps
Creating Nodes Learn how to create nodes in response to events
Custom Systems Build systems that react to events