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 {
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user