From 202fc41f4238d419e1edb2a7ab0ec1caf553d750 Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 6 Mar 2026 08:52:13 +0100 Subject: [PATCH] fade timescale for smooth item anims --- .../renderers/item/item-renderer.tsx | 126 +++++++++++++----- .../interactive/interactive-system.tsx | 26 +++- 2 files changed, 118 insertions(+), 34 deletions(-) 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) => (