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,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
}