Creating Custom Renderers for New Node Types in Pascal Editor
To create a custom renderer for a new node type in Pascal Editor, define a Zod schema in the core package, register it in the AnyNode discriminated union, implement a React‑Three‑Fiber component that calls useRegistry, and add a conditional branch to the central NodeRenderer dispatcher.
Pascal Editor enforces a strict separation between data (the core schema) and presentation (the viewer). When you need to extend the scene graph with custom entities—such as lights, sensors, or procedural geometry—you must modify three specific layers in the pascalorg/editor repository. Because the viewer package never imports from the editor app, changes made following this pattern automatically work in both the full editing interface and the read‑only preview route.
Step 1: Define the Node Schema in the Core Package
Every node type begins with a Zod schema that extends BaseNode. Create a new file under packages/core/src/schema/nodes/ that defines the node’s discriminant key and properties.
// packages/core/src/schema/nodes/light.ts
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
export const LightNode = BaseNode.extend({
id: objectId('light'),
type: nodeType('light'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
color: z.string().default('#ffffff'),
intensity: z.number().default(1),
}).describe(
dedent`
Light node – a point light placed in the scene.
- position: XYZ coordinates
- color: CSS hex string
- intensity: light strength
`
)
export type LightNode = z.infer<typeof LightNode>
Next, expose the new schema to the rest of the application by appending it to the AnyNode discriminated union in types.ts. This union drives TypeScript inference throughout the editor and viewer.
// packages/core/src/schema/types.ts
import { LightNode } from './nodes/light'
export const AnyNode = z.discriminatedUnion('type', [
SiteNode,
BuildingNode,
WallNode,
// … existing nodes …
LightNode, // ← new entry
])
The type field serves as the discriminant that allows the viewer to narrow union members without manual type casting.
Step 2: Implement the React‑Three‑Fiber Renderer
The viewer renders nodes via React‑Three‑Fiber components located in packages/viewer/src/components/renderers/. Each renderer must register its Three.js object with the global registry and forward pointer events to support selection and hover states.
// packages/viewer/src/components/renderers/light/light-renderer.tsx
import { useRegistry, type LightNode } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { PointLight } from '@react-three/drei'
import { useNodeEvents } from '../../../hooks/use-node-events'
export const LightRenderer = ({ node }: { node: LightNode }) => {
const ref = useRef<PointLight>(null!)
// Register the object for systemic lookup by ID
useRegistry(node.id, 'light', ref)
// Inject selection / hover handlers
const handlers = useNodeEvents(node, 'light')
// Memoize to prevent unnecessary re‑renders
const lightProps = useMemo(
() => ({
position: node.position,
color: node.color,
intensity: node.intensity,
}),
[node.position, node.color, node.intensity]
)
return <PointLight ref={ref} {...handlers} {...lightProps} />
}
useRegistry(node.id, 'light', ref) stores a reference to the Three.js object so external systems—such as exporters or physics engines—can retrieve it by ID, mirroring the pattern seen in WallRenderer. useNodeEvents(node, 'light') returns event handlers that wire the object into the editor’s interaction layer, enabling standard behaviors like click‑to‑select and hover highlighting.
Step 3: Register the Renderer in the Node Dispatcher
The NodeRenderer component acts as the central switchboard that maps node types to their corresponding React components. Import your new renderer and add a conditional branch that matches the discriminant key defined in the schema.
// packages/viewer/src/components/renderers/node-renderer.tsx
import { LightRenderer } from './light/light-renderer'
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId])
if (!node) return null
return (
<>
{node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'light' && <LightRenderer node={node} />} // ← new branch
</>
)
}
Because AnyNode is a discriminated union, TypeScript automatically narrows the node type inside each conditional block, ensuring type safety without explicit casting.
Optional: Add Editor UI Controls
If the full editor UI must expose editable properties for the new node, create a panel component under packages/editor/src/components/ui/panels/. Follow the pattern established by existing panels (e.g., wall-panel.tsx): read the node via useScene, render form inputs, and call markDirty after mutations to trigger viewer updates.
Summary
- Define the schema in
packages/core/src/schema/nodes/<type>.tsusingBaseNode.extend, then append the exported type to theAnyNodeunion inpackages/core/src/schema/types.ts. - Build the renderer in
packages/viewer/src/components/renderers/<type>/<type>-renderer.tsx, utilizinguseRegistryfor object lookup anduseNodeEventsfor interaction handling. - Expose the renderer in
packages/viewer/src/components/renderers/node-renderer.tsxby adding a conditional check against the node’stypediscriminant. - Maintain isolation between the core schema and viewer to ensure the new node renders identically in the editor and preview modes.
Frequently Asked Questions
What is the purpose of useRegistry in Pascal Editor?
useRegistry stores a reference to a Three.js object in a global, ID‑indexed map. According to the pascalorg/editor source code, systems such as the selection manager, raycaster, and export utilities query this registry to retrieve objects by their stable node ID rather than by React component instance.
Why does Pascal Editor use a discriminated union for node types?
The AnyNode discriminated union—defined in packages/core/src/schema/types.ts—uses a shared type field to enable TypeScript’s exhaustive narrowing. This pattern guarantees that the NodeRenderer switch statement handles every possible node variant, catching missing cases at compile time and eliminating the need for runtime type guards or unsafe casting.
Can I use custom Three.js geometry in my node renderer?
Yes. The renderer is a standard React‑Three‑Fiber component, so you may return any valid Three.js primitive, custom BufferGeometry, or imported GLTF model. Ensure you still invoke useRegistry with a ref to the root mesh or group so that pointer events and external lookups function correctly.
How do I make my new node type selectable in the editor?
Selection support requires two steps: first, attach the spread handlers from useNodeEvents(node, '<type>') to your root Three.js element in the renderer; second, ensure the node schema includes the standard selectable property inherited from BaseNode. The editor’s input system automatically recognizes objects that combine registry entries with event handler props.
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 →