Pascal Editor
An open-source, local-first 3D building editor built with React Three Fiber and WebGPU. Run it in the browser or from the CLI, and connect AI agents through MCP.
@pascal-app/core](https://img.shields.io/npm/v/@pascal-app/core?label=%40pascal-app%2Fcore)](https://www.npmjs.com/package/@pascal-app/core) [](https://www.npmjs.com/package/@pascal-app/viewer)
(Twitter)](https://img.shields.io/badge/follow-%40pascal__app-black?logo=x&logoColor=white)](https://x.com/pascal_app)
https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b
Run the Editor Locally
Node.js 22.13 or newer can create a persistent local Pascal installation without cloning this repository:
npx @pascal-app/cli editor
The CLI starts the editor and an authenticated MCP service in the background, selects collision-free loopback ports, and keeps projects in
~/.pascal/data/pascal.db. Configure an agent to launch pascal mcp connect. See [Run Pascal
locally](https://editor.pascal.app/docs/developers/local-editor) for pnpm/Bun commands, project management, MCP setup, updates, storage paths, and
troubleshooting. The npm release is the older runtime described below; use the verified GitHub preview when a task needs the new read-only furniture
candidate check.
Candidate-enabled CLI preview
The npm beta tag currently resolves to @pascal-app/[email protected], which predates the read-only furniture candidate input in this repository. To
use that capability before the next npm release, install the verified GitHub prerelease built from commit aa653f2f523f81f361ac20cb42b745faf7e46844:
```bash PASCAL_PREVIEW_VERSION='1.0.0-beta.1.agent-skills.0' PASCAL_PREVIEW_PREFIX="${XDG_DATA_HOME:-$HOME/.local/share}/pascal-preview"
PASCAL_PREVIEW_DOWNLOAD="$(mktemp -d)" cd "$PASCAL_PREVIEW_DOWNLOAD"
curl --fail --location --remote-name \
"https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.1-agent-skills.0/pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" curl --fail
--location --remote-name \ "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.1-agent-skills.0/SHA256SUMS.txt"
macOS
shasum -a 256 -c SHA256SUMS.txt
Linux: use sha256sum -c SHA256SUMS.txt instead.
npm install --global --prefix "$PASCAL_PREVIEW_PREFIX" --ignore-scripts \ "./pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" export
PATH="$PASCAL_PREVIEW_PREFIX/bin:$PATH" pascal --version pascal update --version "$PASCAL_PREVIEW_VERSION" pascal editor --no-open ```
The expected archive SHA-256 is 814ffa8c6f6a5fced73bf909c616d9a78feff18fd61fd0b4b7d65e74fad5a33d. The same-version update command installs and
activates this CLI's bundled runtime, restarting an older running service when necessary. Keep an existing PASCAL_HOME unchanged so stored projects
remain in the same data directory; pascal editor alone reuses any healthy service, including an older one. Keep the preview prefix on the agent
host's PATH before using the Claude plugin-provided connector, running pascal mcp setup claude or pascal mcp setup codex for another
installation path, or configuring pascal mcp connect manually. This GitHub prerelease is not an npm version.
Use one active agent client per local CLI service. The standalone local HTTP runtime shares active scene state between clients; use separate
PASCAL_HOME directories and service processes when independent concurrent work is required.
Agent skills
Install Pascal's public agent workflows from this repository with skills.sh:
npx skills add pascalorg/editor \
--skill pascal-3d \
--skill furniture-fit
Claude Code users can install the same canonical skill source as a plugin:
/plugin marketplace add pascalorg/editor
/plugin install pascal-agent-skills@pascal
The Claude plugin also supplies the local pascal mcp connect server. Install and start the Pascal CLI first, and keep pascal on the PATH used to
launch Claude Code. This local connector needs no Pascal account or API key and does not upload projects automatically.
Claude Code 2.1.258 loads both the user-scoped pascal server created by pascal mcp setup claude and the plugin-provided server. Remove the manual
entry before reloading or restarting Claude Code so only the plugin owns the connection lifecycle:
claude mcp remove --scope user pascal
Use /mcp to remove or disable any project- or local-scoped Pascal connection too. Leaving both connections active violates the
one-active-agent-client-per-local-service requirement. When the intended project is hosted in a Pascal account or organization, disable the
plugin-provided local server in /mcp and configure the hosted endpoint from the skill setup guide instead.
Codex users can install the same plugin from the repository marketplace:
codex plugin marketplace add pascalorg/editor
codex plugin add pascal-agent-skills@pascal
OpenClaw installation becomes available after the skills are published under Pascal's ClawHub publisher. See skills/README.md for
the owner-qualified install and verification commands.
pascal-3d covers safe local or hosted MCP setup and verified scene work.
furniture-fit produces a bounded, evidence-based footprint assessment without claiming unsupported height, swing,
or delivery checks. See skills/README.md for package details and validation.
The skills inspect the connected MCP tool schemas before using optional fields. A capability present in this repository may be absent from an older
installed or hosted release; the agent should report the narrower supported result instead of assuming source-only inputs are available.
These workflows require a connected Pascal MCP server for their tool-backed actions. An OpenAI directory submission must therefore use With MCP
and submit the production hosted MCP endpoint together with the skills. The repository package does not prove that the endpoint, OAuth flow, reviewer
credentials, domain verification, or portal scan is ready for review.
MCP Registry
server.json is Pascal's manifest for the official MCP Registry. Its version tracks the hosted MCP implementation independently of the
npm package version. Pull requests validate the manifest and production endpoint. A Pascal organization owner publishes an approved version from
main with the official registry publisher.
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>. Capture sessions are an optional transport-neutral extension:
npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes
npm install @pascal-app/capture-protocol @pascal-app/capture-viewer
import { loadPlugin } from '@pascal-app/core'
import { builtinPlugin } from '@pascal-app/nodes'
await loadPlugin(builtinPlugin)
See the @pascal-app/viewer quick start for a React example.
Repository Architecture
This is a Turborepo monorepo with the reusable editor packages, the standalone app, and the CLI that distributes it:
``` editor/ ├── apps/ │ └── editor/ # Next.js application ├── packages/ │ ├── core/ # Schemas, scene state, and registry contracts │ ├── viewer/ # 3D
rendering runtime and shared systems │ ├── capture-protocol/ # Static/live capture-session contracts │ ├── capture-viewer/ # Capture source runtime
and reference renderers │ ├── editor/ # Editing tools and UI components │ ├── nodes/ # Built-in node definitions, renderers, and systems │ ├── cli/ #
Persistent local editor installer and process manager │ ├── mcp/ # Model Context Protocol server and scene storage │ └── ui/ # Shared UI components
### Separation of Concerns
| Package | Responsibility | |---------|---------------| | **@pascal-app/core** | Node schemas, scene state (Zustand), registry contracts, spatial
queries, and event bus | | **@pascal-app/viewer** | 3D rendering via React Three Fiber, shared render systems, default camera/controls, and
post-processing | | **@pascal-app/capture-protocol** | Versioned capture manifests, normalized streams, and transport-neutral static/live sources | |
**@pascal-app/capture-viewer** | Viewer child runtime and reference model, device-motion, and point-cloud layers | | **@pascal-app/editor** | Editing
tools, panels, selection, and direct-manipulation UI | | **@pascal-app/nodes** | Built-in registry plugin with node definitions, renderers, geometry,
and systems | | **@pascal-app/cli** | Installs and manages a versioned standalone editor runtime and persistent local data | | **@pascal-app/mcp** |
Exposes scene tools, resources, prompts, and local storage to MCP-compatible AI hosts | | **apps/editor** | Standalone 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:
| Store | Package | Responsibility | |-------|---------|----------------| | `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty
nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. | | `useViewer` | `@pascal-app/viewer` | Viewer state: current selection
(building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. | | `useEditor` | `apps/editor` | Editor state: active tool,
structure layer visibility, panel states, editor-specific preferences. |
**Access patterns:**
```typescript // Subscribe to state changes (React component) const nodes = useScene((state) => state.nodes) const levelId = useViewer((state) =>
state.selection.levelId) const activeTool = useEditor((state) => state.tool)
// Access state outside React (callbacks, systems)
const node = 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:
```typescript 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:
```typescript useScene.getState() = { nodes: Record
Set
createNode(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:
```typescript
sceneRegistry = {
nodes: Map<id, Object3D>, // ID → 3D object
byType: {
wall: Set<id>,
item: Set<id>,
zone: Set<id>,
// ...
}
}
Renderers register their refs using the useRegistry hook:
const ref = 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):
const WallRenderer = ({ node }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
return ( <mesh ref={ref}> <boxGeometry args={[0, 0, 0]} /> {/* Replaced by WallSystem */} <meshStandardMaterial /> {node.children.map(id =>
<NodeRenderer key={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`):**
| System | Responsibility | |--------|---------------| | `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | |
`SlabSystem` | Generates floor geometry from polygons | | `CeilingSystem` | Generates ceiling geometry | | `RoofSystem` | Generates roof geometry | |
`ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |
**Viewer Systems (in `@pascal-app/viewer`):**
| System | Responsibility | |--------|---------------| | `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo
modes) | | `ScanSystem` | Controls 3D scan visibility | | `GuideSystem` | Controls guide image visibility |
**Processing Pattern:**
```typescript
useFrame(() => {
for (const id of dirtyNodes) {
const obj = sceneRegistry.nodes.get(id)
const node = 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.
```typescript // Automatic: createNode, updateNode, deleteNode mark nodes dirty useScene.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 events
emitter.on('wall:click', (event) => { ... })
emitter.on('item:enter', (event) => { ... })
emitter.on('zone:context-menu', (event) => { ... })
// Grid events (background)
emitter.on('grid:click', (event) => { ... })
// Event payload
NodeEvent {
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:
```typescript 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 guide — Create a plugin: the
Pluginshape, panel contributions, discovery,
lifecycle, and what's in/out of v1. - Worked example — pascalorg/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
| Path | Description | |------|-------------| | packages/core/src/schema/ | Node type definitions (Zod schemas) | |
packages/core/src/store/use-scene.ts | Scene 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.
- New node kinds and sidebar panels ship as plugins rather than edits to the built-ins —
pascalorg/plugin-trees is a worked example - Questions and ideas go to
Discussions; reproducible bugs go to Issues -
Participation is covered by our Code of Conduct - Security problems go to SECURITY.md, not a public issue
Contributors
style="border-radius:50%"> <img src="https://avatars.githubusercontent.com/u/6551176?v=4" width="60"
height="60" alt="Wassim Samad" style="border-radius:50%"> <img
src="https://avatars.githubusercontent.com/sudhir9297?v=4" width="60" height="60" alt="Sudhir" style="border-radius:50%">