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:

  • BaseNode supplies common fields including object, id, type, name, parentId, visible, and metadata.
  • objectId('furn') automatically generates typed identifiers like furn_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 checks node.type === 'furn' and loads the GLB model from node.asset.src using 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

Summary

  • Define your schema by extending BaseNode in packages/core/src/schema/nodes/ to establish custom fields, validation, and defaults.
  • Export the schema from packages/core/src/schema/index.ts to automatically include it in the AnyNode discriminated union.
  • Instantiate nodes by calling .parse() for runtime validation, then pass the result to useScene.getState().createNode(node, parentId).
  • Optional: Add specialized renderers in packages/viewer/ or systems in packages/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:

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 →