feat(viewer): re-light + re-control baked GLBs from the scene graph

Add a GLB interactive layer (`GlbInteractive`) that re-creates the item
interactivity the parametric viewer has — point lights and the controls
overlay — on top of a baked artifact. Effects + controls come from the
DB scene graph (joined to baked nodes by `pascalId`, no sidecar); world
transforms come from the baked Object3Ds. Lights are portaled into their
item node so they ride level stacking, and intensity tracks the shared
`useInteractive` store so overlay dimming works. Baked scenes load "lit"
(toggles default on) for a showcase viewer feel.

Extract `ControlWidget` into its own module so the parametric
`InteractiveSystem` and the GLB overlay render identical controls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-24 17:47:07 -04:00
co-authored by Claude Opus 4.8
parent 0e55d86416
commit d80ffd67d5
5 changed files with 373 additions and 85 deletions
@@ -0,0 +1,270 @@
'use client'
import {
type AnyNodeId,
type Control,
type ControlValue,
type Interactive,
type LightEffect,
pointInPolygon,
type SceneGraph,
type SliderControl,
useInteractive,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { createPortal } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import { type Object3D, Vector3 } from 'three'
import { lerp } from 'three/src/math/MathUtils.js'
import { useShallow } from 'zustand/react/shallow'
import useViewer from '../../store/use-viewer'
import { ControlWidget } from '../../systems/interactive/control-widget'
/** An interactive item recovered from the scene graph so the baked GLB can be
* re-lit / re-animated by joining on `pascalId`. The GLB carries the geometry
* + identity; the effects + controls live in the DB scene graph (no sidecar). */
export type GlbInteractiveItem = {
pascalId: AnyNodeId
label: string
/** Item height (world units) for placing the controls overlay above it. */
height: number
interactive: Interactive
}
/** A baked zone's identity node + its local floor polygon (from `extras`). */
export type GlbZoneRef = {
id: string
node: Object3D
polygon: [number, number][]
}
/** Pull the interactive items out of a scene graph. Only items that actually
* carry effects (light / animation) are returned — everything else baked
* faithfully and needs no runtime help. */
export function buildGlbInteractiveItems(
sceneGraph: SceneGraph | null | undefined,
): GlbInteractiveItem[] {
const nodes = sceneGraph?.nodes
if (!nodes) return []
const items: GlbInteractiveItem[] = []
for (const [id, raw] of Object.entries(nodes)) {
const node = raw as {
type?: string
scale?: [number, number, number]
asset?: { name?: string; dimensions?: [number, number, number]; interactive?: Interactive }
}
if (node?.type !== 'item') continue
const interactive = node.asset?.interactive
if (!interactive?.effects?.length) continue
const dims = node.asset?.dimensions ?? [1, 1, 1]
const scaleY = node.scale?.[1] ?? 1
items.push({
pascalId: id as AnyNodeId,
label: node.asset?.name ?? id,
height: (dims[1] ?? 1) * scaleY,
interactive,
})
}
return items
}
/** Light intensity for the current control state. Mirrors the parametric
* `ItemLightSystem`: a missing toggle/slider value means the viewer default
* (lit / full). An explicit toggle-off drops to the range minimum. */
function resolveLightIntensity(
effect: LightEffect,
controls: Control[],
values: ControlValue[] | undefined,
): number {
const toggleIndex = controls.findIndex((c) => c.kind === 'toggle')
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true
if (!isOn) return effect.intensityRange[0]
const sliderIndex = controls.findIndex((c) => c.kind === 'slider')
let t = 1
if (sliderIndex >= 0) {
const slider = controls[sliderIndex] as SliderControl
const raw = (values?.[sliderIndex] as number) ?? slider.default ?? slider.max
t = slider.max > slider.min ? (raw - slider.min) / (slider.max - slider.min) : 1
}
return lerp(effect.intensityRange[0], effect.intensityRange[1], t)
}
const _itemPos = new Vector3()
/**
* Re-creates the item-driven interactivity the parametric viewer has — lights
* and (later) ambient animation + the controls overlay — on top of a baked
* GLB. Effects come from the DB scene graph (`items`); world transforms come
* from the baked Object3Ds (`identity`), joined on `pascalId`. Nothing is
* stamped into the GLB itself, so the artifact stays integrator-clean.
*/
export function GlbInteractive({
items,
identity,
zones,
}: {
items: GlbInteractiveItem[]
identity: Map<string, Object3D>
zones: GlbZoneRef[]
}) {
// Seed control state for every interactive item. The viewer shows a baked
// scene "lit": toggles default ON (the editor defaults them off) and sliders
// to their authored default, so lamps glow and fans spin on load. Explicit
// overlay toggles then win. Cleared on unmount so the global store never
// carries state across scenes.
useEffect(() => {
const store = useInteractive.getState()
for (const item of items) {
store.initItem(item.pascalId, item.interactive)
item.interactive.controls.forEach((control, i) => {
if (control.kind === 'toggle') store.setControlValue(item.pascalId, i, true)
})
}
return () => {
const store = useInteractive.getState()
for (const item of items) store.removeItem(item.pascalId)
}
}, [items])
const lightItems = useMemo(
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')),
[items],
)
// Controls overlay is scoped to the focused zone (matches the parametric
// viewer). Project the zone's baked-local polygon into world space once so an
// item's world position can be point-tested regardless of level stacking.
const focusedZoneId = useViewer((s) => s.selection.zoneId)
const worldPolygon = useMemo<[number, number][] | null>(() => {
if (!focusedZoneId) return null
const zone = zones.find((z) => z.id === focusedZoneId)
if (!zone) return null
zone.node.updateWorldMatrix(true, false)
return zone.polygon.map(([x, z]) => {
const v = new Vector3(x, 0, z).applyMatrix4(zone.node.matrixWorld)
return [v.x, v.z]
})
}, [focusedZoneId, zones])
return (
<>
{lightItems.map((item) => {
const object = identity.get(item.pascalId)
return object ? <GlbItemLight item={item} key={item.pascalId} object={object} /> : null
})}
{items.map((item) => {
const object = identity.get(item.pascalId)
return object ? (
<GlbItemControls
item={item}
key={item.pascalId}
object={object}
worldPolygon={worldPolygon}
/>
) : null
})}
</>
)
}
/** One point light, portaled into its item's baked node so it rides level
* stacking. Intensity tracks the shared interactive store (overlay dimming). */
function GlbItemLight({ item, object }: { item: GlbInteractiveItem; object: Object3D }) {
const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
const effect = item.interactive.effects.find((e) => e.kind === 'light') as LightEffect | undefined
if (!effect) return null
const intensity = resolveLightIntensity(effect, item.interactive.controls, values)
return createPortal(
<pointLight
castShadow={false}
color={effect.color}
decay={2}
distance={effect.distance ?? 0}
intensity={intensity}
position={effect.offset}
/>,
object,
)
}
const FADE_MS = 300
/** Controls overlay for one item — fades in while the item sits inside the
* focused zone, portaled above the baked node. */
function GlbItemControls({
item,
object,
worldPolygon,
}: {
item: GlbInteractiveItem
object: Object3D
worldPolygon: [number, number][] | null
}) {
const controlValues = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
const setControlValue = useInteractive((s) => s.setControlValue)
let visible = false
if (worldPolygon?.length) {
object.getWorldPosition(_itemPos)
visible = pointInPolygon(_itemPos.x, _itemPos.z, worldPolygon)
}
// Fade in on mount and fade out before unmounting the <Html>.
const [mounted, setMounted] = useState(false)
const [shown, setShown] = useState(false)
useEffect(() => {
if (visible) {
setMounted(true)
let raf2 = 0
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setShown(true))
})
return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}
setShown(false)
const timeout = setTimeout(() => setMounted(false), FADE_MS)
return () => clearTimeout(timeout)
}, [visible])
if (!(mounted && controlValues)) return null
return createPortal(
<Html
center
distanceFactor={8}
eps={-1}
position={[0, item.height + 0.3, 0]}
zIndexRange={[20, 0]}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
background: 'rgba(0,0,0,0.75)',
backdropFilter: 'blur(8px)',
borderRadius: 8,
padding: '8px 12px',
minWidth: 120,
pointerEvents: visible ? 'auto' : 'none',
userSelect: 'none',
opacity: shown ? 1 : 0,
transition: `opacity ${FADE_MS}ms ease`,
}}
>
{item.interactive.controls.map((control, i) => (
<ControlWidget
control={control}
key={i}
onChange={(v) => setControlValue(item.pascalId, i, v)}
value={controlValues[i] ?? false}
/>
))}
</div>
</Html>,
object,
)
}
@@ -12,6 +12,7 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
import { ZONE_LAYER } from '../../lib/layers' import { ZONE_LAYER } from '../../lib/layers'
import { createSurfaceRoleMaterial } from '../../lib/materials' import { createSurfaceRoleMaterial } from '../../lib/materials'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive'
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
const EXPLODED_GAP = 5 const EXPLODED_GAP = 5
@@ -279,12 +280,16 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet
*/ */
export function GlbScene({ export function GlbScene({
url, url,
interactiveItems,
onLevelsChange, onLevelsChange,
onIdentityChange, onIdentityChange,
onHoverChange, onHoverChange,
onWalkthroughChange, onWalkthroughChange,
}: { }: {
url: string url: string
/** Light / animation effects + controls recovered from the DB scene graph,
* joined to the baked nodes by `pascalId` to re-light + re-animate the GLB. */
interactiveItems?: GlbInteractiveItem[]
onLevelsChange?: (levels: GlbLevel[]) => void onLevelsChange?: (levels: GlbLevel[]) => void
onIdentityChange?: (identity: GlbIdentity) => void onIdentityChange?: (identity: GlbIdentity) => void
onHoverChange?: (hover: GlbHover) => void onHoverChange?: (hover: GlbHover) => void
@@ -972,6 +977,11 @@ export function GlbScene({
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerOut={handlePointerOut} onPointerOut={handlePointerOut}
/> />
{/* Re-light + re-animate the baked artifact from the DB scene graph,
joined to the baked nodes by pascalId. */}
{interactiveItems?.length ? (
<GlbInteractive identity={identity} items={interactiveItems} zones={zoneEntries} />
) : null}
{/* Floating room labels. Each group's matrix is synced to its zone node {/* Floating room labels. Each group's matrix is synced to its zone node
every frame (above) so the label rides level stacking; the div fades every frame (above) so the label rides level stacking; the div fades
with the room fill via a CSS transition. */} with the room fill via a CSS transition. */}
+5
View File
@@ -18,6 +18,11 @@ export {
default as BVHEcctrl, default as BVHEcctrl,
type MovementInput, type MovementInput,
} from './components/viewer/bvh-ecctrl' } from './components/viewer/bvh-ecctrl'
export {
buildGlbInteractiveItems,
GlbInteractive,
type GlbInteractiveItem,
} from './components/viewer/glb-interactive'
export { export {
type GlbHover, type GlbHover,
type GlbIdentity, type GlbIdentity,
@@ -0,0 +1,87 @@
'use client'
import type { Control, ControlValue } from '@pascal-app/core'
/** One interactive control (toggle / slider / temperature) rendered inside the
* item controls overlay. Shared by the parametric `InteractiveSystem` and the
* baked-GLB `GlbInteractive` overlay so both look and behave identically. */
export const ControlWidget = ({
control,
value,
onChange,
}: {
control: Control
value: ControlValue
onChange: (v: ControlValue) => void
}) => {
const labelStyle: React.CSSProperties = {
color: 'white',
fontSize: 11,
fontFamily: 'monospace',
display: 'flex',
flexDirection: 'column',
gap: 2,
}
if (control.kind === 'toggle') {
return (
<button
onClick={() => onChange(!value)}
style={{
background: value ? '#4ade80' : '#374151',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'monospace',
transition: 'background 0.2s',
}}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
if (control.kind === 'slider') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}
{control.unit ? ` ${control.unit}` : ''}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={control.step}
type="range"
value={value as number}
/>
</label>
)
}
if (control.kind === 'temperature') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}°{control.unit}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={1}
type="range"
value={value as number}
/>
</label>
)
}
return null
}
@@ -2,8 +2,6 @@
import { import {
type AnyNodeId, type AnyNodeId,
type Control,
type ControlValue,
type ItemNode, type ItemNode,
pointInPolygon, pointInPolygon,
sceneRegistry, sceneRegistry,
@@ -17,6 +15,7 @@ import { useEffect, useState } from 'react'
import { type Object3D, Vector3 } from 'three' import { type Object3D, Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { ControlWidget } from './control-widget'
const _tempVec = new Vector3() const _tempVec = new Vector3()
@@ -146,86 +145,3 @@ const ItemControlsOverlay = ({
itemObj, itemObj,
) )
} }
// ---- Control widgets ----
const ControlWidget = ({
control,
value,
onChange,
}: {
control: Control
value: ControlValue
onChange: (v: ControlValue) => void
}) => {
const labelStyle: React.CSSProperties = {
color: 'white',
fontSize: 11,
fontFamily: 'monospace',
display: 'flex',
flexDirection: 'column',
gap: 2,
}
if (control.kind === 'toggle') {
return (
<button
onClick={() => onChange(!value)}
style={{
background: value ? '#4ade80' : '#374151',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'monospace',
transition: 'background 0.2s',
}}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
if (control.kind === 'slider') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}
{control.unit ? ` ${control.unit}` : ''}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={control.step}
type="range"
value={value as number}
/>
</label>
)
}
if (control.kind === 'temperature') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}°{control.unit}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={1}
type="range"
value={value as number}
/>
</label>
)
}
return null
}