diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue
index befc1f322..b69eab4aa 100644
--- a/src/components/load3d/Load3D.vue
+++ b/src/components/load3d/Load3D.vue
@@ -33,6 +33,9 @@
v-model:playing="playing"
v-model:selected-speed="selectedSpeed"
v-model:selected-animation="selectedAnimation"
+ v-model:animation-progress="animationProgress"
+ v-model:animation-duration="animationDuration"
+ @seek="handleSeek"
/>
+
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
+import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
diff --git a/src/components/load3d/controls/AnimationControls.vue b/src/components/load3d/controls/AnimationControls.vue
index a1e2ae96c..16df631e5 100644
--- a/src/components/load3d/controls/AnimationControls.vue
+++ b/src/components/load3d/controls/AnimationControls.vue
@@ -1,42 +1,64 @@
-
-
+
+
-
+
+
+
+ {{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
+
+
diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts
index 045e8c572..8b589fc2b 100644
--- a/src/composables/useLoad3d.ts
+++ b/src/composables/useLoad3d.ts
@@ -60,6 +60,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => {
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
+ const animationProgress = ref(0)
+ const animationDuration = ref(0)
const loading = ref(false)
const loadingMessage = ref('')
const isPreview = ref(false)
@@ -357,6 +359,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => {
}
}
+ const handleSeek = (progress: number) => {
+ if (load3d && animationDuration.value > 0) {
+ const time = (progress / 100) * animationDuration.value
+ load3d.setAnimationTime(time)
+ }
+ }
+
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
sceneConfig.value.backgroundImage = ''
@@ -514,6 +523,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => {
animationListChange: (newValue: AnimationItem[]) => {
animations.value = newValue
},
+ animationProgressChange: (data: {
+ progress: number
+ currentTime: number
+ duration: number
+ }) => {
+ animationProgress.value = data.progress
+ animationDuration.value = data.duration
+ },
cameraChanged: (cameraState: CameraState) => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) {
@@ -573,6 +590,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => {
playing,
selectedSpeed,
selectedAnimation,
+ animationProgress,
+ animationDuration,
loading,
loadingMessage,
@@ -585,6 +604,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => {
handleStopRecording,
handleExportRecording,
handleClearRecording,
+ handleSeek,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,
diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts
index cd9acf8d4..9022c2d2d 100644
--- a/src/composables/useLoad3dViewer.ts
+++ b/src/composables/useLoad3dViewer.ts
@@ -3,6 +3,7 @@ import { ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
+ AnimationItem,
BackgroundRenderModeType,
CameraState,
CameraType,
@@ -49,6 +50,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const isSplatModel = ref(false)
const isPlyModel = ref(false)
+ // Animation state
+ const animations = ref([])
+ const playing = ref(false)
+ const selectedSpeed = ref(1)
+ const selectedAnimation = ref(0)
+ const animationProgress = ref(0)
+ const animationDuration = ref(0)
+
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
@@ -174,6 +183,61 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
})
+ // Animation watches
+ watch(playing, (newValue) => {
+ if (load3d) {
+ load3d.toggleAnimation(newValue)
+ }
+ })
+
+ watch(selectedSpeed, (newValue) => {
+ if (load3d && newValue) {
+ load3d.setAnimationSpeed(newValue)
+ }
+ })
+
+ watch(selectedAnimation, (newValue) => {
+ if (load3d && newValue !== undefined) {
+ load3d.updateSelectedAnimation(newValue)
+ }
+ })
+
+ const handleSeek = (progress: number) => {
+ if (load3d && animationDuration.value > 0) {
+ const time = (progress / 100) * animationDuration.value
+ load3d.setAnimationTime(time)
+ }
+ }
+
+ const setupAnimationEvents = () => {
+ if (!load3d) return
+
+ load3d.addEventListener(
+ 'animationListChange',
+ (newValue: AnimationItem[]) => {
+ animations.value = newValue
+ }
+ )
+
+ load3d.addEventListener(
+ 'animationProgressChange',
+ (data: { progress: number; currentTime: number; duration: number }) => {
+ animationProgress.value = data.progress
+ animationDuration.value = data.duration
+ }
+ )
+
+ // Initialize animation list if animations already exist
+ if (load3d.hasAnimations()) {
+ const clips = load3d.animationManager.animationClips
+ animations.value = clips.map((clip, index) => ({
+ name: clip.name || `Animation ${index + 1}`,
+ index
+ }))
+ animationDuration.value = load3d.getAnimationDuration()
+ }
+ }
+
/**
* Initialize viewer in node mode (with source Load3d)
*/
@@ -270,6 +334,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
upDirection: upDirection.value,
materialMode: materialMode.value
}
+
+ setupAnimationEvents()
} catch (error) {
console.error('Error initializing Load3d viewer:', error)
useToastStore().addAlert(
@@ -310,6 +376,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isPlyModel.value = load3d.isPlyModel()
isPreview.value = true
+
+ setupAnimationEvents()
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
@@ -527,6 +595,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isSplatModel,
isPlyModel,
+ // Animation state
+ animations,
+ playing,
+ selectedSpeed,
+ selectedAnimation,
+ animationProgress,
+ animationDuration,
+
// Methods
initializeViewer,
initializeStandaloneViewer,
@@ -539,6 +615,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
refreshViewport,
handleBackgroundImageUpdate,
handleModelDrop,
+ handleSeek,
cleanup
}
}
diff --git a/src/extensions/core/load3d/AnimationManager.ts b/src/extensions/core/load3d/AnimationManager.ts
index a451da8cd..80fc6f153 100644
--- a/src/extensions/core/load3d/AnimationManager.ts
+++ b/src/extensions/core/load3d/AnimationManager.ts
@@ -125,6 +125,13 @@ export class AnimationManager implements AnimationManagerInterface {
}
this.animationActions = [action]
+
+ // Emit initial progress to set duration
+ this.eventManager.emitEvent('animationProgressChange', {
+ progress: 0,
+ currentTime: 0,
+ duration: clip.duration
+ })
}
toggleAnimation(play?: boolean): void {
@@ -150,8 +157,58 @@ export class AnimationManager implements AnimationManagerInterface {
update(delta: number): void {
if (this.currentAnimation && this.isAnimationPlaying) {
this.currentAnimation.update(delta)
+
+ if (this.animationActions.length > 0) {
+ const action = this.animationActions[0]
+ const clip = action.getClip()
+ const progress = (action.time / clip.duration) * 100
+ this.eventManager.emitEvent('animationProgressChange', {
+ progress,
+ currentTime: action.time,
+ duration: clip.duration
+ })
+ }
}
}
+ getAnimationTime(): number {
+ if (this.animationActions.length === 0) return 0
+ return this.animationActions[0].time
+ }
+
+ getAnimationDuration(): number {
+ if (this.animationActions.length === 0) return 0
+ return this.animationActions[0].getClip().duration
+ }
+
+ setAnimationTime(time: number): void {
+ if (this.animationActions.length === 0) return
+ const duration = this.getAnimationDuration()
+ const clampedTime = Math.max(0, Math.min(time, duration))
+
+ // Temporarily unpause to allow time update, then restore
+ const wasPaused = this.animationActions.map((action) => action.paused)
+ this.animationActions.forEach((action) => {
+ action.paused = false
+ action.time = clampedTime
+ })
+
+ if (this.currentAnimation) {
+ this.currentAnimation.setTime(clampedTime)
+ this.currentAnimation.update(0)
+ }
+
+ // Restore paused state
+ this.animationActions.forEach((action, i) => {
+ action.paused = wasPaused[i]
+ })
+
+ this.eventManager.emitEvent('animationProgressChange', {
+ progress: (clampedTime / duration) * 100,
+ currentTime: clampedTime,
+ duration
+ })
+ }
+
reset(): void {}
}
diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts
index 2fb8ff6bb..410887afc 100644
--- a/src/extensions/core/load3d/Load3d.ts
+++ b/src/extensions/core/load3d/Load3d.ts
@@ -726,6 +726,19 @@ class Load3d {
return this.animationManager.animationClips.length > 0
}
+ public getAnimationTime(): number {
+ return this.animationManager.getAnimationTime()
+ }
+
+ public getAnimationDuration(): number {
+ return this.animationManager.getAnimationDuration()
+ }
+
+ public setAnimationTime(time: number): void {
+ this.animationManager.setAnimationTime(time)
+ this.forceRender()
+ }
+
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts
index 2b043fdb1..8d32c54a7 100644
--- a/src/extensions/core/load3d/interfaces.ts
+++ b/src/extensions/core/load3d/interfaces.ts
@@ -146,6 +146,9 @@ export interface AnimationManagerInterface extends BaseManager {
updateSelectedAnimation(index: number): void
toggleAnimation(play?: boolean): void
update(delta: number): void
+ getAnimationTime(): number
+ getAnimationDuration(): number
+ setAnimationTime(time: number): void
}
export interface ModelManagerInterface {
diff --git a/src/services/load3dService.ts b/src/services/load3dService.ts
index bc2585727..6e569ab85 100644
--- a/src/services/load3dService.ts
+++ b/src/services/load3dService.ts
@@ -1,4 +1,5 @@
import { toRaw } from 'vue'
+import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils'
import { nodeToLoad3dMap } from '@/composables/useLoad3d'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
@@ -75,13 +76,20 @@ export class Load3dService {
const sourceModel = source.modelManager.currentModel
if (sourceModel) {
+ // Remove existing model from target scene before adding new one
+ const existingModel = target.getModelManager().currentModel
+ if (existingModel) {
+ target.getSceneManager().scene.remove(existingModel)
+ }
+
if (source.isSplatModel()) {
const originalURL = source.modelManager.originalURL
if (originalURL) {
await target.loadModel(originalURL)
}
} else {
- const modelClone = sourceModel.clone()
+ // Use SkeletonUtils.clone for proper skeletal animation support
+ const modelClone = SkeletonUtils.clone(sourceModel)
target.getModelManager().currentModel = modelClone
target.getSceneManager().scene.add(modelClone)
@@ -105,6 +113,14 @@ export class Load3dService {
target.getModelManager().appliedTexture =
source.getModelManager().appliedTexture
}
+
+ // Copy animation state
+ if (source.hasAnimations()) {
+ target.animationManager.setupModelAnimations(
+ modelClone,
+ sourceOriginalModel
+ )
+ }
}
}