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 useRegistry hook handles unmounting, but explicit clear() 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.ts maintains a Map of all node objects and typed Set collections for fast categorical lookups.
  • The useRegistry hook uses useLayoutEffect to register THREE.Object3D references synchronously before painting and automatically removes them on unmount.
  • Renderers in packages/viewer/src/components/renderers/ call useRegistry(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() in packages/editor/src/lib/scene.ts frees 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →