sync: update core and viewer from monorepo (#143)

Major changes:
- Roof system rewrite with roof-segment support
- Scene store refactor
- Spatial grid improvements
- Item light system
- Post-processing and selection manager updates
- Perf monitor component

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-03-20 23:18:30 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bafc5973a3
commit 1d28e4208f
32 changed files with 1921 additions and 619 deletions
@@ -4,7 +4,6 @@ import {
type Interactive,
type ItemNode,
type LightEffect,
type SliderControl,
useInteractive,
useRegistry,
useScene,
@@ -14,12 +13,13 @@ import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { useFrame } from '@react-three/fiber'
import { Suspense, useEffect, useMemo, useRef } from 'react'
import type { AnimationAction, Group, Material, Mesh, PointLight } from 'three'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { useItemLightPool } from '../../../store/use-item-light-pool'
import { NodeRenderer } from '../node-renderer'
// Shared materials to avoid creating new instances for every mesh
@@ -167,7 +167,13 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
/>
)}
{lightEffects.map((effect, i) => (
<ItemLight effect={effect} interactive={interactive!} key={i} nodeId={node.id} />
<ItemLightRegistrar
effect={effect}
index={i}
interactive={interactive!}
key={i}
nodeId={node.id}
/>
))}
</>
)
@@ -245,54 +251,22 @@ const ItemAnimation = ({
return null
}
const ItemLight = ({
const ItemLightRegistrar = ({
nodeId,
effect,
interactive,
index,
}: {
nodeId: AnyNodeId
effect: LightEffect
interactive: Interactive
index: number
}) => {
const lightRef = useRef<PointLight>(null!)
// Precompute stable indices — interactive is frozen at mount
const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')
const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider')
const sliderControl =
sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null
useEffect(() => {
const key = `${nodeId}:${index}`
useItemLightPool.getState().register(key, nodeId, effect, interactive)
return () => useItemLightPool.getState().unregister(key)
}, [nodeId, index, effect, interactive])
useFrame((_, delta) => {
if (!lightRef.current) return
const values = useInteractive.getState().items[nodeId]?.controlValues
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : true
// Normalize slider to 0-1 (default full intensity if no slider)
let t = 1
if (sliderControl) {
const raw = (values?.[sliderIndex] as number) ?? sliderControl.min
t = (raw - sliderControl.min) / (sliderControl.max - sliderControl.min)
}
const target = isOn
? MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], t)
: effect.intensityRange[0]
lightRef.current.intensity = MathUtils.lerp(
lightRef.current.intensity,
target,
Math.min(delta * 12, 1),
)
})
return (
<pointLight
castShadow={false}
color={effect.color}
distance={effect.distance ?? 0}
intensity={effect.intensityRange[0]}
position={effect.offset}
ref={lightRef}
/>
)
return null
}
@@ -8,6 +8,7 @@ import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer'
@@ -33,6 +34,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer node={node} />}
{node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />}
{node.type === 'scan' && <ScanRenderer node={node} />}
{node.type === 'guide' && <GuideRenderer node={node} />}
</>
@@ -0,0 +1,29 @@
import { type RoofSegmentNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!)
useRegistry(node.id, 'roof-segment', ref)
const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors)
return (
<mesh
material={debugColors ? roofDebugMaterials : roofMaterials}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
{/* RoofSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
@@ -0,0 +1,18 @@
import * as THREE from 'three'
// Production materials — match the rest of the scene (white walls, light-gray slabs).
// Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle
export const roofMaterials: THREE.Material[] = [
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 0: Wall/Trim
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 1, side: THREE.FrontSide }), // 1: Deck
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 2: Interior
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
]
// Debug materials — vivid, distinct colours to identify each surface group.
export const roofDebugMaterials: THREE.Material[] = [
new THREE.MeshStandardMaterial({ color: '#eaeaea', roughness: 0.8, side: THREE.DoubleSide }), // 0: Wall
new THREE.MeshStandardMaterial({ color: '#000000', roughness: 0.9, side: THREE.FrontSide }), // 1: Deck
new THREE.MeshStandardMaterial({ color: '#dddddd', roughness: 0.9, side: THREE.DoubleSide }), // 2: Interior
new THREE.MeshStandardMaterial({ color: '#4ade80', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
]
@@ -1,28 +1,40 @@
import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import type { Mesh } from 'three'
import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials'
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const ref = useRef<Mesh>(null!)
const ref = useRef<THREE.Group>(null!)
useRegistry(node.id, 'roof', ref)
const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors)
return (
<mesh
castShadow
<group
position={node.position}
receiveShadow
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
{/* RoofSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
<meshStandardMaterial color="white" />
</mesh>
<mesh
castShadow
material={debugColors ? roofDebugMaterials : roofMaterials}
name="merged-roof"
receiveShadow
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
</group>
)
}
@@ -19,10 +19,10 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
// Create a simple line loop at ground level
for (const [x, z] of points) {
positions.push(x!, Y_OFFSET, z!)
positions.push(x ?? 0, Y_OFFSET, z ?? 0)
}
// Close the loop
positions.push(points[0]![0]!, Y_OFFSET, points[0]![1]!)
positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0)
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
@@ -10,11 +10,12 @@ import {
WindowSystem,
} from '@pascal-app/core'
import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame } from '@react-three/fiber'
import { useMemo, useRef } from 'react'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { LevelSystem } from '../../systems/level/level-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
@@ -22,6 +23,7 @@ import { ZoneSystem } from '../../systems/zone/zone-system'
import { SceneRenderer } from '../renderers/scene-renderer'
import { GroundOccluder } from './ground-occluder'
import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor'
import PostProcessing from './post-processing'
import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera'
@@ -59,12 +61,44 @@ declare module '@react-three/fiber' {
extend(THREE as any)
/**
* Monitors the WebGPU device for loss events and logs them.
* WebGPU device loss can happen when:
* - Tab is backgrounded and OS reclaims GPU
* - Driver crash or GPU reset
* - Browser security policy kills the context
*/
function GPUDeviceWatcher() {
const gl = useThree((s) => s.gl)
useEffect(() => {
const backend = (gl as any).backend
const device: GPUDevice | undefined = backend?.device
if (!device) return
device.lost.then((info) => {
console.error(
`[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` +
'The page must be reloaded to recover the GPU context.',
)
})
}, [gl])
return null
}
interface ViewerProps {
children?: React.ReactNode
selectionManager?: 'default' | 'custom'
perf?: boolean
}
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => {
const Viewer: React.FC<ViewerProps> = ({
children,
selectionManager = 'default',
perf = false,
}) => {
const theme = useViewer((state) => state.theme)
return (
@@ -72,11 +106,10 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]}
gl={async (props) => {
gl={(props) => {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
await renderer.init()
return renderer
}}
shadows={{
@@ -110,11 +143,22 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
<WindowSystem />
<ZoneSystem />
<PostProcessing />
{/* <DebugRenderer /> */}
<GPUDeviceWatcher />
<ItemLightSystem />
{selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />}
{children}
</Canvas>
)
}
const DebugRenderer = () => {
useFrame(({ gl, scene, camera }) => {
gl.render(scene, camera)
})
return null
}
export default Viewer
@@ -0,0 +1,60 @@
import { useScene } from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useRef, useState } from 'react'
const SAMPLE_INTERVAL = 0.5 // seconds between display updates
export const PerfMonitor = () => {
const [stats, setStats] = useState({ fps: 0, frameMs: 0, drawCalls: 0, triangles: 0, dirty: 0 })
const frameCount = useRef(0)
const elapsed = useRef(0)
const lastMs = useRef(0)
useFrame(({ gl, clock }) => {
frameCount.current++
const now = clock.elapsedTime
const dt = now - elapsed.current
if (dt >= SAMPLE_INTERVAL) {
const fps = Math.round(frameCount.current / dt)
const frameMs = lastMs.current
const info = gl.info
const drawCalls = info.render?.calls ?? 0
const triangles = info.render?.triangles ?? 0
const dirty = useScene.getState().dirtyNodes.size
setStats({ fps, frameMs, drawCalls, triangles, dirty })
frameCount.current = 0
elapsed.current = now
}
lastMs.current = Math.round(clock.getDelta() * 1000 * 10) / 10
})
return (
<Html
position={[0, 0, 0]}
style={{ position: 'fixed', top: 8, left: 8, pointerEvents: 'none' }}
zIndexRange={[100, 100]}
>
<div
style={{
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.5,
color: stats.fps < 30 ? '#f87171' : stats.fps < 55 ? '#fbbf24' : '#4ade80',
background: 'rgba(0,0,0,0.7)',
borderRadius: 6,
padding: '6px 10px',
whiteSpace: 'pre',
}}
>
{`FPS ${stats.fps}
DRAW ${stats.drawCalls}
TRI ${(stats.triangles / 1000).toFixed(1)}k
DIRTY ${stats.dirty}`}
</div>
</Html>
)
}
@@ -1,9 +1,10 @@
import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Color, Layers, UnsignedByteType } from 'three'
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { traa } from 'three/addons/tsl/display/TRAANode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import {
add,
colorToDirection,
@@ -22,7 +23,6 @@ import {
vec4,
velocity,
} from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import useViewer from '../../store/use-viewer'
@@ -30,19 +30,22 @@ import useViewer from '../../store/use-viewer'
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
export const SSGI_PARAMS = {
enabled: true,
sliceCount: 2,
stepCount: 8,
sliceCount: 1,
stepCount: 4,
radius: 1,
expFactor: 1.5,
thickness: 0.5,
backfaceLighting: 0.5,
aoIntensity: 1.5,
giIntensity: 0.5,
giIntensity: 0,
useLinearThickness: false,
useScreenSpaceSampling: true,
useTemporalFiltering: true,
useTemporalFiltering: false,
}
const MAX_PIPELINE_RETRIES = 3
const RETRY_DELAY_MS = 500
const DARK_BG = '#1f2433'
const LIGHT_BG = '#ffffff'
@@ -50,6 +53,7 @@ const PostProcessingPasses = () => {
const { gl: renderer, scene, camera } = useThree()
const renderPipelineRef = useRef<RenderPipeline | null>(null)
const hasPipelineErrorRef = useRef(false)
const retryCountRef = useRef(0)
const [isInitialized, setIsInitialized] = useState(false)
// Background color uniform — updated every frame via lerp, read by the TSL pipeline.
@@ -66,6 +70,18 @@ const PostProcessingPasses = () => {
return l
}, [])
// Subscribe to projectId so the pipeline rebuilds on project switch
const projectId = useViewer((s) => s.projectId)
// Bump this to force a pipeline rebuild (used by retry logic)
const [pipelineVersion, setPipelineVersion] = useState(0)
const requestPipelineRebuild = useCallback(() => {
setPipelineVersion((v) => v + 1)
}, [])
// Renderer initialization
useEffect(() => {
let mounted = true
@@ -93,6 +109,12 @@ const PostProcessingPasses = () => {
}
}, [renderer])
// Reset retry count when project changes
useEffect(() => {
retryCountRef.current = 0
}, [])
// Build / rebuild the post-processing pipeline
useEffect(() => {
if (!(renderer && scene && camera && isInitialized)) {
return
@@ -100,6 +122,12 @@ const PostProcessingPasses = () => {
hasPipelineErrorRef.current = false
// Clear outliner arrays synchronously to prevent stale Object3D refs
// from the previous project leaking into the new pipeline's outline passes.
const outliner = useViewer.getState().outliner
outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0
try {
// Scene pass with MRT for SSGI
const scenePass = pass(scene, camera)
@@ -148,9 +176,20 @@ const PostProcessingPasses = () => {
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
// Extract GI and AO from SSGI pass
const giTexture = (giPass as any).getTextureNode()
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
// convertToTexture() inside denoise() will call rtt() on this vec4 node automatically.
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
denoisePass.index.value = 0
denoisePass.radius.value = 4
const gi = giPass.rgb
const ao = giPass.a
const ao = (denoisePass as any).r
// const gi = giPass.rgb;
// const ao = giPass.a;
// Background detection via alpha: renderer clears with alpha=0 (setClearAlpha(0) in useFrame),
// so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1.
@@ -272,12 +311,24 @@ const PostProcessingPasses = () => {
renderPipelineRef.current.render()
} catch (error) {
hasPipelineErrorRef.current = true
console.error(
'[viewer] Post-processing render pass failed. Disabling post FX for this session.',
error,
)
renderPipelineRef.current.dispose()
console.error('[viewer] Post-processing render pass failed.', error)
if (renderPipelineRef.current) {
renderPipelineRef.current.dispose()
}
renderPipelineRef.current = null
if (retryCountRef.current < MAX_PIPELINE_RETRIES) {
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
retryCountRef.current++
console.warn(
`[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
)
setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
} else {
console.error(
'[viewer] Post-processing retries exhausted. Rendering without post FX for this session.',
)
}
}
}, 1)
@@ -2,6 +2,7 @@
import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
emitter,
type ItemNode,
@@ -34,6 +35,7 @@ type SelectableNodeType =
| 'slab'
| 'ceiling'
| 'roof'
| 'roof-segment'
// Expand polygon outward by a small amount to include items on edges
const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {
@@ -150,7 +152,7 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
return false
}
if (node.type === 'roof') {
if (node.type === 'roof' || node.type === 'roof-segment') {
// Roofs on the same level are valid when zone is selected
return true
}
@@ -219,12 +221,20 @@ const getStrategy = (): SelectionStrategy | null => {
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
return {
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'],
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
handleClick: (node, nativeEvent) => {
let nodeToSelect = node
if (node.type === 'roof-segment' && node.parentId) {
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
if (parentNode && parentNode.type === 'roof') {
nodeToSelect = parentNode
}
}
const { selectedIds } = useViewer.getState().selection
useViewer
.getState()
.setSelection({ selectedIds: computeNextIds(node, selectedIds, nativeEvent) })
.setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) })
},
handleDeselect: () => {
const { selectedIds } = useViewer.getState().selection
@@ -236,7 +246,16 @@ const getStrategy = (): SelectionStrategy | null => {
}
},
isValid: (node) => {
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door']
const validTypes = [
'wall',
'item',
'slab',
'ceiling',
'roof',
'roof-segment',
'window',
'door',
]
if (!validTypes.includes(node.type)) return false
return isNodeInZone(node, levelId, zoneId)
},
@@ -288,6 +307,7 @@ export const SelectionManager = () => {
'slab',
'ceiling',
'roof',
'roof-segment',
'window',
'door',
]