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:
co-authored by
Claude Opus 4.8
parent
0e55d86416
commit
d80ffd67d5
@@ -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 { createSurfaceRoleMaterial } from '../../lib/materials'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive'
|
||||
|
||||
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
||||
const EXPLODED_GAP = 5
|
||||
@@ -279,12 +280,16 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet
|
||||
*/
|
||||
export function GlbScene({
|
||||
url,
|
||||
interactiveItems,
|
||||
onLevelsChange,
|
||||
onIdentityChange,
|
||||
onHoverChange,
|
||||
onWalkthroughChange,
|
||||
}: {
|
||||
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
|
||||
onIdentityChange?: (identity: GlbIdentity) => void
|
||||
onHoverChange?: (hover: GlbHover) => void
|
||||
@@ -972,6 +977,11 @@ export function GlbScene({
|
||||
onPointerMove={handlePointerMove}
|
||||
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
|
||||
every frame (above) so the label rides level stacking; the div fades
|
||||
with the room fill via a CSS transition. */}
|
||||
|
||||
@@ -18,6 +18,11 @@ export {
|
||||
default as BVHEcctrl,
|
||||
type MovementInput,
|
||||
} from './components/viewer/bvh-ecctrl'
|
||||
export {
|
||||
buildGlbInteractiveItems,
|
||||
GlbInteractive,
|
||||
type GlbInteractiveItem,
|
||||
} from './components/viewer/glb-interactive'
|
||||
export {
|
||||
type GlbHover,
|
||||
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 {
|
||||
type AnyNodeId,
|
||||
type Control,
|
||||
type ControlValue,
|
||||
type ItemNode,
|
||||
pointInPolygon,
|
||||
sceneRegistry,
|
||||
@@ -17,6 +15,7 @@ import { useEffect, useState } from 'react'
|
||||
import { type Object3D, Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { ControlWidget } from './control-widget'
|
||||
|
||||
const _tempVec = new Vector3()
|
||||
|
||||
@@ -146,86 +145,3 @@ const ItemControlsOverlay = ({
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user