bl_info = { "name": "VF Delivery", "author": "John Einselen - Vectorform LLC", "version": (0, 12, 6), "blender": (3, 3, 1), "location": "Scene > VF Tools > Delivery", "description": "Quickly export selected objects to a specified directory", "warning": "inexperienced developer, use at your own risk", "doc_url": "https://github.com/jeinselen/VF-BlenderDelivery", "tracker_url": "https://github.com/jeinselen/VF-BlenderDelivery/issues", "category": "3D View"} import bpy from bpy.app.handlers import persistent import mathutils import struct import numpy as np import os # With help from: # https://stackoverflow.com/questions/54464682/best-way-to-undo-previous-steps-in-a-series-of-steps # https://stackoverflow.com/questions/37335653/unable-to-completely-deselect-all-objects-in-blender-using-scripting-or-key-a # https://blender.stackexchange.com/questions/200341/apply-modifiers-in-all-objects-at-once # https://github.com/CheeryLee/blender_apply_modifiers/blob/master/apply_modifiers.py # https://blender.stackexchange.com/a/146573/123159 # Define allowed object types VF_delivery_object_types = ['CURVE', 'MESH', 'META', 'SURFACE', 'FONT'] # Not all types are supported by all exporters, see the GitHub documentation for more details ########################################################################### # Main class class VFDELIVERY_OT_file(bpy.types.Operator): bl_idname = "vfdelivery.file" bl_label = "Deliver File" bl_description = "Quickly export selected objects or collection to a specified directory" # bl_options = {'REGISTER', 'UNDO'} def remap(self, val, start, stop): val = (val - start) / (stop - start) return val def execute(self, context): # Set up local variables location = bpy.path.abspath(bpy.context.scene.vf_delivery_settings.file_location) format = bpy.context.scene.vf_delivery_settings.file_type file_format = "." + format.lower().split("-")[0] # Get only the characters before a dash to support multiple variations of a single format combined = True if bpy.context.scene.vf_delivery_settings.file_grouping == "COMBINED" else False active_object = bpy.context.active_object # Create directory if it doesn't exist yet if not os.path.exists(location): os.makedirs(location) # Save then override the current mode to OBJECT if active_object is not None: object_mode = active_object.mode bpy.ops.object.mode_set(mode = 'OBJECT') # Check if at least one object is selected, if not, convert selected collection into object selection if bpy.context.object and bpy.context.object.select_get(): file_name = active_object.name else: file_name = bpy.context.collection.name for obj in bpy.context.collection.all_objects: obj.select_set(True) if format != "CSV-1": # Push an undo state (seems easier than trying to re-select previously selected non-mesh objects) bpy.ops.ed.undo_push() # Deselect any non-mesh objects for obj in bpy.context.selected_objects: if obj.type not in VF_delivery_object_types: obj.select_set(False) # MESH (REALTIME 3D) if format == "FBX" or format == "GLB" or format == "OBJ" or format == "USDZ": # Push an undo state (easier than trying to re-select previously selected non-MESH objects?) bpy.ops.ed.undo_push() # Track number of undo steps to retrace after export is complete undo_steps = 1 # Loop through each of the selected objects # But only set individual selections if file export is set to individual # Otherwise loop once and exit (see the if statement at the very end) for obj in bpy.context.selected_objects: if not combined: # deselect everything for selobj in bpy.context.selected_objects: selobj.select_set(False) # select individual object obj.select_set(True) file_name = obj.name # Note to future self; you probably missed the comment block just above. Please stop freaking out. When combined is true the loop is exited after the first export pass. You can stop frantically scrolling for multi-export errors, you'll just get to the end of this section and figure out the solution is already implemented. Again. if format == "FBX": bpy.ops.export_scene.fbx( filepath = location + file_name + file_format, check_existing = False, # Always overwrite existing files use_selection = True, use_visible = True, use_active_collection = False, # This is now hardcoded, as we're converting collection selection into object selection manually above global_scale = 1.0, # 1.0 apply_unit_scale = True, apply_scale_options = 'FBX_SCALE_NONE', # FBX_SCALE_NONE = All Local use_space_transform = True, axis_forward = '-Z', axis_up = 'Y', object_types = {'ARMATURE', 'CAMERA', 'EMPTY', 'LIGHT', 'MESH', 'OTHER'}, bake_space_transform = True, # True (this is "!experimental!") use_mesh_modifiers = True, # Come back to this...manually trigger application of mesh modifiers and convert attributes to UV maps use_mesh_modifiers_render = True, mesh_smooth_type = 'OFF', # OFF = Normals Only use_subsurf = False, # Seems unhelpful for realtime (until realtime supports live subdivision cross-platform) use_mesh_edges = False, use_tspace = False, use_triangles = True, # This wasn't included in the "perfect" Unity settings, but seems logical? use_custom_props = False, use_armature_deform_only = True, # True add_leaf_bones = False, # False primary_bone_axis = 'X', # X Axis secondary_bone_axis = 'Y', # Y Axis armature_nodetype = 'NULL', bake_anim = True, bake_anim_use_all_bones = True, bake_anim_use_nla_strips = True, bake_anim_use_all_actions = True, bake_anim_force_startend_keying = True, # Some recommend False, but Unity may not load animations nicely without starting keyframes bake_anim_step = 1.0, bake_anim_simplify_factor = 1.0, path_mode = 'AUTO', embed_textures = False, batch_mode = 'OFF', use_batch_own_dir = False, use_metadata = True) elif format == "GLB": bpy.ops.export_scene.gltf( filepath = location + file_name + file_format, check_existing = False, # Always overwrite existing files export_format = 'GLB', export_copyright = '', export_image_format = 'JPEG', export_texcoords = True, export_normals = True, export_draco_mesh_compression_enable = True, export_draco_mesh_compression_level = 6, export_draco_position_quantization = 14, export_draco_normal_quantization = 10, export_draco_texcoord_quantization = 12, export_draco_color_quantization = 10, export_draco_generic_quantization = 12, export_tangents = False, export_materials = 'EXPORT', export_colors = True, use_mesh_edges = False, use_mesh_vertices = False, export_cameras = False, use_selection = True, use_visible = True, use_renderable = True, use_active_collection = False, # This is hardcoded now, as collections are converted manually to object selections above use_active_scene = False, export_extras = False, export_yup = True, export_apply = True, export_animations = True, export_frame_range = True, export_frame_step = 1, export_force_sampling = True, export_nla_strips = True, export_def_bones = True, # Changed from default export_optimize_animation_size = True, # Changed from default, may cause issues with stepped animations export_current_frame = False, export_skins = True, export_all_influences = False, export_morph = True, export_morph_normal = True, export_morph_tangent = False, export_lights = False, will_save_settings = False, filter_glob = '*.glb;*.gltf') elif format == "OBJ": if bpy.app.version[0] < 4: # Blender 3.x bpy.ops.export_scene.obj( filepath = location + file_name + file_format, check_existing = False, # Always overwrite existing files use_selection = True, use_animation = False, use_mesh_modifiers = True, use_edges = False, # Changed from default use_smooth_groups = False, use_smooth_groups_bitflags = False, use_normals = True, use_uvs = True, use_materials = True, use_triangles = True, # Changed from default use_nurbs = False, use_vertex_groups = False, use_blen_objects = True, group_by_object = False, group_by_material = False, keep_vertex_order = True, # Changed from default global_scale = 100.0, path_mode = 'AUTO', axis_forward = '-Z', axis_up = 'Y') else: # Blender 4.x bpy.ops.wm.obj_export( filepath = location + file_name + file_format, check_existing = False, # Always overwrite existing files export_animation = False, #start_frame = bpy.context.scene.frame_start, #end_frame = bpy.context.scene.frame_end, forward_axis = 'NEGATIVE_Z', up_axis = 'Y', global_scale = 100.0, apply_modifiers = True, export_eval_mode = 'DAG_EVAL_RENDER', # Apply render modifiers, not viewport export_selected_objects = True, # Export only selected object(s) export_uv = True, export_normals = True, export_colors = False, export_materials = True, export_pbr_extensions = True, # Changed from default path_mode = 'AUTO', export_triangulated_mesh = True, # Changed from default export_curves_as_nurbs = False, export_object_groups = False, export_material_groups = False, export_vertex_groups = False, export_smooth_groups = False, smooth_group_bitflags = False) elif format == "USDZ": bpy.ops.wm.usd_export( filepath = location + file_name + file_format, check_existing = False, # Changed from default # Removed GUI options selected_objects_only = True, # Changed from default visible_objects_only = True, export_animation = False, # May need to add an option for enabling animation exports depending on the project export_hair = False, export_uvmaps = True, # Need to test this: USD uses "st" as the default uv map name, and the exporter apparently doesn't convert Blender's default "UVmap" automatically? export_normals = True, export_materials = True, use_instancing = False, evaluation_mode = 'RENDER', generate_preview_surface = True, export_textures = True, overwrite_textures = True, # Changed from default relative_paths = True) # Interrupt the loop if we're exporting all objects to the same file if combined: break # Undo the previously completed object modifications for i in range(undo_steps): bpy.ops.ed.undo() # MESH (3D PRINTING) elif format == "STL": batch = 'OFF' if combined else 'OBJECT' output = location + file_name + file_format if combined else location bpy.ops.export_mesh.stl( filepath = output, ascii = False, check_existing = False, # Dangerous! use_selection = True, batch_mode = batch, global_scale = 1.0, use_scene_unit = False, use_mesh_modifiers = True, axis_forward = 'Y', axis_up = 'Z', filter_glob = '*.stl') # VOLUME (3D TEXTURE) elif format == "VF": # Define the data to be saved fourcc = "VF_V" # Replace with the appropriate FourCC of either 'VF_F' for value or 'VF_V' for vec3 # Name of the custom attribute attribute_name = 'field_vector' # Get the active selected object obj = bpy.context.object # Ensure the selected object is a mesh with equal to or fewer than 65536 vertices and the necessary properties and attributes if obj and obj.type == 'MESH' and len(obj.data.vertices) <= 65536 and obj.data.get('vf_point_grid_x') is not None and obj.data.get('vf_point_grid_y') is not None and obj.data.get('vf_point_grid_z') is not None: # Get evaluated object obj = bpy.context.evaluated_depsgraph_get().objects.get(obj.name) # Check if named attribute exists if attribute_name in obj.data.attributes: # Create empty array array = [] # For each attribute entry, collect the results for data in obj.data.attributes[attribute_name].data: # Check if the attribute includes a value if hasattr(data, 'value'): array.append(data.value) # Check if the attribute includes a vector elif hasattr(data, 'vector'): # Swizzle XZY order for Blender to Unity coordinate conversion array.append((data.vector.x, data.vector.z, data.vector.y)) else: print(f"Values not found in '{attribute_name}' attribute.") return {'CANCELLED'} # Set array size using custom properties size_x = obj.data["vf_point_grid_x"] size_y = obj.data["vf_point_grid_z"] # Swizzle XZY order for Unity coordinate system size_z = obj.data["vf_point_grid_y"] # Swizzle XZY order for Unity coordinate system # Calculate the stride based on the data type is_float_data = fourcc[3] == 'F' stride = 1 if is_float_data else 3 # Create a new binary file for writing with open(location + obj.name + file_format, 'wb') as file: # Write the FourCC file.write(struct.pack('4s', fourcc.encode('utf-8'))) # Write the volume size file.write(struct.pack('HHH', size_x, size_y, size_z)) # Write the data for value in array: if is_float_data: file.write(struct.pack('f', value)) else: file.write(struct.pack('fff', *value)) else: print(f"Selected object does not contain '{attribute_name}' values.") return {'CANCELLED'} else: print(f"Selected object is not a mesh") # Cancel processing return {'CANCELLED'} elif format == "PNG" or format == "EXR": # Name of the custom attribute attribute_name = 'field_vector' # Get the active selected object obj = bpy.context.object # Ensure the selected object is a mesh with equal to or fewer than 65536 vertices and the necessary properties and attributes # The actual limit for 3D textures in Unity is 2048 x 2048 x 2048 = 8,589,934,592 # However...that would result in an image over 4 million pixels wide, and I just don't want to deal with the ramifications of that right now if obj and obj.type == 'MESH' and len(obj.data.vertices) <= 65536 and obj.data.get('vf_point_grid_x') is not None and obj.data.get('vf_point_grid_y') is not None and obj.data.get('vf_point_grid_z') is not None: # Get evaluated object obj = bpy.context.evaluated_depsgraph_get().objects.get(obj.name) # Check if named attribute exists if attribute_name in obj.data.attributes: # Get remapping values start = context.scene.vf_delivery_settings.data_range[0] stop = context.scene.vf_delivery_settings.data_range[1] # Create empty array array = [] # For each attribute entry, collect the results for data in obj.data.attributes[attribute_name].data: # Check if the attribute includes a value if hasattr(data, 'value'): # Instead of nested arrays, just create a flat list of values val = self.remap(data.value, start, stop) array.append(val) array.append(val) array.append(val) array.append(1.0) # Check if the attribute includes a vector elif hasattr(data, 'vector'): # Instead of nested arrays, just create a flat list of values if format == 'PNG': array.append(self.remap(data.vector.x, start, stop)) # Swizzle ZY order for Blender to Unity coordinate conversion array.append(self.remap(data.vector.z, start, stop)) array.append(self.remap(data.vector.y, start, stop)) else: array.append(data.vector.x) # Swizzle ZY order for Blender to Unity coordinate conversion array.append(data.vector.z) array.append(data.vector.y) array.append(1.0) else: print(f"Values not found in '{attribute_name}' attribute.") return {'CANCELLED'} # Get output sizes using custom properties grid_x = obj.data["vf_point_grid_x"] grid_y = obj.data["vf_point_grid_y"] grid_z = obj.data["vf_point_grid_z"] # Set image width (horizontal * depth) and height (vertical) # Swizzle ZY order for Unity coordinate system image_width = grid_x * grid_y image_height = grid_z # Create image image = bpy.data.images.new("3DtextureOutput", width=image_width, height=image_height, alpha=False, float_buffer=True, is_data=True) # Image content # Swizzle ZY order for Unity coordinate system array = np.array(array).reshape((grid_y, grid_z, grid_x, 4)) # Flip vertical axis array = array[:,::-1,:] # Rotate array = np.rot90(array, axes=(0, 1)) # Flatten into string of colour values image.pixels = array.flatten() # Save image image.filepath_raw = location + obj.name + file_format if format == 'PNG': image.file_format = 'PNG' else: image.file_format = 'OPEN_EXR' image.save() else: print(f"Selected object does not contain '{attribute_name}' values.") return {'CANCELLED'} else: print(f"Selected object is not a mesh") return {'CANCELLED'} # DATA (XYZ POSITIONS) elif format == "CSV-1": # Save timeline position frame_current = bpy.context.scene.frame_current # Set variables frame_start = bpy.context.scene.frame_start frame_end = bpy.context.scene.frame_end space = bpy.context.scene.vf_delivery_settings.csv_position for obj in bpy.context.selected_objects: # Collect data array = [["x","y","z"]] for i in range(frame_start, frame_end + 1): bpy.context.scene.frame_set(i) loc, rot, scale = obj.matrix_world.decompose() if space == "WORLD" else obj.matrix_local.decompose() array.append([loc.x, loc.y, loc.z]) # Save out CSV file np.savetxt( location + obj.name + file_format, array, delimiter = ",", newline = '\n', fmt = '% s' ) # Reset timeline position bpy.context.scene.frame_set(frame_current) elif format == "CSV-2": for obj in bpy.context.selected_objects: # Get evaluated object obj = bpy.context.evaluated_depsgraph_get().objects.get(obj.name) # Collect data with temporary mesh conversion array = [["x","y","z"]] for v in obj.to_mesh().vertices: array.append([v.co.x, v.co.y, v.co.z]) # Remove temporary mesh conversion obj.to_mesh_clear() # Save out CSV file np.savetxt( location + obj.name + file_format, array, delimiter = ",", newline = '\n', fmt = '% s' ) if format != "CSV-1": # Undo the previously completed non-mesh object deselection bpy.ops.ed.undo() # Reset to original mode if active_object is not None: bpy.ops.object.mode_set(mode = object_mode) # Done return {'FINISHED'} ########################################################################### # Project settings and UI rendering classes class vfDeliverySettings(bpy.types.PropertyGroup): file_type: bpy.props.EnumProperty( name = 'Pipeline', description = 'Sets the format for delivery output', items = [ ('FBX', 'FBX — Unity 3D', 'Export FBX binary file for Unity 3D'), ('GLB', 'GLB — ThreeJS', 'Export GLTF compressed binary file for ThreeJS'), ('OBJ', 'OBJ — Element3D', 'Export OBJ file for VideoCopilot Element 3D'), ('USDZ', 'USDZ — Xcode', 'Export USDZ file for Apple platforms including Xcode'), (None), ('STL', 'STL — 3D Printing', 'Export individual STL file of each selected object for 3D printing'), (None), ('VF', 'VF — Unity 3D Volume Field', 'Export volume field for Unity 3D (best used with the VFX Graph)'), ('PNG', 'PNG — 3D Texture Strip', 'Export volume field as an image strip for Godot, Unity 3D, or Unreal Engine'), ('EXR', 'EXR — 3D Texture Strip', 'Export volume field as an image strip for Godot, Unity 3D, or Unreal Engine'), (None), ('CSV-1', 'CSV — Item Position', 'Export CSV file of the selected object\'s position for all frames within the render range'), ('CSV-2', 'CSV — Point Position', 'Export CSV file of the selected object\'s points in object space') ], default = 'FBX') file_location: bpy.props.StringProperty( name = "Delivery Location", description = "Delivery location for all exported files", default = "//", maxlen = 4096, subtype = "DIR_PATH") file_grouping: bpy.props.EnumProperty( name = 'Grouping', description = 'Sets combined or individual file outputs', items = [ ('COMBINED', 'Combined', 'Export selection in one file'), ('INDIVIDUAL', 'Individual', 'Export selection as individual files') ], default = 'COMBINED') data_range: bpy.props.FloatVectorProperty( name='Range', description='Range of data to be normalised within 0-1 image values', size=2, default=(-1.0, 1.0), step=1, precision=2, soft_min=-1.0, soft_max= 1.0, min=-1000.0, max= 1000.0) csv_position: bpy.props.EnumProperty( name = 'Position', description = 'Sets local or world space coordinates', items = [ ('WORLD', 'World', 'World space'), ('LOCAL', 'Local', 'Local object space') ], default = 'WORLD') # csv_rotation: bpy.props.EnumProperty( # name = 'Rotation', # description = 'Sets the formatting of rotation values', # items = [ # ('RAD', 'Radians', 'Output rotation in radians'), # ('DEG', 'Degrees', 'Output rotation in degrees') # ], # default = 'RAD') class VFTOOLS_PT_delivery(bpy.types.Panel): bl_space_type = "VIEW_3D" bl_region_type = "UI" bl_category = 'VF Tools' bl_order = 0 bl_options = {'DEFAULT_CLOSED'} bl_label = "Delivery" bl_idname = "VFTOOLS_PT_delivery" @classmethod def poll(cls, context): return True def draw_header(self, context): try: layout = self.layout except Exception as exc: print(str(exc) + " | Error in VF Delivery panel header") def draw(self, context): try: # Set up variables file_format = "." + context.scene.vf_delivery_settings.file_type.lower().split("-")[0] # Get only the characters before a dash to support multiple variations of a single format button_enable = True button_icon = "FILE" button_title = '' info_box = '' show_group = True show_range = False show_csv = False object_count = 0 # Check if at least one object is selected if bpy.context.object and bpy.context.object.select_get(): # Volume Field: count only an active mesh with the necessary data elements # Does not check for named attributes, however, since that requires applying all modifiers if context.scene.vf_delivery_settings.file_type == "VF" or context.scene.vf_delivery_settings.file_type == "PNG" or context.scene.vf_delivery_settings.file_type == "EXR": obj = bpy.context.object # Validate object data (doesn't check if the geometry nodes modifier actually includes a named attribute) if obj.type == 'MESH' and len(obj.data.vertices) <= 65536 and obj.data.get('vf_point_grid_x') is not None and obj.data.get('vf_point_grid_y') is not None and obj.data.get('vf_point_grid_z') is not None and ('field_vector' in obj.data.attributes or 'NODES' in [modifier.type for modifier in obj.modifiers]): object_count = 1 # info_box = 'Volume export requires,"field_vector" attribute in,Geometry Node modifier' if context.scene.vf_delivery_settings.file_type == "PNG" or context.scene.vf_delivery_settings.file_type == "EXR": info_box = 'Columns: ' + str(obj.data["vf_point_grid_y"]) else: info_box = 'Volume export requires:,mesh with <=65536 points,"vf_point_grid..." properties,"field_vector" attribute' # CSV: count any items elif context.scene.vf_delivery_settings.file_type == "CSV-1": object_count = len(bpy.context.selected_objects) # Geometry: count only supported meshes and curves that are not hidden else: object_count = len([obj for obj in bpy.context.selected_objects if obj.type in VF_delivery_object_types]) # Button title if (object_count > 1 and context.scene.vf_delivery_settings.file_grouping == "COMBINED" and not (context.scene.vf_delivery_settings.file_type == "CSV-1" or context.scene.vf_delivery_settings.file_type == "CSV-2")): button_title = bpy.context.active_object.name + file_format elif object_count == 1: if bpy.context.active_object.type not in VF_delivery_object_types and context.scene.vf_delivery_settings.file_grouping == "INDIVIDUAL": for obj in bpy.context.selected_objects: if obj.type in VF_delivery_object_types: button_title = obj.name + file_format else: button_title = bpy.context.active_object.name + file_format else: button_title = str(object_count) + " files" # Button icon button_icon = "OUTLINER_OB_MESH" # Active collection fallback (except for Volume Field) elif not (context.scene.vf_delivery_settings.file_type == "VF" or context.scene.vf_delivery_settings.file_type == "PNG" or context.scene.vf_delivery_settings.file_type == "EXR"): # Volume Field: requires an active mesh object, collections are not supported # CSV-1: count any items within the collection if context.scene.vf_delivery_settings.file_type == "CSV-1": object_count = len(bpy.context.collection.all_objects) # Geometry: count only supported data types (mesh, curve, etcetera) for everything else else: object_count = len([obj for obj in bpy.context.collection.all_objects if obj.type in VF_delivery_object_types]) # Button title if context.scene.vf_delivery_settings.file_grouping == "COMBINED" and not (context.scene.vf_delivery_settings.file_type == "CSV-1" or context.scene.vf_delivery_settings.file_type == "CSV-2"): button_title = bpy.context.collection.name + file_format else: button_title = str(object_count) + " files" # Button icon button_icon = "OUTLINER_COLLECTION" # If no usable items (CSV-1) or meshes (everything else) are found, disable the button # Keeping the message generic allows this to be used universally if object_count == 0: button_enable = False button_icon = "X" if context.scene.vf_delivery_settings.file_type == "CSV-1": button_title = "Select item" else: button_title = "Select mesh" # Specific display cases if context.scene.vf_delivery_settings.file_type == "VF" or context.scene.vf_delivery_settings.file_type == "PNG" or context.scene.vf_delivery_settings.file_type == "EXR": show_group = False show_csv = False if context.scene.vf_delivery_settings.file_type == "PNG": show_range = True if context.scene.vf_delivery_settings.file_type == "CSV-1": show_group = False show_csv = True if context.scene.vf_delivery_settings.file_type == "CSV-2": show_group = False show_csv = False # UI Layout layout = self.layout layout.use_property_decorate = False # No animation layout.prop(context.scene.vf_delivery_settings, 'file_location', text = '') layout.prop(context.scene.vf_delivery_settings, 'file_type', text = '') if show_group: layout.prop(context.scene.vf_delivery_settings, 'file_grouping', expand = True) if show_range: layout.prop(context.scene.vf_delivery_settings, 'data_range') if show_csv: layout.prop(context.scene.vf_delivery_settings, 'csv_position', expand = True) if button_enable: layout.operator(VFDELIVERY_OT_file.bl_idname, text = button_title, icon = button_icon) else: disabled = layout.row() disabled.active = False disabled.enabled = False disabled.operator(VFDELIVERY_OT_file.bl_idname, text = button_title, icon = button_icon) if info_box: box = layout.box() col = box.column(align=True) for line in info_box.split(','): col.label(text=line) except Exception as exc: print(str(exc) + " | Error in VF Delivery panel") classes = (VFDELIVERY_OT_file, vfDeliverySettings, VFTOOLS_PT_delivery) ########################################################################### # Addon registration functions def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.vf_delivery_settings = bpy.props.PointerProperty(type = vfDeliverySettings) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) del bpy.types.Scene.vf_delivery_settings if __name__ == "__main__": register()