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))