mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +00:00
feat: add skeleton visualization toggle for 3D models (#7857)
## Summary For better support animation 3d model custon node, such as https://github.com/jtydhr88/ComfyUI-HY-Motion1, add ability to show/hide skeleton bones in Load3D nodes for models with skeletal animation. Uses THREE.SkeletonHelper with root bone detection to properly support both FBX and GLB model formats. ## Screenshots https://github.com/user-attachments/assets/df9de4a6-549e-4227-aa00-8859d71f43d1 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7857-feat-add-skeleton-visualization-toggle-for-3D-models-2e06d73d365081a39f49f81f72657a70) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
v-model:light-config="lightConfig"
|
v-model:light-config="lightConfig"
|
||||||
:is-splat-model="isSplatModel"
|
:is-splat-model="isSplatModel"
|
||||||
:is-ply-model="isPlyModel"
|
:is-ply-model="isPlyModel"
|
||||||
|
:has-skeleton="hasSkeleton"
|
||||||
@update-background-image="handleBackgroundImageUpdate"
|
@update-background-image="handleBackgroundImageUpdate"
|
||||||
@export-model="handleExportModel"
|
@export-model="handleExportModel"
|
||||||
/>
|
/>
|
||||||
@@ -116,6 +117,7 @@ const {
|
|||||||
isPreview,
|
isPreview,
|
||||||
isSplatModel,
|
isSplatModel,
|
||||||
isPlyModel,
|
isPlyModel,
|
||||||
|
hasSkeleton,
|
||||||
hasRecording,
|
hasRecording,
|
||||||
recordingDuration,
|
recordingDuration,
|
||||||
animations,
|
animations,
|
||||||
|
|||||||
@@ -58,8 +58,10 @@
|
|||||||
v-if="showModelControls"
|
v-if="showModelControls"
|
||||||
v-model:material-mode="modelConfig!.materialMode"
|
v-model:material-mode="modelConfig!.materialMode"
|
||||||
v-model:up-direction="modelConfig!.upDirection"
|
v-model:up-direction="modelConfig!.upDirection"
|
||||||
|
v-model:show-skeleton="modelConfig!.showSkeleton"
|
||||||
:hide-material-mode="isSplatModel"
|
:hide-material-mode="isSplatModel"
|
||||||
:is-ply-model="isPlyModel"
|
:is-ply-model="isPlyModel"
|
||||||
|
:has-skeleton="hasSkeleton"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CameraControls
|
<CameraControls
|
||||||
@@ -99,9 +101,14 @@ import type {
|
|||||||
} from '@/extensions/core/load3d/interfaces'
|
} from '@/extensions/core/load3d/interfaces'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { isSplatModel = false, isPlyModel = false } = defineProps<{
|
const {
|
||||||
|
isSplatModel = false,
|
||||||
|
isPlyModel = false,
|
||||||
|
hasSkeleton = false
|
||||||
|
} = defineProps<{
|
||||||
isSplatModel?: boolean
|
isSplatModel?: boolean
|
||||||
isPlyModel?: boolean
|
isPlyModel?: boolean
|
||||||
|
hasSkeleton?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||||
|
|||||||
@@ -70,6 +70,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasSkeleton">
|
||||||
|
<Button
|
||||||
|
v-tooltip.right="{
|
||||||
|
value: t('load3d.showSkeleton'),
|
||||||
|
showDelay: 300
|
||||||
|
}"
|
||||||
|
size="icon"
|
||||||
|
variant="textonly"
|
||||||
|
:class="cn('rounded-full', showSkeleton && 'bg-blue-500')"
|
||||||
|
:aria-label="t('load3d.showSkeleton')"
|
||||||
|
@click="showSkeleton = !showSkeleton"
|
||||||
|
>
|
||||||
|
<i class="pi pi-sitemap text-lg text-white" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -84,13 +100,19 @@ import type {
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
const {
|
||||||
|
hideMaterialMode = false,
|
||||||
|
isPlyModel = false,
|
||||||
|
hasSkeleton = false
|
||||||
|
} = defineProps<{
|
||||||
hideMaterialMode?: boolean
|
hideMaterialMode?: boolean
|
||||||
isPlyModel?: boolean
|
isPlyModel?: boolean
|
||||||
|
hasSkeleton?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||||
const upDirection = defineModel<UpDirection>('upDirection')
|
const upDirection = defineModel<UpDirection>('upDirection')
|
||||||
|
const showSkeleton = defineModel<boolean>('showSkeleton')
|
||||||
|
|
||||||
const showUpDirection = ref(false)
|
const showUpDirection = ref(false)
|
||||||
const showMaterialMode = ref(false)
|
const showMaterialMode = ref(false)
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ describe('useLoad3d', () => {
|
|||||||
},
|
},
|
||||||
'Model Config': {
|
'Model Config': {
|
||||||
upDirection: 'original',
|
upDirection: 'original',
|
||||||
materialMode: 'original'
|
materialMode: 'original',
|
||||||
|
showSkeleton: false
|
||||||
},
|
},
|
||||||
'Camera Config': {
|
'Camera Config': {
|
||||||
cameraType: 'perspective',
|
cameraType: 'perspective',
|
||||||
@@ -107,6 +108,8 @@ describe('useLoad3d', () => {
|
|||||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||||
isSplatModel: vi.fn().mockReturnValue(false),
|
isSplatModel: vi.fn().mockReturnValue(false),
|
||||||
isPlyModel: vi.fn().mockReturnValue(false),
|
isPlyModel: vi.fn().mockReturnValue(false),
|
||||||
|
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||||
|
setShowSkeleton: vi.fn(),
|
||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
@@ -143,7 +146,8 @@ describe('useLoad3d', () => {
|
|||||||
})
|
})
|
||||||
expect(composable.modelConfig.value).toEqual({
|
expect(composable.modelConfig.value).toEqual({
|
||||||
upDirection: 'original',
|
upDirection: 'original',
|
||||||
materialMode: 'original'
|
materialMode: 'original',
|
||||||
|
showSkeleton: false
|
||||||
})
|
})
|
||||||
expect(composable.cameraConfig.value).toEqual({
|
expect(composable.cameraConfig.value).toEqual({
|
||||||
cameraType: 'perspective',
|
cameraType: 'perspective',
|
||||||
@@ -410,7 +414,8 @@ describe('useLoad3d', () => {
|
|||||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||||
expect(mockNode.properties['Model Config']).toEqual({
|
expect(mockNode.properties['Model Config']).toEqual({
|
||||||
upDirection: '+y',
|
upDirection: '+y',
|
||||||
materialMode: 'wireframe'
|
materialMode: 'wireframe',
|
||||||
|
showSkeleton: false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -696,10 +701,13 @@ describe('useLoad3d', () => {
|
|||||||
'backgroundImageLoadingEnd',
|
'backgroundImageLoadingEnd',
|
||||||
'modelLoadingStart',
|
'modelLoadingStart',
|
||||||
'modelLoadingEnd',
|
'modelLoadingEnd',
|
||||||
|
'skeletonVisibilityChange',
|
||||||
'exportLoadingStart',
|
'exportLoadingStart',
|
||||||
'exportLoadingEnd',
|
'exportLoadingEnd',
|
||||||
'recordingStatusChange',
|
'recordingStatusChange',
|
||||||
'animationListChange'
|
'animationListChange',
|
||||||
|
'animationProgressChange',
|
||||||
|
'cameraChanged'
|
||||||
]
|
]
|
||||||
|
|
||||||
expectedEvents.forEach((event) => {
|
expectedEvents.forEach((event) => {
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
|||||||
|
|
||||||
const modelConfig = ref<ModelConfig>({
|
const modelConfig = ref<ModelConfig>({
|
||||||
upDirection: 'original',
|
upDirection: 'original',
|
||||||
materialMode: 'original'
|
materialMode: 'original',
|
||||||
|
showSkeleton: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasSkeleton = ref(false)
|
||||||
|
|
||||||
const cameraConfig = ref<CameraConfig>({
|
const cameraConfig = ref<CameraConfig>({
|
||||||
cameraType: 'perspective',
|
cameraType: 'perspective',
|
||||||
fov: 75
|
fov: 75
|
||||||
@@ -273,6 +276,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
|||||||
nodeRef.value.properties['Model Config'] = newValue
|
nodeRef.value.properties['Model Config'] = newValue
|
||||||
load3d.setUpDirection(newValue.upDirection)
|
load3d.setUpDirection(newValue.upDirection)
|
||||||
load3d.setMaterialMode(newValue.materialMode)
|
load3d.setMaterialMode(newValue.materialMode)
|
||||||
|
load3d.setShowSkeleton(newValue.showSkeleton)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
@@ -503,6 +507,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||||
isPlyModel.value = load3d?.isPlyModel() ?? 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) => {
|
exportLoadingStart: (message: string) => {
|
||||||
loadingMessage.value = message || t('load3d.exportingModel')
|
loadingMessage.value = message || t('load3d.exportingModel')
|
||||||
@@ -584,6 +594,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
|||||||
isPreview,
|
isPreview,
|
||||||
isSplatModel,
|
isSplatModel,
|
||||||
isPlyModel,
|
isPlyModel,
|
||||||
|
hasSkeleton,
|
||||||
hasRecording,
|
hasRecording,
|
||||||
recordingDuration,
|
recordingDuration,
|
||||||
animations,
|
animations,
|
||||||
|
|||||||
@@ -156,8 +156,9 @@ class Load3DConfiguration {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
upDirection: 'original',
|
upDirection: 'original',
|
||||||
materialMode: 'original'
|
materialMode: 'original',
|
||||||
} as ModelConfig
|
showSkeleton: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
||||||
|
|||||||
@@ -727,6 +727,19 @@ class Load3d {
|
|||||||
return this.animationManager.animationClips.length > 0
|
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 {
|
public getAnimationTime(): number {
|
||||||
return this.animationManager.getAnimationTime()
|
return this.animationManager.getAnimationTime()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export class SceneModelManager implements ModelManagerInterface {
|
|||||||
originalURL: string | null = null
|
originalURL: string | null = null
|
||||||
appliedTexture: THREE.Texture | null = null
|
appliedTexture: THREE.Texture | null = null
|
||||||
textureLoader: THREE.TextureLoader
|
textureLoader: THREE.TextureLoader
|
||||||
|
skeletonHelper: THREE.SkeletonHelper | null = null
|
||||||
|
showSkeleton: boolean = false
|
||||||
|
|
||||||
private scene: THREE.Scene
|
private scene: THREE.Scene
|
||||||
private renderer: THREE.WebGLRenderer
|
private renderer: THREE.WebGLRenderer
|
||||||
@@ -414,9 +416,69 @@ export class SceneModelManager implements ModelManagerInterface {
|
|||||||
this.appliedTexture = null
|
this.appliedTexture = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.skeletonHelper) {
|
||||||
|
this.scene.remove(this.skeletonHelper)
|
||||||
|
this.skeletonHelper.dispose()
|
||||||
|
this.skeletonHelper = null
|
||||||
|
}
|
||||||
|
this.showSkeleton = false
|
||||||
|
|
||||||
this.originalMaterials = new WeakMap()
|
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 {
|
addModelToScene(model: THREE.Object3D): void {
|
||||||
this.currentModel = model
|
this.currentModel = model
|
||||||
model.name = 'MainModel'
|
model.name = 'MainModel'
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface SceneConfig {
|
|||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
upDirection: UpDirection
|
upDirection: UpDirection
|
||||||
materialMode: MaterialMode
|
materialMode: MaterialMode
|
||||||
|
showSkeleton: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraConfig {
|
export interface CameraConfig {
|
||||||
|
|||||||
@@ -1642,6 +1642,7 @@
|
|||||||
"loadingModel": "Loading 3D Model...",
|
"loadingModel": "Loading 3D Model...",
|
||||||
"upDirection": "Up Direction",
|
"upDirection": "Up Direction",
|
||||||
"materialMode": "Material Mode",
|
"materialMode": "Material Mode",
|
||||||
|
"showSkeleton": "Show Skeleton",
|
||||||
"scene": "Scene",
|
"scene": "Scene",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
|
|||||||
Reference in New Issue
Block a user