How to Register Node Renderers with the Pascal Editor Scene Registry
Pascal Editor uses a centralized sceneRegistry object and the useRegistry React hook to synchronously register every node renderer's root THREE.Object3D before the first paint, enabling fast lookups by ID or type and automatic cleanup on unmount.
The pascalorg/editor repository separates rendering logic from scene management by maintaining a type-aware registry of all visual nodes. By registering node renderers with the Pascal Editor scene registry, systems like selection, export, and level-stacking can reliably access the underlying Three.js objects without traversing the React component tree. This pattern ensures deterministic lookup performance and memory-safe unmounting across the entire application.
Understanding the Scene Registry Architecture
The core registry is implemented as a singleton in packages/core/src/hooks/scene-registry/scene-registry.ts. It exposes a sceneRegistry object with two complementary data structures: a master Map for ID-based lookups and typed Set collections for category-based queries.
// packages/core/src/hooks/scene-registry/scene-registry.ts
export const sceneRegistry = {
nodes: new Map<string, THREE.Object3D>(),
byType: {
wall: new Set<string>(),
window: new Set<string>(),
slab: new Set<string>(),
// … one Set per node type
}
}
The nodes map stores the root THREE.Object3D for every registered node, keyed by its unique ID. The byType record groups these IDs into sets according to their semantic type (wall, window, etc.), allowing systems to quickly iterate over specific categories without filtering the entire scene.
The useRegistry Hook: Synchronous Registration
Renderers consume the registry through the useRegistry hook, which guarantees that the 3-D object is discoverable before the browser paints the first frame. The hook uses useLayoutEffect to execute registration synchronously after the DOM mutation but before the visual update.
// packages/core/src/hooks/scene-registry/scene-registry.ts
export function useRegistry(
id: string,
type: keyof typeof sceneRegistry.byType,
ref: React.RefObject<THREE.Object3D>
) {
useLayoutEffect(() => {
const obj = ref.current
if (!obj) return
sceneRegistry.nodes.set(id, obj) // master lookup
sceneRegistry.byType[type].add(id) // typed lookup
return () => {
sceneRegistry.nodes.delete(id) // cleanup on unmount
sceneRegistry.byType[type].delete(id)
}
}, [id, type, ref])
}
When the component unmounts, the effect’s cleanup function removes the ID from both the master map and the typed set. This prevents stale references that would otherwise break systems attempting to query wall heights, selection bounds, or export geometry.
Implementing a Wall Node Renderer
Each renderer is responsible for creating a React ref attached to its root mesh or group, then passing that ref to useRegistry along with the node ID and type. In packages/viewer/src/components/renderers/wall/wall-renderer.tsx, the WallRenderer registers the wall’s mesh so that the WallSystem can later replace its geometry while the registry maintains the correct object reference.
// packages/viewer/src/components/renderers/wall/wall-renderer.tsx
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useRef, useLayoutEffect } from 'react'
import type { Mesh } from 'three'
export const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef<Mesh>(null!)
// Register the wall mesh with the scene registry
useRegistry(node.id, 'wall', ref)
// Mark dirty so WallSystem rebuilds geometry
useLayoutEffect(() => useScene.getState().markDirty(node.id), [node.id])
return (
<mesh ref={ref} visible={node.visible}>
{/* geometry will be replaced by WallSystem */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
The registration must target the outermost group that represents the whole node. If a renderer creates multiple meshes, it should register the parent group so that systems receive a single consistent reference for transformations and queries.
Registering Window and Specialized Renderers
The same pattern applies to windows, doors, and custom nodes. The WindowRenderer in packages/viewer/src/components/renderers/window/window-renderer.tsx registers with type 'window', allowing the scene registry to distinguish it from walls when calculating level heights or collision bounds.
// packages/viewer/src/components/renderers/window/window-renderer.tsx
import { useRegistry, type WindowNode } from '@pascal-app/core'
import { useRef } from 'react'
import type { Mesh } from 'three'
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'window', ref)
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
return (
<mesh ref={ref} position={node.position} rotation={node.rotation} visible={node.visible}>
<boxGeometry args={[0, 0, 0]} />
<meshStandardMaterial color="#d1d5db" />
</mesh>
)
}
Because the registry tracks transient state through the same ID-based mechanism, systems can optionally filter nodes by metadata without breaking the core lookup contract.
Querying Registered Objects from Systems
Systems consume the registry to read world positions, dimensions, or visibility states without coupling to React component internals. The LevelSystem in packages/viewer/src/systems/level/level-utils.ts queries sceneRegistry.nodes to determine the vertical position of walls when calculating level heights.
// packages/viewer/src/systems/level/level-utils.ts
import { sceneRegistry, type WallNode } from '@pascal-app/core'
const meshY = sceneRegistry.nodes.get(childId as string)?.position.y ?? 0
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
This direct access pattern eliminates the need for prop drilling or context providers, keeping geometry calculations in pure TypeScript logic while the React layer handles only visual representation.
Memory Management and Scene Teardown
To prevent memory leaks when unloading projects, packages/editor/src/lib/scene.ts invokes sceneRegistry.clear(), which wipes the nodes map and clears every typed set. This ensures that no orphaned Object3D references persist between sessions.
- One registration per node ID: If a renderer attempts to register multiple objects under the same ID, later registrations overwrite earlier ones, so always register the single root group.
- Automatic cleanup: The
useRegistryhook handles unmounting, but explicitclear()is required for full scene resets.
The same “register → cleanup” contract is mirrored in packages/editor/src/store/use-command-registry.ts, demonstrating a consistent resource management pattern throughout the Pascal Editor codebase.
Summary
- The scene registry in
packages/core/src/hooks/scene-registry/scene-registry.tsmaintains aMapof all node objects and typedSetcollections for fast categorical lookups. - The
useRegistryhook usesuseLayoutEffectto registerTHREE.Object3Dreferences synchronously before painting and automatically removes them on unmount. - Renderers in
packages/viewer/src/components/renderers/calluseRegistry(id, type, ref)to expose their root mesh to systems like LevelSystem. - Systems query the registry via
sceneRegistry.nodes.get(id)to read positions and dimensions without React overhead. - Calling
sceneRegistry.clear()inpackages/editor/src/lib/scene.tsfrees all references when a scene is unloaded, preventing memory leaks.
Frequently Asked Questions
What is the difference between the nodes map and the byType sets in the registry?
The nodes property is a Map<string, THREE.Object3D> that provides O(1) lookup of any node's 3-D object by its unique ID. The byType property is a record of Set<string> instances that group node IDs by their semantic category (wall, window, slab, etc.). Systems use nodes for direct object access and byType to iterate over specific categories without scanning the entire scene.
Why does Pascal Editor use useLayoutEffect instead of useEffect for registration?
useLayoutEffect fires synchronously after all DOM mutations but before the browser paints the screen. This guarantees that the THREE.Object3D is registered in sceneRegistry.nodes before any system attempts to query it during the same render cycle, preventing race conditions in selection or level-calculation logic that runs immediately after mounting.
How should I register a renderer that uses multiple meshes for a single node?
You should register only the outermost parent group that contains all child meshes. Create a single RefObject for the root group, attach that ref to the <group> element in JSX, and pass it to useRegistry. Systems expect one Object3D per node ID; registering child meshes individually would create ambiguous references and break height calculations or bounding-box queries.
When is the registry cleared during the application lifecycle?
The registry is cleared when a scene is explicitly unloaded or the editor resets, via the sceneRegistry.clear() method called in packages/editor/src/lib/scene.ts. This wipes both the nodes map and all byType sets, ensuring no stale Object3D references persist when loading a new project. Individual entries are removed automatically when their renderer unmounts thanks to the cleanup function inside useRegistry.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →