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:
Terry Jia
2026-01-06 21:11:06 -05:00
committed by GitHub
parent 6e5dfc0109
commit dad1eafecc
10 changed files with 137 additions and 9 deletions

View File

@@ -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,

View File

@@ -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"
/>
<CameraControls
@@ -99,9 +101,14 @@ import type {
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { isSplatModel = false, isPlyModel = false } = defineProps<{
const {
isSplatModel = false,
isPlyModel = false,
hasSkeleton = false
} = defineProps<{
isSplatModel?: boolean
isPlyModel?: boolean
hasSkeleton?: boolean
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -70,6 +70,22 @@
</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>
</template>
@@ -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>('materialMode')
const upDirection = defineModel<UpDirection>('upDirection')
const showSkeleton = defineModel<boolean>('showSkeleton')
const showUpDirection = ref(false)
const showMaterialMode = ref(false)

View File

@@ -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) => {

View File

@@ -40,9 +40,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const modelConfig = ref<ModelConfig>({
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
showSkeleton: false
})
const hasSkeleton = ref(false)
const cameraConfig = ref<CameraConfig>({
cameraType: 'perspective',
fov: 75
@@ -273,6 +276,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
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<LGraphNode | null>) => {
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<LGraphNode | null>) => {
isPreview,
isSplatModel,
isPlyModel,
hasSkeleton,
hasRecording,
recordingDuration,
animations,

View File

@@ -156,8 +156,9 @@ class Load3DConfiguration {
return {
upDirection: 'original',
materialMode: 'original'
} as ModelConfig
materialMode: 'original',
showSkeleton: false
}
}
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {

View File

@@ -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()
}

View File

@@ -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'

View File

@@ -34,6 +34,7 @@ export interface SceneConfig {
export interface ModelConfig {
upDirection: UpDirection
materialMode: MaterialMode
showSkeleton: boolean
}
export interface CameraConfig {

View File

@@ -1642,6 +1642,7 @@
"loadingModel": "Loading 3D Model...",
"upDirection": "Up Direction",
"materialMode": "Material Mode",
"showSkeleton": "Show Skeleton",
"scene": "Scene",
"model": "Model",
"camera": "Camera",