Implementing Custom Node Types in Pascal Editor Using Zod Schemas
To implement a custom node type in Pascal Editor, extend the BaseNode Zod schema with your specific fields, export it from packages/core/src/schema/index.ts to register it with the AnyNode union, and instantiate it using useScene.getState().createNode() after validating with .parse().
Pascal Editor stores every building element as a Zod-validated node within the core @pascal-app/core package. Implementing custom node types in Pascal Editor using Zod schemas ensures full type safety, runtime validation, and seamless integration with the editor's undo/redo systems powered by Zundo. This guide walks through the exact implementation pattern used in the pascalorg/editor repository, referencing the discriminated union architecture and specific file paths that define the scene graph.
Step 1: Define the Zod Schema by Extending BaseNode
Create a new schema file in packages/core/src/schema/nodes/ to define your custom node's shape and validation rules. The following example implements a FurnitureNode that follows the same pattern as the built-in WallNode:
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { ItemNode } from './item'
export const FurnitureNode = BaseNode.extend({
// Unique identifier with the "furn" prefix
id: objectId('furn'),
// Discriminated union key
type: nodeType('furn'),
// Position & orientation relative to the level
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
// Custom fields for furniture
name: z.string(),
material: z.enum(['wood', 'metal', 'plastic']).optional(),
// Optional link to an existing ItemNode asset
asset: ItemNode.shape.asset,
}).describe(
dedent`
Furniture node – a custom piece of furniture that can be placed on a floor.
- position / rotation / scale follow the same convention as ItemNode.
- material is an optional enum for quick styling.
`
)
export type FurnitureNode = z.infer<typeof FurnitureNode>
Key implementation details:
BaseNodesupplies common fields includingobject,id,type,name,parentId,visible, andmetadata.objectId('furn')automatically generates typed identifiers likefurn_4k7g9b1c..describe()attaches markdown documentation that appears in generated docs and IDE tooltips.
Step 2: Export the Schema to Register with AnyNode
Add the newly created export to the central schema index at packages/core/src/schema/index.ts so the rest of the codebase can import the type:
// packages/core/src/schema/index.ts
export { WallNode } from './nodes/wall'
export { FurnitureNode } from './nodes/furniture' // ← new line
This export automatically includes FurnitureNode in the discriminated union defined in packages/core/src/schema/types.ts:
export const AnyNode = z.discriminatedUnion('type', [
SiteNode,
BuildingNode,
LevelNode,
WallNode,
ItemNode,
// …
FurnitureNode, // ← added automatically via the index import
])
The union uses the type field as the discriminator, enabling TypeScript to narrow node types correctly throughout the editor.
Step 3: Create Runtime Instances Using useScene
Instantiate your custom node in any component or tool by parsing the schema and calling the store method. The useScene hook (implemented in packages/core/src/store/use-scene.ts) manages the flat SceneState.nodes dictionary and provides the createNode function:
import { useScene } from '@pascal-app/core'
import { FurnitureNode } from '@pascal-app/core/schema'
function addSampleChair() {
const { createNode, getState } = useScene.getState()
// Parse the schema – ensures validation & TypeScript inference
const chair = FurnitureNode.parse({
name: 'Dining Chair',
material: 'wood',
position: [2, 0, 3],
asset: {
id: 'chair01',
category: 'furniture',
name: 'Wooden Chair',
thumbnail: '/thumbs/chair.png',
src: '/models/chair.glb',
dimensions: [0.5, 0.9, 0.5],
attachTo: 'floor',
},
})
// Attach to the current level (or any parent node)
const currentLevelId = getState().rootNodeIds[0]
createNode(chair, currentLevelId)
}
Because FurnitureNode is a member of AnyNode, the Zundo-based store accepts it without additional type declarations. Systems iterating over scene.nodes will ignore the new type unless you add specific logic, which prevents breaking existing functionality.
Step 4: Extend Systems and Renderers (Optional)
If your custom node requires specialized rendering or behavior, extend the viewer and core systems while maintaining the decoupled architecture:
- Renderer: Add a component in
packages/viewer/src/components/renderers/that checksnode.type === 'furn'and loads the GLB model fromnode.asset.srcusing Three.js. - System: Add logic in
packages/core/src/systems/to handle physics, spatial queries, or custom interactions for the new node type.
The apps/editor package never imports viewer internals, ensuring your custom node remains portable across different frontends using the Core API.
Key Source Files for Custom Node Implementation
packages/core/src/schema/base.ts– DefinesBaseNode,objectId, andnodeTypeutilities.packages/core/src/schema/nodes/wall.ts– Canonical reference implementation showing the extension pattern.packages/core/src/schema/index.ts– Central export hub; required for registering new nodes.packages/core/src/schema/types.ts– Builds theAnyNodediscriminated union that powers type-safe stores.packages/core/src/store/use-scene.ts– ProvidescreateNode,createNodes, and Zundo-powered state management.
Summary
- Define your schema by extending
BaseNodeinpackages/core/src/schema/nodes/to establish custom fields, validation, and defaults. - Export the schema from
packages/core/src/schema/index.tsto automatically include it in theAnyNodediscriminated union. - Instantiate nodes by calling
.parse()for runtime validation, then pass the result touseScene.getState().createNode(node, parentId). - Optional: Add specialized renderers in
packages/viewer/or systems inpackages/core/src/systems/for custom visual or logical behavior.
Frequently Asked Questions
What is the purpose of the AnyNode discriminated union in Pascal Editor?
The AnyNode type, defined in packages/core/src/schema/types.ts, creates a TypeScript discriminated union keyed on the type field. This allows the useScene store and other systems to handle all node types exhaustively while maintaining strict type safety, ensuring that only valid node shapes are accepted by generic scene operations.
How does Zod validation impact runtime behavior when creating custom nodes?
Zod schemas enforce runtime validation through the .parse() method. When you call FurnitureNode.parse(), Zod validates that the object conforms to the schema, strips unknown fields, applies defaults (such as the [0,0,0] position defaults), and returns a type-safe object that matches the TypeScript z.infer<typeof FurnitureNode> type.
Can custom nodes reference existing node assets like ItemNode?
Yes, custom nodes can reuse existing asset structures by importing and referencing other node schemas. In the FurnitureNode example, the schema imports ItemNode and references ItemNode.shape.asset to maintain consistency with existing asset definitions, ensuring that furniture loads GLB models using the same validation logic as standard items.
Where should custom renderers for new node types be implemented?
Custom renderers belong in packages/viewer/src/components/renderers/ and should check node.type to determine rendering responsibilities. The viewer package is deliberately decoupled from the editor application, so you must expose the new node through the public @pascal-app/core API, then handle the Three.js mesh creation and GLB loading within the viewer's renderer components.
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 →