fade timescale for smooth item anims
This commit is contained in:
@@ -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 && (
|
||||
<ItemAnimation
|
||||
nodeId={node.id}
|
||||
animEffect={animEffect}
|
||||
interactive={interactive ?? null}
|
||||
actions={actions}
|
||||
animations={animations}
|
||||
/>
|
||||
)}
|
||||
{lightEffects.map((effect, i) => (
|
||||
<ItemLight key={i} nodeId={node.id} effect={effect} interactive={interactive!} />
|
||||
))}
|
||||
@@ -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<string, AnimationAction | null>
|
||||
animations: { name: string }[]
|
||||
}) => {
|
||||
const activeClipRef = useRef<string | null>(null)
|
||||
const fadingOutRef = useRef<AnimationAction | null>(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 (
|
||||
|
||||
@@ -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(
|
||||
<Html center position={[0, height + 0.3, 0]} zIndexRange={[20, 0]} occlude distanceFactor={8}>
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user