'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 overlays only exist while a zone is selected and the item sits // inside it. Mounting them unconditionally is not an option: each drei // 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 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) => ( ))} ) } // ---- 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(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 . 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.
{controls.map((control, i) => ( setControlValue(nodeId, i, v)} value={controlValues[i] ?? false} /> ))}
, 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 ( ) } if (control.kind === 'slider') { return ( ) } if (control.kind === 'temperature') { return ( ) } return null }