Skip to content

Commit 7fcfa4c

Browse files
authored
feat: add animation progress bar for 3D nodes and viewer (#7839)
## Summary - Add draggable progress bar to AnimationControls component - Display current time / total duration - Allow seeking through animations when paused or playing - Add animation controls to 3D Viewer fix #7830 and #7831 ## Screenshots (if applicable) https://github.com/user-attachments/assets/f6d0668c-c7a4-497e-8345-9ef6e47a41c6 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7839-feat-add-animation-progress-bar-for-3D-nodes-and-viewer-2de6d73d36508101ac98f673206b30d9) by [Unito](https://www.unito.io)
1 parent 8d1f8ed commit 7fcfa4c

File tree

9 files changed

+282
-29
lines changed

9 files changed

+282
-29
lines changed

src/components/load3d/Load3D.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
v-model:playing="playing"
3434
v-model:selected-speed="selectedSpeed"
3535
v-model:selected-animation="selectedAnimation"
36+
v-model:animation-progress="animationProgress"
37+
v-model:animation-duration="animationDuration"
38+
@seek="handleSeek"
3639
/>
3740
</div>
3841
<div
@@ -119,6 +122,8 @@ const {
119122
playing,
120123
selectedSpeed,
121124
selectedAnimation,
125+
animationProgress,
126+
animationDuration,
122127
loading,
123128
loadingMessage,
124129
@@ -130,6 +135,7 @@ const {
130135
handleStopRecording,
131136
handleExportRecording,
132137
handleClearRecording,
138+
handleSeek,
133139
handleBackgroundImageUpdate,
134140
handleExportModel,
135141
handleModelDrop,

src/components/load3d/Load3dViewerContent.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@
1515
@dragleave.stop="handleDragLeave"
1616
@drop.prevent.stop="handleDrop"
1717
/>
18+
<AnimationControls
19+
v-if="viewer.animations.value && viewer.animations.value.length > 0"
20+
v-model:animations="viewer.animations.value"
21+
v-model:playing="viewer.playing.value"
22+
v-model:selected-speed="viewer.selectedSpeed.value"
23+
v-model:selected-animation="viewer.selectedAnimation.value"
24+
v-model:animation-progress="viewer.animationProgress.value"
25+
v-model:animation-duration="viewer.animationDuration.value"
26+
@seek="viewer.handleSeek"
27+
/>
1828
<div
1929
v-if="isDragging"
2030
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -85,6 +95,7 @@
8595
<script setup lang="ts">
8696
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
8797
98+
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
8899
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
89100
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
90101
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,81 @@
11
<template>
22
<div
33
v-if="animations && animations.length > 0"
4-
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full items-center justify-center gap-2 pt-2"
4+
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full flex-col items-center gap-2 pt-2"
55
>
6-
<Button
7-
size="icon"
8-
variant="textonly"
9-
class="rounded-full"
10-
:aria-label="$t('g.playPause')"
11-
@click="togglePlay"
12-
>
13-
<i
14-
:class="['pi', playing ? 'pi-pause' : 'pi-play', 'text-lg text-white']"
6+
<div class="flex items-center justify-center gap-2">
7+
<Button
8+
size="icon"
9+
variant="textonly"
10+
class="rounded-full"
11+
:aria-label="$t('g.playPause')"
12+
@click="togglePlay"
13+
>
14+
<i
15+
:class="[
16+
'pi',
17+
playing ? 'pi-pause' : 'pi-play',
18+
'text-lg text-white'
19+
]"
20+
/>
21+
</Button>
22+
23+
<Select
24+
v-model="selectedSpeed"
25+
:options="speedOptions"
26+
option-label="name"
27+
option-value="value"
28+
class="w-24"
29+
/>
30+
31+
<Select
32+
v-model="selectedAnimation"
33+
:options="animations"
34+
option-label="name"
35+
option-value="index"
36+
class="w-32"
1537
/>
16-
</Button>
17-
18-
<Select
19-
v-model="selectedSpeed"
20-
:options="speedOptions"
21-
option-label="name"
22-
option-value="value"
23-
class="w-24"
24-
/>
25-
26-
<Select
27-
v-model="selectedAnimation"
28-
:options="animations"
29-
option-label="name"
30-
option-value="index"
31-
class="w-32"
32-
/>
38+
</div>
39+
40+
<div class="flex w-full max-w-xs items-center gap-2 px-4">
41+
<Slider
42+
:model-value="[animationProgress]"
43+
:min="0"
44+
:max="100"
45+
:step="0.1"
46+
class="flex-1"
47+
@update:model-value="handleSliderChange"
48+
/>
49+
<span class="min-w-16 text-xs text-white">
50+
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
51+
</span>
52+
</div>
3353
</div>
3454
</template>
3555

3656
<script setup lang="ts">
3757
import Select from 'primevue/select'
58+
import { computed } from 'vue'
3859
3960
import Button from '@/components/ui/button/Button.vue'
61+
import Slider from '@/components/ui/slider/Slider.vue'
4062
4163
type Animation = { name: string; index: number }
4264
4365
const animations = defineModel<Animation[]>('animations')
4466
const playing = defineModel<boolean>('playing')
4567
const selectedSpeed = defineModel<number>('selectedSpeed')
4668
const selectedAnimation = defineModel<number>('selectedAnimation')
69+
const animationProgress = defineModel<number>('animationProgress', {
70+
default: 0
71+
})
72+
const animationDuration = defineModel<number>('animationDuration', {
73+
default: 0
74+
})
75+
76+
const emit = defineEmits<{
77+
seek: [progress: number]
78+
}>()
4779
4880
const speedOptions = [
4981
{ name: '0.1x', value: 0.1 },
@@ -53,7 +85,25 @@ const speedOptions = [
5385
{ name: '2x', value: 2 }
5486
]
5587
56-
const togglePlay = () => {
88+
const currentTime = computed(() => {
89+
if (!animationDuration.value) return 0
90+
return (animationProgress.value / 100) * animationDuration.value
91+
})
92+
93+
function formatTime(seconds: number): string {
94+
const mins = Math.floor(seconds / 60)
95+
const secs = (seconds % 60).toFixed(1)
96+
return mins > 0 ? `${mins}:${secs.padStart(4, '0')}` : `${secs}s`
97+
}
98+
99+
function togglePlay() {
57100
playing.value = !playing.value
58101
}
102+
103+
function handleSliderChange(value: number[] | undefined) {
104+
if (!value) return
105+
const progress = value[0]
106+
animationProgress.value = progress
107+
emit('seek', progress)
108+
}
59109
</script>

src/composables/useLoad3d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
6060
const playing = ref(false)
6161
const selectedSpeed = ref(1)
6262
const selectedAnimation = ref(0)
63+
const animationProgress = ref(0)
64+
const animationDuration = ref(0)
6365
const loading = ref(false)
6466
const loadingMessage = ref('')
6567
const isPreview = ref(false)
@@ -357,6 +359,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
357359
}
358360
}
359361

362+
const handleSeek = (progress: number) => {
363+
if (load3d && animationDuration.value > 0) {
364+
const time = (progress / 100) * animationDuration.value
365+
load3d.setAnimationTime(time)
366+
}
367+
}
368+
360369
const handleBackgroundImageUpdate = async (file: File | null) => {
361370
if (!file) {
362371
sceneConfig.value.backgroundImage = ''
@@ -514,6 +523,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
514523
animationListChange: (newValue: AnimationItem[]) => {
515524
animations.value = newValue
516525
},
526+
animationProgressChange: (data: {
527+
progress: number
528+
currentTime: number
529+
duration: number
530+
}) => {
531+
animationProgress.value = data.progress
532+
animationDuration.value = data.duration
533+
},
517534
cameraChanged: (cameraState: CameraState) => {
518535
const rawNode = toRaw(nodeRef.value)
519536
if (rawNode) {
@@ -573,6 +590,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
573590
playing,
574591
selectedSpeed,
575592
selectedAnimation,
593+
animationProgress,
594+
animationDuration,
576595
loading,
577596
loadingMessage,
578597

@@ -585,6 +604,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
585604
handleStopRecording,
586605
handleExportRecording,
587606
handleClearRecording,
607+
handleSeek,
588608
handleBackgroundImageUpdate,
589609
handleExportModel,
590610
handleModelDrop,

src/composables/useLoad3dViewer.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ref, toRaw, watch } from 'vue'
33
import Load3d from '@/extensions/core/load3d/Load3d'
44
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
55
import type {
6+
AnimationItem,
67
BackgroundRenderModeType,
78
CameraState,
89
CameraType,
@@ -49,6 +50,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
4950
const isSplatModel = ref(false)
5051
const isPlyModel = ref(false)
5152

53+
// Animation state
54+
const animations = ref<AnimationItem[]>([])
55+
const playing = ref(false)
56+
const selectedSpeed = ref(1)
57+
const selectedAnimation = ref(0)
58+
const animationProgress = ref(0)
59+
const animationDuration = ref(0)
60+
5261
let load3d: Load3d | null = null
5362
let sourceLoad3d: Load3d | null = null
5463

@@ -174,6 +183,61 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
174183
}
175184
})
176185

186+
// Animation watches
187+
watch(playing, (newValue) => {
188+
if (load3d) {
189+
load3d.toggleAnimation(newValue)
190+
}
191+
})
192+
193+
watch(selectedSpeed, (newValue) => {
194+
if (load3d && newValue) {
195+
load3d.setAnimationSpeed(newValue)
196+
}
197+
})
198+
199+
watch(selectedAnimation, (newValue) => {
200+
if (load3d && newValue !== undefined) {
201+
load3d.updateSelectedAnimation(newValue)
202+
}
203+
})
204+
205+
const handleSeek = (progress: number) => {
206+
if (load3d && animationDuration.value > 0) {
207+
const time = (progress / 100) * animationDuration.value
208+
load3d.setAnimationTime(time)
209+
}
210+
}
211+
212+
const setupAnimationEvents = () => {
213+
if (!load3d) return
214+
215+
load3d.addEventListener(
216+
'animationListChange',
217+
(newValue: AnimationItem[]) => {
218+
animations.value = newValue
219+
}
220+
)
221+
222+
load3d.addEventListener(
223+
'animationProgressChange',
224+
(data: { progress: number; currentTime: number; duration: number }) => {
225+
animationProgress.value = data.progress
226+
animationDuration.value = data.duration
227+
}
228+
)
229+
230+
// Initialize animation list if animations already exist
231+
if (load3d.hasAnimations()) {
232+
const clips = load3d.animationManager.animationClips
233+
animations.value = clips.map((clip, index) => ({
234+
name: clip.name || `Animation ${index + 1}`,
235+
index
236+
}))
237+
animationDuration.value = load3d.getAnimationDuration()
238+
}
239+
}
240+
177241
/**
178242
* Initialize viewer in node mode (with source Load3d)
179243
*/
@@ -270,6 +334,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
270334
upDirection: upDirection.value,
271335
materialMode: materialMode.value
272336
}
337+
338+
setupAnimationEvents()
273339
} catch (error) {
274340
console.error('Error initializing Load3d viewer:', error)
275341
useToastStore().addAlert(
@@ -310,6 +376,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
310376
isPlyModel.value = load3d.isPlyModel()
311377

312378
isPreview.value = true
379+
380+
setupAnimationEvents()
313381
} catch (error) {
314382
console.error('Error initializing standalone 3D viewer:', error)
315383
useToastStore().addAlert('Failed to load 3D model')
@@ -527,6 +595,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
527595
isSplatModel,
528596
isPlyModel,
529597

598+
// Animation state
599+
animations,
600+
playing,
601+
selectedSpeed,
602+
selectedAnimation,
603+
animationProgress,
604+
animationDuration,
605+
530606
// Methods
531607
initializeViewer,
532608
initializeStandaloneViewer,
@@ -539,6 +615,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
539615
refreshViewport,
540616
handleBackgroundImageUpdate,
541617
handleModelDrop,
618+
handleSeek,
542619
cleanup
543620
}
544621
}

0 commit comments

Comments
 (0)