From a5c08b59505333ad4169bc2fa87a2a1b5186b288 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 07:34:41 -0400 Subject: [PATCH] feat(viewer): pool GLB item lights + stop overlay click propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../src/components/viewer/glb-interactive.tsx | 376 +++++++++++++++--- .../src/components/viewer/glb-scene.tsx | 3 + 2 files changed, 322 insertions(+), 57 deletions(-) diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index b2da2cdb..e6d1f22f 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -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 @@ -110,6 +87,9 @@ export function GlbInteractive({ /** Baked animation actions keyed by clip name — ambient item loops play from * `: loop`. */ actions: Record + /** 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(() => { + 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 ? : null - })} + {animationItems.map((item) => ( ))} @@ -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( - , - 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, + interactiveState: ReturnType, +): 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 +}) { + const lightRefs = useRef>(Array.from({ length: POOL_SIZE }, () => null)) + const slots = useRef( + 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() + 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() + const assignedKeys = new Set() + // 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) => ( + { + 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. */}
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index fe669780..7169f4c0 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -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}