Skip to content

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

Repository Architecture

This is a Turborepo monorepo with three main packages:

editor-v2/
├── apps/
│ └── editor/ # Next.js application
├── packages/
│ ├── core/ # Schema definitions, state management, systems
│ └── viewer/ # 3D rendering components

Separation of Concerns

PackageResponsibility
@pascal-app/coreNode schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus
@pascal-app/viewer3D rendering via React Three Fiber, default camera/controls, post-processing
apps/editorUI components, tools, custom behaviors, editor-specific systems

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

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:3000

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

Releases are managed with Changesets. Publishing is automated — you don't run npm publish by hand.

  1. In any PR that changes a published package, add a changeset describing the change:

    bun changeset

    Pick the affected packages and bump type (patch/minor/major); commit the generated file in .changeset/.

  2. When the PR merges to main, the Release workflow opens (or updates) a Version Packages PR that applies the pending changesets — bumping versions and writing changelogs.

  3. Merging that Version Packages PR publishes the affected packages to npm and tags the release.


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

Contributors

Aymeric RabotWassim SamadSudhir


pascalorg/editor | Trendshift

About

Create and share 3D architectural projects.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages