diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue index b69eab4aa..f6942e7c3 100644 --- a/src/components/load3d/Load3D.vue +++ b/src/components/load3d/Load3D.vue @@ -24,6 +24,7 @@ v-model:light-config="lightConfig" :is-splat-model="isSplatModel" :is-ply-model="isPlyModel" + :has-skeleton="hasSkeleton" @update-background-image="handleBackgroundImageUpdate" @export-model="handleExportModel" /> @@ -116,6 +117,7 @@ const { isPreview, isSplatModel, isPlyModel, + hasSkeleton, hasRecording, recordingDuration, animations, diff --git a/src/components/load3d/Load3DControls.vue b/src/components/load3d/Load3DControls.vue index cabf5c0e5..1609f39ea 100644 --- a/src/components/load3d/Load3DControls.vue +++ b/src/components/load3d/Load3DControls.vue @@ -58,8 +58,10 @@ v-if="showModelControls" v-model:material-mode="modelConfig!.materialMode" v-model:up-direction="modelConfig!.upDirection" + v-model:show-skeleton="modelConfig!.showSkeleton" :hide-material-mode="isSplatModel" :is-ply-model="isPlyModel" + :has-skeleton="hasSkeleton" /> () const sceneConfig = defineModel('sceneConfig') diff --git a/src/components/load3d/controls/ModelControls.vue b/src/components/load3d/controls/ModelControls.vue index 581a49721..bb0758055 100644 --- a/src/components/load3d/controls/ModelControls.vue +++ b/src/components/load3d/controls/ModelControls.vue @@ -70,6 +70,22 @@ + +
+ +
@@ -84,13 +100,19 @@ import type { import { t } from '@/i18n' import { cn } from '@/utils/tailwindUtil' -const { hideMaterialMode = false, isPlyModel = false } = defineProps<{ +const { + hideMaterialMode = false, + isPlyModel = false, + hasSkeleton = false +} = defineProps<{ hideMaterialMode?: boolean isPlyModel?: boolean + hasSkeleton?: boolean }>() const materialMode = defineModel('materialMode') const upDirection = defineModel('upDirection') +const showSkeleton = defineModel('showSkeleton') const showUpDirection = ref(false) const showMaterialMode = ref(false) diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index b5e1251c2..833e95efd 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -54,7 +54,8 @@ describe('useLoad3d', () => { }, 'Model Config': { upDirection: 'original', - materialMode: 'original' + materialMode: 'original', + showSkeleton: false }, 'Camera Config': { cameraType: 'perspective', @@ -107,6 +108,8 @@ describe('useLoad3d', () => { exportModel: vi.fn().mockResolvedValue(undefined), isSplatModel: vi.fn().mockReturnValue(false), isPlyModel: vi.fn().mockReturnValue(false), + hasSkeleton: vi.fn().mockReturnValue(false), + setShowSkeleton: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), remove: vi.fn(), @@ -143,7 +146,8 @@ describe('useLoad3d', () => { }) expect(composable.modelConfig.value).toEqual({ upDirection: 'original', - materialMode: 'original' + materialMode: 'original', + showSkeleton: false }) expect(composable.cameraConfig.value).toEqual({ cameraType: 'perspective', @@ -410,7 +414,8 @@ describe('useLoad3d', () => { expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe') expect(mockNode.properties['Model Config']).toEqual({ upDirection: '+y', - materialMode: 'wireframe' + materialMode: 'wireframe', + showSkeleton: false }) }) @@ -696,10 +701,13 @@ describe('useLoad3d', () => { 'backgroundImageLoadingEnd', 'modelLoadingStart', 'modelLoadingEnd', + 'skeletonVisibilityChange', 'exportLoadingStart', 'exportLoadingEnd', 'recordingStatusChange', - 'animationListChange' + 'animationListChange', + 'animationProgressChange', + 'cameraChanged' ] expectedEvents.forEach((event) => { diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 8b589fc2b..ca6871e15 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -40,9 +40,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { const modelConfig = ref({ upDirection: 'original', - materialMode: 'original' + materialMode: 'original', + showSkeleton: false }) + const hasSkeleton = ref(false) + const cameraConfig = ref({ cameraType: 'perspective', fov: 75 @@ -273,6 +276,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { nodeRef.value.properties['Model Config'] = newValue load3d.setUpDirection(newValue.upDirection) load3d.setMaterialMode(newValue.materialMode) + load3d.setShowSkeleton(newValue.showSkeleton) } }, { deep: true } @@ -503,6 +507,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { loading.value = false isSplatModel.value = load3d?.isSplatModel() ?? false isPlyModel.value = load3d?.isPlyModel() ?? false + hasSkeleton.value = load3d?.hasSkeleton() ?? false + // Reset skeleton visibility when loading new model + modelConfig.value.showSkeleton = false + }, + skeletonVisibilityChange: (value: boolean) => { + modelConfig.value.showSkeleton = value }, exportLoadingStart: (message: string) => { loadingMessage.value = message || t('load3d.exportingModel') @@ -584,6 +594,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { isPreview, isSplatModel, isPlyModel, + hasSkeleton, hasRecording, recordingDuration, animations, diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts index 0510a9c82..d3e6351fd 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.ts @@ -156,8 +156,9 @@ class Load3DConfiguration { return { upDirection: 'original', - materialMode: 'original' - } as ModelConfig + materialMode: 'original', + showSkeleton: false + } } private applySceneConfig(config: SceneConfig, bgImagePath?: string) { diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index f17683b64..91173346b 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -727,6 +727,19 @@ class Load3d { return this.animationManager.animationClips.length > 0 } + public hasSkeleton(): boolean { + return this.modelManager.hasSkeleton() + } + + public setShowSkeleton(show: boolean): void { + this.modelManager.setShowSkeleton(show) + this.forceRender() + } + + public getShowSkeleton(): boolean { + return this.modelManager.showSkeleton + } + public getAnimationTime(): number { return this.animationManager.getAnimationTime() } diff --git a/src/extensions/core/load3d/SceneModelManager.ts b/src/extensions/core/load3d/SceneModelManager.ts index 8f042c2be..a480e4554 100644 --- a/src/extensions/core/load3d/SceneModelManager.ts +++ b/src/extensions/core/load3d/SceneModelManager.ts @@ -30,6 +30,8 @@ export class SceneModelManager implements ModelManagerInterface { originalURL: string | null = null appliedTexture: THREE.Texture | null = null textureLoader: THREE.TextureLoader + skeletonHelper: THREE.SkeletonHelper | null = null + showSkeleton: boolean = false private scene: THREE.Scene private renderer: THREE.WebGLRenderer @@ -414,9 +416,69 @@ export class SceneModelManager implements ModelManagerInterface { this.appliedTexture = null } + if (this.skeletonHelper) { + this.scene.remove(this.skeletonHelper) + this.skeletonHelper.dispose() + this.skeletonHelper = null + } + this.showSkeleton = false + this.originalMaterials = new WeakMap() } + hasSkeleton(): boolean { + if (!this.currentModel) return false + let found = false + this.currentModel.traverse((child) => { + if (child instanceof THREE.SkinnedMesh && child.skeleton) { + found = true + } + }) + return found + } + + setShowSkeleton(show: boolean): void { + this.showSkeleton = show + + if (show) { + if (!this.skeletonHelper && this.currentModel) { + let rootBone: THREE.Bone | null = null + this.currentModel.traverse((child) => { + if (child instanceof THREE.Bone && !rootBone) { + if (!(child.parent instanceof THREE.Bone)) { + rootBone = child + } + } + }) + + if (rootBone) { + this.skeletonHelper = new THREE.SkeletonHelper(rootBone) + this.scene.add(this.skeletonHelper) + } else { + let skinnedMesh: THREE.SkinnedMesh | null = null + this.currentModel.traverse((child) => { + if (child instanceof THREE.SkinnedMesh && !skinnedMesh) { + skinnedMesh = child + } + }) + + if (skinnedMesh) { + this.skeletonHelper = new THREE.SkeletonHelper(skinnedMesh) + this.scene.add(this.skeletonHelper) + } + } + } else if (this.skeletonHelper) { + this.skeletonHelper.visible = true + } + } else { + if (this.skeletonHelper) { + this.skeletonHelper.visible = false + } + } + + this.eventManager.emitEvent('skeletonVisibilityChange', show) + } + addModelToScene(model: THREE.Object3D): void { this.currentModel = model model.name = 'MainModel' diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 7954f6bfe..d368d8d3c 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -34,6 +34,7 @@ export interface SceneConfig { export interface ModelConfig { upDirection: UpDirection materialMode: MaterialMode + showSkeleton: boolean } export interface CameraConfig { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 9f287087b..2cad2d06e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1642,6 +1642,7 @@ "loadingModel": "Loading 3D Model...", "upDirection": "Up Direction", "materialMode": "Material Mode", + "showSkeleton": "Show Skeleton", "scene": "Scene", "model": "Model", "camera": "Camera",