diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx
index e05acdfd..c2236122 100644
--- a/packages/viewer/src/components/renderers/item/item-renderer.tsx
+++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx
@@ -12,13 +12,12 @@ import {
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
-import { Suspense, useEffect, useMemo, useRef } from 'react'
import { useFrame } from '@react-three/fiber'
-import type { Group, Material, Mesh, PointLight } from 'three'
+import { Suspense, useEffect, useMemo, useRef } from 'react'
+import type { AnimationAction, Group, Material, Mesh, PointLight } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
-import { useShallow } from 'zustand/react/shallow'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { NodeRenderer } from '../node-renderer'
@@ -99,32 +98,6 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
// Freeze the interactive definition at mount — asset schemas don't change at runtime
const interactiveRef = useRef(node.asset.interactive)
- // Subscribe only to this node's control values — useShallow prevents re-renders from other nodes' changes
- const controlValues = useInteractive(useShallow((state) => state.items[node.id]?.controlValues))
-
- useEffect(() => {
- const interactive = interactiveRef.current
- const animEffect = interactive?.effects.find(
- (e): e is AnimationEffect => e.kind === 'animation',
- )
-
- if (!animEffect) {
- // Non-interactive: play first available animation as default
- if (animations.length > 0) actions[animations[0]!.name]?.play()
- return
- }
-
- if (!controlValues) return
-
- const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
- const isOn = toggleIndex >= 0 ? Boolean(controlValues[toggleIndex]) : false
-
- for (const action of Object.values(actions)) action?.stop()
-
- const clipName = isOn ? animEffect.clips.on : (animEffect.clips.off ?? animEffect.clips.loop)
- if (clipName) actions[clipName]?.play()
- }, [controlValues, actions, animations])
-
if (nodes.cutout) {
nodes.cutout.visible = false
}
@@ -169,7 +142,10 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
}, [scene])
const interactive = interactiveRef.current
- const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
+ const animEffect =
+ interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation') ?? null
+ const lightEffects =
+ interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
return (
<>
@@ -181,6 +157,15 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
rotation={node.asset.rotation}
{...handlers}
/>
+ {animations.length > 0 && (
+
+ )}
{lightEffects.map((effect, i) => (
))}
@@ -188,6 +173,78 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
)
}
+const ItemAnimation = ({
+ nodeId,
+ animEffect,
+ interactive,
+ actions,
+ animations,
+}: {
+ nodeId: AnyNodeId
+ animEffect: AnimationEffect | null
+ interactive: Interactive | null
+ actions: Record
+ animations: { name: string }[]
+}) => {
+ const activeClipRef = useRef(null)
+ const fadingOutRef = useRef(null)
+
+ // Reactive: derive target clip name — only re-renders when the clip name itself changes
+ const targetClip = useInteractive((s) => {
+ const values = s.items[nodeId]?.controlValues
+ if (!animEffect) return animations[0]?.name ?? null
+ const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
+ const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : false
+ return isOn
+ ? (animEffect.clips.on ?? null)
+ : (animEffect.clips.off ?? animEffect.clips.loop ?? null)
+ })
+
+ // When target clip changes: kick off the transition
+ useEffect(() => {
+ // Cancel any ongoing fade-out immediately
+ if (fadingOutRef.current) {
+ fadingOutRef.current.timeScale = 0
+ fadingOutRef.current = null
+ }
+ // Move current clip to fade-out
+ if (activeClipRef.current && activeClipRef.current !== targetClip) {
+ const old = actions[activeClipRef.current]
+ if (old?.isRunning()) fadingOutRef.current = old
+ }
+ // Start new clip at timeScale 0.01 (as 0 would cause isRunning to be false and thus not play at all), then fade in to 1
+ activeClipRef.current = targetClip
+ if (targetClip) {
+ const next = actions[targetClip]
+ if (next) {
+ next.timeScale = 0.01
+ next.play()
+ }
+ }
+ }, [targetClip, actions])
+
+ // useFrame: only lerping — no logic
+ useFrame((_, delta) => {
+ if (fadingOutRef.current) {
+ const action = fadingOutRef.current
+ action.timeScale = MathUtils.lerp(action.timeScale, 0, Math.min(delta * 5, 1))
+ if (action.timeScale < 0.01) {
+ action.timeScale = 0
+ fadingOutRef.current = null
+ }
+ }
+ if (activeClipRef.current) {
+ const action = actions[activeClipRef.current]
+ if (action?.isRunning() && action.timeScale < 1) {
+ action.timeScale = MathUtils.lerp(action.timeScale, 1, Math.min(delta * 5, 1))
+ if (1 - action.timeScale < 0.01) action.timeScale = 1
+ }
+ }
+ })
+
+ return null
+}
+
const ItemLight = ({
nodeId,
effect,
@@ -201,7 +258,8 @@ const ItemLight = ({
// Precompute stable indices — interactive is frozen at mount
const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')
const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider')
- const sliderControl = sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null
+ const sliderControl =
+ sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null
useFrame((_, delta) => {
if (!lightRef.current) return
@@ -220,7 +278,11 @@ const ItemLight = ({
? MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], t)
: effect.intensityRange[0]
- lightRef.current.intensity = MathUtils.lerp(lightRef.current.intensity, target, Math.min(delta * 12, 1))
+ lightRef.current.intensity = MathUtils.lerp(
+ lightRef.current.intensity,
+ target,
+ Math.min(delta * 12, 1),
+ )
})
return (
diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx
index aa1415c0..83c89c66 100644
--- a/packages/viewer/src/systems/interactive/interactive-system.tsx
+++ b/packages/viewer/src/systems/interactive/interactive-system.tsx
@@ -5,15 +5,20 @@ import {
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 { useState } from 'react'
-import type { Object3D } from 'three'
+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 ----
@@ -50,11 +55,26 @@ const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => {
const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues))
const setControlValue = useInteractive((state) => state.setControlValue)
+ 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
+ })
+
if (!itemObj || !controlValues || !node?.asset.interactive) return null
const { controls } = node.asset.interactive
const [, height] = node.asset.dimensions
+ let opacity = 0
+ let pointerEvents: 'auto' | 'none' = 'none'
+ if (zoneId && zonePolygon?.length) {
+ itemObj.getWorldPosition(_tempVec)
+ const inside = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon)
+ opacity = inside ? 1 : 0.1
+ pointerEvents = inside ? 'auto' : 'none'
+ }
return createPortal(
@@ -68,8 +88,10 @@ const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => {
borderRadius: 8,
padding: '8px 12px',
minWidth: 120,
- pointerEvents: 'auto',
+ pointerEvents,
userSelect: 'none',
+ opacity,
+ transition: 'opacity 0.3s ease',
}}
>
{controls.map((control, i) => (