* feat(walkthrough): unify first-person and baked walkthrough UI into a shared HUD The builder first-person overlay (crosshair, Exit Street View button, hints card) and the baked-GLB walkthrough HUD were two divergent UIs. Extract the GLB-style HUD (reticle, floor/room labels, Esc pill, interact prompt) into a shared WalkthroughHud in packages/editor, feed it from FirstPersonControls via a small useFirstPersonHud store (interact target each frame, floor/zone labels sampled from the camera), and align FOV/projection handling with the baked controller. The now-unused WalkthroughControls glide controller is removed from packages/viewer (WALKTHROUGH_FOV moves to the GLB controller module). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(viewer-ui): shared ViewerControlsBar + ViewerSceneHeader for preview and embedders Preview mode's ViewerOverlay was an older copy of the community viewer UI (separate scan/guide/camera buttons, own render/theme/edges menus, 4-state wall mode). Extract the community design into shared prop-driven components: ViewerControlsBar (visibility, level/wall modes, display menu, walkthrough, orbit/top view) and ViewerSceneHeader (back, project info, optional stats slot, breadcrumb, levels card). ViewerOverlay is now a thin composition of them. The display menu gains the edges submenu everywhere; the vestigial translucent wall mode is dropped (a stale value renders as cutaway). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(walkthrough): hold-Ctrl crouch + P screenshot pause in both controllers Crouch swaps the capsule for a short one (shrinks around the centre, so a mid-jump crouch lowers the head and raises the feet — enough to thread window openings), lowers the eye with a short lerp, and slows movement; standing back up is gated on headroom via an upward raycast against the collider world. Tuning constants live in the GLB controller module and are shared with the editor first-person controller. P releases the pointer lock without leaving the walkthrough so the cursor is free for an OS screenshot (macOS region capture needs a movable pointer); clicking the canvas re-locks and resumes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(walkthrough): crouched profile fits ~1 m openings The float gap counts toward the effective obstacle height — the capsule rides floatHeight (0.5 m) above the ground, so the old crouch spanned 0.5–1.3 m and a 1.14 m opening still blocked it. Crouching now also lowers the float gap (0.25 m) and uses a shorter capsule (0.7 m), for an effective 0.25–0.95 m span; the stand-up headroom check grows to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(walkthrough): seamless screenshot pause — auto-release pointer lock on ⌘ Replace the P shortcut: macOS swallows the full ⇧⌘4 but the ⌘-down keystroke still reaches the page, so the moment ⌘ (or PrintScreen) goes down while locked the cursor is released without leaving the walkthrough — the native screenshot flow just works, no user education. The HUD pill flips to "Click to resume" (click-through, so the resuming click lands on the canvas) via a new walkthroughSuspended flag on the viewer store, reset on lock/exit/unmount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(walkthrough): screenshot pause is P again, advertised in the HUD The ⌘ auto-release fired on every command combo and felt broken. Back to an explicit P toggle, now discoverable: the HUD bottom shows a "P free cursor" pill next to "Esc to exit", and while paused it flips to "Click or P to resume · Esc to exit" (click-through so the resuming click lands on the canvas). P re-locks as well as releasing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(walkthrough): freeze crouch during cursor pause; no editor hints in first person While the P pause is active, Ctrl no longer toggles crouch — ⌃⇧⌘4 (clipboard screenshot) was crouching the player mid-capture; the held state stays frozen until resume. HelperManager now renders nothing in first-person mode, so the Ctrl multi-select hint no longer pops over the walkthrough HUD (Ctrl is crouch there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: biome formatting + pre-existing useOptionalChain fix in wall panel The wall-panel lint error predates this branch (#526); fixed here to unblock the quality gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Pascal Editor
A 3D building editor built with React Three Fiber and WebGPU.
https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b
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'
await loadPlugin(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
| 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/editor | Editing tools, panels, selection, and direct-manipulation UI |
| @pascal-app/nodes | Built-in registry plugin with node definitions, renderers, geometry, and systems |
| 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:
// 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:
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 nodes
rootNodeIds: string[], // Top-level nodes (sites)
dirtyNodes: Set<string>, // Nodes pending system updates
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:
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:
- Renderer creates a placeholder mesh/group
- Registers it with
useRegistry - 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:
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.
// 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:
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 |