feat(viewer): pool GLB item lights + stop overlay click propagation

Match the parametric ItemLightSystem instead of mounting a light per item:
a fixed pool of point lights is assigned to the nearest/most-visible lit
items each tick (camera-proximity scored, hysteresis, level factor), snapped
to each item's world position + offset, and faded on reassignment — so a large
house doesn't blow the renderer's light budget. The controls overlay already
mounts its <Html> only while the item sits in the focused zone (not hide/show);
add stopPropagation on the overlay so toggling a control no longer bubbles to
the canvas and deselects the zone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 07:34:41 -04:00
co-authored by Claude Opus 4.8
parent 0514ec1858
commit a5c08b5950
2 changed files with 322 additions and 57 deletions
@@ -2,8 +2,6 @@
import { import {
type AnyNodeId, type AnyNodeId,
type Control,
type ControlValue,
type Interactive, type Interactive,
type LightEffect, type LightEffect,
pointInPolygon, pointInPolygon,
@@ -12,10 +10,9 @@ import {
useInteractive, useInteractive,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { createPortal } from '@react-three/fiber' import { createPortal, useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { type AnimationAction, LoopRepeat, type Object3D, Vector3 } from 'three' import { type AnimationAction, LoopRepeat, MathUtils, type Object3D, type PointLight, Vector3 } from 'three'
import { lerp } from 'three/src/math/MathUtils.js'
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 '../../systems/interactive/control-widget' import { ControlWidget } from '../../systems/interactive/control-widget'
@@ -68,41 +65,21 @@ export function buildGlbInteractiveItems(
return items 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() const _itemPos = new Vector3()
/** /**
* Re-creates the item-driven interactivity the parametric viewer has — lights * Re-creates the item-driven interactivity the parametric viewer has — pooled
* and (later) ambient animation + the controls overlay — on top of a baked * lights, ambient animation, and the controls overlay — on top of a baked GLB.
* GLB. Effects come from the DB scene graph (`items`); world transforms come * Effects come from the DB scene graph (`items`); world transforms come from the
* from the baked Object3Ds (`identity`), joined on `pascalId`. Nothing is * baked Object3Ds (`identity`), joined on `pascalId`. Nothing is stamped into
* stamped into the GLB itself, so the artifact stays integrator-clean. * the GLB itself, so the artifact stays integrator-clean.
*/ */
export function GlbInteractive({ export function GlbInteractive({
items, items,
identity, identity,
zones, zones,
actions, actions,
levelOrder,
}: { }: {
items: GlbInteractiveItem[] items: GlbInteractiveItem[]
identity: Map<string, Object3D> identity: Map<string, Object3D>
@@ -110,6 +87,9 @@ export function GlbInteractive({
/** Baked animation actions keyed by clip name — ambient item loops play from /** Baked animation actions keyed by clip name — ambient item loops play from
* `<pascalId>: loop`. */ * `<pascalId>: loop`. */
actions: Record<string, AnimationAction | null> actions: Record<string, AnimationAction | null>
/** Level pascalIds bottom-to-top, so the light pool can prefer ground-floor
* lights when nothing is focused (mirrors the parametric level factor). */
levelOrder: string[]
}) { }) {
// Seed control state for every interactive item. The viewer shows a baked // Seed control state for every interactive item. The viewer shows a baked
// scene "lit": toggles default ON (the editor defaults them off) and sliders // scene "lit": toggles default ON (the editor defaults them off) and sliders
@@ -130,15 +110,45 @@ export function GlbInteractive({
} }
}, [items]) }, [items])
const lightItems = useMemo(
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')),
[items],
)
const animationItems = useMemo( const animationItems = useMemo(
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')), () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')),
[items], [items],
) )
// Light registrations: one per item with a light effect, joined to its baked
// node. Fed to a fixed pool (below) rather than mounting a light per item.
const lightRegs = useMemo<GlbLightReg[]>(() => {
const regs: GlbLightReg[] = []
for (const item of items) {
const effect = item.interactive.effects.find((e) => e.kind === 'light') as
| LightEffect
| undefined
if (!effect) continue
const object = identity.get(item.pascalId)
if (!object) continue
const controls = item.interactive.controls
const toggleIndex = controls.findIndex((c) => c.kind === 'toggle')
const sliderIndex = controls.findIndex((c) => c.kind === 'slider')
const slider = sliderIndex >= 0 ? (controls[sliderIndex] as SliderControl) : null
regs.push({
key: item.pascalId,
object,
effect,
toggleIndex,
sliderIndex,
hasSlider: !!slider,
sliderMin: slider?.min ?? 0,
sliderMax: slider?.max ?? 1,
levelId: findLevelId(object),
})
}
return regs
}, [items, identity])
const levelIndexById = useMemo(
() => new Map(levelOrder.map((id, i) => [id, i] as const)),
[levelOrder],
)
// Controls overlay is scoped to the focused zone (matches the parametric // 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 // 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. // item's world position can be point-tested regardless of level stacking.
@@ -156,10 +166,7 @@ export function GlbInteractive({
return ( return (
<> <>
{lightItems.map((item) => { <GlbItemLights levelIndexById={levelIndexById} regs={lightRegs} />
const object = identity.get(item.pascalId)
return object ? <GlbItemLight item={item} key={item.pascalId} object={object} /> : null
})}
{animationItems.map((item) => ( {animationItems.map((item) => (
<GlbItemAnimation actions={actions} item={item} key={item.pascalId} /> <GlbItemAnimation actions={actions} item={item} key={item.pascalId} />
))} ))}
@@ -178,23 +185,273 @@ export function GlbInteractive({
) )
} }
/** One point light, portaled into its item's baked node so it rides level // ── Pooled item lights ──────────────────────────────────────────────────────
* stacking. Intensity tracks the shared interactive store (overlay dimming). */ //
function GlbItemLight({ item, object }: { item: GlbInteractiveItem; object: Object3D }) { // Mirrors the parametric `ItemLightSystem`: a fixed pool of point lights is
const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues)) // assigned to the nearest/most-visible lit items each tick (camera-proximity
const effect = item.interactive.effects.find((e) => e.kind === 'light') as LightEffect | undefined // scored, with hysteresis), snapped to the item's world position + offset, and
if (!effect) return null // faded in/out on reassignment. Mounting a light per item instead would blow
const intensity = resolveLightIntensity(effect, item.interactive.controls, values) // the renderer's light budget on a large house.
return createPortal(
const POOL_SIZE = 12
const REASSIGN_INTERVAL = 0.2
const HYSTERESIS = 0.15
const CAM_MOVE_DIST = 0.5
const CAM_ROT_DOT = 0.995
type GlbLightReg = {
key: AnyNodeId
object: Object3D
effect: LightEffect
toggleIndex: number
sliderIndex: number
hasSlider: boolean
sliderMin: number
sliderMax: number
levelId: string | null
}
type SlotRuntime = { key: string | null; pendingKey: string | null; isFadingOut: boolean }
const _camPos = new Vector3()
const _camFwd = new Vector3()
const _dir = new Vector3()
const _lightWorld = new Vector3()
/** The nearest level-identity ancestor's pascalId, for the level factor. */
function findLevelId(object: Object3D): string | null {
let cur: Object3D | null = object
while (cur) {
const ud = cur.userData as { kind?: string; pascalId?: string }
if (ud.kind === 'level' && ud.pascalId) return ud.pascalId
cur = cur.parent
}
return null
}
function scoreReg(
reg: GlbLightReg,
selectedLevelId: string | null,
levelMode: string,
levelIndexById: Map<string, number>,
interactiveState: ReturnType<typeof useInteractive.getState>,
): number {
// Toggled-off lights contribute no illumination — drop them from the pool.
if (reg.toggleIndex >= 0 && !interactiveState.items[reg.key]?.controlValues?.[reg.toggleIndex]) {
return Number.POSITIVE_INFINITY
}
reg.object.getWorldPosition(_lightWorld)
_lightWorld.x += reg.effect.offset[0]
_lightWorld.y += reg.effect.offset[1]
_lightWorld.z += reg.effect.offset[2]
_dir.copy(_lightWorld).sub(_camPos).normalize()
const angular = 1 - _camFwd.dot(_dir)
const dist = _camPos.distanceTo(_lightWorld) / 200
let levelPenalty = 0
if (selectedLevelId) {
if (reg.levelId !== selectedLevelId) levelPenalty = levelMode === 'solo' ? 100 : 0.8
} else if (reg.levelId && (levelIndexById.get(reg.levelId) ?? 0) !== 0) {
levelPenalty = 0.3
}
return angular * 0.7 + dist * 0.3 + levelPenalty
}
function GlbItemLights({
regs,
levelIndexById,
}: {
regs: GlbLightReg[]
levelIndexById: Map<string, number>
}) {
const lightRefs = useRef<Array<PointLight | null>>(Array.from({ length: POOL_SIZE }, () => null))
const slots = useRef<SlotRuntime[]>(
Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })),
)
const reassignTimer = useRef(0)
const prevCamPos = useRef(new Vector3())
const prevCamFwd = useRef(new Vector3(0, 0, -1))
const regByKey = useMemo(() => new Map(regs.map((r) => [r.key as string, r])), [regs])
useFrame(({ camera }, delta) => {
const dt = Math.min(delta, 0.1)
const interactiveState = useInteractive.getState()
camera.getWorldPosition(_camPos)
camera.getWorldDirection(_camFwd)
const camMoved =
_camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST ||
_camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT
reassignTimer.current -= delta
if (reassignTimer.current <= 0 || camMoved) {
reassignTimer.current = REASSIGN_INTERVAL
prevCamPos.current.copy(_camPos)
prevCamFwd.current.copy(_camFwd)
const viewer = useViewer.getState()
const selectedLevelId = viewer.selection.levelId
const levelMode = viewer.levelMode
const scored = regs.map((reg) => ({
key: reg.key as string,
score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState),
}))
scored.sort((a, b) => a.score - b.score)
const desired = scored
.filter((s) => Number.isFinite(s.score))
.slice(0, POOL_SIZE)
.map((s) => s.key)
const currentlyAssigned = new Map<string, number>()
for (let i = 0; i < POOL_SIZE; i++) {
const s = slots.current[i]
const k = s?.key ?? s?.pendingKey
if (k) currentlyAssigned.set(k, i)
}
const usedSlots = new Set<number>()
const assignedKeys = new Set<string>()
// Pass 1: keep existing slots whose key is still wanted.
for (const key of desired) {
const existingSlot = currentlyAssigned.get(key)
if (existingSlot !== undefined && !usedSlots.has(existingSlot)) {
usedSlots.add(existingSlot)
assignedKeys.add(key)
}
}
// Pass 2: assign the rest to free slots, evicting only on a clear win.
let freeSlot = 0
for (const key of desired) {
if (assignedKeys.has(key)) continue
while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++
if (freeSlot >= POOL_SIZE) break
const freeSlotData = slots.current[freeSlot]
const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null
if (currentKey && !desired.includes(currentKey)) {
const currentScore =
scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY
const newScore = scored.find((s) => s.key === key)?.score ?? 0
if (currentScore - newScore < HYSTERESIS) {
freeSlot++
continue
}
}
usedSlots.add(freeSlot)
assignedKeys.add(key)
const slot = slots.current[freeSlot]
if (slot && slot.key !== key) {
slot.pendingKey = key
slot.isFadingOut = slot.key !== null
if (!slot.isFadingOut) {
slot.key = key
slot.pendingKey = null
const light = lightRefs.current[freeSlot]
const reg = regByKey.get(key)
if (light && reg) {
light.color.set(reg.effect.color)
light.distance = reg.effect.distance ?? 0
}
}
}
freeSlot++
}
// Retire slots whose key is no longer wanted.
for (let i = 0; i < POOL_SIZE; i++) {
if (!usedSlots.has(i)) {
const slot = slots.current[i]
if (slot?.key && !desired.includes(slot.key)) {
slot.pendingKey = null
slot.isFadingOut = true
}
}
}
}
// Per-frame: fade, snap position, and track intensity from control state.
for (let i = 0; i < POOL_SIZE; i++) {
const light = lightRefs.current[i]
const slot = slots.current[i]
if (!(light && slot)) continue
if (slot.isFadingOut) {
light.visible = true
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
if (light.intensity < 0.01) {
light.intensity = 0
light.visible = false
slot.isFadingOut = false
slot.key = slot.pendingKey
slot.pendingKey = null
if (slot.key) {
const reg = regByKey.get(slot.key)
if (reg) {
light.color.set(reg.effect.color)
light.distance = reg.effect.distance ?? 0
}
}
}
continue
}
if (!slot.key) {
light.intensity = 0
light.visible = false
continue
}
const reg = regByKey.get(slot.key)
if (!reg) {
slot.key = null
light.intensity = 0
light.visible = false
continue
}
reg.object.getWorldPosition(_lightWorld)
light.position.set(
_lightWorld.x + reg.effect.offset[0],
_lightWorld.y + reg.effect.offset[1],
_lightWorld.z + reg.effect.offset[2],
)
const values = interactiveState.items[reg.key]?.controlValues
const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true
let t = 1
if (reg.hasSlider) {
const raw = (values?.[reg.sliderIndex] as number) ?? reg.sliderMin
t =
reg.sliderMax > reg.sliderMin
? (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin)
: 1
}
const targetIntensity = isOn
? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)
: reg.effect.intensityRange[0]
if (targetIntensity > 0) light.visible = true
light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12)
if (targetIntensity <= 0 && light.intensity < 0.01) {
light.intensity = 0
light.visible = false
}
}
})
return (
<>
{Array.from({ length: POOL_SIZE }, (_, i) => (
<pointLight <pointLight
castShadow={false} castShadow={false}
color={effect.color} intensity={0}
decay={2} key={i}
distance={effect.distance ?? 0} ref={(el) => {
intensity={intensity} lightRefs.current[i] = el
position={effect.offset} }}
/>, visible={false}
object, />
))}
</>
) )
} }
@@ -280,7 +537,12 @@ function GlbItemControls({
position={[0, item.height + 0.3, 0]} position={[0, item.height + 0.3, 0]}
zIndexRange={[20, 0]} zIndexRange={[20, 0]}
> >
{/* Stop pointer/click events from reaching the canvas — otherwise R3F's
pointer-missed fires and deselects the zone the moment you toggle. */}
<div <div
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -391,6 +391,8 @@ export function GlbScene({
} }
}, [gltf.scene]) }, [gltf.scene])
const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]) const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries])
// Level pascalIds bottom-to-top, for the interactive light pool's level factor.
const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels])
// The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted // The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted
// on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding // on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding
@@ -992,6 +994,7 @@ export function GlbScene({
actions={actions} actions={actions}
identity={identity} identity={identity}
items={interactiveItems} items={interactiveItems}
levelOrder={levelOrder}
zones={zoneEntries} zones={zoneEntries}
/> />
) : null} ) : null}