diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx new file mode 100644 index 00000000..c4411d7e --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -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 + 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 ? : null + })} + {items.map((item) => { + const object = identity.get(item.pascalId) + return object ? ( + + ) : 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( + , + 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 . + 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( + +
+ {item.interactive.controls.map((control, i) => ( + setControlValue(item.pascalId, i, v)} + value={controlValues[i] ?? false} + /> + ))} +
+ , + object, + ) +} diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 43563b26..cd518ad8 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -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 ? ( + + ) : 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. */} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 4af1f2eb..2550ac36 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -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, diff --git a/packages/viewer/src/systems/interactive/control-widget.tsx b/packages/viewer/src/systems/interactive/control-widget.tsx new file mode 100644 index 00000000..b6648fd7 --- /dev/null +++ b/packages/viewer/src/systems/interactive/control-widget.tsx @@ -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 ( + + ) + } + + if (control.kind === 'slider') { + return ( + + ) + } + + if (control.kind === 'temperature') { + return ( + + ) + } + + return null +} diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx index 3d6e684c..8559f017 100644 --- a/packages/viewer/src/systems/interactive/interactive-system.tsx +++ b/packages/viewer/src/systems/interactive/interactive-system.tsx @@ -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 ( - - ) - } - - if (control.kind === 'slider') { - return ( - - ) - } - - if (control.kind === 'temperature') { - return ( - - ) - } - - return null -}