fade timescale for smooth item anims
This commit is contained in:
@@ -12,13 +12,12 @@ import {
|
|||||||
import { useAnimations } from '@react-three/drei'
|
import { useAnimations } from '@react-three/drei'
|
||||||
import { Clone } from '@react-three/drei/core/Clone'
|
import { Clone } from '@react-three/drei/core/Clone'
|
||||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
|
||||||
import { useFrame } from '@react-three/fiber'
|
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 { MathUtils } from 'three'
|
||||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
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
|
// Freeze the interactive definition at mount — asset schemas don't change at runtime
|
||||||
const interactiveRef = useRef(node.asset.interactive)
|
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) {
|
if (nodes.cutout) {
|
||||||
nodes.cutout.visible = false
|
nodes.cutout.visible = false
|
||||||
}
|
}
|
||||||
@@ -169,7 +142,10 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
}, [scene])
|
}, [scene])
|
||||||
|
|
||||||
const interactive = interactiveRef.current
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -181,6 +157,15 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
rotation={node.asset.rotation}
|
rotation={node.asset.rotation}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
/>
|
/>
|
||||||
|
{animations.length > 0 && (
|
||||||
|
<ItemAnimation
|
||||||
|
nodeId={node.id}
|
||||||
|
animEffect={animEffect}
|
||||||
|
interactive={interactive ?? null}
|
||||||
|
actions={actions}
|
||||||
|
animations={animations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{lightEffects.map((effect, i) => (
|
{lightEffects.map((effect, i) => (
|
||||||
<ItemLight key={i} nodeId={node.id} effect={effect} interactive={interactive!} />
|
<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 = ({
|
const ItemLight = ({
|
||||||
nodeId,
|
nodeId,
|
||||||
effect,
|
effect,
|
||||||
@@ -201,7 +258,8 @@ const ItemLight = ({
|
|||||||
// Precompute stable indices — interactive is frozen at mount
|
// Precompute stable indices — interactive is frozen at mount
|
||||||
const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')
|
const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')
|
||||||
const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider')
|
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) => {
|
useFrame((_, delta) => {
|
||||||
if (!lightRef.current) return
|
if (!lightRef.current) return
|
||||||
@@ -220,7 +278,11 @@ const ItemLight = ({
|
|||||||
? MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], t)
|
? MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], t)
|
||||||
: effect.intensityRange[0]
|
: 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 (
|
return (
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import {
|
|||||||
type Control,
|
type Control,
|
||||||
type ControlValue,
|
type ControlValue,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
|
pointInPolygon,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useScene,
|
useScene,
|
||||||
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { createPortal, useFrame } from '@react-three/fiber'
|
import { createPortal, useFrame } from '@react-three/fiber'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Object3D } from 'three'
|
import { type Object3D, Vector3 } from 'three'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
|
const _tempVec = new Vector3()
|
||||||
|
|
||||||
// ---- Parent: one overlay per interactive item ----
|
// ---- 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 controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues))
|
||||||
const setControlValue = useInteractive((state) => state.setControlValue)
|
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
|
if (!itemObj || !controlValues || !node?.asset.interactive) return null
|
||||||
|
|
||||||
const { controls } = node.asset.interactive
|
const { controls } = node.asset.interactive
|
||||||
const [, height] = node.asset.dimensions
|
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(
|
return createPortal(
|
||||||
<Html center position={[0, height + 0.3, 0]} zIndexRange={[20, 0]} occlude distanceFactor={8}>
|
<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,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
pointerEvents: 'auto',
|
pointerEvents,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
opacity,
|
||||||
|
transition: 'opacity 0.3s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{controls.map((control, i) => (
|
{controls.map((control, i) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user