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:
co-authored by
Claude Opus 4.8
parent
0514ec1858
commit
a5c08b5950
@@ -2,8 +2,6 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type Control,
|
||||
type ControlValue,
|
||||
type Interactive,
|
||||
type LightEffect,
|
||||
pointInPolygon,
|
||||
@@ -12,10 +10,9 @@ import {
|
||||
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 AnimationAction, LoopRepeat, type Object3D, Vector3 } from 'three'
|
||||
import { lerp } from 'three/src/math/MathUtils.js'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type AnimationAction, LoopRepeat, MathUtils, type Object3D, type PointLight, Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { ControlWidget } from '../../systems/interactive/control-widget'
|
||||
@@ -68,41 +65,21 @@ export function buildGlbInteractiveItems(
|
||||
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.
|
||||
* Re-creates the item-driven interactivity the parametric viewer has — pooled
|
||||
* lights, ambient animation, and 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,
|
||||
actions,
|
||||
levelOrder,
|
||||
}: {
|
||||
items: GlbInteractiveItem[]
|
||||
identity: Map<string, Object3D>
|
||||
@@ -110,6 +87,9 @@ export function GlbInteractive({
|
||||
/** Baked animation actions keyed by clip name — ambient item loops play from
|
||||
* `<pascalId>: loop`. */
|
||||
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
|
||||
// scene "lit": toggles default ON (the editor defaults them off) and sliders
|
||||
@@ -130,15 +110,45 @@ export function GlbInteractive({
|
||||
}
|
||||
}, [items])
|
||||
|
||||
const lightItems = useMemo(
|
||||
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')),
|
||||
[items],
|
||||
)
|
||||
const animationItems = useMemo(
|
||||
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')),
|
||||
[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
|
||||
// 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.
|
||||
@@ -156,10 +166,7 @@ export function GlbInteractive({
|
||||
|
||||
return (
|
||||
<>
|
||||
{lightItems.map((item) => {
|
||||
const object = identity.get(item.pascalId)
|
||||
return object ? <GlbItemLight item={item} key={item.pascalId} object={object} /> : null
|
||||
})}
|
||||
<GlbItemLights levelIndexById={levelIndexById} regs={lightRegs} />
|
||||
{animationItems.map((item) => (
|
||||
<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
|
||||
* 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,
|
||||
// ── Pooled item lights ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors the parametric `ItemLightSystem`: a fixed pool of point lights is
|
||||
// assigned to the nearest/most-visible lit items each tick (camera-proximity
|
||||
// scored, with hysteresis), snapped to the item's world position + offset, and
|
||||
// faded in/out on reassignment. Mounting a light per item instead would blow
|
||||
// the renderer's light budget on a large house.
|
||||
|
||||
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
|
||||
castShadow={false}
|
||||
intensity={0}
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
lightRefs.current[i] = el
|
||||
}}
|
||||
visible={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,7 +537,12 @@ function GlbItemControls({
|
||||
position={[0, item.height + 0.3, 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
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -391,6 +391,8 @@ export function GlbScene({
|
||||
}
|
||||
}, [gltf.scene])
|
||||
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
|
||||
// on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding
|
||||
@@ -992,6 +994,7 @@ export function GlbScene({
|
||||
actions={actions}
|
||||
identity={identity}
|
||||
items={interactiveItems}
|
||||
levelOrder={levelOrder}
|
||||
zones={zoneEntries}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user