Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

1,283 Commits

Repository files navigation

Pascal Editor

A 3D building editor built with React Three Fiber and WebGPU.

MIT Licensenpm @pascal-app/corenpm @pascal-app/viewerDiscordX (Twitter)

pascal_editor.mp4

Using Published Packages

The viewer runtime and built-in node definitions are separate packages. Install the full built-in viewer set, then load the built-in plugin once before mounting <Viewer>:

npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes
import{loadPlugin}from'@pascal-app/core'import{builtinPlugin}from'@pascal-app/nodes'awaitloadPlugin(builtinPlugin)

See the @pascal-app/viewer quick start for a React example.

Repository Architecture

This is a Turborepo monorepo with four main runtime packages:

editor/
├── apps/
│ └── editor/ # Next.js application
├── packages/
│ ├── core/ # Schemas, scene state, and registry contracts
│ ├── viewer/ # 3D rendering runtime and shared systems
│ ├── editor/ # Editing tools and UI components
│ ├── nodes/ # Built-in node definitions, renderers, and systems
│ └── ui/ # Shared UI components

Separation of Concerns

PackageResponsibility
@pascal-app/coreNode schemas, scene state (Zustand), registry contracts, spatial queries, and event bus
@pascal-app/viewer3D rendering via React Three Fiber, shared render systems, default camera/controls, and post-processing
@pascal-app/editorEditing tools, panels, selection, and direct-manipulation UI
@pascal-app/nodesBuilt-in registry plugin with node definitions, renderers, geometry, and systems
apps/editorStandalone Next.js host for the editor packages

The viewer renders the scene with sensible defaults. The editor extends it with interactive tools, selection management, and editing capabilities.

Stores

Each package has its own Zustand store for managing state:

StorePackageResponsibility
useScene@pascal-app/coreScene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo.
useViewer@pascal-app/viewerViewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode.
useEditorapps/editorEditor state: active tool, structure layer visibility, panel states, editor-specific preferences.

Access patterns:

// Subscribe to state changes (React component)constnodes=useScene((state)=>state.nodes)constlevelId=useViewer((state)=>state.selection.levelId)constactiveTool=useEditor((state)=>state.tool)// Access state outside React (callbacks, systems)constnode=useScene.getState().nodes[id]useViewer.getState().setSelection({levelId: 'level_123'})

Core Concepts

Nodes

Nodes are the data primitives that describe the 3D scene. All nodes extend BaseNode:

BaseNode{
id: string// Auto-generated with type prefix (e.g., "wall_abc123")
type: string// Discriminator for type-safe handling
parentId: string|null// Parent node reference
visible: boolean
camera?: Camera// Optional saved camera position
metadata?: JSON// Arbitrary metadata (e.g., { isTransient: true })}

Node Hierarchy:

Site
└── Building
└── Level
├── Wall → Item (doors, windows)
├── Slab
├── Ceiling → Item (lights)
├── Roof
├── Zone
├── Scan (3D reference)
└── Guide (2D reference)

Nodes are stored in a flat dictionary (Record<id, Node>), not a nested tree. Parent-child relationships are defined via parentId and children arrays.


Scene State (Zustand Store)

The scene is managed by a Zustand store in @pascal-app/core:

useScene.getState()={nodes: Record<id,AnyNode>,// All nodesrootNodeIds: string[],// Top-level nodes (sites)dirtyNodes: Set<string>,// Nodes pending system updatescreateNode(node,parentId),updateNode(id,updates),deleteNode(id),}

Middleware:

  • Persist - Saves to IndexedDB (excludes transient nodes)
  • Temporal (Zundo) - Undo/redo with 50-step history

Scene Registry

The registry maps node IDs to their Three.js objects for fast lookup:

sceneRegistry={nodes: Map<id,Object3D>,// ID → 3D objectbyType: {wall: Set<id>,item: Set<id>,zone: Set<id>,// ...}}

Renderers register their refs using the useRegistry hook:

constref=useRef<Mesh>(null!)useRegistry(node.id,'wall',ref)

This allows systems to access 3D objects directly without traversing the scene graph.


Node Renderers

Renderers are React components that create Three.js objects for each node type:

SceneRenderer
└── NodeRenderer (dispatches by type)
├── BuildingRenderer
├── LevelRenderer
├── WallRenderer
├── SlabRenderer
├── ZoneRenderer
├── ItemRenderer
└── ...

Pattern:

  1. Renderer creates a placeholder mesh/group
  2. Registers it with useRegistry
  3. Systems update geometry based on node data

Example (simplified):

constWallRenderer=({ node })=>{constref=useRef<Mesh>(null!)useRegistry(node.id,'wall',ref)return(<meshref={ref}><boxGeometryargs={[0,0,0]}/>{/* Replaced by WallSystem */}<meshStandardMaterial/>{node.children.map(id=><NodeRendererkey={id}nodeId={id}/>)}</mesh>)}

Systems

Systems are React components that run in the render loop (useFrame) to update geometry and transforms. They process dirty nodes marked by the store.

Core Systems (in @pascal-app/core):

SystemResponsibility
WallSystemGenerates wall geometry with mitering and CSG cutouts for doors/windows
SlabSystemGenerates floor geometry from polygons
CeilingSystemGenerates ceiling geometry
RoofSystemGenerates roof geometry
ItemSystemPositions items on walls, ceilings, or floors (slab elevation)

Viewer Systems (in @pascal-app/viewer):

SystemResponsibility
LevelSystemHandles level visibility and vertical positioning (stacked/exploded/solo modes)
ScanSystemControls 3D scan visibility
GuideSystemControls guide image visibility

Processing Pattern:

useFrame(()=>{for(constidofdirtyNodes){constobj=sceneRegistry.nodes.get(id)constnode=useScene.getState().nodes[id]// Update geometry, transforms, etc.updateGeometry(obj,node)dirtyNodes.delete(id)}})

Dirty Nodes

When a node changes, it's marked as dirty in useScene.getState().dirtyNodes. Systems check this set each frame and only recompute geometry for dirty nodes.

// Automatic: createNode, updateNode, deleteNode mark nodes dirtyuseScene.getState().updateNode(wallId,{thickness: 0.2})// → wallId added to dirtyNodes// → WallSystem regenerates geometry next frame// → wallId removed from dirtyNodes

Manual marking:

useScene.getState().dirtyNodes.add(wallId)

Event Bus

Inter-component communication uses a typed event emitter (mitt):

// Node eventsemitter.on('wall:click',(event)=>{ ... })emitter.on('item:enter',(event)=>{ ... })emitter.on('zone:context-menu',(event)=>{ ... })// Grid events (background)emitter.on('grid:click',(event)=>{ ... })// Event payloadNodeEvent{
node: AnyNode
position: [x,y,z]
localPosition: [x,y,z]
normal?: [x,y,z]
stopPropagation: ()=>void}

Spatial Grid Manager

Handles collision detection and placement validation:

spatialGridManager.canPlaceOnFloor(levelId,position,dimensions,rotation)spatialGridManager.canPlaceOnWall(wallId,t,height,dimensions)spatialGridManager.getSlabElevationAt(levelId,x,z)

Used by item placement tools to validate positions and calculate slab elevations.


Editor Architecture

The editor extends the viewer with:

Tools

Tools are activated via the toolbar and handle user input for specific operations:

  • SelectTool - Selection and manipulation
  • WallTool - Draw walls
  • ZoneTool - Create zones
  • ItemTool - Place furniture/fixtures
  • SlabTool - Create floor slabs

Selection Manager

The editor uses a custom selection manager with hierarchical navigation:

Site → Building → Level → Zone → Items

Each depth level has its own selection strategy for hover/click behavior.

Editor-Specific Systems

  • ZoneSystem - Controls zone visibility based on level mode
  • Custom camera controls with node focusing

Data Flow

User Action (click, drag)
↓
Tool Handler
↓
useScene.createNode() / updateNode()
↓
Node added/updated in store
Node marked dirty
↓
React re-renders NodeRenderer
useRegistry() registers 3D object
↓
System detects dirty node (useFrame)
Updates geometry via sceneRegistry
Clears dirty flag

Building a Plugin

The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same Plugin manifest the built-ins use — there is no separate internal API.

  • Developer guideCreate a plugin: the Plugin shape, panel contributions, discovery, lifecycle, and what's in/out of v1.
  • Worked examplepascalorg/plugin-trees: a standalone plugin with procedural trees, flowers, grass, and a presets panel. Clone it as a starting point.

Technology Stack

  • React 19 + Next.js 16
  • Three.js (WebGPU renderer)
  • React Three Fiber + Drei
  • Zustand (state management)
  • Zod (schema validation)
  • Zundo (undo/redo)
  • three-bvh-csg (Boolean geometry operations)
  • Turborepo (monorepo management)
  • Bun (package manager)

Getting Started

Development

Run the development server from the root directory to enable hot reload for all packages:

# Install dependencies
bun install
# Run development server (builds packages + starts editor with watch mode)
bun dev
# This will:# 1. Build @pascal-app/core and @pascal-app/viewer# 2. Start watching both packages for changes# 3. Start the Next.js editor dev server# Open http://localhost:3002

Important: Always run bun dev from the root directory to ensure the package watchers are running. This enables hot reload when you edit files in packages/core/src/ or packages/viewer/src/.

Building for Production

# Build all packages
turbo build
# Build specific package
turbo build --filter=@pascal-app/core

Publishing Packages

# Build packages
turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer
# Publish to npm
npm publish --workspace=@pascal-app/core --access public
npm publish --workspace=@pascal-app/viewer --access public

Key Files

PathDescription
packages/core/src/schema/Node type definitions (Zod schemas)
packages/core/src/store/use-scene.tsScene state store
packages/core/src/hooks/scene-registry/3D object registry
packages/core/src/systems/Geometry generation systems
packages/viewer/src/components/renderers/Node renderers
packages/viewer/src/components/viewer/Main Viewer component
apps/editor/components/tools/Editor tools
apps/editor/store/Editor-specific state

Contributing

Bug fixes, features, docs and ideas are all welcome. Start with CONTRIBUTING.md for setup, code style and the PR flow.


Contributors

Aymeric RabotWassim SamadSudhir


pascalorg/editor | Trendshift

Releases

Sponsor this project

Packages

Contributors

Languages