diff --git a/hello_world.zip b/hello_world.zip deleted file mode 100644 index ea8be87..0000000 Binary files a/hello_world.zip and /dev/null differ diff --git a/hello_world/__init__.py b/hello_world/__init__.py deleted file mode 100644 index 163dad0..0000000 --- a/hello_world/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from . import util - -def register(): - print("Hello World") - util.extra_print() -def unregister(): - print("Goodbye World") \ No newline at end of file diff --git a/hello_world/blender_manifest.toml b/hello_world/blender_manifest.toml deleted file mode 100644 index 258fd50..0000000 --- a/hello_world/blender_manifest.toml +++ /dev/null @@ -1,74 +0,0 @@ -schema_version = "1.0.0" - -# Example of manifest file for a Blender extension -# Change the values according to your extension -id = "hello_world" -version = "1.0.0" -name = "Hello World" -tagline = "This is another extension" -maintainer = "Simon Nordon " -# Supported types: "add-on", "theme" -type = "add-on" - -# Optional link to documentation, support, source files, etc -# Optional link to documentation, support, source files, etc -# website = "https://extensions.blender.org/add-ons/my-example-package/" - -# Optional list defined by Blender and server, see: -# https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html -tags = ["Animation", "Sequencer"] - -blender_version_min = "4.2.0" -# # Optional: Blender version that the extension does not support, earlier versions are supported. -# # This can be omitted and defined later on the extensions platform if an issue is found. -# blender_version_max = "5.1.0" - -# License conforming to https://spdx.org/licenses/ (use "SPDX: prefix) -# https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html -license = [ - "SPDX:GPL-2.0-or-later", -] -# Optional: required by some licenses. -# copyright = [ -# "2002-2024 Developer Name", -# "1998 Company Name", -# ] - -# Optional list of supported platforms. If omitted, the extension will be available in all operating systems. -# platforms = ["windows-x64", "macos-arm64", "linux-x64"] -# Other supported platforms: "windows-arm64", "macos-x64" - -# Optional: bundle 3rd party Python modules. -# https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html -# wheels = [ -# "./wheels/hexdump-3.3-py3-none-any.whl", -# "./wheels/jsmin-3.0.1-py3-none-any.whl", -# ] - -# # Optional: add-ons can list which resources they will require: -# # * files (for access of any filesystem operations) -# # * network (for internet access) -# # * clipboard (to read and/or write the system clipboard) -# # * camera (to capture photos and videos) -# # * microphone (to capture audio) -# # -# # If using network, remember to also check `bpy.app.online_access` -# # https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html#internet-access -# # -# # For each permission it is important to also specify the reason why it is required. -# # Keep this a single short sentence without a period (.) at the end. -# # For longer explanations use the documentation or detail page. -# -# [permissions] -# network = "Need to sync motion-capture data to server" -# files = "Import/export FBX from/to disk" -# clipboard = "Copy and paste bone transforms" - -# Optional: build settings. -# https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build -# [build] -# paths_exclude_pattern = [ -# "__pycache__/", -# "/.git/", -# "/*.zip", -# ] \ No newline at end of file diff --git a/hello_world/util.py b/hello_world/util.py deleted file mode 100644 index 7581498..0000000 --- a/hello_world/util.py +++ /dev/null @@ -1,2 +0,0 @@ -def extra_print(): - print("Extra print from hello-world/util.py") \ No newline at end of file diff --git a/index.html b/index.html index a383e8b..85775fc 100644 --- a/index.html +++ b/index.html @@ -19,16 +19,16 @@ Size - hello_world-1.0.0 - Hello World - This is another extension + unity_exporter-1.0.0 + UnityExporter + UnityExporter Blender Addon ~ 4.2.0 - ~ all - 2.7KB + 16.5KB -

Built 2024-08-06, 01:33

+

Built 2024-08-06, 11:30

diff --git a/index.json b/index.json index cc976f7..1fe0df0 100644 --- a/index.json +++ b/index.json @@ -4,23 +4,22 @@ "data": [ { "schema_version": "1.0.0", - "id": "hello_world", - "name": "Hello World", - "tagline": "This is another extension", + "id": "unity_exporter", + "name": "UnityExporter", + "tagline": "UnityExporter Blender Addon", "version": "1.0.0", "type": "add-on", - "maintainer": "Simon Nordon ", + "maintainer": "Simon Nordon ", "license": [ "SPDX:GPL-2.0-or-later" ], "blender_version_min": "4.2.0", "tags": [ - "Animation", - "Sequencer" + "Object" ], - "archive_url": "./hello_world.zip", - "archive_size": 2753, - "archive_hash": "sha256:b1349f2703f9eab7ddf77acfe14dd12095b2529caf36deb044a63b22117e3761" + "archive_url": "./unity_exporter.zip", + "archive_size": 16892, + "archive_hash": "sha256:379ff50e4e45fefc3afc0908d546f141b91e6b80ff55d01d5126b8fb5d826900" } ] } \ No newline at end of file diff --git a/unity_exporter.zip b/unity_exporter.zip new file mode 100644 index 0000000..a985aa1 Binary files /dev/null and b/unity_exporter.zip differ diff --git a/unity_exporter/unity_exporter_panel.py b/unity_exporter/unity_exporter_panel.py index 56155ab..3554270 100644 --- a/unity_exporter/unity_exporter_panel.py +++ b/unity_exporter/unity_exporter_panel.py @@ -1,8 +1,9 @@ import bpy import os - import bpy.utils +from . import unity_fbx_exporter + # Handler to load the directory path when a Blender file is loaded def load_directory_path_handler(dummy): bpy.context.scene.directory_path = bpy.context.scene.get("last_directory_path", "") @@ -32,12 +33,53 @@ def draw(self, context): scene = context.scene layout = self.layout layout.prop(scene, "directory_path") + + row = layout.row() + row.operator("unity_exporter.clean_image_names") + row.operator("unity_exporter.clean_mesh_names") + layout.operator("unity_exporter.export_images") + layout.operator("unity_exporter.export_selected") + +class UnityExporterCleanMeshNames(bpy.types.Operator): + bl_idname = "unity_exporter.clean_mesh_names" + bl_label = "Clean Mesh Names" + bl_description = "Match mesh to object name" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + selected_objects = bpy.context.selected_objects + + if not selected_objects: + self.report({'WARNING'}, 'No objects selected.') + return {'CANCELLED'} + + # Make sure each mesh data has the same name as the object it belongs to + for obj in selected_objects: + if obj.type == 'MESH': + obj.data.name = obj.name + return {'FINISHED'} + +class UnityExporterCleanImageNames(bpy.types.Operator): + bl_idname = "unity_exporter.clean_image_names" + bl_label = "Clean Image Names" + bl_description = "Match image to material name" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + # Ensure each image has the same name as the material it belongs to + for mat in bpy.data.materials: + if mat.node_tree: + for node in mat.node_tree.nodes: + if node.type == 'TEX_IMAGE': + node.image.name = mat.name + return {'FINISHED'} class UnityExporterExportImages(bpy.types.Operator): bl_idname = "unity_exporter.export_images" - bl_label = "Export Images to Unity" + bl_label = "Export Images" + bl_description = "Export all images in the blend file to the selected directory" def execute(self, context): # Get the directory path from the user @@ -75,10 +117,63 @@ def execute(self, context): self.report({'INFO'}, 'Images exported successfully.') return {'FINISHED'} +class UnityExporterExportSelected(bpy.types.Operator): + bl_idname = "unity_exporter.export_selected" + bl_label = "Export Selected" + bl_description = "Export selected objects to Unity as an fbx." + + def execute(self, context): + try: + # Get the directory path from the user + directory_path = context.scene.directory_path + if not directory_path: + self.report({'WARNING'}, 'Please specify a directory path.') + return {'CANCELLED'} + + if not os.path.exists(directory_path): + self.report({'WARNING'}, 'The specified directory path does not exist.') + return {'CANCELLED'} + + # Get selected objects + selected_objects = bpy.context.selected_objects + if not selected_objects: + self.report({'WARNING'}, 'No objects selected.') + return {'CANCELLED'} + + # Get the name of the active object + if not bpy.context.active_object: + self.report({'WARNING'}, 'No active object found.') + return {'CANCELLED'} + + name_of_active_object = bpy.context.active_object.name + fbx_file_path = os.path.join(directory_path, f"{name_of_active_object}.fbx") + + # Ensure the directory path is writable + if not os.access(directory_path, os.W_OK): + self.report({'ERROR'}, 'The specified directory path is not writable.') + return {'CANCELLED'} + + # Perform the export + try: + bpy.ops.export_scene.fbx(filepath=fbx_file_path, use_selection=True) + self.report({'INFO'}, f"Exported selected objects to {fbx_file_path}") + except Exception as e: + self.report({'ERROR'}, f"Failed to export FBX: {str(e)}") + return {'CANCELLED'} + + except Exception as e: + self.report({'ERROR'}, f"An unexpected error occurred: {str(e)}") + return {'CANCELLED'} + + return {'FINISHED'} + def register(): print("Registering unity_exporter_panel") bpy.utils.register_class(UnityExporterPanel) + bpy.utils.register_class(UnityExporterCleanMeshNames) + bpy.utils.register_class(UnityExporterCleanImageNames) bpy.utils.register_class(UnityExporterExportImages) + bpy.utils.register_class(UnityExporterExportSelected) bpy.types.Scene.directory_path = bpy.props.StringProperty( name="Directory Path", description="Choose a directory", @@ -90,7 +185,10 @@ def register(): def unregister(): bpy.utils.unregister_class(UnityExporterPanel) + bpy.utils.unregister_class(UnityExporterCleanMeshNames) + bpy.utils.unregister_class(UnityExporterCleanImageNames) bpy.utils.unregister_class(UnityExporterExportImages) + bpy.utils.unregister_class(UnityExporterExportSelected) del bpy.types.Scene.directory_path bpy.app.handlers.load_post.remove(load_directory_path_handler) bpy.app.handlers.save_pre.remove(save_directory_path_handler) diff --git a/unity_exporter/unity_fbx_exporter.py b/unity_exporter/unity_fbx_exporter.py new file mode 100644 index 0000000..c8c19d0 --- /dev/null +++ b/unity_exporter/unity_fbx_exporter.py @@ -0,0 +1,242 @@ +import bpy +import mathutils +import math + + +# Multi-user datablocks are preserved here. Unique copies are made for applying the rotation. +# Eventually multi-user datablocks become single-user and gets processed. +# Therefore restoring the multi-user data assigns a shared but already processed datablock. +shared_data = dict() + +# All objects and collections in this view layer must be visible while being processed. +# apply_rotation and matrix changes don't have effect otherwise. +# Visibility will be restored right before saving the FBX. +hidden_collections = [] +hidden_objects = [] +disabled_collections = [] +disabled_objects = [] + + +def unhide_collections(col): + global hidden_collections + global disabled_collections + + # No need to unhide excluded collections. Their objects aren't included in current view layer. + if col.exclude: + return + + # Find hidden child collections and unhide them + hidden = [item for item in col.children if not item.exclude and item.hide_viewport] + for item in hidden: + item.hide_viewport = False + + # Add them to the list so they could be restored later + hidden_collections.extend(hidden) + + # Same with the disabled collections + disabled = [item for item in col.children if not item.exclude and item.collection.hide_viewport] + for item in disabled: + item.collection.hide_viewport = False + + disabled_collections.extend(disabled) + + # Recursively unhide child collections + for item in col.children: + unhide_collections(item) + + +def unhide_objects(): + global hidden_objects + global disabled_objects + + view_layer_objects = [ob for ob in bpy.data.objects if ob.name in bpy.context.view_layer.objects] + + for ob in view_layer_objects: + if ob.hide_get(): + hidden_objects.append(ob) + ob.hide_set(False) + if ob.hide_viewport: + disabled_objects.append(ob) + ob.hide_viewport = False + + +def make_single_user_data(): + global shared_data + + for ob in bpy.data.objects: + if ob.data and ob.data.users > 1: + # Figure out actual users of this datablock (not counting fake users) + users = [user for user in bpy.data.objects if user.data == ob.data] + if len(users) > 1: + # Store shared mesh data (MESH objects only). + # Other shared datablocks (CURVE, FONT, etc) are always exported as separate meshes + # by the built-in FBX exporter. + if ob.type == 'MESH': + # Shared mesh data will be restored if users have no active modifiers + modifiers = 0 + for user in users: + modifiers += len([mod for mod in user.modifiers if mod.show_viewport]) + if modifiers == 0: + shared_data[ob.name] = ob.data + + # Single-user data is mandatory in all object types, otherwise we can't apply the rotation. + ob.data = ob.data.copy() + + +def apply_object_modifiers(): + # Select objects in current view layer not using an armature modifier + bpy.ops.object.select_all(action='DESELECT') + for ob in bpy.data.objects: + if ob.name in bpy.context.view_layer.objects: + bypass_modifiers = False + for mod in ob.modifiers: + if mod.type == 'ARMATURE': + bypass_modifiers = True + if not bypass_modifiers: + ob.select_set(True) + + # Conversion to mesh may not be available depending on the remaining objects + if bpy.ops.object.convert.poll(): + print("Converting to meshes:", bpy.context.selected_objects) + bpy.ops.object.convert(target='MESH') + + +def reset_parent_inverse(ob): + if (ob.parent): + mat_world = ob.matrix_world.copy() + ob.matrix_parent_inverse.identity() + ob.matrix_basis = ob.parent.matrix_world.inverted() @ mat_world + + +def apply_rotation(ob): + bpy.ops.object.select_all(action='DESELECT') + ob.select_set(True) + bpy.ops.object.transform_apply(location = False, rotation = True, scale = False) + + +def fix_object(ob): + # Only fix objects in current view layer + if ob.name in bpy.context.view_layer.objects: + + # Reset parent's inverse so we can work with local transform directly + reset_parent_inverse(ob) + + # Create a copy of the local matrix and set a pure X-90 matrix + mat_original = ob.matrix_local.copy() + ob.matrix_local = mathutils.Matrix.Rotation(math.radians(-90.0), 4, 'X') + + # Apply the rotation to the object + apply_rotation(ob) + + # Reapply the previous local transform with an X+90 rotation + ob.matrix_local = mat_original @ mathutils.Matrix.Rotation(math.radians(90.0), 4, 'X') + + # Recursively fix child objects in current view layer. + # Children may be in the current view layer even if their parent isn't. + for child in ob.children: + fix_object(child) + + +def export_unity_fbx(context, filepath): + global shared_data + global hidden_collections + global hidden_objects + global disabled_collections + global disabled_objects + + print("Preparing 3D model for Unity...") + + # Root objects: Empty, Mesh, Curve, Surface, Font or Armature without parent + root_objects = [item for item in bpy.data.objects if (item.type == "EMPTY" or item.type == "MESH" or item.type == "ARMATURE" or item.type == "FONT" or item.type == "CURVE" or item.type == "SURFACE") and not item.parent] + + # Preserve current scene + # undo_push examples, including exporters' execute: + # https://programtalk.com/python-examples/bpy.ops.ed.undo_push (Examples 4, 5 and 6) + # https://sourcecodequery.com/example-method/bpy.ops.ed.undo (Examples 1 and 2) + + bpy.ops.ed.undo_push(message="Prepare Unity FBX") + + shared_data = dict() + hidden_collections = [] + hidden_objects = [] + disabled_collections = [] + disabled_objects = [] + + selection = bpy.context.selected_objects + + # Object mode + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode="OBJECT") + + # Ensure all the collections and objects in this view layer are visible + unhide_collections(bpy.context.view_layer.layer_collection) + unhide_objects() + + # Create a single copy in multi-user datablocks. Will be restored after fixing rotations. + make_single_user_data() + + # Apply modifiers to objects (except those affected by an armature) + apply_object_modifiers() + + try: + # Fix rotations + for ob in root_objects: + print(ob.name, ob.type) + fix_object(ob) + + # Restore multi-user meshes + for item in shared_data: + bpy.data.objects[item].data = shared_data[item] + + # Recompute the transforms out of the changed matrices + bpy.context.view_layer.update() + + # Restore hidden and disabled objects + for ob in hidden_objects: + ob.hide_set(True) + for ob in disabled_objects: + ob.hide_viewport = True + + # Restore hidden and disabled collections + for col in hidden_collections: + col.hide_viewport = True + for col in disabled_collections: + col.collection.hide_viewport = True + + # Restore selection + bpy.ops.object.select_all(action='DESELECT') + for ob in selection: + ob.select_set(True) + + # Export FBX file + params = dict(filepath=filepath, + apply_scale_options='FBX_SCALE_UNITS', + object_types={'EMPTY', 'MESH', 'ARMATURE'}, + use_active_collection=False, + use_selection=True, + use_armature_deform_only=False, + add_leaf_bones=False, + primary_bone_axis="Y", + secondary_bone_axis="X", + use_tspace=False, + use_triangles=False) + + print("Invoking default FBX Exporter:", params) + bpy.ops.export_scene.fbx(**params) + + except Exception as e: + bpy.ops.ed.undo_push(message="") + bpy.ops.ed.undo() + bpy.ops.ed.undo_push(message="Export Unity FBX") + print(e) + print("File not saved.") + # Always finish with 'FINISHED' so Undo is handled properly + return {'FINISHED'} + + # Restore scene and finish + + bpy.ops.ed.undo_push(message="") + bpy.ops.ed.undo() + bpy.ops.ed.undo_push(message="Export Unity FBX") + print("FBX file for Unity saved.") + return {'FINISHED'} \ No newline at end of file