Create and share 3D architectural projects.
A 3D building editor built with React Three Fiber and WebGPU. https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b
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
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.
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:
…
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 running pascal mcp setup claude, pascal mcp setup codex, 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.
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
Codex users can install the same plugin from the repository marketplace:
codex plugin marketplace add pascalorg/editor
codex plugin add pascal-agent-skills@pascal
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.
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.
This is a Turborepo monorepo with the reusable editor packages, the standalone app, and the CLI that distributes it:
…
The viewer renders the scene with sensible defaults. The editor extends it with interactive tools, selection management, and editing capabilities.
Each package has its own Zustand store for managing state:
Store Package ResponsibilityuseScene
@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:
// 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' })
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.
The scene is managed by a Zustand store in @pascal-app/core:
useScene.getState() = {
nodes: Record<id, AnyNode>, // All nodes
rootNodeIds: string[], // Top-level nodes (sites)
dirtyNodes: Set<string>, // Nodes pending system updates
createNode(node, parentId),
updateNode(id, updates),
deleteNode(id),
}
Middleware:
The registry maps node IDs to their Three.js objects for fast lookup:
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.
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:
useRegistryExample (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 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):
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):
LevelSystem
Handles level visibility and vertical positioning (stacked/exploded/solo modes)
ScanSystem
Controls 3D scan visibility
GuideSystem
Controls guide image visibility
Processing Pattern:
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)
}
})
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 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)
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
}
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.
The editor extends the viewer with:
Tools are activated via the toolbar and handle user input for specific operations: