1+ from . import bake_utils
2+ import bpy
3+ import logging
4+ from bpy .types import Operator
5+ import os
6+
7+ logger = logging .getLogger (__name__ )
8+ logger .addHandler (logging .NullHandler ())
9+
10+ class ExportFBX (Operator ):
11+ """Export selected mesh objects to FBX format with textures"""
12+ bl_idname = "meddle.export_fbx"
13+ bl_label = "Export FBX"
14+ bl_description = "Export selected mesh objects to FBX format with textures in a subfolder"
15+ bl_options = {'REGISTER' , 'UNDO' }
16+
17+ # Directory path for export
18+ directory : bpy .props .StringProperty (
19+ name = "Export Directory" ,
20+ description = "Directory to export FBX and textures" ,
21+ subtype = 'DIR_PATH'
22+ )
23+
24+ def invoke (self , context , event ):
25+ """Open file browser for directory selection"""
26+ # Set default directory to blend file location or user home
27+ if bpy .data .filepath :
28+ self .directory = os .path .dirname (bpy .data .filepath )
29+ else :
30+ self .directory = os .path .expanduser ("~" )
31+
32+ context .window_manager .fileselect_add (self )
33+ return {'RUNNING_MODAL' }
34+
35+ @classmethod
36+ def poll (cls , context ):
37+ return bake_utils .require_mesh_or_armature_selected (context )
38+
39+ def execute (self , context ):
40+ """Execute the FBX export operation"""
41+ if not self .directory :
42+ self .report ({'ERROR' }, "No directory selected" )
43+ return {'CANCELLED' }
44+
45+ try :
46+ # Get selected objects
47+ selected_objects = context .selected_objects
48+ mesh_objects = [obj for obj in selected_objects if obj .type == 'MESH' ]
49+ armature_objects = [obj for obj in selected_objects if obj .type == 'ARMATURE' ]
50+
51+ # If armatures are selected, include their child meshes
52+ for armature in armature_objects :
53+ for obj in bpy .data .objects :
54+ if obj .type == 'MESH' and obj .parent == armature :
55+ if obj not in mesh_objects and obj not in selected_objects :
56+ selected_objects .append (obj )
57+ if obj not in mesh_objects :
58+ mesh_objects .append (obj )
59+
60+ if not mesh_objects and not armature_objects :
61+ self .report ({'ERROR' }, "No mesh or armature objects selected" )
62+ return {'CANCELLED' }
63+
64+ # Create textures subfolder
65+ textures_folder = os .path .join (self .directory , "textures" )
66+ os .makedirs (textures_folder , exist_ok = True )
67+ logger .info (f"Created textures folder: { textures_folder } " )
68+
69+ # Export images to textures folder
70+ self .export_textures (context , mesh_objects , textures_folder )
71+
72+ # Generate FBX filename
73+ fbx_filename = self .generate_fbx_filename (context , selected_objects )
74+ fbx_path = os .path .join (self .directory , fbx_filename )
75+
76+ # Export FBX with packed images
77+ self .export_fbx_file (context , selected_objects , fbx_path )
78+
79+ self .report ({'INFO' }, f"Successfully exported to { fbx_path } " )
80+ logger .info (f"Export complete: { fbx_path } " )
81+
82+ return {'FINISHED' }
83+
84+ except Exception as e :
85+ self .report ({'ERROR' }, f"Export failed: { str (e )} " )
86+ logger .error (f"Export failed: { e } " , exc_info = True )
87+ return {'CANCELLED' }
88+
89+ def export_textures (self , context , mesh_objects , textures_folder ):
90+ """Export all textures used by mesh materials to the textures folder"""
91+ exported_images = set ()
92+ image_count = 0
93+
94+ for mesh_obj in mesh_objects :
95+ if not mesh_obj .data .materials :
96+ continue
97+
98+ for mat_slot in mesh_obj .data .materials :
99+ if not mat_slot or not mat_slot .use_nodes :
100+ continue
101+
102+ material = mat_slot
103+ for node in material .node_tree .nodes :
104+ if node .type != 'TEX_IMAGE' or not node .image :
105+ continue
106+
107+ image = node .image
108+
109+ # Skip if already exported
110+ if image .name in exported_images :
111+ continue
112+
113+ # Determine output filename
114+ if image .filepath :
115+ # Use existing filename
116+ base_name = os .path .basename (image .filepath )
117+ if not base_name :
118+ base_name = f"{ image .name } .png"
119+ else :
120+ base_name = f"{ image .name } .png"
121+
122+ # Ensure .png extension
123+ if not base_name .lower ().endswith ('.png' ):
124+ base_name = os .path .splitext (base_name )[0 ] + '.png'
125+
126+ output_path = os .path .join (textures_folder , base_name )
127+
128+ # Save the image
129+ try :
130+ # Save a copy of the image
131+ original_filepath = image .filepath_raw
132+ original_format = image .file_format
133+
134+ image .filepath_raw = output_path
135+ image .file_format = 'PNG'
136+ image .save ()
137+
138+ # Restore original settings
139+ image .filepath_raw = original_filepath
140+ image .file_format = original_format
141+
142+ logger .info (f"Exported texture: { base_name } " )
143+ exported_images .add (image .name )
144+ image_count += 1
145+ except Exception as e :
146+ logger .warning (f"Failed to export texture { image .name } : { e } " )
147+
148+ self .report ({'INFO' }, f"Exported { image_count } texture(s) to textures folder" )
149+ logger .info (f"Exported { image_count } textures to { textures_folder } " )
150+
151+ def generate_fbx_filename (self , context , selected_objects ):
152+ """Generate a filename for the FBX export"""
153+ # Try to use armature name, or first mesh name, or default
154+ armature = next ((obj for obj in selected_objects if obj .type == 'ARMATURE' ), None )
155+
156+ if armature :
157+ base_name = armature .name
158+ elif selected_objects :
159+ base_name = selected_objects [0 ].name
160+ else :
161+ base_name = "export"
162+
163+ # Sanitize filename
164+ base_name = "" .join (c for c in base_name if c .isalnum () or c in (' ' , '_' , '-' )).strip ()
165+ if not base_name :
166+ base_name = "export"
167+
168+ return f"{ base_name } .fbx"
169+
170+ def export_fbx_file (self , context , selected_objects , fbx_path ):
171+ """Export the FBX file with selected objects"""
172+ # Ensure objects are selected
173+ bpy .ops .object .select_all (action = 'DESELECT' )
174+ for obj in selected_objects :
175+ obj .select_set (True )
176+
177+ # Set active object
178+ armature = next ((obj for obj in selected_objects if obj .type == 'ARMATURE' ), None )
179+ if armature :
180+ context .view_layer .objects .active = armature
181+ elif selected_objects :
182+ context .view_layer .objects .active = selected_objects [0 ]
183+
184+ # Export FBX with appropriate settings
185+ bpy .ops .export_scene .fbx (
186+ filepath = fbx_path ,
187+ use_selection = True ,
188+ global_scale = 1.0 ,
189+ apply_unit_scale = True ,
190+ apply_scale_options = 'FBX_SCALE_NONE' ,
191+ bake_space_transform = False ,
192+ object_types = {'ARMATURE' , 'MESH' },
193+ use_mesh_modifiers = True ,
194+ use_mesh_modifiers_render = True ,
195+ mesh_smooth_type = 'FACE' ,
196+ use_tspace = True ,
197+ use_custom_props = False ,
198+ add_leaf_bones = False ,
199+ primary_bone_axis = 'Y' ,
200+ secondary_bone_axis = 'X' ,
201+ armature_nodetype = 'NULL' ,
202+ bake_anim = False ,
203+ path_mode = 'COPY' , # Copy textures to export location
204+ embed_textures = True , # Embed textures in FBX
205+ batch_mode = 'OFF'
206+ )
207+
208+ logger .info (f"FBX exported to: { fbx_path } " )
0 commit comments