* feat(materials): dynamic library registry, picker source tabs, create-material entry point Core gains a runtime material registry (registerLibraryMaterials/ unregisterLibraryMaterials/subscribeLibraryMaterials) so embedders can feed user/community materials; library: refs to registered materials resolve in the viewer unchanged. MaterialCatalogItem carries an optional source (pascal|community|mine|workspace). MaterialPicker gets a source filter row and an optional onCreateMaterialRequest '+ New material' tile, threaded through MaterialPaintPanel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(materials): underline source tabs in picker, matching catalog browse surfaces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(materials): add 'other' category for uncategorized library materials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): rebuild node material when a texture slot is cleared Reused cached WebGPU materials keep a compiled TextureNode per slot; nulling the slot for a cold texture load (or a preset without the map) without needsUpdate leaves the node's per-frame material reference pulling null, crashing the render pass in TextureNode.update. Cold loads are the norm for freshly generated library materials, whose maps aren't in the texture cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): standard materials always carry a displacement texture three's WebGPU shadow pass copies each object's displacementMap onto one shared per-light shadow material while caching per-mesh shadow node graphs against that shared override. A mesh whose shadow graph was built with a displacement TextureNode (painted with a generated material — the only presets carrying height maps) crashes the render pass with "null (reading 'matrix')" the moment a material without a displacement texture is swapped onto it, which is exactly what the paint hover preview does. Give every MeshStandardNodeMaterial a shared 1x1 black displacement texel (zero offset) when it has no real height map, so the copied slot is never null in either direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@pascal-app/viewer
3D viewer component for Pascal building editor.
Installation
npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes
Peer Dependencies
npm install next react react-dom three @react-three/fiber @react-three/drei lucide-react zustand
What's Included
- Viewer Component - WebGPU-powered 3D viewer with camera controls
- Node Rendering Runtime - Registry-driven dispatch for node renderers supplied by
@pascal-app/nodes - Post-Processing - SSGI (ambient occlusion + global illumination), TRAA (anti-aliasing), outline effects
- Level System - Level visibility and positioning (stacked/exploded/solo modes)
- Wall Cutout System - Dynamic wall hiding based on camera position
- Asset URL Helpers - CDN URL resolution for models and textures
Usage
import { loadPlugin } from '@pascal-app/core'
import { builtinPlugin } from '@pascal-app/nodes'
import { Viewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
const registryReady = loadPlugin(builtinPlugin)
function App() {
const [ready, setReady] = useState(false)
useEffect(() => {
void registryReady.then(() => setReady(true))
}, [])
if (!ready) return null
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Viewer />
</div>
)
}
Load the built-in plugin once, before mounting any viewer. Without it, the registry has no node
definitions and scene nodes cannot render. Host-provided plugins use the same loadPlugin API.
Custom Camera Controls
import { Viewer } from '@pascal-app/viewer'
import { CameraControls } from '@react-three/drei'
function App() {
return (
<Viewer selectionManager="custom">
<CameraControls />
</Viewer>
)
}
Viewer State
import { useViewer } from '@pascal-app/viewer'
function ViewerControls() {
const levelMode = useViewer(s => s.levelMode)
const setLevelMode = useViewer(s => s.setLevelMode)
const wallMode = useViewer(s => s.wallMode)
const setWallMode = useViewer(s => s.setWallMode)
return (
<div>
<button onClick={() => setLevelMode('stacked')}>Stacked</button>
<button onClick={() => setLevelMode('exploded')}>Exploded</button>
<button onClick={() => setWallMode('cutaway')}>Cutaway</button>
<button onClick={() => setWallMode('up')}>Full Height</button>
</div>
)
}
Asset CDN Helpers
import { resolveCdnUrl, ASSETS_CDN_URL } from '@pascal-app/viewer'
// Resolves relative paths to CDN URLs
const url = resolveCdnUrl('/items/chair/model.glb')
// → 'https://pascal-cdn.wawasensei.dev/items/chair/model.glb'
// Handles external URLs and asset:// protocol
const externalUrl = resolveCdnUrl('https://example.com/model.glb')
// → 'https://example.com/model.glb' (unchanged)
Features
- WebGPU Rendering - Hardware-accelerated rendering via Three.js WebGPU
- Post-Processing - SSGI for realistic lighting, outline effects for selection
- Level Modes - Stacked, exploded, or solo level display
- Wall Cutaway - Automatic wall hiding for interior views
- Camera Modes - Perspective and orthographic projection
- Scan/Guide Support - 3D scans and 2D guide images
License
MIT