import { UI } from '../../UI.ts' import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' import { FBXExporter } from '@comfyorg/fbx-exporter-three' import { type AnimationClip, Scene, type SkinnedMesh, type Object3D } from 'three' import { type DownloadSettings, ExportContents, ExportFormat, type FbxExportPreset } from './DownloadSettings.ts' import { ExportBoneNamingService } from './ExportBoneNamingService.ts' import { ExportHierarchyService } from './ExportHierarchyService.ts' import { ExportAnimationCleanupService } from './ExportAnimationCleanupService.ts' import { GlbSkinCleanupService } from './GlbSkinCleanupService.ts' import { AnimationUtility } from '../animations-listing/AnimationUtility.ts' import { type AnimationExportSelection } from '../animations-listing/interfaces/AnimationExportSelection.ts' import { FbxTextureCompatibilityService } from './FbxTextureCompatibilityService.ts' // Note: EventTarget is a built-in interface and do not need to import it export class StepExportToFile extends EventTarget { private readonly ui: UI = UI.getInstance() private animation_clips_to_export: AnimationClip[] = [] private readonly fbx_exporter: FBXExporter = new FBXExporter() public set_animation_clips_to_export (all_animations_clips: AnimationClip[], export_selections: AnimationExportSelection[]): void { this.animation_clips_to_export = [] export_selections.forEach((selection) => { const source_clip: AnimationClip | undefined = all_animations_clips[selection.animation_index] if (source_clip === undefined) { return } if (selection.mirror_export_mode === 'none') { this.animation_clips_to_export.push(source_clip.clone()) return } if (selection.mirror_export_mode === 'mirrored') { this.animation_clips_to_export.push(AnimationUtility.create_mirrored_clip(source_clip)) return } // Export both normal and mirrored variants. this.animation_clips_to_export.push(source_clip.clone()) this.animation_clips_to_export.push(AnimationUtility.create_mirrored_clip(source_clip)) }) } public export ( skinned_meshes: SkinnedMesh[], filename: string = 'exported_model', download_settings: DownloadSettings): Promise { if (this.animation_clips_to_export.length === 0) { console.log('ERROR: No animation clips added to export') return Promise.reject(new Error('No animation clips added to export')) } const export_scene = new Scene() const export_clips = this.animation_clips_to_export.map((clip) => { const cloned_clip = clip.clone() // Make action names consistent by replacing any whitespace with underscores cloned_clip.name = cloned_clip.name.replace(/\s+/g, '_') return cloned_clip }) // Apply bone naming before re-parenting: the service traverses each skinned mesh to // find its bones, which are still children of the mesh at this point. const restore_bone_names = ExportBoneNamingService.apply_download_settings( skinned_meshes, export_clips, download_settings.bone_naming_structure() ) // "Skeleton only" exports just the bone hierarchy (the root bone subtree that hangs off // each skinned mesh) instead of the full skinned mesh, which excludes the mesh geometry. // The animation clips still target the bone names, so the selected animations continue // to work against the exported skeleton. const skeleton_only = download_settings.export_contents() === ExportContents.Skeleton const objects_to_export: Object3D[] = ExportHierarchyService.collect_objects_to_export(skinned_meshes, skeleton_only) // The base animation data can contain tracks for joints the user removed (e.g. fingers), // and keyframe times can contain float32 duplicates that glTF validators reject. // This runs after bone renaming so the track names match the final bone names. ExportAnimationCleanupService.clean_clips_for_export(export_clips, objects_to_export) // When exporting to a file, we need to temporarily move the exported objects to a new // scene. An object can only be part of one scene at a time, so we must move it back to // its original parent after exporting. For a skeleton-only export, the root bone's // original parent is its skinned mesh. const original_parents = new Map() objects_to_export.forEach((object_to_export) => { // Save the original parent original_parents.set(object_to_export, object_to_export.parent) export_scene.add(object_to_export) }) console.log('SKINNED MESH DATA TO EXPORT:', skinned_meshes) console.log('animations to export', export_clips) return this.export_scene( export_scene, export_clips, filename, download_settings.export_format(), download_settings.fbx_export_preset() ) .then(() => { // Move the exported objects back to their original parents objects_to_export.forEach((exported_object) => { const original_par = original_parents.get(exported_object) if (original_par != null) { original_par.add(exported_object) } else { // If there was no original parent, remove it from the scene export_scene.remove(exported_object) console.log('ERROR: No original parent found for exported object when exporting and re-parenting to original scene') } }) restore_bone_names() }) .catch((error) => { console.log('Error exporting file:', error) restore_bone_names() throw error }) } public async export_scene ( exported_scene: Scene, animations_to_export: AnimationClip[], file_name: string, export_format: ExportFormat, fbx_export_preset: FbxExportPreset ): Promise { if (export_format === ExportFormat.FBX) { await this.export_fbx(exported_scene, animations_to_export, file_name, fbx_export_preset) return } await this.export_glb(exported_scene, animations_to_export, file_name) } public async export_glb (exported_scene: Scene, animations_to_export: AnimationClip[], file_name: string): Promise { await new Promise((resolve, reject) => { const gltf_exporter = new GLTFExporter() const export_options = { binary: true, onlyVisible: false, embedImages: true, animations: animations_to_export } gltf_exporter.parse( exported_scene, (result: ArrayBuffer) => { // Handle the result of the export if (result !== null) { const cleaned_result = GlbSkinCleanupService.remove_skin_skeleton_properties(result) this.save_array_buffer(cleaned_result, `${file_name}.glb`) resolve() // Resolve the promise when the export is complete } else { console.log('ERROR: result is not an instance of ArrayBuffer') reject(new Error('Export result is not an ArrayBuffer')) } }, (error: any) => { console.log('An error happened during parsing', error) reject(error) // Reject the promise if an error occurs }, export_options ) }) } public async export_fbx ( exported_scene: Scene, animations_to_export: AnimationClip[], file_name: string, fbx_export_preset: FbxExportPreset ): Promise { exported_scene.animations = animations_to_export const texture_compatibility_session = new FbxTextureCompatibilityService(exported_scene) try { const result = await this.fbx_exporter.parseAsync(exported_scene, { preset: fbx_export_preset, includeAnimations: true, animations: animations_to_export, onlyVisible: false, embedTextures: true }) this.save_uint8_array(result, `${file_name}.fbx`) } finally { texture_compatibility_session.restore() exported_scene.animations = [] } } private save_file (blob: Blob, filename: string): void { if (this.ui.dom_export_button_hidden_link != null) { this.ui.dom_export_button_hidden_link.href = URL.createObjectURL(blob) this.ui.dom_export_button_hidden_link.download = filename this.ui.dom_export_button_hidden_link.click() } else { console.log('ERROR: dom_export_button_hidden_link is null') } } // used for GLB to turn content into a byte array for saving private save_array_buffer (buffer: ArrayBuffer, filename: string): void { this.save_file(new Blob([buffer], { type: 'application/octet-stream' }), filename) } // used for FBX exporter to turn content into a byte array for saving private save_uint8_array (buffer: Uint8Array, filename: string): void { this.save_file(new Blob([buffer], { type: 'application/octet-stream' }), filename) } }