ai-chatbot/packages/viewer/src/systems/interactive/interactive-system.tsx
Wassim SAMAD cf24b62c44
fix(viewer): UI flicker on camera move from interactive overlays + dirty-mark leaks (#401)
* chore: sync bun.lock with 0.9.1 workspace versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): stop interactive overlays from starving frames and flickering the UI

Every interactive item mounted a drei <Html occlude> overlay
unconditionally — invisible (opacity 0) when no zone was selected, but
still alive. With `occlude` as a bare boolean, drei raycasts the entire
scene per overlay on every camera-move frame, and rewrites each
element's z-index while toggling display when the occlusion flips. On
scenes with hundreds of interactive items (recessed lights, ceiling
fans) this starved the frame budget and made the whole DOM UI blink
during camera moves while the WebGPU canvas stayed healthy.

Overlays now mount only while a zone is selected and the item sits
inside its polygon, fade in/out over 300ms (the child components stay
rendered so the exit transition can play before the <Html> unmounts),
and drop `occlude` entirely. eps=-1 works around a drei mount bug: its
mount path writes the element transform without the distanceFactor
scale, and with a static camera the eps guard never re-applies it, so
freshly mounted overlays stayed mis-scaled until the camera moved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): break down dirty nodes by kind in the perf overlay

DIRTY now reads e.g. "29 (12 wall, 9 ceiling, 8 item)" — sorted by
count, only non-zero kinds, with a "missing" bucket for dirty ids whose
node no longer exists. Makes dirty-mark leaks attributable at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(core): skip dirty marks for kinds with no dirty consumer

dirtyNodes is consumed by GeometrySystem (def.geometry),
FloorElevationSystem (capabilities.floorPlaced), and the legacy
per-kind viewer systems. Site, building, level, zone, and guide match
none of those, so their marks were never cleared: they accumulated for
the whole session (every child create/delete dirties its parent),
permanently defeated every consumer's empty-set early exit each frame,
and polluted the perf overlay's DIRTY readout.

NodeDefinition gains an explicit dirtyTracking?: boolean opt-out
(default tracked — no derivable predicate exists since wall's dirty
consumption lives in the viewer while zone/guide/level declare
def.system for unrelated per-frame work). markDirty consults the
registry; the five structural kinds opt out.

Also fixes a second leak: deleteNodesAction never removed deleted ids
from the dirty set, and every consumer skips missing nodes without
clearing them, so marks on deleted nodes lived forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: replace concise-arrow forEach with for...of in deleteNodesAction

biome's useIterableCallbackReturn rejects forEach callbacks that
implicitly return a value (Set.add / clearDirty).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:05:06 -04:00

232 lines
6.4 KiB
TypeScript

'use client'
import {
type AnyNodeId,
type Control,
type ControlValue,
type ItemNode,
pointInPolygon,
sceneRegistry,
useInteractive,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useEffect, useState } from 'react'
import { type Object3D, Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow'
import useViewer from '../../store/use-viewer'
const _tempVec = new Vector3()
// ---- Parent: one overlay per interactive item inside the selected zone ----
//
// The <Html> overlays only exist while a zone is selected and the item sits
// inside it. Mounting them unconditionally is not an option: each drei <Html>
// repositions and re-sorts its DOM element on every camera-move frame, and
// with `occlude` it also raycasts the entire scene per overlay per frame. On
// large scenes (hundreds of interactive items) that starves the frame budget
// and the display/z-index churn makes the whole DOM UI flicker.
//
// The child components stay rendered (returning null) so an overlay can fade
// out before its <Html> unmounts.
export const InteractiveSystem = () => {
const zoneId = useViewer((s) => s.selection.zoneId)
const zonePolygon = useScene((s) => {
if (!zoneId) return null
const z = s.nodes[zoneId] as ZoneNode | undefined
return z?.polygon ?? null
})
const interactiveNodeIds = useScene(
useShallow((state) =>
Object.values(state.nodes)
.filter((n): n is ItemNode => n.type === 'item' && n.asset.interactive != null)
.map((n) => n.id),
),
)
return (
<>
{interactiveNodeIds.map((id) => (
<ItemControlsOverlay key={id} nodeId={id} zonePolygon={zonePolygon} />
))}
</>
)
}
// ---- Child: polls sceneRegistry then portals controls into the item group ----
const FADE_MS = 300
const ItemControlsOverlay = ({
nodeId,
zonePolygon,
}: {
nodeId: AnyNodeId
zonePolygon: ZoneNode['polygon'] | null
}) => {
const node = useScene((state) => state.nodes[nodeId] as ItemNode)
const [itemObj, setItemObj] = useState<Object3D | null>(null)
useFrame(() => {
if (itemObj) return
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) setItemObj(obj)
})
const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues))
const setControlValue = useInteractive((state) => state.setControlValue)
let visible = false
if (itemObj && zonePolygon?.length) {
itemObj.getWorldPosition(_tempVec)
visible = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon)
}
// Fade in on mount and fade out before unmounting the <Html>.
const [mounted, setMounted] = useState(false)
const [shown, setShown] = useState(false)
useEffect(() => {
if (visible) {
setMounted(true)
// Double rAF: the overlay has to paint once at opacity 0 before the
// opacity-1 style lands, otherwise the fade-in transition is skipped.
let raf2 = 0
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setShown(true))
})
return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}
setShown(false)
const timeout = setTimeout(() => setMounted(false), FADE_MS)
return () => clearTimeout(timeout)
}, [visible])
if (!(mounted && itemObj && controlValues && node?.asset.interactive)) return null
const { controls } = node.asset.interactive
const [, height] = node.asset.dimensions
return createPortal(
// eps=-1 forces drei to re-apply translate/scale every frame: its mount
// path writes a transform without the distanceFactor scale, and with a
// static camera the eps guard would skip the fix until the camera moves.
<Html center distanceFactor={8} eps={-1} position={[0, height + 0.3, 0]} zIndexRange={[20, 0]}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
background: 'rgba(0,0,0,0.75)',
backdropFilter: 'blur(8px)',
borderRadius: 8,
padding: '8px 12px',
minWidth: 120,
pointerEvents: visible ? 'auto' : 'none',
userSelect: 'none',
opacity: shown ? 1 : 0,
transition: `opacity ${FADE_MS}ms ease`,
}}
>
{controls.map((control, i) => (
<ControlWidget
control={control}
key={i}
onChange={(v) => setControlValue(nodeId, i, v)}
value={controlValues[i] ?? false}
/>
))}
</div>
</Html>,
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
}