diff --git a/4.5.2_LTS/io_scene_fbx/__init__.py b/4.5.2_LTS/io_scene_fbx/__init__.py new file mode 100644 index 0000000..3585a9a --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/__init__.py @@ -0,0 +1,756 @@ +# SPDX-FileCopyrightText: 2011-2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +bl_info = { + "name": "FBX format", + "author": "Campbell Barton, Bastien Montagne, Jens Restemeier, @Mysteryem", + "version": (5, 12, 4), + "blender": (4, 2, 0), + "location": "File > Import-Export", + "description": "FBX IO meshes, UVs, vertex colors, materials, textures, cameras, lamps and actions", + "warning": "", + "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/scene_fbx.html", + "support": 'OFFICIAL', + "category": "Import-Export", +} + + +if "bpy" in locals(): + import importlib + if "import_fbx" in locals(): + importlib.reload(import_fbx) + if "export_fbx_bin" in locals(): + importlib.reload(export_fbx_bin) + if "export_fbx" in locals(): + importlib.reload(export_fbx) + + +import bpy +from bpy.props import ( + StringProperty, + BoolProperty, + FloatProperty, + EnumProperty, + CollectionProperty, +) +from bpy_extras.io_utils import ( + ImportHelper, + ExportHelper, + orientation_helper, + path_reference_mode, + axis_conversion, + poll_file_object_drop, +) + + +@orientation_helper(axis_forward='-Z', axis_up='Y') +class ImportFBX(bpy.types.Operator, ImportHelper): + """Load a FBX file""" + bl_idname = "import_scene.fbx" + bl_label = "Import FBX" + bl_options = {'UNDO', 'PRESET'} + + directory: StringProperty() + + filename_ext = ".fbx" + filter_glob: StringProperty(default="*.fbx", options={'HIDDEN'}) + + files: CollectionProperty( + name="File Path", + type=bpy.types.OperatorFileListElement, + ) + + ui_tab: EnumProperty( + items=(('MAIN', "Main", "Main basic settings"), + ('ARMATURE', "Armatures", "Armature-related settings"), + ), + name="ui_tab", + description="Import options categories", + ) + + use_manual_orientation: BoolProperty( + name="Manual Orientation", + description="Specify orientation and scale, instead of using embedded data in FBX file", + default=False, + ) + global_scale: FloatProperty( + name="Scale", + min=0.001, max=1000.0, + default=1.0, + ) + bake_space_transform: BoolProperty( + name="Apply Transform", + description="Bake space transform into object data, avoids getting unwanted rotations to objects when " + "target space is not aligned with Blender's space " + "(WARNING! experimental option, use at own risk, known to be broken with armatures/animations)", + default=False, + ) + + use_custom_normals: BoolProperty( + name="Custom Normals", + description="Import custom normals, if available (otherwise Blender will recompute them)", + default=True, + ) + colors_type: EnumProperty( + name="Vertex Colors", + items=(('NONE', "None", "Do not import color attributes"), + ('SRGB', "sRGB", "Expect file colors in sRGB color space"), + ('LINEAR', "Linear", "Expect file colors in linear color space"), + ), + description="Import vertex color attributes", + default='SRGB', + ) + + use_image_search: BoolProperty( + name="Image Search", + description="Search subdirs for any associated images (WARNING: may be slow)", + default=True, + ) + + use_alpha_decals: BoolProperty( + name="Alpha Decals", + description="Treat materials with alpha as decals (no shadow casting)", + default=False, + ) + decal_offset: FloatProperty( + name="Decal Offset", + description="Displace geometry of alpha meshes", + min=0.0, max=1.0, + default=0.0, + ) + + use_anim: BoolProperty( + name="Import Animation", + description="Import FBX animation", + default=True, + ) + anim_offset: FloatProperty( + name="Animation Offset", + description="Offset to apply to animation during import, in frames", + default=1.0, + ) + + use_subsurf: BoolProperty( + name="Subdivision Data", + description="Import FBX subdivision information as subdivision surface modifiers", + default=False, + ) + + use_custom_props: BoolProperty( + name="Custom Properties", + description="Import user properties as custom properties", + default=True, + ) + use_custom_props_enum_as_string: BoolProperty( + name="Import Enums As Strings", + description="Store enumeration values as strings", + default=True, + ) + + ignore_leaf_bones: BoolProperty( + name="Ignore Leaf Bones", + description="Ignore the last bone at the end of each chain (used to mark the length of the previous bone)", + default=False, + ) + force_connect_children: BoolProperty( + name="Force Connect Children", + description="Force connection of children bones to their parent, even if their computed head/tail " + "positions do not match (can be useful with pure-joints-type armatures)", + default=False, + ) + automatic_bone_orientation: BoolProperty( + name="Automatic Bone Orientation", + description="Try to align the major bone axis with the bone children", + default=False, + ) + primary_bone_axis: EnumProperty( + name="Primary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='Y', + ) + secondary_bone_axis: EnumProperty( + name="Secondary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='X', + ) + + use_prepost_rot: BoolProperty( + name="Use Pre/Post Rotation", + description="Use pre/post rotation from FBX transform (you may have to disable that in some cases)", + default=True, + ) + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False # No animation. + + import_panel_include(layout, self) + import_panel_transform(layout, self) + import_panel_animation(layout, self) + import_panel_armature(layout, self) + + def execute(self, context): + keywords = self.as_keywords(ignore=("filter_glob", "directory", "ui_tab", "filepath", "files")) + + from . import import_fbx + import os + + if self.files: + ret = {'CANCELLED'} + dirname = os.path.dirname(self.filepath) + for file in self.files: + path = os.path.join(dirname, file.name) + if import_fbx.load(self, context, filepath=path, **keywords) == {'FINISHED'}: + ret = {'FINISHED'} + return ret + else: + return import_fbx.load(self, context, filepath=self.filepath, **keywords) + + def invoke(self, context, event): + return self.invoke_popup(context) + + +def import_panel_include(layout, operator): + header, body = layout.panel("FBX_import_include", default_closed=False) + header.label(text="Include") + if body: + body.prop(operator, "use_custom_normals") + body.prop(operator, "use_subsurf") + body.prop(operator, "use_custom_props") + sub = body.row() + sub.enabled = operator.use_custom_props + sub.prop(operator, "use_custom_props_enum_as_string") + body.prop(operator, "use_image_search") + body.prop(operator, "colors_type") + + +def import_panel_transform(layout, operator): + header, body = layout.panel("FBX_import_transform", default_closed=False) + header.label(text="Transform") + if body: + body.prop(operator, "global_scale") + body.prop(operator, "decal_offset") + row = body.row() + row.prop(operator, "bake_space_transform") + row.label(text="", icon='ERROR') + body.prop(operator, "use_prepost_rot") + + import_panel_transform_orientation(body, operator) + + +def import_panel_transform_orientation(layout, operator): + header, body = layout.panel("FBX_import_transform_manual_orientation", default_closed=False) + header.use_property_split = False + header.prop(operator, "use_manual_orientation", text="") + header.label(text="Manual Orientation") + if body: + body.enabled = operator.use_manual_orientation + body.prop(operator, "axis_forward") + body.prop(operator, "axis_up") + + +def import_panel_animation(layout, operator): + header, body = layout.panel("FBX_import_animation", default_closed=True) + header.use_property_split = False + header.prop(operator, "use_anim", text="") + header.label(text="Animation") + if body: + body.enabled = operator.use_anim + body.prop(operator, "anim_offset") + + +def import_panel_armature(layout, operator): + header, body = layout.panel("FBX_import_armature", default_closed=True) + header.label(text="Armature") + if body: + body.prop(operator, "ignore_leaf_bones") + body.prop(operator, "force_connect_children"), + body.prop(operator, "automatic_bone_orientation"), + sub = body.column() + sub.enabled = not operator.automatic_bone_orientation + sub.prop(operator, "primary_bone_axis") + sub.prop(operator, "secondary_bone_axis") + + +@orientation_helper(axis_forward='-Z', axis_up='Y') +class ExportFBX(bpy.types.Operator, ExportHelper): + """Write a FBX file""" + bl_idname = "export_scene.fbx" + bl_label = "Export FBX" + bl_options = {'UNDO', 'PRESET'} + + filename_ext = ".fbx" + filter_glob: StringProperty(default="*.fbx", options={'HIDDEN'}) + + # List of operator properties, the attributes will be assigned + # to the class instance from the operator settings before calling. + + use_selection: BoolProperty( + name="Selected Objects", + description="Export selected and visible objects only", + default=False, + ) + use_visible: BoolProperty( + name='Visible Objects', + description='Export visible objects only', + default=False + ) + use_active_collection: BoolProperty( + name="Active Collection", + description="Export only objects from the active collection (and its children)", + default=False, + ) + collection: StringProperty( + name="Source Collection", + description="Export only objects from this collection (and its children)", + default="", + ) + global_scale: FloatProperty( + name="Scale", + description="Scale all data (Some importers do not support scaled armatures!)", + min=0.001, max=1000.0, + soft_min=0.01, soft_max=1000.0, + default=1.0, + ) + apply_unit_scale: BoolProperty( + name="Apply Unit", + description="Take into account current Blender units settings (if unset, raw Blender Units values are used as-is)", + default=True, + ) + apply_scale_options: EnumProperty( + items=(('FBX_SCALE_NONE', "All Local", + "Apply custom scaling and units scaling to each object transformation, FBX scale remains at 1.0"), + ('FBX_SCALE_UNITS', "FBX Units Scale", + "Apply custom scaling to each object transformation, and units scaling to FBX scale"), + ('FBX_SCALE_CUSTOM', "FBX Custom Scale", + "Apply custom scaling to FBX scale, and units scaling to each object transformation"), + ('FBX_SCALE_ALL', "FBX All", + "Apply custom scaling and units scaling to FBX scale"), + ), + name="Apply Scalings", + description="How to apply custom and units scalings in generated FBX file " + "(Blender uses FBX scale to detect units on import, " + "but many other applications do not handle the same way)", + ) + + use_space_transform: BoolProperty( + name="Use Space Transform", + description="Apply global space transform to the object rotations. When disabled " + "only the axis space is written to the file and all object transforms are left as-is", + default=True, + ) + bake_space_transform: BoolProperty( + name="Apply Transform", + description="Bake space transform into object data, avoids getting unwanted rotations to objects when " + "target space is not aligned with Blender's space " + "(WARNING! experimental option, use at own risk, known to be broken with armatures/animations)", + default=False, + ) + + object_types: EnumProperty( + name="Object Types", + options={'ENUM_FLAG'}, + items=(('EMPTY', "Empty", ""), + ('CAMERA', "Camera", ""), + ('LIGHT', "Lamp", ""), + ('ARMATURE', "Armature", "WARNING: not supported in dupli/group instances"), + ('MESH', "Mesh", ""), + ('OTHER', "Other", "Other geometry types, like curve, metaball, etc. (converted to meshes)"), + ), + description="Which kind of object to export", + default={'EMPTY', 'CAMERA', 'LIGHT', 'ARMATURE', 'MESH', 'OTHER'}, + ) + + use_mesh_modifiers: BoolProperty( + name="Apply Modifiers", + description="Apply modifiers to mesh objects (except Armature ones) - " + "WARNING: prevents exporting shape keys", + default=True, + ) + use_mesh_modifiers_render: BoolProperty( + name="Use Modifiers Render Setting", + description="Use render settings when applying modifiers to mesh objects (DISABLED in Blender 2.8)", + default=True, + ) + mesh_smooth_type: EnumProperty( + name="Smoothing", + items=(('OFF', "Normals Only", "Export only normals instead of writing edge or face smoothing data"), + ('FACE', "Face", "Write face smoothing"), + ('EDGE', "Edge", "Write edge smoothing"), + ), + description="Export smoothing information " + "(prefer 'Normals Only' option if your target importer understand split normals)", + default='OFF', + ) + colors_type: EnumProperty( + name="Vertex Colors", + items=(('NONE', "None", "Do not export color attributes"), + ('SRGB', "sRGB", "Export colors in sRGB color space"), + ('LINEAR', "Linear", "Export colors in linear color space"), + ), + description="Export vertex color attributes", + default='SRGB', + ) + prioritize_active_color: BoolProperty( + name="Prioritize Active Color", + description="Make sure active color will be exported first. Could be important " + "since some other software can discard other color attributes besides the first one", + default=False, + ) + use_subsurf: BoolProperty( + name="Export Subdivision Surface", + description="Export the last Catmull-Rom subdivision modifier as FBX subdivision " + "(does not apply the modifier even if 'Apply Modifiers' is enabled)", + default=False, + ) + use_mesh_edges: BoolProperty( + name="Loose Edges", + description="Export loose edges (as two-vertices polygons)", + default=False, + ) + use_tspace: BoolProperty( + name="Tangent Space", + description="Add binormal and tangent vectors, together with normal they form the tangent space " + "(will only work correctly with tris/quads only meshes!)", + default=False, + ) + use_triangles: BoolProperty( + name="Triangulate Faces", + description="Convert all faces to triangles", + default=False, + ) + use_custom_props: BoolProperty( + name="Custom Properties", + description="Export custom properties", + default=False, + ) + add_leaf_bones: BoolProperty( + name="Add Leaf Bones", + description="Append a final bone to the end of each chain to specify last bone length " + "(use this when you intend to edit the armature from exported data)", + default=True # False for commit! + ) + primary_bone_axis: EnumProperty( + name="Primary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='Y', + ) + secondary_bone_axis: EnumProperty( + name="Secondary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='X', + ) + use_armature_deform_only: BoolProperty( + name="Only Deform Bones", + description="Only write deforming bones (and non-deforming ones when they have deforming children)", + default=False, + ) + armature_nodetype: EnumProperty( + name="Armature FBXNode Type", + items=(('NULL', "Null", "'Null' FBX node, similar to Blender's Empty (default)"), + ('ROOT', "Root", "'Root' FBX node, supposed to be the root of chains of bones..."), + ('LIMBNODE', "LimbNode", "'LimbNode' FBX node, a regular joint between two bones..."), + ), + description="FBX type of node (object) used to represent Blender's armatures " + "(use the Null type unless you experience issues with the other app, " + "as other choices may not import back perfectly into Blender...)", + default='NULL', + ) + bake_anim: BoolProperty( + name="Baked Animation", + description="Export baked keyframe animation", + default=True, + ) + bake_anim_use_all_bones: BoolProperty( + name="Key All Bones", + description="Force exporting at least one key of animation for all bones " + "(needed with some target applications, like UE4)", + default=True, + ) + bake_anim_use_nla_strips: BoolProperty( + name="NLA Strips", + description="Export each non-muted NLA strip as a separated FBX's AnimStack, if any, " + "instead of global scene animation", + default=True, + ) + bake_anim_use_all_actions: BoolProperty( + name="All Actions", + description="Export each action as a separated FBX's AnimStack, instead of global scene animation " + "(note that animated objects will get all actions compatible with them, " + "others will get no animation at all)", + default=True, + ) + bake_anim_force_startend_keying: BoolProperty( + name="Force Start/End Keying", + description="Always add a keyframe at start and end of actions for animated channels", + default=True, + ) + bake_anim_step: FloatProperty( + name="Sampling Rate", + description="How often to evaluate animated values (in frames)", + min=0.01, max=100.0, + soft_min=0.1, soft_max=10.0, + default=1.0, + ) + bake_anim_simplify_factor: FloatProperty( + name="Simplify", + description="How much to simplify baked values (0.0 to disable, the higher the more simplified)", + min=0.0, max=100.0, # No simplification to up to 10% of current magnitude tolerance. + soft_min=0.0, soft_max=10.0, + default=1.0, # default: min slope: 0.005, max frame step: 10. + ) + path_mode: path_reference_mode + embed_textures: BoolProperty( + name="Embed Textures", + description="Embed textures in FBX binary file (only for \"Copy\" path mode!)", + default=False, + ) + batch_mode: EnumProperty( + name="Batch Mode", + items=(('OFF', "Off", "Active scene to file"), + ('SCENE', "Scene", "Each scene as a file"), + ('COLLECTION', "Collection", + "Each collection (data-block ones) as a file, does not include content of children collections"), + ('SCENE_COLLECTION', "Scene Collections", + "Each collection (including master, non-data-block ones) of each scene as a file, " + "including content from children collections"), + ('ACTIVE_SCENE_COLLECTION', "Active Scene Collections", + "Each collection (including master, non-data-block one) of the active scene as a file, " + "including content from children collections"), + ), + ) + use_batch_own_dir: BoolProperty( + name="Batch Own Dir", + description="Create a dir for each exported file", + default=True, + ) + use_metadata: BoolProperty( + name="Use Metadata", + default=True, + options={'HIDDEN'}, + ) + stellar_blade_fix: BoolProperty( + name="Inverted Bones Fix", + description="Export with bone inversions needed by Stellar Blade (by Lami21 and Njaecha)", + default=False, + ) + + stellar_blade_skeleton: EnumProperty( + name="Skeleton File", + items=( + ("EVE", "EVE", "Match with EVE's skeleton file (CH_P_EVE_01_Skeleton)"), + ("LILY", "Lily", "Match with Lily's skeleton file (CH_NPC_01_Skeleton)") + ), + description="Decides which skeleton file to match with for the bone flipping." + ) + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False # No animation. + + # Are we inside the File browser + is_file_browser = context.space_data.type == 'FILE_BROWSER' + + export_main(layout, self, is_file_browser) + export_panel_include(layout, self, is_file_browser) + export_panel_transform(layout, self) + export_panel_geometry(layout, self) + export_panel_armature(layout, self) + export_panel_animation(layout, self) + export_panel_stellar_blade(layout, self) + + @property + def check_extension(self): + return self.batch_mode == 'OFF' + + def execute(self, context): + from mathutils import Matrix + if not self.filepath: + raise Exception("filepath not set") + + global_matrix = (axis_conversion(to_forward=self.axis_forward, + to_up=self.axis_up, + ).to_4x4() + if self.use_space_transform else Matrix()) + + keywords = self.as_keywords(ignore=("check_existing", + "filter_glob", + "ui_tab", + )) + + keywords["global_matrix"] = global_matrix + + from . import export_fbx_bin + return export_fbx_bin.save(self, context, **keywords) + + +def export_main(layout, operator, is_file_browser): + row = layout.row(align=True) + row.prop(operator, "path_mode") + sub = row.row(align=True) + sub.enabled = (operator.path_mode == 'COPY') + sub.prop(operator, "embed_textures", text="", icon='PACKAGE' if operator.embed_textures else 'UGLYPACKAGE') + if is_file_browser: + row = layout.row(align=True) + row.prop(operator, "batch_mode") + sub = row.row(align=True) + sub.prop(operator, "use_batch_own_dir", text="", icon='NEWFOLDER') + + +def export_panel_include(layout, operator, is_file_browser): + header, body = layout.panel("FBX_export_include", default_closed=False) + header.label(text="Include") + if body: + sublayout = body.column(heading="Limit to") + sublayout.enabled = (operator.batch_mode == 'OFF') + if is_file_browser: + sublayout.prop(operator, "use_selection") + sublayout.prop(operator, "use_visible") + sublayout.prop(operator, "use_active_collection") + + body.column().prop(operator, "object_types") + body.prop(operator, "use_custom_props") + + +def export_panel_transform(layout, operator): + header, body = layout.panel("FBX_export_transform", default_closed=False) + header.label(text="Transform") + if body: + body.prop(operator, "global_scale") + body.prop(operator, "apply_scale_options") + + body.prop(operator, "axis_forward") + body.prop(operator, "axis_up") + + body.prop(operator, "apply_unit_scale") + body.prop(operator, "use_space_transform") + row = body.row() + row.prop(operator, "bake_space_transform") + row.label(text="", icon='ERROR') + + +def export_panel_geometry(layout, operator): + header, body = layout.panel("FBX_export_geometry", default_closed=True) + header.label(text="Geometry") + if body: + body.prop(operator, "mesh_smooth_type") + body.prop(operator, "use_subsurf") + body.prop(operator, "use_mesh_modifiers") + #sub = body.row() + # sub.enabled = operator.use_mesh_modifiers and False # disabled in 2.8... + #sub.prop(operator, "use_mesh_modifiers_render") + body.prop(operator, "use_mesh_edges") + body.prop(operator, "use_triangles") + sub = body.row() + # ~ sub.enabled = operator.mesh_smooth_type in {'OFF'} + sub.prop(operator, "use_tspace") + body.prop(operator, "colors_type") + body.prop(operator, "prioritize_active_color") + + +def export_panel_armature(layout, operator): + header, body = layout.panel("FBX_export_armature", default_closed=True) + header.label(text="Armature") + if body: + body.prop(operator, "primary_bone_axis") + body.prop(operator, "secondary_bone_axis") + body.prop(operator, "armature_nodetype") + body.prop(operator, "use_armature_deform_only") + body.prop(operator, "add_leaf_bones") + + +def export_panel_animation(layout, operator): + header, body = layout.panel("FBX_export_bake_animation", default_closed=True) + header.use_property_split = False + header.prop(operator, "bake_anim", text="") + header.label(text="Animation") + if body: + body.enabled = operator.bake_anim + body.prop(operator, "bake_anim_use_all_bones") + body.prop(operator, "bake_anim_use_nla_strips") + body.prop(operator, "bake_anim_use_all_actions") + body.prop(operator, "bake_anim_force_startend_keying") + body.prop(operator, "bake_anim_step") + body.prop(operator, "bake_anim_simplify_factor") + +def export_panel_stellar_blade(layout, operator): + header, body = layout.panel("FBX_export_stellarblade", default_closed=False) + header.label(text="StellarBlade") + if body: + body.label(text="Stellar Blade FBX Fix (by Lemi21 and Njaecha) v0.3") + body.prop(operator, "stellar_blade_fix") + body.prop(operator, "stellar_blade_skeleton") + + +class IO_FH_fbx(bpy.types.FileHandler): + bl_idname = "IO_FH_fbx" + bl_label = "FBX" + bl_import_operator = "import_scene.fbx" + bl_export_operator = "export_scene.fbx" + bl_file_extensions = ".fbx" + + @classmethod + def poll_drop(cls, context): + return poll_file_object_drop(context) + + +def menu_func_import(self, context): + self.layout.operator(ImportFBX.bl_idname, text="FBX (.fbx)") + + +def menu_func_export(self, context): + self.layout.operator(ExportFBX.bl_idname, text="FBX (.fbx)") + + +classes = ( + ImportFBX, + ExportFBX, + IO_FH_fbx, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + + bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + bpy.types.TOPBAR_MT_file_export.append(menu_func_export) + + +def unregister(): + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) + bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) + + for cls in classes: + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() diff --git a/4.5.2_LTS/io_scene_fbx/data_types.py b/4.5.2_LTS/io_scene_fbx/data_types.py new file mode 100644 index 0000000..328ba3a --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/data_types.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2006-2012 assimp team +# SPDX-FileCopyrightText: 2013 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +BOOL = b'B'[0] +CHAR = b'C'[0] +INT8 = b'Z'[0] +INT16 = b'Y'[0] +INT32 = b'I'[0] +INT64 = b'L'[0] +FLOAT32 = b'F'[0] +FLOAT64 = b'D'[0] +BYTES = b'R'[0] +STRING = b'S'[0] +INT32_ARRAY = b'i'[0] +INT64_ARRAY = b'l'[0] +FLOAT32_ARRAY = b'f'[0] +FLOAT64_ARRAY = b'd'[0] +BOOL_ARRAY = b'b'[0] +BYTE_ARRAY = b'c'[0] + +# Some other misc defines +# Known combinations so far - supposed meaning: A = animatable, A+ = animated, U = UserProp +# VALID_NUMBER_FLAGS = {b'A', b'A+', b'AU', b'A+U'} # Not used... + +# array types - actual length may vary (depending on underlying C implementation)! +import array + +# For now, bytes and bool are assumed always 1byte. +ARRAY_BOOL = 'b' +ARRAY_BYTE = 'B' + +ARRAY_INT32 = None +ARRAY_INT64 = None +for _t in 'ilq': + size = array.array(_t).itemsize + if size == 4: + ARRAY_INT32 = _t + elif size == 8: + ARRAY_INT64 = _t + if ARRAY_INT32 and ARRAY_INT64: + break +if not ARRAY_INT32: + raise Exception("Impossible to get a 4-bytes integer type for array!") +if not ARRAY_INT64: + raise Exception("Impossible to get an 8-bytes integer type for array!") + +ARRAY_FLOAT32 = None +ARRAY_FLOAT64 = None +for _t in 'fd': + size = array.array(_t).itemsize + if size == 4: + ARRAY_FLOAT32 = _t + elif size == 8: + ARRAY_FLOAT64 = _t + if ARRAY_FLOAT32 and ARRAY_FLOAT64: + break +if not ARRAY_FLOAT32: + raise Exception("Impossible to get a 4-bytes float type for array!") +if not ARRAY_FLOAT64: + raise Exception("Impossible to get an 8-bytes float type for array!") diff --git a/4.5.2_LTS/io_scene_fbx/encode_bin.py b/4.5.2_LTS/io_scene_fbx/encode_bin.py new file mode 100644 index 0000000..a36f4e0 --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/encode_bin.py @@ -0,0 +1,434 @@ +# SPDX-FileCopyrightText: 2013 Campbell Barton +# +# SPDX-License-Identifier: GPL-2.0-or-later + +try: + from . import data_types + from .fbx_utils_threading import MultiThreadedTaskConsumer +except: + import data_types + from fbx_utils_threading import MultiThreadedTaskConsumer + +from struct import pack +from contextlib import contextmanager +import array +import numpy as np +import zlib + +_BLOCK_SENTINEL_LENGTH = ... +_BLOCK_SENTINEL_DATA = ... +_ELEM_META_FORMAT = ... +_ELEM_META_SIZE = ... +_IS_BIG_ENDIAN = (__import__("sys").byteorder != 'little') +_HEAD_MAGIC = b'Kaydara FBX Binary\x20\x20\x00\x1a\x00' + +# fbx has very strict CRC rules, all based on file timestamp +# until we figure these out, write files at a fixed time. (workaround!) + +# Assumes: CreationTime +_TIME_ID = b'1970-01-01 10:00:00:000' +_FILE_ID = b'\x28\xb3\x2a\xeb\xb6\x24\xcc\xc2\xbf\xc8\xb0\x2a\xa9\x2b\xfc\xf1' +_FOOT_ID = b'\xfa\xbc\xab\x09\xd0\xc8\xd4\x66\xb1\x76\xfb\x83\x1c\xf7\x26\x7e' + +# Awful exceptions: those "classes" of elements seem to need block sentinel even when having no children and some props. +_ELEMS_ID_ALWAYS_BLOCK_SENTINEL = {b"AnimationStack", b"AnimationLayer"} + + +class FBXElem: + __slots__ = ( + "id", + "props", + "props_type", + "elems", + + "_props_length", # combine length of props + "_end_offset", # byte offset from the start of the file. + ) + + def __init__(self, id): + assert(len(id) < 256) # length must fit in a uint8 + self.id = id + self.props = [] + self.props_type = bytearray() + self.elems = [] + self._end_offset = -1 + self._props_length = -1 + + @classmethod + @contextmanager + def enable_multithreading_cm(cls): + """Temporarily enable multithreaded array compression. + + The context manager handles starting up and shutting down the threads. + + Only exits once all the threads are done (either all tasks were completed or an error occurred and the threads + were stopped prematurely). + + Writing to a file is temporarily disabled as a safeguard.""" + # __enter__() + orig_func = cls._add_compressed_array_helper + orig_write = cls._write + + def insert_compressed_array(props, insert_at, data, length): + # zlib.compress releases the GIL, so can be multithreaded. + data = zlib.compress(data, 1) + comp_len = len(data) + + encoding = 1 + data = pack('<3I', length, encoding, comp_len) + data + props[insert_at] = data + + with MultiThreadedTaskConsumer.new_cpu_bound_cm(insert_compressed_array) as wrapped_func: + try: + def _add_compressed_array_helper_multi(self, data, length): + # Append a dummy value that will be replaced with the compressed array data later. + self.props.append(...) + # The index to insert the compressed array into. + insert_at = len(self.props) - 1 + # Schedule the array to be compressed on a separate thread and then inserted into the hierarchy at + # `insert_at`. + wrapped_func(self.props, insert_at, data, length) + + # As an extra safeguard, temporarily replace the `_write` function to raise an error if called. + def temp_write(*_args, **_kwargs): + raise RuntimeError("Writing is not allowed until multithreaded array compression has been disabled") + + cls._add_compressed_array_helper = _add_compressed_array_helper_multi + cls._write = temp_write + + # Return control back to the caller of __enter__(). + yield + finally: + # __exit__() + # Restore the original functions. + cls._add_compressed_array_helper = orig_func + cls._write = orig_write + # Exiting the MultiThreadedTaskConsumer context manager will wait for all scheduled tasks to complete. + + def add_bool(self, data): + assert(isinstance(data, bool)) + data = pack('?', data) + + self.props_type.append(data_types.BOOL) + self.props.append(data) + + def add_char(self, data): + assert(isinstance(data, bytes)) + assert(len(data) == 1) + data = pack('vertex-indices array by the loop->edge-index array. + t_pvi_edge_keys = t_ev_pair_view[t_lei] + + # Sort each [edge_start_n, edge_end_n] pair to get edge keys. Heapsort seems to be the fastest for this specific + # use case. + t_pvi_edge_keys.sort(axis=1, kind='heapsort') + + # Note that finding unique edge keys means that if there are multiple edges that share the same vertices (which + # shouldn't normally happen), only the first edge found in loops will be exported along with its per-edge data. + # To export separate edges that share the same vertices, fast_first_axis_unique can be replaced with np.unique + # with t_lei as the first argument, finding unique edges rather than unique edge keys. + # + # Since we want the unique values in their original order, the only part we care about is the indices of the + # first occurrence of the unique elements in t_pvi_edge_keys, so we can use our fast uniqueness helper function. + t_eli = fast_first_axis_unique(t_pvi_edge_keys, return_unique=False, return_index=True) + + # To get the indices of the elements in t_pvi_edge_keys that produce unique values, but in the original order of + # t_pvi_edge_keys, t_eli must be sorted. + # Due to loops and their edge keys tending to have a partial ordering within meshes, sorting with kind='stable' + # with radix sort tends to be faster than the default of kind='quicksort' with introsort. + t_eli.sort(kind='stable') + + # Edge index of each element in unique t_pvi_edge_keys, used to map per-edge data such as sharp and creases. + t_pvi_edge_indices = t_lei[t_eli] + + # We have to ^-1 last index of each loop. + # Ensure t_pvi is the correct number of bits before inverting. + # t_lvi may be used again later, so always create a copy to avoid modifying it in the next step. + t_pvi = t_lvi.astype(pvi_fbx_dtype) + # The index of the end of each loop is one before the index of the start of the next loop. + t_pvi[t_ls[1:] - 1] ^= -1 + # The index of the end of the last loop will be the very last index. + t_pvi[-1] ^= -1 + del t_pvi_edge_keys + else: + # Should be empty, but make sure it's the correct type. + t_pvi = np.empty(0, dtype=pvi_fbx_dtype) + t_eli = np.empty(0, dtype=eli_fbx_dtype) + + # And finally we can write data! + t_pvi = astype_view_signedness(t_pvi, pvi_fbx_dtype) + t_eli = astype_view_signedness(t_eli, eli_fbx_dtype) + elem_data_single_int32_array(geom, b"PolygonVertexIndex", t_pvi) + elem_data_single_int32_array(geom, b"Edges", t_eli) + del t_pvi + del t_eli + del t_ev + del t_ev_pair_view + + # And now, layers! + + # Smoothing. + if smooth_type in {'FACE', 'EDGE'}: + ps_fbx_dtype = np.int32 + _map = b"" + if smooth_type == 'FACE': + # The FBX integer values are usually interpreted as boolean where 0 is False (sharp) and 1 is True + # (smooth). + # The values may also be used to represent smoothing group bitflags, but this does not seem well-supported. + t_ps = MESH_ATTRIBUTE_SHARP_FACE.get_ndarray(attributes) + if t_ps is not None: + # FBX sharp is False, but Blender sharp is True, so invert. + t_ps = np.logical_not(t_ps) + else: + # The mesh has no "sharp_face" attribute, so every face is smooth. + t_ps = np.ones(len(me.polygons), dtype=ps_fbx_dtype) + _map = b"ByPolygon" + else: # EDGE + _map = b"ByEdge" + if t_pvi_edge_indices.size: + # Write Edge Smoothing. + # Note edge is sharp also if it's used by more than two faces, or one of its faces is flat. + mesh_poly_nbr = len(me.polygons) + mesh_edge_nbr = len(me.edges) + mesh_loop_nbr = len(me.loops) + # t_ls and t_lei may contain extra polygons or loops added for loose edges that are not present in the + # mesh data, so create views that exclude the extra data added for loose edges. + mesh_t_ls_view = t_ls[:mesh_poly_nbr] + mesh_t_lei_view = t_lei[:mesh_loop_nbr] + + # - Get sharp edges from edges used by more than two loops (and therefore more than two faces) + e_more_than_two_faces_mask = np.bincount(mesh_t_lei_view, minlength=mesh_edge_nbr) > 2 + + # - Get sharp edges from the "sharp_edge" attribute. The attribute may not exist, in which case, there + # are no edges marked as sharp. + e_use_sharp_mask = MESH_ATTRIBUTE_SHARP_EDGE.get_ndarray(attributes) + if e_use_sharp_mask is not None: + # - Combine with edges that are sharp because they're in more than two faces + e_use_sharp_mask = np.logical_or(e_use_sharp_mask, e_more_than_two_faces_mask, out=e_use_sharp_mask) + else: + e_use_sharp_mask = e_more_than_two_faces_mask + + # - Get sharp edges from flat shaded faces + p_flat_mask = MESH_ATTRIBUTE_SHARP_FACE.get_ndarray(attributes) + if p_flat_mask is not None: + # Convert flat shaded polygons to flat shaded loops by repeating each element by the number of sides + # of that polygon. + # Polygon sides can be calculated from the element-wise difference of loop starts appended by the + # number of loops. Alternatively, polygon sides can be retrieved directly from the 'loop_total' + # attribute of polygons, but since we already have t_ls, it tends to be quicker to calculate from + # t_ls. + polygon_sides = np.diff(mesh_t_ls_view, append=mesh_loop_nbr) + p_flat_loop_mask = np.repeat(p_flat_mask, polygon_sides) + # Convert flat shaded loops to flat shaded (sharp) edge indices. + # Note that if an edge is in multiple loops that are part of flat shaded faces, its edge index will + # end up in sharp_edge_indices_from_polygons multiple times. + sharp_edge_indices_from_polygons = mesh_t_lei_view[p_flat_loop_mask] + + # - Combine with edges that are sharp because a polygon they're in has flat shading + e_use_sharp_mask[sharp_edge_indices_from_polygons] = True + del sharp_edge_indices_from_polygons + del p_flat_loop_mask + del polygon_sides + del p_flat_mask + + # - Convert sharp edges to sharp edge keys (t_pvi) + ek_use_sharp_mask = e_use_sharp_mask[t_pvi_edge_indices] + + # - Sharp edges are indicated in FBX as zero (False), so invert + t_ps = np.invert(ek_use_sharp_mask, out=ek_use_sharp_mask) + del ek_use_sharp_mask + del e_use_sharp_mask + del mesh_t_lei_view + del mesh_t_ls_view + else: + t_ps = np.empty(0, dtype=ps_fbx_dtype) + t_ps = t_ps.astype(ps_fbx_dtype, copy=False) + lay_smooth = elem_data_single_int32(geom, b"LayerElementSmoothing", 0) + elem_data_single_int32(lay_smooth, b"Version", FBX_GEOMETRY_SMOOTHING_VERSION) + elem_data_single_string(lay_smooth, b"Name", b"") + elem_data_single_string(lay_smooth, b"MappingInformationType", _map) + elem_data_single_string(lay_smooth, b"ReferenceInformationType", b"Direct") + elem_data_single_int32_array(lay_smooth, b"Smoothing", t_ps) # Sight, int32 for bool... + del t_ps + del t_ls + del t_lei + + # Edge crease for subdivision + if write_crease: + ec_fbx_dtype = np.float64 + if t_pvi_edge_indices.size: + ec_bl_dtype = np.single + edge_creases = me.edge_creases + if edge_creases: + t_ec_raw = np.empty(len(me.edges), dtype=ec_bl_dtype) + edge_creases.data.foreach_get("value", t_ec_raw) + + # Convert to t_pvi edge-keys. + t_ec_ek_raw = t_ec_raw[t_pvi_edge_indices] + + # Blender squares those values before sending them to OpenSubdiv, when other software don't, + # so we need to compensate that to get similar results through FBX... + # Use the precision of the fbx dtype for the calculation since it's usually higher precision. + t_ec_ek_raw = t_ec_ek_raw.astype(ec_fbx_dtype, copy=False) + t_ec = np.square(t_ec_ek_raw, out=t_ec_ek_raw) + del t_ec_ek_raw + del t_ec_raw + else: + # todo: Blender edge creases are optional now, we may be able to avoid writing the array to FBX when + # there are no edge creases. + t_ec = np.zeros(t_pvi_edge_indices.shape, dtype=ec_fbx_dtype) + else: + t_ec = np.empty(0, dtype=ec_fbx_dtype) + + lay_crease = elem_data_single_int32(geom, b"LayerElementEdgeCrease", 0) + elem_data_single_int32(lay_crease, b"Version", FBX_GEOMETRY_CREASE_VERSION) + elem_data_single_string(lay_crease, b"Name", b"") + elem_data_single_string(lay_crease, b"MappingInformationType", b"ByEdge") + elem_data_single_string(lay_crease, b"ReferenceInformationType", b"Direct") + elem_data_single_float64_array(lay_crease, b"EdgeCrease", t_ec) + del t_ec + + # And we are done with edges! + del t_pvi_edge_indices + + # Loop normals. + tspacenumber = 0 + if write_normals: + normal_bl_dtype = np.single + normal_fbx_dtype = np.float64 + match me.normals_domain: + case 'POINT': + # All faces are smooth shaded, so we can get normals from the vertices. + normal_source = me.vertex_normals + normal_mapping = b"ByVertice" + # External software support for b"ByPolygon" normals does not seem to be as widely available as the other + # mappings. See blender/blender#117470. + # case 'FACE': + # # Either all faces or all edges are sharp, so we can get normals from the faces. + # normal_source = me.polygon_normals + # normal_mapping = b"ByPolygon" + case 'CORNER' | 'FACE': + # We have a mix of sharp/smooth edges/faces or custom split normals, so need to get normals from + # corners. + normal_source = me.corner_normals + normal_mapping = b"ByPolygonVertex" + case _: + # Unreachable + raise AssertionError("Unexpected normals domain '%s'" % me.normals_domain) + # Each normal has 3 components, so the length is multiplied by 3. + t_normal = np.empty(len(normal_source) * 3, dtype=normal_bl_dtype) + normal_source.foreach_get("vector", t_normal) + t_normal = nors_transformed(t_normal, geom_mat_no, normal_fbx_dtype) + normal_idx_fbx_dtype = np.int32 + lay_nor = elem_data_single_int32(geom, b"LayerElementNormal", 0) + elem_data_single_int32(lay_nor, b"Version", FBX_GEOMETRY_NORMAL_VERSION) + elem_data_single_string(lay_nor, b"Name", b"") + elem_data_single_string(lay_nor, b"MappingInformationType", normal_mapping) + # FBX SDK documentation says that normals should use IndexToDirect. + elem_data_single_string(lay_nor, b"ReferenceInformationType", b"IndexToDirect") + + # Tuple of unique sorted normals and then the index in the unique sorted normals of each normal in t_normal. + # Since we don't care about how the normals are sorted, only that they're unique, we can use the fast unique + # helper function. + t_normal, t_normal_idx = fast_first_axis_unique(t_normal.reshape(-1, 3), return_inverse=True) + + # Convert to the type for fbx + t_normal_idx = astype_view_signedness(t_normal_idx, normal_idx_fbx_dtype) + + elem_data_single_float64_array(lay_nor, b"Normals", t_normal) + # Normal weights, no idea what it is. + # t_normal_w = np.zeros(len(t_normal), dtype=np.float64) + # elem_data_single_float64_array(lay_nor, b"NormalsW", t_normal_w) + + elem_data_single_int32_array(lay_nor, b"NormalsIndex", t_normal_idx) + + del t_normal_idx + # del t_normal_w + del t_normal + + # tspace + if scene_data.settings.use_tspace: + tspacenumber = len(me.uv_layers) + if tspacenumber: + # We can only compute tspace on tessellated meshes, need to check that here... + lt_bl_dtype = np.uintc + t_lt = np.empty(len(me.polygons), dtype=lt_bl_dtype) + me.polygons.foreach_get("loop_total", t_lt) + if (t_lt > 4).any(): + del t_lt + scene_data.settings.report( + {'WARNING'}, + tip_("Mesh '%s' has polygons with more than 4 vertices, " + "cannot compute/export tangent space for it") % me.name) + else: + del t_lt + num_loops = len(me.loops) + t_ln = np.empty(num_loops * 3, dtype=normal_bl_dtype) + # t_lnw = np.zeros(len(me.loops), dtype=np.float64) + uv_names = [uvlayer.name for uvlayer in me.uv_layers] + # Annoying, `me.calc_tangent` errors in case there is no geometry... + if num_loops > 0: + for name in uv_names: + me.calc_tangents(uvmap=name) + for idx, uvlayer in enumerate(me.uv_layers): + name = uvlayer.name + # Loop bitangents (aka binormals). + # NOTE: this is not supported by importer currently. + me.loops.foreach_get("bitangent", t_ln) + lay_nor = elem_data_single_int32(geom, b"LayerElementBinormal", idx) + elem_data_single_int32(lay_nor, b"Version", FBX_GEOMETRY_BINORMAL_VERSION) + elem_data_single_string_unicode(lay_nor, b"Name", name) + elem_data_single_string(lay_nor, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_nor, b"ReferenceInformationType", b"Direct") + elem_data_single_float64_array(lay_nor, b"Binormals", + nors_transformed(t_ln, geom_mat_no, normal_fbx_dtype)) + # Binormal weights, no idea what it is. + # elem_data_single_float64_array(lay_nor, b"BinormalsW", t_lnw) + + # Loop tangents. + # NOTE: this is not supported by importer currently. + me.loops.foreach_get("tangent", t_ln) + lay_nor = elem_data_single_int32(geom, b"LayerElementTangent", idx) + elem_data_single_int32(lay_nor, b"Version", FBX_GEOMETRY_TANGENT_VERSION) + elem_data_single_string_unicode(lay_nor, b"Name", name) + elem_data_single_string(lay_nor, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_nor, b"ReferenceInformationType", b"Direct") + elem_data_single_float64_array(lay_nor, b"Tangents", + nors_transformed(t_ln, geom_mat_no, normal_fbx_dtype)) + # Tangent weights, no idea what it is. + # elem_data_single_float64_array(lay_nor, b"TangentsW", t_lnw) + + del t_ln + # del t_lnw + me.free_tangents() + + # Write VertexColor Layers. + colors_type = scene_data.settings.colors_type + vcolnumber = 0 if colors_type == 'NONE' else len(me.color_attributes) + if vcolnumber: + color_prop_name = "color_srgb" if colors_type == 'SRGB' else "color" + # ByteColorAttribute color also gets returned by the API as single precision float + bl_lc_dtype = np.single + fbx_lc_dtype = np.float64 + fbx_lcidx_dtype = np.int32 + + color_attributes = me.color_attributes + if scene_data.settings.prioritize_active_color: + active_color = me.color_attributes.active_color + color_attributes = sorted(color_attributes, key=lambda x: x == active_color, reverse=True) + + for colindex, collayer in enumerate(color_attributes): + is_point = collayer.domain == "POINT" + vcollen = len(me.vertices if is_point else me.loops) + # Each rgba component is flattened in the array + t_lc = np.empty(vcollen * 4, dtype=bl_lc_dtype) + collayer.data.foreach_get(color_prop_name, t_lc) + lay_vcol = elem_data_single_int32(geom, b"LayerElementColor", colindex) + elem_data_single_int32(lay_vcol, b"Version", FBX_GEOMETRY_VCOLOR_VERSION) + elem_data_single_string_unicode(lay_vcol, b"Name", collayer.name) + elem_data_single_string(lay_vcol, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_vcol, b"ReferenceInformationType", b"IndexToDirect") + + # Use the fast uniqueness helper function since we don't care about sorting. + t_lc, col_indices = fast_first_axis_unique(t_lc.reshape(-1, 4), return_inverse=True) + + if is_point: + # for "point" domain colors, we could directly emit them + # with a "ByVertex" mapping type, but some software does not + # properly understand that. So expand to full "ByPolygonVertex" + # index map. + # Ignore loops added for loose edges. + col_indices = col_indices[t_lvi[:len(me.loops)]] + + t_lc = t_lc.astype(fbx_lc_dtype, copy=False) + col_indices = astype_view_signedness(col_indices, fbx_lcidx_dtype) + + elem_data_single_float64_array(lay_vcol, b"Colors", t_lc) + elem_data_single_int32_array(lay_vcol, b"ColorIndex", col_indices) + + del t_lc + del col_indices + + # Write UV layers. + # Note: LayerElementTexture is deprecated since FBX 2011 - luckily! + # Textures are now only related to materials, in FBX! + uvnumber = len(me.uv_layers) + if uvnumber: + luv_bl_dtype = np.single + luv_fbx_dtype = np.float64 + lv_idx_fbx_dtype = np.int32 + + t_luv = np.empty(len(me.loops) * 2, dtype=luv_bl_dtype) + # Fast view for sort-based uniqueness of pairs. + t_luv_fast_pair_view = fast_first_axis_flat(t_luv.reshape(-1, 2)) + # It must be a view of t_luv otherwise it won't update when t_luv is updated. + assert(t_luv_fast_pair_view.base is t_luv) + + # Looks like this mapping is also expected to convey UV islands (arg..... :((((( ). + # So we need to generate unique triplets (uv, vertex_idx) here, not only just based on UV values. + # Ignore loops added for loose edges. + t_lvidx = t_lvi[:len(me.loops)] + + # If we were to create a combined array of (uv, vertex_idx) elements, we could find unique triplets by sorting + # that array by first sorting by the vertex_idx column and then sorting by the uv column using a stable sorting + # algorithm. + # This is exactly what we'll do, but without creating the combined array, because only the uv elements are + # included in the export and the vertex_idx column is the same for every uv layer. + + # Because the vertex_idx column is the same for every uv layer, the vertex_idx column can be sorted in advance. + # argsort gets the indices that sort the array, which are needed to be able to sort the array of uv pairs in the + # same way to create the indices that recreate the full uvs from the unique uvs. + # Loops and vertices tend to naturally have a partial ordering, which makes sorting with kind='stable' (radix + # sort) faster than the default of kind='quicksort' (introsort) in most cases. + perm_vidx = t_lvidx.argsort(kind='stable') + + # Mask and uv indices arrays will be modified and re-used by each uv layer. + unique_mask = np.empty(len(me.loops), dtype=np.bool_) + unique_mask[:1] = True + uv_indices = np.empty(len(me.loops), dtype=lv_idx_fbx_dtype) + + for uvindex, uvlayer in enumerate(me.uv_layers): + lay_uv = elem_data_single_int32(geom, b"LayerElementUV", uvindex) + elem_data_single_int32(lay_uv, b"Version", FBX_GEOMETRY_UV_VERSION) + elem_data_single_string_unicode(lay_uv, b"Name", uvlayer.name) + elem_data_single_string(lay_uv, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_uv, b"ReferenceInformationType", b"IndexToDirect") + + uvlayer.uv.foreach_get("vector", t_luv) + + # t_luv_fast_pair_view is a view in a dtype that compares elements by individual bytes, but float types have + # separate byte representations of positive and negative zero. For uniqueness, these should be considered + # the same, so replace all -0.0 with 0.0 in advance. + t_luv[t_luv == -0.0] = 0.0 + + # These steps to create unique_uv_pairs are the same as how np.unique would find unique values by sorting a + # structured array where each element is a triplet of (uv, vertex_idx), except uv and vertex_idx are + # separate arrays here and vertex_idx has already been sorted in advance. + + # Sort according to the vertex_idx column, using the precalculated indices that sort it. + sorted_t_luv_fast = t_luv_fast_pair_view[perm_vidx] + + # Get the indices that would sort the sorted uv pairs. Stable sorting must be used to maintain the sorting + # of the vertex indices. + perm_uv_pairs = sorted_t_luv_fast.argsort(kind='stable') + # Use the indices to sort both the uv pairs and the vertex_idx columns. + perm_combined = perm_vidx[perm_uv_pairs] + sorted_vidx = t_lvidx[perm_combined] + sorted_t_luv_fast = sorted_t_luv_fast[perm_uv_pairs] + + # Create a mask where either the uv pair doesn't equal the previous value in the array, or the vertex index + # doesn't equal the previous value, these will be the unique uv-vidx triplets. + # For an imaginary triplet array: + # ... + # [(0.4, 0.2), 0] + # [(0.4, 0.2), 1] -> Unique because vertex index different from previous + # [(0.4, 0.2), 2] -> Unique because vertex index different from previous + # [(0.7, 0.6), 2] -> Unique because uv different from previous + # [(0.7, 0.6), 2] + # ... + # Output the result into unique_mask. + np.logical_or(sorted_t_luv_fast[1:] != sorted_t_luv_fast[:-1], sorted_vidx[1:] != sorted_vidx[:-1], + out=unique_mask[1:]) + + # Get each uv pair marked as unique by the unique_mask and then view as the original dtype. + unique_uvs = sorted_t_luv_fast[unique_mask].view(luv_bl_dtype) + + # NaN values are considered invalid and indicate a bug somewhere else in Blender or in an addon, we want + # these bugs to be reported instead of hiding them by allowing the export to continue. + if np.isnan(unique_uvs).any(): + raise RuntimeError("UV layer %s on %r has invalid UVs containing NaN values" % (uvlayer.name, me)) + + # Convert to the type needed for fbx + unique_uvs = unique_uvs.astype(luv_fbx_dtype, copy=False) + + # Set the indices of pairs in unique_uvs that reconstruct the pairs in t_luv into uv_indices. + # uv_indices will then be the same as an inverse array returned by np.unique with return_inverse=True. + uv_indices[perm_combined] = np.cumsum(unique_mask, dtype=uv_indices.dtype) - 1 + + elem_data_single_float64_array(lay_uv, b"UV", unique_uvs) + elem_data_single_int32_array(lay_uv, b"UVIndex", uv_indices) + del unique_uvs + del sorted_t_luv_fast + del sorted_vidx + del perm_uv_pairs + del perm_combined + del uv_indices + del unique_mask + del perm_vidx + del t_lvidx + del t_luv + del t_luv_fast_pair_view + del t_lvi + + # Face's materials. + me_fbxmaterials_idx = scene_data.mesh_material_indices.get(me) + if me_fbxmaterials_idx is not None: + # We cannot use me.materials here, as this array is filled with None in case materials are linked to object... + me_blmaterials = me_obj.materials + if me_fbxmaterials_idx and me_blmaterials: + lay_ma = elem_data_single_int32(geom, b"LayerElementMaterial", 0) + elem_data_single_int32(lay_ma, b"Version", FBX_GEOMETRY_MATERIAL_VERSION) + elem_data_single_string(lay_ma, b"Name", b"") + nbr_mats = len(me_fbxmaterials_idx) + multiple_fbx_mats = nbr_mats > 1 + # If a mesh does not have more than one material its material_index attribute can be ignored. + # If a mesh has multiple materials but all its polygons are assigned to the first material, its + # material_index attribute may not exist. + t_pm = None if not multiple_fbx_mats else MESH_ATTRIBUTE_MATERIAL_INDEX.get_ndarray(attributes) + if t_pm is not None: + fbx_pm_dtype = np.int32 + + # We have to validate mat indices, and map them to FBX indices. + # Note a mat might not be in me_fbxmaterials_idx (e.g. node mats are ignored). + + # The first valid material will be used for materials out of bounds of me_blmaterials or materials not + # in me_fbxmaterials_idx. + def_me_blmaterial_idx, def_ma = next( + (i, me_fbxmaterials_idx[m]) for i, m in enumerate(me_blmaterials) if m in me_fbxmaterials_idx) + + # Set material indices that are out of bounds to the default material index + mat_idx_limit = len(me_blmaterials) + # Material indices shouldn't be negative, but they technically could be. Viewing as unsigned before + # checking for indices that are too large means that a single >= check will pick up both negative + # indices and indices that are too large. + t_pm[t_pm.view("u%i" % t_pm.itemsize) >= mat_idx_limit] = def_me_blmaterial_idx + + # Map to FBX indices. Materials not in me_fbxmaterials_idx will be set to the default material index. + blmat_fbx_idx = np.fromiter((me_fbxmaterials_idx.get(m, def_ma) for m in me_blmaterials), + dtype=fbx_pm_dtype) + t_pm = blmat_fbx_idx[t_pm] + + elem_data_single_string(lay_ma, b"MappingInformationType", b"ByPolygon") + # XXX Logically, should be "Direct" reference type, since we do not have any index array, and have one + # value per polygon... + # But looks like FBX expects it to be IndexToDirect here (maybe because materials are already + # indices??? *sigh*). + elem_data_single_string(lay_ma, b"ReferenceInformationType", b"IndexToDirect") + elem_data_single_int32_array(lay_ma, b"Materials", t_pm) + else: + elem_data_single_string(lay_ma, b"MappingInformationType", b"AllSame") + elem_data_single_string(lay_ma, b"ReferenceInformationType", b"IndexToDirect") + if multiple_fbx_mats: + # There's no material_index attribute, so every material index is effectively zero. + # In the order of the mesh's materials, get the FBX index of the first material that is exported. + all_same_idx = next(me_fbxmaterials_idx[m] for m in me_blmaterials if m in me_fbxmaterials_idx) + else: + # There's only one fbx material, so the index will always be zero. + all_same_idx = 0 + elem_data_single_int32_array(lay_ma, b"Materials", [all_same_idx]) + del t_pm + + # And the "layer TOC"... + + layer = elem_data_single_int32(geom, b"Layer", 0) + elem_data_single_int32(layer, b"Version", FBX_GEOMETRY_LAYER_VERSION) + if write_normals: + lay_nor = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_nor, b"Type", b"LayerElementNormal") + elem_data_single_int32(lay_nor, b"TypedIndex", 0) + if tspacenumber: + lay_binor = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_binor, b"Type", b"LayerElementBinormal") + elem_data_single_int32(lay_binor, b"TypedIndex", 0) + lay_tan = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_tan, b"Type", b"LayerElementTangent") + elem_data_single_int32(lay_tan, b"TypedIndex", 0) + if smooth_type in {'FACE', 'EDGE'}: + lay_smooth = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_smooth, b"Type", b"LayerElementSmoothing") + elem_data_single_int32(lay_smooth, b"TypedIndex", 0) + if write_crease: + lay_smooth = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_smooth, b"Type", b"LayerElementEdgeCrease") + elem_data_single_int32(lay_smooth, b"TypedIndex", 0) + if vcolnumber: + lay_vcol = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_vcol, b"Type", b"LayerElementColor") + elem_data_single_int32(lay_vcol, b"TypedIndex", 0) + if uvnumber: + lay_uv = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_uv, b"Type", b"LayerElementUV") + elem_data_single_int32(lay_uv, b"TypedIndex", 0) + if me_fbxmaterials_idx is not None: + lay_ma = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_ma, b"Type", b"LayerElementMaterial") + elem_data_single_int32(lay_ma, b"TypedIndex", 0) + + # Add other uv and/or vcol layers... + for vcolidx, uvidx, tspaceidx in zip_longest(range(1, vcolnumber), range(1, uvnumber), range(1, tspacenumber), + fillvalue=0): + layer = elem_data_single_int32(geom, b"Layer", max(vcolidx, uvidx)) + elem_data_single_int32(layer, b"Version", FBX_GEOMETRY_LAYER_VERSION) + if vcolidx: + lay_vcol = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_vcol, b"Type", b"LayerElementColor") + elem_data_single_int32(lay_vcol, b"TypedIndex", vcolidx) + if uvidx: + lay_uv = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_uv, b"Type", b"LayerElementUV") + elem_data_single_int32(lay_uv, b"TypedIndex", uvidx) + if tspaceidx: + lay_binor = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_binor, b"Type", b"LayerElementBinormal") + elem_data_single_int32(lay_binor, b"TypedIndex", tspaceidx) + lay_tan = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_tan, b"Type", b"LayerElementTangent") + elem_data_single_int32(lay_tan, b"TypedIndex", tspaceidx) + + # Shape keys... + fbx_data_mesh_shapes_elements(root, me_obj, me, scene_data, tmpl, props) + + elem_props_template_finalize(tmpl, props) + done_meshes.add(me_key) + + +def fbx_data_material_elements(root, ma, scene_data): + """ + Write the Material data block. + """ + + ambient_color = (0.0, 0.0, 0.0) + if scene_data.data_world: + ambient_color = next(iter(scene_data.data_world.keys())).color + + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=True) + ma_key, _objs = scene_data.data_materials[ma] + ma_type = b"Phong" + + fbx_ma = elem_data_single_int64(root, b"Material", get_fbx_uuid_from_key(ma_key)) + fbx_ma.add_string(fbx_name_class(ma.name.encode(), b"Material")) + fbx_ma.add_string(b"") + + elem_data_single_int32(fbx_ma, b"Version", FBX_MATERIAL_VERSION) + # those are not yet properties, it seems... + elem_data_single_string(fbx_ma, b"ShadingModel", ma_type) + elem_data_single_int32(fbx_ma, b"MultiLayer", 0) # Should be bool... + + tmpl = elem_props_template_init(scene_data.templates, b"Material") + props = elem_properties(fbx_ma) + + elem_props_template_set(tmpl, props, "p_string", b"ShadingModel", ma_type.decode()) + elem_props_template_set(tmpl, props, "p_color", b"DiffuseColor", ma_wrap.base_color) + # Not in Principled BSDF, so assuming always 1 + elem_props_template_set(tmpl, props, "p_number", b"DiffuseFactor", 1.0) + # Principled BSDF only has an emissive color, so we assume factor to be always 1.0. + elem_props_template_set(tmpl, props, "p_color", b"EmissiveColor", ma_wrap.emission_color) + elem_props_template_set(tmpl, props, "p_number", b"EmissiveFactor", ma_wrap.emission_strength) + # Not in Principled BSDF, so assuming always 0 + elem_props_template_set(tmpl, props, "p_color", b"AmbientColor", ambient_color) + elem_props_template_set(tmpl, props, "p_number", b"AmbientFactor", 0.0) + # Sweetness... Looks like we are not the only ones to not know exactly how FBX is supposed to work (see T59850). + # According to one of its developers, Unity uses that formula to extract alpha value: + # + # alpha = 1 - TransparencyFactor + # if (alpha == 1 or alpha == 0): + # alpha = 1 - TransparentColor.r + # + # Until further info, let's assume this is correct way to do, hence the following code for TransparentColor. + if ma_wrap.alpha < 1.0e-5 or ma_wrap.alpha > (1.0 - 1.0e-5): + elem_props_template_set(tmpl, props, "p_color", b"TransparentColor", (1.0 - ma_wrap.alpha,) * 3) + else: + elem_props_template_set(tmpl, props, "p_color", b"TransparentColor", ma_wrap.base_color) + elem_props_template_set(tmpl, props, "p_number", b"TransparencyFactor", 1.0 - ma_wrap.alpha) + elem_props_template_set(tmpl, props, "p_number", b"Opacity", ma_wrap.alpha) + elem_props_template_set(tmpl, props, "p_vector_3d", b"NormalMap", (0.0, 0.0, 0.0)) + elem_props_template_set(tmpl, props, "p_double", b"BumpFactor", ma_wrap.normalmap_strength) + # Not sure about those... + """ + b"Bump": ((0.0, 0.0, 0.0), "p_vector_3d"), + b"DisplacementColor": ((0.0, 0.0, 0.0), "p_color_rgb"), + b"DisplacementFactor": (0.0, "p_double"), + """ + # TODO: use specular tint? + elem_props_template_set(tmpl, props, "p_color", b"SpecularColor", ma_wrap.base_color) + elem_props_template_set(tmpl, props, "p_number", b"SpecularFactor", ma_wrap.specular / 2.0) + # See Material template about those two! + # XXX Totally empirical conversion, trying to adapt it + # (from 0.0 - 100.0 FBX shininess range to 1.0 - 0.0 Principled BSDF range)... + shininess = (1.0 - ma_wrap.roughness) * 10 + shininess *= shininess + elem_props_template_set(tmpl, props, "p_number", b"Shininess", shininess) + elem_props_template_set(tmpl, props, "p_number", b"ShininessExponent", shininess) + elem_props_template_set(tmpl, props, "p_color", b"ReflectionColor", ma_wrap.base_color) + elem_props_template_set(tmpl, props, "p_number", b"ReflectionFactor", ma_wrap.metallic) + + elem_props_template_finalize(tmpl, props) + + # Custom properties. + if scene_data.settings.use_custom_props: + fbx_data_element_custom_properties(props, ma) + + +def _gen_vid_path(img, scene_data): + msetts = scene_data.settings.media_settings + fname_rel = bpy_extras.io_utils.path_reference(img.filepath, msetts.base_src, msetts.base_dst, msetts.path_mode, + msetts.subdir, msetts.copy_set, img.library) + fname_abs = os.path.normpath(os.path.abspath(os.path.join(msetts.base_dst, fname_rel))) + return fname_abs, fname_rel + + +def fbx_data_texture_file_elements(root, blender_tex_key, scene_data): + """ + Write the (file) Texture data block. + """ + # XXX All this is very fuzzy to me currently... + # Textures do not seem to use properties as much as they could. + # For now assuming most logical and simple stuff. + + ma, sock_name = blender_tex_key + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=True) + tex_key, _fbx_prop = scene_data.data_textures[blender_tex_key] + tex = getattr(ma_wrap, sock_name) + img = tex.image + fname_abs, fname_rel = _gen_vid_path(img, scene_data) + + fbx_tex = elem_data_single_int64(root, b"Texture", get_fbx_uuid_from_key(tex_key)) + fbx_tex.add_string(fbx_name_class(sock_name.encode(), b"Texture")) + fbx_tex.add_string(b"") + + elem_data_single_string(fbx_tex, b"Type", b"TextureVideoClip") + elem_data_single_int32(fbx_tex, b"Version", FBX_TEXTURE_VERSION) + elem_data_single_string(fbx_tex, b"TextureName", fbx_name_class(sock_name.encode(), b"Texture")) + elem_data_single_string(fbx_tex, b"Media", fbx_name_class(img.name.encode(), b"Video")) + elem_data_single_string_unicode(fbx_tex, b"FileName", fname_abs) + elem_data_single_string_unicode(fbx_tex, b"RelativeFilename", fname_rel) + + alpha_source = 0 # None + if img.alpha_mode != 'NONE': + # ~ if tex.texture.use_calculate_alpha: + # ~ alpha_source = 1 # RGBIntensity as alpha. + # ~ else: + # ~ alpha_source = 2 # Black, i.e. alpha channel. + alpha_source = 2 # Black, i.e. alpha channel. + # BlendMode not useful for now, only affects layered textures afaics. + mapping = 0 # UV. + uvset = None + if tex.texcoords == 'ORCO': # XXX Others? + if tex.projection == 'FLAT': + mapping = 1 # Planar + elif tex.projection == 'CUBE': + mapping = 4 # Box + elif tex.projection == 'TUBE': + mapping = 3 # Cylindrical + elif tex.projection == 'SPHERE': + mapping = 2 # Spherical + elif tex.texcoords == 'UV': + mapping = 0 # UV + # Yuck, UVs are linked by mere names it seems... :/ + # XXX TODO how to get that now??? + # uvset = tex.uv_layer + wrap_mode = 1 # Clamp + if tex.extension == 'REPEAT': + wrap_mode = 0 # Repeat + + tmpl = elem_props_template_init(scene_data.templates, b"TextureFile") + props = elem_properties(fbx_tex) + elem_props_template_set(tmpl, props, "p_enum", b"AlphaSource", alpha_source) + elem_props_template_set(tmpl, props, "p_bool", b"PremultiplyAlpha", + img.alpha_mode in {'STRAIGHT'}) # Or is it PREMUL? + elem_props_template_set(tmpl, props, "p_enum", b"CurrentMappingType", mapping) + if uvset is not None: + elem_props_template_set(tmpl, props, "p_string", b"UVSet", uvset) + elem_props_template_set(tmpl, props, "p_enum", b"WrapModeU", wrap_mode) + elem_props_template_set(tmpl, props, "p_enum", b"WrapModeV", wrap_mode) + elem_props_template_set(tmpl, props, "p_vector_3d", b"Translation", tex.translation) + elem_props_template_set(tmpl, props, "p_vector_3d", b"Rotation", (-r for r in tex.rotation)) + elem_props_template_set(tmpl, props, "p_vector_3d", b"Scaling", + (((1.0 / s) if s != 0.0 else 1.0) for s in tex.scale)) + # UseMaterial should always be ON imho. + elem_props_template_set(tmpl, props, "p_bool", b"UseMaterial", True) + elem_props_template_set(tmpl, props, "p_bool", b"UseMipMap", False) + elem_props_template_finalize(tmpl, props) + + # No custom properties, since that's not a data-block anymore. + + +def fbx_data_video_elements(root, vid, scene_data): + """ + Write the actual image data block. + """ + msetts = scene_data.settings.media_settings + + vid_key, _texs = scene_data.data_videos[vid] + fname_abs, fname_rel = _gen_vid_path(vid, scene_data) + + fbx_vid = elem_data_single_int64(root, b"Video", get_fbx_uuid_from_key(vid_key)) + fbx_vid.add_string(fbx_name_class(vid.name.encode(), b"Video")) + fbx_vid.add_string(b"Clip") + + elem_data_single_string(fbx_vid, b"Type", b"Clip") + # XXX No Version??? + + tmpl = elem_props_template_init(scene_data.templates, b"Video") + props = elem_properties(fbx_vid) + elem_props_template_set(tmpl, props, "p_string_url", b"Path", fname_abs) + elem_props_template_finalize(tmpl, props) + + elem_data_single_int32(fbx_vid, b"UseMipMap", 0) + elem_data_single_string_unicode(fbx_vid, b"Filename", fname_abs) + elem_data_single_string_unicode(fbx_vid, b"RelativeFilename", fname_rel) + + if scene_data.settings.media_settings.embed_textures: + if vid.packed_file is not None: + # We only ever embed a given file once! + if fname_abs not in msetts.embedded_set: + elem_data_single_bytes(fbx_vid, b"Content", vid.packed_file.data) + msetts.embedded_set.add(fname_abs) + else: + filepath = bpy.path.abspath(vid.filepath) + # We only ever embed a given file once! + if filepath not in msetts.embedded_set: + try: + with open(filepath, 'br') as f: + elem_data_single_bytes(fbx_vid, b"Content", f.read()) + except Exception as e: + print("WARNING: embedding file {} failed ({})".format(filepath, e)) + elem_data_single_bytes(fbx_vid, b"Content", b"") + msetts.embedded_set.add(filepath) + # Looks like we'd rather not write any 'Content' element in this case (see T44442). + # Sounds suspect, but let's try it! + # ~ else: + #~ elem_data_single_bytes(fbx_vid, b"Content", b"") + + # Blender currently has no UI for editing custom properties on Images, but the importer will import Image custom + # properties from either a Video Node or a Texture Node, preferring a Video node if one exists. We'll propagate + # these custom properties only to Video Nodes because that is most likely where they were imported from, and Texture + # Nodes are more like Blender's Shader Nodes than Images, which is what we're exporting here. + if scene_data.settings.use_custom_props: + fbx_data_element_custom_properties(props, vid) + + +def fbx_data_armature_elements(root, arm_obj, scene_data): + """ + Write: + * Bones "data" (NodeAttribute::LimbNode, contains pretty much nothing!). + * Deformers (i.e. Skin), bind between an armature and a mesh. + ** SubDeformers (i.e. Cluster), one per bone/vgroup pair. + * BindPose. + Note armature itself has no data, it is a mere "Null" Model... + """ + mat_world_arm = arm_obj.fbx_object_matrix(scene_data, global_space=True) + bones = tuple(bo_obj for bo_obj in arm_obj.bones if bo_obj in scene_data.objects) + + bone_radius_scale = 33.0 + + # Bones "data". + for bo_obj in bones: + bo = bo_obj.bdata + bo_data_key = scene_data.data_bones[bo_obj] + fbx_bo = elem_data_single_int64(root, b"NodeAttribute", get_fbx_uuid_from_key(bo_data_key)) + fbx_bo.add_string(fbx_name_class(bo.name.encode(), b"NodeAttribute")) + fbx_bo.add_string(b"LimbNode") + elem_data_single_string(fbx_bo, b"TypeFlags", b"Skeleton") + + tmpl = elem_props_template_init(scene_data.templates, b"Bone") + props = elem_properties(fbx_bo) + elem_props_template_set(tmpl, props, "p_double", b"Size", bo.head_radius * bone_radius_scale) + elem_props_template_finalize(tmpl, props) + + # Custom properties. + if scene_data.settings.use_custom_props: + fbx_data_element_custom_properties(props, bo) + + # Store Blender bone length - XXX Not much useful actually :/ + # (LimbLength can't be used because it is a scale factor 0-1 for the parent-child distance: + # http://docs.autodesk.com/FBX/2014/ENU/FBX-SDK-Documentation/cpp_ref/class_fbx_skeleton.html#a9bbe2a70f4ed82cd162620259e649f0f ) + # elem_props_set(props, "p_double", "BlenderBoneLength".encode(), (bo.tail_local - bo.head_local).length, custom=True) + + # Skin deformers and BindPoses. + # Note: we might also use Deformers for our "parent to vertex" stuff??? + deformer = scene_data.data_deformers_skin.get(arm_obj, None) + if deformer is not None: + for me, (skin_key, ob_obj, clusters) in deformer.items(): + # BindPose. + mat_world_obj, mat_world_bones = fbx_data_bindpose_element(root, ob_obj, me, scene_data, + arm_obj, mat_world_arm, bones) + + # Deformer. + fbx_skin = elem_data_single_int64(root, b"Deformer", get_fbx_uuid_from_key(skin_key)) + fbx_skin.add_string(fbx_name_class(arm_obj.name.encode(), b"Deformer")) + fbx_skin.add_string(b"Skin") + + elem_data_single_int32(fbx_skin, b"Version", FBX_DEFORMER_SKIN_VERSION) + elem_data_single_float64(fbx_skin, b"Link_DeformAcuracy", 50.0) # Only vague idea what it is... + + # Pre-process vertex weights so that the vertices only need to be iterated once. + ob = ob_obj.bdata + bo_vg_idx = {bo_obj.bdata.name: ob.vertex_groups[bo_obj.bdata.name].index + for bo_obj in clusters.keys() if bo_obj.bdata.name in ob.vertex_groups} + valid_idxs = set(bo_vg_idx.values()) + vgroups = {vg.index: {} for vg in ob.vertex_groups} + for idx, v in enumerate(me.vertices): + for vg in v.groups: + if (w := vg.weight) and (vg_idx := vg.group) in valid_idxs: + vgroups[vg_idx][idx] = w + + for bo_obj, clstr_key in clusters.items(): + bo = bo_obj.bdata + # Find which vertices are affected by this bone/vgroup pair, and matching weights. + # Note we still write a cluster for bones not affecting the mesh, to get 'rest pose' data + # (the TransformBlah matrices). + vg_idx = bo_vg_idx.get(bo.name, None) + indices, weights = ((), ()) if vg_idx is None or not vgroups[vg_idx] else zip(*vgroups[vg_idx].items()) + + # Create the cluster. + fbx_clstr = elem_data_single_int64(root, b"Deformer", get_fbx_uuid_from_key(clstr_key)) + fbx_clstr.add_string(fbx_name_class(bo.name.encode(), b"SubDeformer")) + fbx_clstr.add_string(b"Cluster") + + elem_data_single_int32(fbx_clstr, b"Version", FBX_DEFORMER_CLUSTER_VERSION) + # No idea what that user data might be... + fbx_userdata = elem_data_single_string(fbx_clstr, b"UserData", b"") + fbx_userdata.add_string(b"") + if indices: + elem_data_single_int32_array(fbx_clstr, b"Indexes", indices) + elem_data_single_float64_array(fbx_clstr, b"Weights", weights) + # Transform, TransformLink and TransformAssociateModel matrices... + # They seem to be doublons of BindPose ones??? Have armature (associatemodel) in addition, though. + # WARNING! Even though official FBX API presents Transform in global space, + # **it is stored in bone space in FBX data!** See: + # http://area.autodesk.com/forum/autodesk-fbx/fbx-sdk/why-the-values-return- + # by-fbxcluster-gettransformmatrix-x-not-same-with-the-value-in-ascii-fbx-file/ + elem_data_single_float64_array( + fbx_clstr, b"Transform", matrix4_to_array( + mat_world_bones[bo_obj].inverted_safe() @ mat_world_obj)) + elem_data_single_float64_array(fbx_clstr, b"TransformLink", matrix4_to_array(mat_world_bones[bo_obj])) + elem_data_single_float64_array(fbx_clstr, b"TransformAssociateModel", matrix4_to_array(mat_world_arm)) + + +def fbx_data_leaf_bone_elements(root, scene_data): + # Write a dummy leaf bone that is used by applications to show the length of the last bone in a chain + for (node_name, _par_uuid, node_uuid, attr_uuid, matrix, hide, size) in scene_data.data_leaf_bones: + # Bone 'data'... + fbx_bo = elem_data_single_int64(root, b"NodeAttribute", attr_uuid) + fbx_bo.add_string(fbx_name_class(node_name.encode(), b"NodeAttribute")) + fbx_bo.add_string(b"LimbNode") + elem_data_single_string(fbx_bo, b"TypeFlags", b"Skeleton") + + tmpl = elem_props_template_init(scene_data.templates, b"Bone") + props = elem_properties(fbx_bo) + elem_props_template_set(tmpl, props, "p_double", b"Size", size) + elem_props_template_finalize(tmpl, props) + + # And bone object. + model = elem_data_single_int64(root, b"Model", node_uuid) + model.add_string(fbx_name_class(node_name.encode(), b"Model")) + model.add_string(b"LimbNode") + + elem_data_single_int32(model, b"Version", FBX_MODELS_VERSION) + + # Object transform info. + loc, rot, scale = matrix.decompose() + rot = rot.to_euler('XYZ') + rot = tuple(convert_rad_to_deg_iter(rot)) + + tmpl = elem_props_template_init(scene_data.templates, b"Model") + # For now add only loc/rot/scale... + props = elem_properties(model) + # Generated leaf bones are obviously never animated! + elem_props_template_set(tmpl, props, "p_lcl_translation", b"Lcl Translation", loc) + elem_props_template_set(tmpl, props, "p_lcl_rotation", b"Lcl Rotation", rot) + elem_props_template_set(tmpl, props, "p_lcl_scaling", b"Lcl Scaling", scale) + elem_props_template_set(tmpl, props, "p_visibility", b"Visibility", float(not hide)) + + # Absolutely no idea what this is, but seems mandatory for validity of the file, and defaults to + # invalid -1 value... + elem_props_template_set(tmpl, props, "p_integer", b"DefaultAttributeIndex", 0) + + elem_props_template_set(tmpl, props, "p_enum", b"InheritType", 1) # RSrs + + # Those settings would obviously need to be edited in a complete version of the exporter, may depends on + # object type, etc. + elem_data_single_int32(model, b"MultiLayer", 0) + elem_data_single_int32(model, b"MultiTake", 0) + # Probably the FbxNode.EShadingMode enum. Full description in fbx_data_object_elements. + elem_data_single_char(model, b"Shading", b"\x01") + elem_data_single_string(model, b"Culling", b"CullingOff") + + elem_props_template_finalize(tmpl, props) + + +def fbx_data_object_elements(root, ob_obj, scene_data): + """ + Write the Object (Model) data blocks. + Note this "Model" can also be bone or dupli! + """ + obj_type = b"Null" # default, sort of empty... + if ob_obj.is_bone: + obj_type = b"LimbNode" + elif (ob_obj.type == 'ARMATURE'): + if scene_data.settings.armature_nodetype == 'ROOT': + obj_type = b"Root" + elif scene_data.settings.armature_nodetype == 'LIMBNODE': + obj_type = b"LimbNode" + else: # Default, preferred option... + obj_type = b"Null" + elif (ob_obj.type in BLENDER_OBJECT_TYPES_MESHLIKE): + obj_type = b"Mesh" + elif (ob_obj.type == 'LIGHT'): + obj_type = b"Light" + elif (ob_obj.type == 'CAMERA'): + obj_type = b"Camera" + model = elem_data_single_int64(root, b"Model", ob_obj.fbx_uuid) + model.add_string(fbx_name_class(ob_obj.name.encode(), b"Model")) + model.add_string(obj_type) + + elem_data_single_int32(model, b"Version", FBX_MODELS_VERSION) + + # Object transform info. + loc, rot, scale, matrix, matrix_rot = ob_obj.fbx_object_tx(scene_data) + rot = tuple(convert_rad_to_deg_iter(rot)) + + tmpl = elem_props_template_init(scene_data.templates, b"Model") + # For now add only loc/rot/scale... + props = elem_properties(model) + elem_props_template_set(tmpl, props, "p_lcl_translation", b"Lcl Translation", loc, + animatable=True, animated=((ob_obj.key, "Lcl Translation") in scene_data.animated)) + elem_props_template_set(tmpl, props, "p_lcl_rotation", b"Lcl Rotation", rot, + animatable=True, animated=((ob_obj.key, "Lcl Rotation") in scene_data.animated)) + elem_props_template_set(tmpl, props, "p_lcl_scaling", b"Lcl Scaling", scale, + animatable=True, animated=((ob_obj.key, "Lcl Scaling") in scene_data.animated)) + elem_props_template_set(tmpl, props, "p_visibility", b"Visibility", float(not ob_obj.hide)) + + # Absolutely no idea what this is, but seems mandatory for validity of the file, and defaults to + # invalid -1 value... + elem_props_template_set(tmpl, props, "p_integer", b"DefaultAttributeIndex", 0) + + elem_props_template_set(tmpl, props, "p_enum", b"InheritType", 1) # RSrs + + # Custom properties. + if scene_data.settings.use_custom_props: + # Here we want customprops from the 'pose' bone, not the 'edit' bone... + bdata = ob_obj.bdata_pose_bone if ob_obj.is_bone else ob_obj.bdata + fbx_data_element_custom_properties(props, bdata) + + # Those settings would obviously need to be edited in a complete version of the exporter, may depends on + # object type, etc. + elem_data_single_int32(model, b"MultiLayer", 0) + elem_data_single_int32(model, b"MultiTake", 0) + # This is probably the FbxNode.EShadingMode enum. Not directly used by the FBX SDK, but the SDK guarantees that the + # value will be passed through from an imported file to an exported one. Common values are 'Y' and 'T'. 'U' and 'W' + # have also been seen in older FBX files. It's not clear which enum member each of these values corresponds to or if + # these values are actually application specific. Blender had been exporting this as a `True` bool for a long time + # seemingly without issue. The '\x01' char is the same value as `True` in raw bytes. + elem_data_single_char(model, b"Shading", b"\x01") + elem_data_single_string(model, b"Culling", b"CullingOff") + + if obj_type == b"Camera": + # Why, oh why are FBX cameras such a mess??? + # And WHY add camera data HERE??? Not even sure this is needed... + render = scene_data.scene.render + width = render.resolution_x * 1.0 + height = render.resolution_y * 1.0 + elem_props_template_set(tmpl, props, "p_enum", b"ResolutionMode", 0) # Don't know what it means + elem_props_template_set(tmpl, props, "p_double", b"AspectW", width) + elem_props_template_set(tmpl, props, "p_double", b"AspectH", height) + elem_props_template_set(tmpl, props, "p_bool", b"ViewFrustum", True) + elem_props_template_set(tmpl, props, "p_enum", b"BackgroundMode", 0) # Don't know what it means + elem_props_template_set(tmpl, props, "p_bool", b"ForegroundTransparent", True) + + elem_props_template_finalize(tmpl, props) + + +def fbx_data_animation_elements(root, scene_data): + """ + Write animation data. + """ + animations = scene_data.animations + if not animations: + return + + # Animation stacks. + for astack_key, alayers, alayer_key, name, f_start, f_end in animations: + astack = elem_data_single_int64(root, b"AnimationStack", get_fbx_uuid_from_key(astack_key)) + astack.add_string(fbx_name_class(name, b"AnimStack")) + astack.add_string(b"") + + astack_tmpl = elem_props_template_init(scene_data.templates, b"AnimationStack") + astack_props = elem_properties(astack) + r = scene_data.scene.render + fps = r.fps / r.fps_base + start = int(convert_sec_to_ktime(f_start / fps)) + end = int(convert_sec_to_ktime(f_end / fps)) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"LocalStart", start) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"LocalStop", end) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"ReferenceStart", start) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"ReferenceStop", end) + elem_props_template_finalize(astack_tmpl, astack_props) + + # For now, only one layer for all animations. + alayer = elem_data_single_int64(root, b"AnimationLayer", get_fbx_uuid_from_key(alayer_key)) + alayer.add_string(fbx_name_class(name, b"AnimLayer")) + alayer.add_string(b"") + + for ob_obj, (alayer_key, acurvenodes) in alayers.items(): + # Animation layer. + # alayer = elem_data_single_int64(root, b"AnimationLayer", get_fbx_uuid_from_key(alayer_key)) + # alayer.add_string(fbx_name_class(ob_obj.name.encode(), b"AnimLayer")) + # alayer.add_string(b"") + + for fbx_prop, (acurvenode_key, acurves, acurvenode_name) in acurvenodes.items(): + # Animation curve node. + acurvenode = elem_data_single_int64(root, b"AnimationCurveNode", get_fbx_uuid_from_key(acurvenode_key)) + acurvenode.add_string(fbx_name_class(acurvenode_name.encode(), b"AnimCurveNode")) + acurvenode.add_string(b"") + + acn_tmpl = elem_props_template_init(scene_data.templates, b"AnimationCurveNode") + acn_props = elem_properties(acurvenode) + + for fbx_item, (acurve_key, def_value, (keys, values), _acurve_valid) in acurves.items(): + elem_props_template_set(acn_tmpl, acn_props, "p_number", fbx_item.encode(), + def_value, animatable=True) + + # Only create Animation curve if needed! + nbr_keys = len(keys) + if nbr_keys: + acurve = elem_data_single_int64(root, b"AnimationCurve", get_fbx_uuid_from_key(acurve_key)) + acurve.add_string(fbx_name_class(b"", b"AnimCurve")) + acurve.add_string(b"") + + # key attributes... + # flags... + keyattr_flags = ( + 1 << 2 | # interpolation mode, 1 = constant, 2 = linear, 3 = cubic. + 1 << 8 | # tangent mode, 8 = auto, 9 = TCB, 10 = user, 11 = generic break, + 1 << 13 | # tangent mode, 12 = generic clamp, 13 = generic time independent, + 1 << 14 | # tangent mode, 13 + 14 = generic clamp progressive. + 0, + ) + # Maybe values controlling TCB & co??? + keyattr_datafloat = (0.0, 0.0, 9.419963346924634e-30, 0.0) + + # And now, the *real* data! + elem_data_single_float64(acurve, b"Default", def_value) + elem_data_single_int32(acurve, b"KeyVer", FBX_ANIM_KEY_VERSION) + elem_data_single_int64_array(acurve, b"KeyTime", astype_view_signedness(keys, np.int64)) + elem_data_single_float32_array(acurve, b"KeyValueFloat", values.astype(np.float32, copy=False)) + elem_data_single_int32_array(acurve, b"KeyAttrFlags", keyattr_flags) + elem_data_single_float32_array(acurve, b"KeyAttrDataFloat", keyattr_datafloat) + elem_data_single_int32_array(acurve, b"KeyAttrRefCount", (nbr_keys,)) + + elem_props_template_finalize(acn_tmpl, acn_props) + + +# ##### Top-level FBX data container. ##### + +# Mapping Blender -> FBX (principled_socket_name, fbx_name). +PRINCIPLED_TEXTURE_SOCKETS_TO_FBX = ( + # ("diffuse", "diffuse", b"DiffuseFactor"), + ("base_color_texture", b"DiffuseColor"), + ("alpha_texture", b"TransparencyFactor"), # Will be inverted in fact, not much we can do really... + # ("base_color_texture", b"TransparentColor"), # Uses diffuse color in Blender! + ("emission_strength_texture", b"EmissiveFactor"), + ("emission_color_texture", b"EmissiveColor"), + # ("ambient", "ambient", b"AmbientFactor"), + # ("", "", b"AmbientColor"), # World stuff in Blender, for now ignore... + ("normalmap_texture", b"NormalMap"), + # Note: unsure about those... :/ + # ("", "", b"Bump"), + # ("", "", b"BumpFactor"), + # ("", "", b"DisplacementColor"), + # ("", "", b"DisplacementFactor"), + ("specular_texture", b"SpecularFactor"), + # ("base_color", b"SpecularColor"), # TODO: use tint? + # See Material template about those two! + ("roughness_texture", b"Shininess"), + ("roughness_texture", b"ShininessExponent"), + # ("mirror", "mirror", b"ReflectionColor"), + ("metallic_texture", b"ReflectionFactor"), +) + + +def fbx_skeleton_from_armature(scene, settings, arm_obj, objects, data_meshes, + data_bones, data_deformers_skin, data_empties, arm_parents): + """ + Create skeleton from armature/bones (NodeAttribute/LimbNode and Model/LimbNode), and for each deformed mesh, + create Pose/BindPose(with sub PoseNode) and Deformer/Skin(with Deformer/SubDeformer/Cluster). + Also supports "parent to bone" (simple parent to Model/LimbNode). + arm_parents is a set of tuples (armature, object) for all successful armature bindings. + """ + # We need some data for our armature 'object' too!!! + data_empties[arm_obj] = get_blender_empty_key(arm_obj.bdata) + + arm_data = arm_obj.bdata.data + bones = {} + for bo in arm_obj.bones: + if settings.use_armature_deform_only: + if bo.bdata.use_deform: + bones[bo] = True + bo_par = bo.parent + while bo_par.is_bone: + bones[bo_par] = True + bo_par = bo_par.parent + elif bo not in bones: # Do not override if already set in the loop above! + bones[bo] = False + else: + bones[bo] = True + + bones = {bo: None for bo, use in bones.items() if use} + + if not bones: + return + + data_bones.update((bo, get_blender_bone_key(arm_obj.bdata, bo.bdata)) for bo in bones) + + for ob_obj in objects: + if not ob_obj.is_deformed_by_armature(arm_obj): + continue + + # Always handled by an Armature modifier... + found = False + for mod in ob_obj.bdata.modifiers: + if mod.type not in {'ARMATURE'} or not mod.object: + continue + # We only support vertex groups binding method, not bone envelopes one! + if mod.object == arm_obj.bdata and mod.use_vertex_groups: + found = True + break + + if not found: + continue + + # Now we have a mesh using this armature. + # Note: bindpose have no relations at all (no connections), so no need for any preprocess for them. + # Create skin & clusters relations (note skins are connected to geometry, *not* model!). + _key, me, _free = data_meshes[ob_obj] + clusters = {bo: get_blender_bone_cluster_key(arm_obj.bdata, me, bo.bdata) for bo in bones} + data_deformers_skin.setdefault(arm_obj, {})[me] = (get_blender_armature_skin_key(arm_obj.bdata, me), + ob_obj, clusters) + + # We don't want a regular parent relationship for those in FBX... + arm_parents.add((arm_obj, ob_obj)) + # Needed to handle matrices/spaces (since we do not parent them to 'armature' in FBX :/ ). + ob_obj.parented_to_armature = True + + objects.update(bones) + + +def fbx_generate_leaf_bones(settings, data_bones): + # find which bons have no children + child_count = {bo: 0 for bo in data_bones.keys()} + for bo in data_bones.keys(): + if bo.parent and bo.parent.is_bone: + child_count[bo.parent] += 1 + + bone_radius_scale = settings.global_scale * 33.0 + + # generate bone data + leaf_parents = [bo for bo, count in child_count.items() if count == 0] + leaf_bones = [] + for parent in leaf_parents: + node_name = parent.name + "_end" + parent_uuid = parent.fbx_uuid + parent_key = parent.key + node_uuid = get_fbx_uuid_from_key(parent_key + "_end_node") + attr_uuid = get_fbx_uuid_from_key(parent_key + "_end_nodeattr") + + hide = parent.hide + size = parent.bdata.head_radius * bone_radius_scale + bone_length = (parent.bdata.tail_local - parent.bdata.head_local).length + matrix = Matrix.Translation((0, bone_length, 0)) + if settings.bone_correction_matrix_inv: + matrix = settings.bone_correction_matrix_inv @ matrix + if settings.bone_correction_matrix: + matrix = matrix @ settings.bone_correction_matrix + leaf_bones.append((node_name, parent_uuid, node_uuid, attr_uuid, matrix, hide, size)) + + return leaf_bones + + +def fbx_animations_do(scene_data, ref_id, f_start, f_end, start_zero, objects=None, force_keep=False): + """ + Generate animation data (a single AnimStack) from objects, for a given frame range. + """ + bake_step = scene_data.settings.bake_anim_step + simplify_fac = scene_data.settings.bake_anim_simplify_factor + scene = scene_data.scene + depsgraph = scene_data.depsgraph + force_keying = scene_data.settings.bake_anim_use_all_bones + force_sek = scene_data.settings.bake_anim_force_startend_keying + gscale = scene_data.settings.global_scale + + if objects is not None: + # Add bones and duplis! + for ob_obj in tuple(objects): + if not ob_obj.is_object: + continue + if ob_obj.type == 'ARMATURE': + objects |= {bo_obj for bo_obj in ob_obj.bones if bo_obj in scene_data.objects} + for dp_obj in ob_obj.dupli_list_gen(depsgraph): + if dp_obj in scene_data.objects: + objects.add(dp_obj) + else: + objects = scene_data.objects + + back_currframe = scene.frame_current + animdata_ob = {} + p_rots = {} + + for ob_obj in objects: + if ob_obj.parented_to_armature: + continue + ACNW = AnimationCurveNodeWrapper + loc, rot, scale, _m, _mr = ob_obj.fbx_object_tx(scene_data) + rot_deg = tuple(convert_rad_to_deg_iter(rot)) + force_key = (simplify_fac == 0.0) or (ob_obj.is_bone and force_keying) + animdata_ob[ob_obj] = (ACNW(ob_obj.key, 'LCL_TRANSLATION', force_key, force_sek, loc), + ACNW(ob_obj.key, 'LCL_ROTATION', force_key, force_sek, rot_deg), + ACNW(ob_obj.key, 'LCL_SCALING', force_key, force_sek, scale)) + p_rots[ob_obj] = rot + + force_key = (simplify_fac == 0.0) + animdata_shapes = {} + + for me, (me_key, _shapes_key, shapes) in scene_data.data_deformers_shape.items(): + # Ignore absolute shape keys for now! + if not me.shape_keys.use_relative: + continue + for shape, (channel_key, geom_key, _shape_verts_co, _shape_verts_idx) in shapes.items(): + acnode = AnimationCurveNodeWrapper(channel_key, 'SHAPE_KEY', force_key, force_sek, (0.0,)) + # Sooooo happy to have to twist again like a mad snake... Yes, we need to write those curves twice. :/ + acnode.add_group(me_key, shape.name, shape.name, (shape.name,)) + animdata_shapes[channel_key] = (acnode, me, shape) + + animdata_cameras = {} + for cam_obj, cam_key in scene_data.data_cameras.items(): + cam = cam_obj.bdata.data + acnode_lens = AnimationCurveNodeWrapper(cam_key, 'CAMERA_FOCAL', force_key, force_sek, (cam.lens,)) + acnode_focus_distance = AnimationCurveNodeWrapper(cam_key, 'CAMERA_FOCUS_DISTANCE', force_key, + force_sek, (cam.dof.focus_distance,)) + animdata_cameras[cam_key] = (acnode_lens, acnode_focus_distance, cam) + + # Get all parent bdata of animated dupli instances, so that we can quickly identify which instances in + # `depsgraph.object_instances` are animated and need their ObjectWrappers' matrices updated each frame. + dupli_parent_bdata = {dup.get_parent().bdata for dup in animdata_ob if dup.is_dupli} + has_animated_duplis = bool(dupli_parent_bdata) + + # Initialize keyframe times array. Each AnimationCurveNodeWrapper will share the same instance. + # `np.arange` excludes the `stop` argument like when using `range`, so we use np.nextafter to get the next + # representable value after f_end and use that as the `stop` argument instead. + currframes = np.arange(f_start, np.nextafter(f_end, np.inf), step=bake_step) + + # Convert from Blender time to FBX time. + fps = scene.render.fps / scene.render.fps_base + real_currframes = currframes - f_start if start_zero else currframes + real_currframes = (real_currframes / fps * FBX_KTIME).astype(np.int64) + + # Generator that yields the animated values of each frame in order. + def frame_values_gen(): + # Precalculate integer frames and subframes. + int_currframes = currframes.astype(int) + subframes = currframes - int_currframes + + # Create simpler iterables that return only the values we care about. + animdata_shapes_only = [shape for _anim_shape, _me, shape in animdata_shapes.values()] + animdata_cameras_only = [camera for _anim_camera_lens, _anim_camera_focus_distance, camera + in animdata_cameras.values()] + # Previous frame's rotation for each object in animdata_ob, this will be updated each frame. + animdata_ob_p_rots = p_rots.values() + + # Iterate through each frame and yield the values for that frame. + # Iterating .data, the memoryview of an array, is faster than iterating the array directly. + for int_currframe, subframe in zip(int_currframes.data, subframes.data): + scene.frame_set(int_currframe, subframe=subframe) + + if has_animated_duplis: + # Changing the scene's frame invalidates existing dupli instances. To get the updated matrices of duplis + # for this frame, we must get the duplis from the depsgraph again. + for dup in depsgraph.object_instances: + if (parent := dup.parent) and parent.original in dupli_parent_bdata: + # ObjectWrapper caches its instances. Attempting to create a new instance updates the existing + # ObjectWrapper instance with the current frame's matrix and then returns the existing instance. + ObjectWrapper(dup) + next_p_rots = [] + for ob_obj, p_rot in zip(animdata_ob, animdata_ob_p_rots): + # We compute baked loc/rot/scale for all objects (rot being euler-compat with previous value!). + loc, rot, scale, _m, _mr = ob_obj.fbx_object_tx(scene_data, rot_euler_compat=p_rot) + next_p_rots.append(rot) + yield from loc + yield from rot + yield from scale + animdata_ob_p_rots = next_p_rots + for shape in animdata_shapes_only: + yield shape.value + for camera in animdata_cameras_only: + yield camera.lens + yield camera.dof.focus_distance + + # Providing `count` to np.fromiter pre-allocates the array, avoiding extra memory allocations while iterating. + num_ob_values = len(animdata_ob) * 9 # Location, rotation and scale, each of which have x, y, and z components + num_shape_values = len(animdata_shapes) # Only 1 value per shape key + num_camera_values = len(animdata_cameras) * 2 # Focal length (`.lens`) and focus distance + num_values_per_frame = num_ob_values + num_shape_values + num_camera_values + num_frames = len(real_currframes) + all_values_flat = np.fromiter(frame_values_gen(), dtype=float, count=num_frames * num_values_per_frame) + + # Restore the scene's current frame. + scene.frame_set(back_currframe, subframe=0.0) + + # View such that each column is all values for a single frame and each row is all values for a single curve. + all_values = all_values_flat.reshape(num_frames, num_values_per_frame).T + # Split into views of the arrays for each curve type. + split_at = [num_ob_values, num_shape_values, num_camera_values] + # For unequal sized splits, np.split takes indices to split at, which can be acquired through a cumulative sum + # across the list. + # The last value isn't needed, because the last split is assumed to go to the end of the array. + split_at = np.cumsum(split_at[:-1]) + all_ob_values, all_shape_key_values, all_camera_values = np.split(all_values, split_at) + + all_anims = [] + + # Set location/rotation/scale curves. + # Split into equal sized views of the arrays for each object. + split_into = len(animdata_ob) + per_ob_values = np.split(all_ob_values, split_into) if split_into > 0 else () + for anims, ob_values in zip(animdata_ob.values(), per_ob_values): + # Split again into equal sized views of the location, rotation and scaling arrays. + loc_xyz, rot_xyz, sca_xyz = np.split(ob_values, 3) + # In-place convert from Blender rotation to FBX rotation. + np.rad2deg(rot_xyz, out=rot_xyz) + + anim_loc, anim_rot, anim_scale = anims + anim_loc.set_keyframes(real_currframes, loc_xyz) + anim_rot.set_keyframes(real_currframes, rot_xyz) + anim_scale.set_keyframes(real_currframes, sca_xyz) + all_anims.extend(anims) + + # Set shape key curves. + # There's only one array per shape key, so there's no need to split `all_shape_key_values`. + for (anim_shape, _me, _shape), shape_key_values in zip(animdata_shapes.values(), all_shape_key_values): + # In-place convert from Blender Shape Key Value to FBX Deform Percent. + shape_key_values *= 100.0 + anim_shape.set_keyframes(real_currframes, shape_key_values) + all_anims.append(anim_shape) + + # Set camera curves. + # Split into equal sized views of the arrays for each camera. + split_into = len(animdata_cameras) + per_camera_values = np.split(all_camera_values, split_into) if split_into > 0 else () + zipped = zip(animdata_cameras.values(), per_camera_values) + for (anim_camera_lens, anim_camera_focus_distance, _camera), (lens_values, focus_distance_values) in zipped: + # In-place convert from Blender focus distance to FBX. + focus_distance_values *= (1000 * gscale) + anim_camera_lens.set_keyframes(real_currframes, lens_values) + anim_camera_focus_distance.set_keyframes(real_currframes, focus_distance_values) + all_anims.append(anim_camera_lens) + all_anims.append(anim_camera_focus_distance) + + animations = {} + + # And now, produce final data (usable by FBX export code) + for anim in all_anims: + anim.simplify(simplify_fac, bake_step, force_keep) + if not anim: + continue + for obj_key, group_key, group, fbx_group, fbx_gname in anim.get_final_data(scene, ref_id, force_keep): + anim_data = animations.setdefault(obj_key, ("dummy_unused_key", {})) + anim_data[1][fbx_group] = (group_key, group, fbx_gname) + + astack_key = get_blender_anim_stack_key(scene, ref_id) + alayer_key = get_blender_anim_layer_key(scene, ref_id) + name = (get_blenderID_name(ref_id) if ref_id else scene.name).encode() + + if start_zero: + f_end -= f_start + f_start = 0.0 + + return (astack_key, animations, alayer_key, name, f_start, f_end) if animations else None + + +def fbx_animations(scene_data): + """ + Generate global animation data from objects. + """ + scene = scene_data.scene + animations = [] + animated = set() + frame_start = 1e100 + frame_end = -1e100 + + def add_anim(animations, animated, anim): + nonlocal frame_start, frame_end + if anim is not None: + animations.append(anim) + f_start, f_end = anim[4:6] + if f_start < frame_start: + frame_start = f_start + if f_end > frame_end: + frame_end = f_end + + _astack_key, astack, _alayer_key, _name, _fstart, _fend = anim + for elem_key, (alayer_key, acurvenodes) in astack.items(): + for fbx_prop, (acurvenode_key, acurves, acurvenode_name) in acurvenodes.items(): + animated.add((elem_key, fbx_prop)) + + # Per-NLA strip animstacks. + if scene_data.settings.bake_anim_use_nla_strips: + strips = [] + ob_actions = [] + for ob_obj in scene_data.objects: + # NLA tracks only for objects, not bones! + if not ob_obj.is_object: + continue + ob = ob_obj.bdata # Back to real Blender Object. + if not ob.animation_data: + continue + + # Some actions are read-only, one cause is being in NLA tweakmode + restore_use_tweak_mode = ob.animation_data.use_tweak_mode + if ob.animation_data.is_property_readonly('action'): + ob.animation_data.use_tweak_mode = False + + # We have to remove active action from objects, it overwrites strips actions otherwise... + ob_actions.append((ob, ob.animation_data.action, restore_use_tweak_mode)) + ob.animation_data.action = None + for track in ob.animation_data.nla_tracks: + if track.mute: + continue + for strip in track.strips: + if strip.mute: + continue + strips.append(strip) + strip.mute = True + + for strip in strips: + strip.mute = False + add_anim(animations, animated, + fbx_animations_do(scene_data, strip, strip.frame_start, strip.frame_end, True, force_keep=True)) + strip.mute = True + scene.frame_set(scene.frame_current, subframe=0.0) + + for strip in strips: + strip.mute = False + + for ob, ob_act, restore_use_tweak_mode in ob_actions: + ob.animation_data.action = ob_act + ob.animation_data.use_tweak_mode = restore_use_tweak_mode + + # All actions. + if scene_data.settings.bake_anim_use_all_actions: + def validate_actions(act, path_resolve): + for fc in act.fcurves: + data_path = fc.data_path + if fc.array_index: + data_path = data_path + "[%d]" % fc.array_index + try: + path_resolve(data_path) + except ValueError: + return False # Invalid. + return True # Valid. + + def restore_object(ob_to, ob_from): + # Restore org state of object (ugh :/ ). + props = ( + 'location', 'rotation_quaternion', 'rotation_axis_angle', 'rotation_euler', 'rotation_mode', 'scale', + 'delta_location', 'delta_rotation_euler', 'delta_rotation_quaternion', 'delta_scale', + 'lock_location', 'lock_rotation', 'lock_rotation_w', 'lock_rotations_4d', 'lock_scale', + 'tag', 'track_axis', 'up_axis', 'active_material', 'active_material_index', + 'matrix_parent_inverse', 'empty_display_type', 'empty_display_size', 'empty_image_offset', 'pass_index', + 'color', 'hide_viewport', 'hide_select', 'hide_render', 'instance_type', + 'use_instance_vertices_rotation', 'use_instance_faces_scale', 'instance_faces_scale', + 'display_type', 'show_bounds', 'display_bounds_type', 'show_name', 'show_axis', 'show_texture_space', + 'show_wire', 'show_all_edges', 'show_transparent', 'show_in_front', + 'show_only_shape_key', 'use_shape_key_edit_mode', 'active_shape_key_index', + ) + for p in props: + if not ob_to.is_property_readonly(p): + setattr(ob_to, p, getattr(ob_from, p)) + + for ob_obj in scene_data.objects: + # Actions only for objects, not bones! + if not ob_obj.is_object: + continue + + ob = ob_obj.bdata # Back to real Blender Object. + + if not ob.animation_data: + continue # Do not export animations for objects that are absolutely not animated, see T44386. + + if ob.animation_data.is_property_readonly('action'): + continue # Cannot re-assign 'active action' to this object (usually related to NLA usage, see T48089). + + # We can't play with animdata and actions and get back to org state easily. + # So we have to add a temp copy of the object to the scene, animate it, and remove it... :/ + ob_copy = ob.copy() + # Great, have to handle bones as well if needed... + pbones_matrices = [pbo.matrix_basis.copy() for pbo in ob.pose.bones] if ob.type == 'ARMATURE' else ... + + org_act = ob.animation_data.action + path_resolve = ob.path_resolve + + for act in bpy.data.actions: + # For now, *all* paths in the action must be valid for the object, to validate the action. + # Unless that action was already assigned to the object! + if act != org_act and not validate_actions(act, path_resolve): + continue + ob.animation_data.action = act + frame_start, frame_end = act.frame_range # sic! + add_anim(animations, animated, + fbx_animations_do(scene_data, (ob, act), frame_start, frame_end, True, + objects={ob_obj}, force_keep=True)) + # Ugly! :/ + if pbones_matrices is not ...: + for pbo, mat in zip(ob.pose.bones, pbones_matrices): + pbo.matrix_basis = mat.copy() + ob.animation_data.action = org_act + restore_object(ob, ob_copy) + scene.frame_set(scene.frame_current, subframe=0.0) + + if pbones_matrices is not ...: + for pbo, mat in zip(ob.pose.bones, pbones_matrices): + pbo.matrix_basis = mat.copy() + ob.animation_data.action = org_act + + bpy.data.objects.remove(ob_copy) + scene.frame_set(scene.frame_current, subframe=0.0) + + # Global (containing everything) animstack, only if not exporting NLA strips and/or all actions. + if not scene_data.settings.bake_anim_use_nla_strips and not scene_data.settings.bake_anim_use_all_actions: + add_anim(animations, animated, fbx_animations_do(scene_data, None, scene.frame_start, scene.frame_end, False)) + + # Be sure to update all matrices back to org state! + scene.frame_set(scene.frame_current, subframe=0.0) + + return animations, animated, frame_start, frame_end + + +def fbx_data_from_scene(scene, depsgraph, settings): + """ + Do some pre-processing over scene's data... + """ + objtypes = settings.object_types + dp_objtypes = objtypes - {'ARMATURE'} # Armatures are not supported as dupli instances currently... + perfmon = PerfMon() + perfmon.level_up() + + # ##### Gathering data... + + perfmon.step("FBX export prepare: Wrapping Objects...") + + # This is rather simple for now, maybe we could end generating templates with most-used values + # instead of default ones? + objects = {} # Because we do not have any ordered set... + for ob in settings.context_objects: + if ob.type not in objtypes: + continue + ob_obj = ObjectWrapper(ob) + objects[ob_obj] = None + # Duplis... + for dp_obj in ob_obj.dupli_list_gen(depsgraph): + if dp_obj.type not in dp_objtypes: + continue + objects[dp_obj] = None + + perfmon.step("FBX export prepare: Wrapping Data (lamps, cameras, empties)...") + + data_lights = {ob_obj.bdata.data: get_blenderID_key(ob_obj.bdata.data) + for ob_obj in objects if ob_obj.type == 'LIGHT'} + # Unfortunately, FBX camera data contains object-level data (like position, orientation, etc.)... + data_cameras = {ob_obj: get_blenderID_key(ob_obj.bdata.data) + for ob_obj in objects if ob_obj.type == 'CAMERA'} + # Yep! Contains nothing, but needed! + data_empties = {ob_obj: get_blender_empty_key(ob_obj.bdata) + for ob_obj in objects if ob_obj.type == 'EMPTY'} + + perfmon.step("FBX export prepare: Wrapping Meshes...") + + data_meshes = {} + for ob_obj in objects: + if ob_obj.type not in BLENDER_OBJECT_TYPES_MESHLIKE: + continue + ob = ob_obj.bdata + org_ob_obj = None + + # Do not want to systematically recreate a new mesh for dupliobject instances, kind of break purpose of those. + if ob_obj.is_dupli: + org_ob_obj = ObjectWrapper(ob) # We get the "real" object wrapper from that dupli instance. + if org_ob_obj in data_meshes: + data_meshes[ob_obj] = data_meshes[org_ob_obj] + continue + + # There are 4 different cases for what we need to do with the original data of each Object: + # 1) The original data can be used without changes. + # 2) A copy of the original data needs to be made. + # - If an export option modifies the data, e.g. Triangulate Faces is enabled. + # - If the Object has Object-linked materials. This is because our current mapping of materials to FBX requires + # that multiple Objects sharing a single mesh must have the same materials. + # 3) The Object needs to be converted to a mesh. + # - All mesh-like Objects that are not meshes need to be converted to a mesh in order to be exported. + # 4) The Object needs to be evaluated and then converted to a mesh. + # - Whenever use_mesh_modifiers is enabled and either there are modifiers to apply or the Object needs to be + # converted to a mesh. + # If multiple cases apply to an Object, then only the last applicable case is relevant. + do_copy = any(ms.link == 'OBJECT' for ms in ob.material_slots) or settings.use_triangles + do_convert = ob.type in BLENDER_OTHER_OBJECT_TYPES + do_evaluate = do_convert and settings.use_mesh_modifiers + + # If the Object is a mesh, and we're applying modifiers, check if there are actually any modifiers to apply. + # If there are then the mesh will need to be evaluated, and we may need to make some temporary changes to the + # modifiers or scene before the mesh is evaluated. + backup_pose_positions = [] + tmp_mods = [] + if ob.type == 'MESH' and settings.use_mesh_modifiers: + # No need to create a new mesh in this case, if no modifier is active! + last_subsurf = None + for mod in ob.modifiers: + # For meshes, when armature export is enabled, disable Armature modifiers here! + # XXX Temp hacks here since currently we only have access to a viewport depsgraph... + # + # NOTE: We put armature to the rest pose instead of disabling it so we still + # have vertex groups in the evaluated mesh. + if mod.type == 'ARMATURE' and 'ARMATURE' in settings.object_types: + object = mod.object + if object and object.type == 'ARMATURE': + armature = object.data + # If armature is already in REST position, there's nothing to back-up + # This cuts down on export time dramatically, if all armatures are already in REST position + # by not triggering dependency graph update + if armature.pose_position != 'REST': + backup_pose_positions.append((armature, armature.pose_position)) + armature.pose_position = 'REST' + elif mod.show_render or mod.show_viewport: + # If exporting with subsurf collect the last Catmull-Clark subsurf modifier + # and disable it. We can use the original data as long as this is the first + # found applicable subsurf modifier. + if settings.use_subsurf and mod.type == 'SUBSURF' and mod.subdivision_type == 'CATMULL_CLARK': + if last_subsurf: + do_evaluate = True + last_subsurf = mod + else: + do_evaluate = True + if settings.use_subsurf and last_subsurf: + # XXX: When exporting with subsurf information temporarily disable + # the last subsurf modifier. + tmp_mods.append((last_subsurf, last_subsurf.show_render, last_subsurf.show_viewport)) + last_subsurf.show_render = False + last_subsurf.show_viewport = False + + if do_evaluate: + # If modifiers has been altered need to update dependency graph. + if backup_pose_positions or tmp_mods: + depsgraph.update() + ob_to_convert = ob.evaluated_get(depsgraph) + # NOTE: The dependency graph might be re-evaluating multiple times, which could + # potentially free the mesh created early on. So we put those meshes to bmain and + # free them afterwards. Not ideal but ensures correct ownership. + tmp_me = bpy.data.meshes.new_from_object( + ob_to_convert, preserve_all_data_layers=True, depsgraph=depsgraph) + + # Usually the materials of the evaluated object will be the same, but modifiers, such as Geometry Nodes, + # can change the materials. + orig_mats = tuple(slot.material for slot in ob.material_slots) + eval_mats = tuple(slot.material.original if slot.material else None + for slot in ob_to_convert.material_slots) + if orig_mats != eval_mats: + # Override the default behaviour of getting materials from ob_obj.bdata.material_slots. + ob_obj.override_materials = eval_mats + elif do_convert: + tmp_me = bpy.data.meshes.new_from_object(ob, preserve_all_data_layers=True, depsgraph=depsgraph) + elif do_copy: + # bpy.data.meshes.new_from_object removes shape keys (see #104714), so create a copy of the mesh instead. + tmp_me = ob.data.copy() + else: + tmp_me = None + + if tmp_me is None: + # Use the original data of this Object. + data_meshes[ob_obj] = (get_blenderID_key(ob.data), ob.data, False) + else: + # Triangulate the mesh if requested + if settings.use_triangles: + import bmesh + bm = bmesh.new() + bm.from_mesh(tmp_me) + bmesh.ops.triangulate(bm, faces=bm.faces) + bm.to_mesh(tmp_me) + bm.free() + # A temporary mesh was created for this Object, which should be deleted once the export is complete. + data_meshes[ob_obj] = (get_blenderID_key(tmp_me), tmp_me, True) + + # Change armatures back. + for armature, pose_position in backup_pose_positions: + print((armature, pose_position)) + armature.pose_position = pose_position + # Update now, so we don't leave modified state after last object was exported. + # Re-enable temporary disabled modifiers. + for mod, show_render, show_viewport in tmp_mods: + mod.show_render = show_render + mod.show_viewport = show_viewport + if backup_pose_positions or tmp_mods: + depsgraph.update() + + # In case "real" source object of that dupli did not yet still existed in data_meshes, create it now! + if org_ob_obj is not None: + data_meshes[org_ob_obj] = data_meshes[ob_obj] + + perfmon.step("FBX export prepare: Wrapping ShapeKeys...") + + # ShapeKeys. + data_deformers_shape = {} + geom_mat_co = settings.global_matrix if settings.bake_space_transform else None + co_bl_dtype = np.single + co_fbx_dtype = np.float64 + idx_fbx_dtype = np.int32 + + def empty_verts_fallbacks(): + """Create fallback arrays for when there are no verts""" + # FBX does not like empty shapes (makes Unity crash e.g.). + # To prevent this, we add a vertex that does nothing, but it keeps the shape key intact + single_vert_co = np.zeros((1, 3), dtype=co_fbx_dtype) + single_vert_idx = np.zeros(1, dtype=idx_fbx_dtype) + return single_vert_co, single_vert_idx + + for me_key, me, _free in data_meshes.values(): + if not (me.shape_keys and len(me.shape_keys.key_blocks) > 1): # We do not want basis-only relative skeys... + continue + if me in data_deformers_shape: + continue + + shapes_key = get_blender_mesh_shape_key(me) + + sk_base = me.shape_keys.key_blocks[0] + + # Get and cache only the cos that we need + @cache + def sk_cos(shape_key): + if shape_key == sk_base: + _cos = MESH_ATTRIBUTE_POSITION.to_ndarray(me.attributes) + else: + _cos = np.empty(len(me.vertices) * 3, dtype=co_bl_dtype) + shape_key.points.foreach_get("co", _cos) + return vcos_transformed(_cos, geom_mat_co, co_fbx_dtype) + + for shape in me.shape_keys.key_blocks[1:]: + # Only write vertices really different from base coordinates! + relative_key = shape.relative_key + if shape == relative_key: + # Shape is its own relative key, so it does nothing + shape_verts_co, shape_verts_idx = empty_verts_fallbacks() + else: + sv_cos = sk_cos(shape) + ref_cos = sk_cos(shape.relative_key) + + # Exclude cos similar to ref_cos and get the indices of the cos that remain + shape_verts_co, shape_verts_idx = shape_difference_exclude_similar(sv_cos, ref_cos) + + if not shape_verts_co.size: + shape_verts_co, shape_verts_idx = empty_verts_fallbacks() + else: + # Ensure the indices are of the correct type + shape_verts_idx = astype_view_signedness(shape_verts_idx, idx_fbx_dtype) + + channel_key, geom_key = get_blender_mesh_shape_channel_key(me, shape) + data = (channel_key, geom_key, shape_verts_co, shape_verts_idx) + data_deformers_shape.setdefault(me, (me_key, shapes_key, {}))[2][shape] = data + + del sk_cos + + perfmon.step("FBX export prepare: Wrapping Armatures...") + + # Armatures! + data_deformers_skin = {} + data_bones = {} + arm_parents = set() + for ob_obj in tuple(objects): + if not (ob_obj.is_object and ob_obj.type in {'ARMATURE'}): + continue + fbx_skeleton_from_armature(scene, settings, ob_obj, objects, data_meshes, + data_bones, data_deformers_skin, data_empties, arm_parents) + + # Generate leaf bones + data_leaf_bones = [] + if settings.add_leaf_bones: + data_leaf_bones = fbx_generate_leaf_bones(settings, data_bones) + + perfmon.step("FBX export prepare: Wrapping World...") + + # Some world settings are embedded in FBX materials... + if scene.world: + data_world = {scene.world: get_blenderID_key(scene.world)} + else: + data_world = {} + + perfmon.step("FBX export prepare: Wrapping Materials...") + + # TODO: Check all the material stuff works even when they are linked to Objects + # (we can then have the same mesh used with different materials...). + # *Should* work, as FBX always links its materials to Models (i.e. objects). + # XXX However, material indices would probably break... + data_materials = {} + for ob_obj in objects: + # If obj is not a valid object for materials, wrapper will just return an empty tuple... + for ma in ob_obj.materials: + if ma is None: + continue # Empty slots! + # Note theoretically, FBX supports any kind of materials, even GLSL shaders etc. + # However, I doubt anything else than Lambert/Phong is really portable! + # Note we want to keep a 'dummy' empty material even when we can't really support it, see T41396. + ma_data = data_materials.setdefault(ma, (get_blenderID_key(ma), [])) + ma_data[1].append(ob_obj) + + perfmon.step("FBX export prepare: Wrapping Textures...") + + # Note FBX textures also hold their mapping info. + # TODO: Support layers? + data_textures = {} + # FbxVideo also used to store static images... + data_videos = {} + # For now, do not use world textures, don't think they can be linked to anything FBX wise... + for ma in data_materials.keys(): + # Note: with nodal shaders, we'll could be generating much more textures, but that's kind of unavoidable, + # given that textures actually do not exist anymore in material context in Blender... + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=True) + for sock_name, fbx_name in PRINCIPLED_TEXTURE_SOCKETS_TO_FBX: + tex = getattr(ma_wrap, sock_name) + if tex is None or tex.image is None: + continue + blender_tex_key = (ma, sock_name) + data_textures[blender_tex_key] = (get_blender_nodetexture_key(*blender_tex_key), fbx_name) + + img = tex.image + vid_data = data_videos.setdefault(img, (get_blenderID_key(img), [])) + vid_data[1].append(blender_tex_key) + + perfmon.step("FBX export prepare: Wrapping Animations...") + + # Animation... + animations = () + animated = set() + frame_start = scene.frame_start + frame_end = scene.frame_end + if settings.bake_anim: + # From objects & bones only for a start. + # Kind of hack, we need a temp scene_data for object's space handling to bake animations... + tmp_scdata = FBXExportData( + None, None, None, + settings, scene, depsgraph, objects, None, None, 0.0, 0.0, + data_empties, data_lights, data_cameras, data_meshes, None, + data_bones, data_leaf_bones, data_deformers_skin, data_deformers_shape, + data_world, data_materials, data_textures, data_videos, + ) + animations, animated, frame_start, frame_end = fbx_animations(tmp_scdata) + + # ##### Creation of templates... + + perfmon.step("FBX export prepare: Generating templates...") + + templates = {} + templates[b"GlobalSettings"] = fbx_template_def_globalsettings(scene, settings, nbr_users=1) + + if data_empties: + templates[b"Null"] = fbx_template_def_null(scene, settings, nbr_users=len(data_empties)) + + if data_lights: + templates[b"Light"] = fbx_template_def_light(scene, settings, nbr_users=len(data_lights)) + + if data_cameras: + templates[b"Camera"] = fbx_template_def_camera(scene, settings, nbr_users=len(data_cameras)) + + if data_bones: + templates[b"Bone"] = fbx_template_def_bone(scene, settings, nbr_users=len(data_bones)) + + if data_meshes: + nbr = len({me_key for me_key, _me, _free in data_meshes.values()}) + if data_deformers_shape: + nbr += sum(len(shapes[2]) for shapes in data_deformers_shape.values()) + templates[b"Geometry"] = fbx_template_def_geometry(scene, settings, nbr_users=nbr) + + if objects: + templates[b"Model"] = fbx_template_def_model(scene, settings, nbr_users=len(objects)) + + if arm_parents: + # Number of Pose|BindPose elements should be the same as number of meshes-parented-to-armatures + templates[b"BindPose"] = fbx_template_def_pose(scene, settings, nbr_users=len(arm_parents)) + + if data_deformers_skin or data_deformers_shape: + nbr = 0 + if data_deformers_skin: + nbr += len(data_deformers_skin) + nbr += sum(len(clusters) for def_me in data_deformers_skin.values() for a, b, clusters in def_me.values()) + if data_deformers_shape: + nbr += len(data_deformers_shape) + nbr += sum(len(shapes[2]) for shapes in data_deformers_shape.values()) + assert(nbr != 0) + templates[b"Deformers"] = fbx_template_def_deformer(scene, settings, nbr_users=nbr) + + # No world support in FBX... + """ + if data_world: + templates[b"World"] = fbx_template_def_world(scene, settings, nbr_users=len(data_world)) + """ + + if data_materials: + templates[b"Material"] = fbx_template_def_material(scene, settings, nbr_users=len(data_materials)) + + if data_textures: + templates[b"TextureFile"] = fbx_template_def_texture_file(scene, settings, nbr_users=len(data_textures)) + + if data_videos: + templates[b"Video"] = fbx_template_def_video(scene, settings, nbr_users=len(data_videos)) + + if animations: + nbr_astacks = len(animations) + nbr_acnodes = 0 + nbr_acurves = 0 + for _astack_key, astack, _al, _n, _fs, _fe in animations: + for _alayer_key, alayer in astack.values(): + for _acnode_key, acnode, _acnode_name in alayer.values(): + nbr_acnodes += 1 + for _acurve_key, _dval, (keys, _values), acurve_valid in acnode.values(): + if len(keys): + nbr_acurves += 1 + + templates[b"AnimationStack"] = fbx_template_def_animstack(scene, settings, nbr_users=nbr_astacks) + # Would be nice to have one layer per animated object, but this seems tricky and not that well supported. + # So for now, only one layer per anim stack. + templates[b"AnimationLayer"] = fbx_template_def_animlayer(scene, settings, nbr_users=nbr_astacks) + templates[b"AnimationCurveNode"] = fbx_template_def_animcurvenode(scene, settings, nbr_users=nbr_acnodes) + templates[b"AnimationCurve"] = fbx_template_def_animcurve(scene, settings, nbr_users=nbr_acurves) + + templates_users = sum(tmpl.nbr_users for tmpl in templates.values()) + + # ##### Creation of connections... + + perfmon.step("FBX export prepare: Generating Connections...") + + connections = [] + + # Objects (with classical parenting). + for ob_obj in objects: + # Bones are handled later. + if not ob_obj.is_bone: + par_obj = ob_obj.parent + # Meshes parented to armature are handled separately, yet we want the 'no parent' connection (0). + if par_obj and ob_obj.has_valid_parent(objects) and (par_obj, ob_obj) not in arm_parents: + connections.append((b"OO", ob_obj.fbx_uuid, par_obj.fbx_uuid, None)) + else: + connections.append((b"OO", ob_obj.fbx_uuid, 0, None)) + + # Armature & Bone chains. + for bo_obj in data_bones.keys(): + par_obj = bo_obj.parent + if par_obj not in objects: + continue + connections.append((b"OO", bo_obj.fbx_uuid, par_obj.fbx_uuid, None)) + + # Object data. + for ob_obj in objects: + if ob_obj.is_bone: + bo_data_key = data_bones[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(bo_data_key), ob_obj.fbx_uuid, None)) + else: + if ob_obj.type == 'LIGHT': + light_key = data_lights[ob_obj.bdata.data] + connections.append((b"OO", get_fbx_uuid_from_key(light_key), ob_obj.fbx_uuid, None)) + elif ob_obj.type == 'CAMERA': + cam_key = data_cameras[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(cam_key), ob_obj.fbx_uuid, None)) + elif ob_obj.type == 'EMPTY' or ob_obj.type == 'ARMATURE': + empty_key = data_empties[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(empty_key), ob_obj.fbx_uuid, None)) + elif ob_obj.type in BLENDER_OBJECT_TYPES_MESHLIKE: + mesh_key, _me, _free = data_meshes[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(mesh_key), ob_obj.fbx_uuid, None)) + + # Leaf Bones + for (_node_name, par_uuid, node_uuid, attr_uuid, _matrix, _hide, _size) in data_leaf_bones: + connections.append((b"OO", node_uuid, par_uuid, None)) + connections.append((b"OO", attr_uuid, node_uuid, None)) + + # 'Shape' deformers (shape keys, only for meshes currently)... + for me_key, shapes_key, shapes in data_deformers_shape.values(): + # shape -> geometry + connections.append((b"OO", get_fbx_uuid_from_key(shapes_key), get_fbx_uuid_from_key(me_key), None)) + for channel_key, geom_key, _shape_verts_co, _shape_verts_idx in shapes.values(): + # shape channel -> shape + connections.append((b"OO", get_fbx_uuid_from_key(channel_key), get_fbx_uuid_from_key(shapes_key), None)) + # geometry (keys) -> shape channel + connections.append((b"OO", get_fbx_uuid_from_key(geom_key), get_fbx_uuid_from_key(channel_key), None)) + + # 'Skin' deformers (armature-to-geometry, only for meshes currently)... + for arm, deformed_meshes in data_deformers_skin.items(): + for me, (skin_key, ob_obj, clusters) in deformed_meshes.items(): + # skin -> geometry + mesh_key, _me, _free = data_meshes[ob_obj] + assert(me == _me) + connections.append((b"OO", get_fbx_uuid_from_key(skin_key), get_fbx_uuid_from_key(mesh_key), None)) + for bo_obj, clstr_key in clusters.items(): + # cluster -> skin + connections.append((b"OO", get_fbx_uuid_from_key(clstr_key), get_fbx_uuid_from_key(skin_key), None)) + # bone -> cluster + connections.append((b"OO", bo_obj.fbx_uuid, get_fbx_uuid_from_key(clstr_key), None)) + + # Materials + mesh_material_indices = {} + _objs_indices = {} + for ma, (ma_key, ob_objs) in data_materials.items(): + for ob_obj in ob_objs: + connections.append((b"OO", get_fbx_uuid_from_key(ma_key), ob_obj.fbx_uuid, None)) + # Get index of this material for this object (or dupliobject). + # Material indices for mesh faces are determined by their order in 'ma to ob' connections. + # Only materials for meshes currently... + # Note in case of dupliobjects a same me/ma idx will be generated several times... + # Should not be an issue in practice, and it's needed in case we export duplis but not the original! + if ob_obj.type not in BLENDER_OBJECT_TYPES_MESHLIKE: + continue + _mesh_key, me, _free = data_meshes[ob_obj] + idx = _objs_indices[ob_obj] = _objs_indices.get(ob_obj, -1) + 1 + # XXX If a mesh has multiple material slots with the same material, they are combined into one slot. + # Even if duplicate materials were exported without combining them into one slot, keeping duplicate + # materials separated does not appear to be common behaviour of external software when importing FBX. + mesh_material_indices.setdefault(me, {})[ma] = idx + del _objs_indices + + # Textures + for (ma, sock_name), (tex_key, fbx_prop) in data_textures.items(): + ma_key, _ob_objs = data_materials[ma] + # texture -> material properties + connections.append((b"OP", get_fbx_uuid_from_key(tex_key), get_fbx_uuid_from_key(ma_key), fbx_prop)) + + # Images + for vid, (vid_key, blender_tex_keys) in data_videos.items(): + for blender_tex_key in blender_tex_keys: + tex_key, _fbx_prop = data_textures[blender_tex_key] + connections.append((b"OO", get_fbx_uuid_from_key(vid_key), get_fbx_uuid_from_key(tex_key), None)) + + # Animations + for astack_key, astack, alayer_key, _name, _fstart, _fend in animations: + # Animstack itself is linked nowhere! + astack_id = get_fbx_uuid_from_key(astack_key) + # For now, only one layer! + alayer_id = get_fbx_uuid_from_key(alayer_key) + connections.append((b"OO", alayer_id, astack_id, None)) + for elem_key, (alayer_key, acurvenodes) in astack.items(): + elem_id = get_fbx_uuid_from_key(elem_key) + # Animlayer -> animstack. + # alayer_id = get_fbx_uuid_from_key(alayer_key) + # connections.append((b"OO", alayer_id, astack_id, None)) + for fbx_prop, (acurvenode_key, acurves, acurvenode_name) in acurvenodes.items(): + # Animcurvenode -> animalayer. + acurvenode_id = get_fbx_uuid_from_key(acurvenode_key) + connections.append((b"OO", acurvenode_id, alayer_id, None)) + # Animcurvenode -> object property. + connections.append((b"OP", acurvenode_id, elem_id, fbx_prop.encode())) + for fbx_item, (acurve_key, default_value, (keys, values), acurve_valid) in acurves.items(): + if len(keys): + # Animcurve -> Animcurvenode. + connections.append((b"OP", get_fbx_uuid_from_key(acurve_key), acurvenode_id, fbx_item.encode())) + + perfmon.level_down() + + # ##### And pack all this! + + return FBXExportData( + templates, templates_users, connections, + settings, scene, depsgraph, objects, animations, animated, frame_start, frame_end, + data_empties, data_lights, data_cameras, data_meshes, mesh_material_indices, + data_bones, data_leaf_bones, data_deformers_skin, data_deformers_shape, + data_world, data_materials, data_textures, data_videos, + ) + + +def fbx_scene_data_cleanup(scene_data): + """ + Some final cleanup... + """ + # Delete temp meshes. + done_meshes = set() + for me_key, me, free in scene_data.data_meshes.values(): + if free and me_key not in done_meshes: + bpy.data.meshes.remove(me) + done_meshes.add(me_key) + + +# ##### Top-level FBX elements generators. ##### + +def fbx_header_elements(root, scene_data, time=None): + """ + Write boiling code of FBX root. + time is expected to be a datetime.datetime object, or None (using now() in this case). + """ + app_vendor = "Blender Foundation" + app_name = "Blender (stable FBX IO)" + app_ver = bpy.app.version_string + + from . import bl_info + addon_ver = bl_info["version"] + del bl_info + + # ##### Start of FBXHeaderExtension element. + header_ext = elem_empty(root, b"FBXHeaderExtension") + + elem_data_single_int32(header_ext, b"FBXHeaderVersion", FBX_HEADER_VERSION) + + elem_data_single_int32(header_ext, b"FBXVersion", FBX_VERSION) + + # No encryption! + elem_data_single_int32(header_ext, b"EncryptionType", 0) + + if time is None: + time = datetime.datetime.now() + elem = elem_empty(header_ext, b"CreationTimeStamp") + elem_data_single_int32(elem, b"Version", 1000) + elem_data_single_int32(elem, b"Year", time.year) + elem_data_single_int32(elem, b"Month", time.month) + elem_data_single_int32(elem, b"Day", time.day) + elem_data_single_int32(elem, b"Hour", time.hour) + elem_data_single_int32(elem, b"Minute", time.minute) + elem_data_single_int32(elem, b"Second", time.second) + elem_data_single_int32(elem, b"Millisecond", time.microsecond // 1000) + + elem_data_single_string_unicode(header_ext, b"Creator", "%s - %s - %d.%d.%d" + % (app_name, app_ver, addon_ver[0], addon_ver[1], addon_ver[2])) + + # 'SceneInfo' seems mandatory to get a valid FBX file... + # TODO use real values! + # XXX Should we use scene.name.encode() here? + scene_info = elem_data_single_string(header_ext, b"SceneInfo", fbx_name_class(b"GlobalInfo", b"SceneInfo")) + scene_info.add_string(b"UserData") + elem_data_single_string(scene_info, b"Type", b"UserData") + elem_data_single_int32(scene_info, b"Version", FBX_SCENEINFO_VERSION) + meta_data = elem_empty(scene_info, b"MetaData") + elem_data_single_int32(meta_data, b"Version", FBX_SCENEINFO_VERSION) + elem_data_single_string(meta_data, b"Title", b"") + elem_data_single_string(meta_data, b"Subject", b"") + elem_data_single_string(meta_data, b"Author", b"") + elem_data_single_string(meta_data, b"Keywords", b"") + elem_data_single_string(meta_data, b"Revision", b"") + elem_data_single_string(meta_data, b"Comment", b"") + + props = elem_properties(scene_info) + elem_props_set(props, "p_string_url", b"DocumentUrl", "/foobar.fbx") + elem_props_set(props, "p_string_url", b"SrcDocumentUrl", "/foobar.fbx") + original = elem_props_compound(props, b"Original") + original("p_string", b"ApplicationVendor", app_vendor) + original("p_string", b"ApplicationName", app_name) + original("p_string", b"ApplicationVersion", app_ver) + original("p_datetime", b"DateTime_GMT", "01/01/1970 00:00:00.000") + original("p_string", b"FileName", "/foobar.fbx") + lastsaved = elem_props_compound(props, b"LastSaved") + lastsaved("p_string", b"ApplicationVendor", app_vendor) + lastsaved("p_string", b"ApplicationName", app_name) + lastsaved("p_string", b"ApplicationVersion", app_ver) + lastsaved("p_datetime", b"DateTime_GMT", "01/01/1970 00:00:00.000") + original("p_string", b"ApplicationNativeFile", bpy.data.filepath) + + # ##### End of FBXHeaderExtension element. + + # FileID is replaced by dummy value currently... + elem_data_single_bytes(root, b"FileId", b"FooBar") + + # CreationTime is replaced by dummy value currently, but anyway... + elem_data_single_string_unicode(root, b"CreationTime", + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}:{:03}" + "".format(time.year, time.month, time.day, time.hour, time.minute, time.second, + time.microsecond * 1000)) + + elem_data_single_string_unicode(root, b"Creator", "%s - %s - %d.%d.%d" + % (app_name, app_ver, addon_ver[0], addon_ver[1], addon_ver[2])) + + # ##### Start of GlobalSettings element. + global_settings = elem_empty(root, b"GlobalSettings") + scene = scene_data.scene + + elem_data_single_int32(global_settings, b"Version", 1000) + + props = elem_properties(global_settings) + up_axis, front_axis, coord_axis = RIGHT_HAND_AXES[scene_data.settings.to_axes] + # ~ # DO NOT take into account global scale here! That setting is applied to object transformations during export + # ~ # (in other words, this is pure blender-exporter feature, and has nothing to do with FBX data). + # ~ if scene_data.settings.apply_unit_scale: + # ~ # Unit scaling is applied to objects' scale, so our unit is effectively FBX one (centimeter). + # ~ scale_factor_org = 1.0 + # ~ scale_factor = 1.0 / units_blender_to_fbx_factor(scene) + # ~ else: + # ~ scale_factor_org = units_blender_to_fbx_factor(scene) + # ~ scale_factor = scale_factor_org + scale_factor = scale_factor_org = scene_data.settings.unit_scale + elem_props_set(props, "p_integer", b"UpAxis", up_axis[0]) + elem_props_set(props, "p_integer", b"UpAxisSign", up_axis[1]) + elem_props_set(props, "p_integer", b"FrontAxis", front_axis[0]) + elem_props_set(props, "p_integer", b"FrontAxisSign", front_axis[1]) + elem_props_set(props, "p_integer", b"CoordAxis", coord_axis[0]) + elem_props_set(props, "p_integer", b"CoordAxisSign", coord_axis[1]) + elem_props_set(props, "p_integer", b"OriginalUpAxis", -1) + elem_props_set(props, "p_integer", b"OriginalUpAxisSign", 1) + elem_props_set(props, "p_double", b"UnitScaleFactor", scale_factor) + elem_props_set(props, "p_double", b"OriginalUnitScaleFactor", scale_factor_org) + elem_props_set(props, "p_color_rgb", b"AmbientColor", (0.0, 0.0, 0.0)) + elem_props_set(props, "p_string", b"DefaultCamera", "Producer Perspective") + + # Global timing data. + r = scene.render + _, fbx_fps_mode = FBX_FRAMERATES[0] # Custom framerate. + fbx_fps = fps = r.fps / r.fps_base + for ref_fps, fps_mode in FBX_FRAMERATES: + if similar_values(fps, ref_fps): + fbx_fps = ref_fps + fbx_fps_mode = fps_mode + break + elem_props_set(props, "p_enum", b"TimeMode", fbx_fps_mode) + elem_props_set(props, "p_timestamp", b"TimeSpanStart", 0) + elem_props_set(props, "p_timestamp", b"TimeSpanStop", FBX_KTIME) + elem_props_set(props, "p_double", b"CustomFrameRate", fbx_fps) + + # ##### End of GlobalSettings element. + + +def fbx_documents_elements(root, scene_data): + """ + Write 'Document' part of FBX root. + Seems like FBX support multiple documents, but until I find examples of such, we'll stick to single doc! + time is expected to be a datetime.datetime object, or None (using now() in this case). + """ + name = scene_data.scene.name + + # ##### Start of Documents element. + docs = elem_empty(root, b"Documents") + + elem_data_single_int32(docs, b"Count", 1) + + doc_uid = get_fbx_uuid_from_key("__FBX_Document__" + name) + doc = elem_data_single_int64(docs, b"Document", doc_uid) + doc.add_string_unicode(name) + doc.add_string_unicode(name) + + props = elem_properties(doc) + elem_props_set(props, "p_object", b"SourceObject") + elem_props_set(props, "p_string", b"ActiveAnimStackName", "") + + # XXX Some kind of ID? Offset? + # Anyway, as long as we have only one doc, probably not an issue. + elem_data_single_int64(doc, b"RootNode", 0) + + +def fbx_references_elements(root, scene_data): + """ + Have no idea what references are in FBX currently... Just writing empty element. + """ + docs = elem_empty(root, b"References") + + +def fbx_definitions_elements(root, scene_data): + """ + Templates definitions. Only used by Objects data afaik (apart from dummy GlobalSettings one). + """ + definitions = elem_empty(root, b"Definitions") + + elem_data_single_int32(definitions, b"Version", FBX_TEMPLATES_VERSION) + elem_data_single_int32(definitions, b"Count", scene_data.templates_users) + + fbx_templates_generate(definitions, scene_data.templates) + + +def fbx_objects_elements(root, scene_data): + """ + Data (objects, geometry, material, textures, armatures, etc.). + """ + perfmon = PerfMon() + perfmon.level_up() + objects = elem_empty(root, b"Objects") + + perfmon.step("FBX export fetch empties (%d)..." % len(scene_data.data_empties)) + + for empty in scene_data.data_empties: + fbx_data_empty_elements(objects, empty, scene_data) + + perfmon.step("FBX export fetch lamps (%d)..." % len(scene_data.data_lights)) + + for lamp in scene_data.data_lights: + fbx_data_light_elements(objects, lamp, scene_data) + + perfmon.step("FBX export fetch cameras (%d)..." % len(scene_data.data_cameras)) + + for cam in scene_data.data_cameras: + fbx_data_camera_elements(objects, cam, scene_data) + + perfmon.step("FBX export fetch meshes (%d)..." + % len({me_key for me_key, _me, _free in scene_data.data_meshes.values()})) + + done_meshes = set() + for me_obj in scene_data.data_meshes: + fbx_data_mesh_elements(objects, me_obj, scene_data, done_meshes) + del done_meshes + + perfmon.step("FBX export fetch objects (%d)..." % len(scene_data.objects)) + + for ob_obj in scene_data.objects: + if ob_obj.is_dupli: + continue + fbx_data_object_elements(objects, ob_obj, scene_data) + for dp_obj in ob_obj.dupli_list_gen(scene_data.depsgraph): + if dp_obj not in scene_data.objects: + continue + fbx_data_object_elements(objects, dp_obj, scene_data) + + perfmon.step("FBX export fetch remaining...") + + for ob_obj in scene_data.objects: + if not (ob_obj.is_object and ob_obj.type == 'ARMATURE'): + continue + fbx_data_armature_elements(objects, ob_obj, scene_data) + + if scene_data.data_leaf_bones: + fbx_data_leaf_bone_elements(objects, scene_data) + + for ma in scene_data.data_materials: + fbx_data_material_elements(objects, ma, scene_data) + + for blender_tex_key in scene_data.data_textures: + fbx_data_texture_file_elements(objects, blender_tex_key, scene_data) + + for vid in scene_data.data_videos: + fbx_data_video_elements(objects, vid, scene_data) + + perfmon.step("FBX export fetch animations...") + start_time = time.process_time() + + fbx_data_animation_elements(objects, scene_data) + + perfmon.level_down() + + +def fbx_connections_elements(root, scene_data): + """ + Relations between Objects (which material uses which texture, and so on). + """ + connections = elem_empty(root, b"Connections") + + for c in scene_data.connections: + elem_connection(connections, *c) + + +def fbx_takes_elements(root, scene_data): + """ + Animations. + """ + # XXX Pretty sure takes are no more needed... + takes = elem_empty(root, b"Takes") + elem_data_single_string(takes, b"Current", b"") + + animations = scene_data.animations + for astack_key, animations, alayer_key, name, f_start, f_end in animations: + scene = scene_data.scene + fps = scene.render.fps / scene.render.fps_base + start_ktime = int(convert_sec_to_ktime(f_start / fps)) + end_ktime = int(convert_sec_to_ktime(f_end / fps)) + + take = elem_data_single_string(takes, b"Take", name) + elem_data_single_string(take, b"FileName", name + b".tak") + take_loc_time = elem_data_single_int64(take, b"LocalTime", start_ktime) + take_loc_time.add_int64(end_ktime) + take_ref_time = elem_data_single_int64(take, b"ReferenceTime", start_ktime) + take_ref_time.add_int64(end_ktime) + + +# ##### "Main" functions. ##### + +# This func can be called with just the filepath +def save_single(operator, scene, depsgraph, filepath="", + global_matrix=Matrix(), + apply_unit_scale=False, + global_scale=1.0, + apply_scale_options='FBX_SCALE_NONE', + axis_up="Z", + axis_forward="Y", + context_objects=None, + object_types=None, + use_mesh_modifiers=True, + use_mesh_modifiers_render=True, + mesh_smooth_type='FACE', + use_subsurf=False, + use_armature_deform_only=False, + bake_anim=True, + bake_anim_use_all_bones=True, + bake_anim_use_nla_strips=True, + bake_anim_use_all_actions=True, + bake_anim_step=1.0, + bake_anim_simplify_factor=1.0, + bake_anim_force_startend_keying=True, + add_leaf_bones=False, + primary_bone_axis='Y', + secondary_bone_axis='X', + use_metadata=True, + path_mode='AUTO', + use_mesh_edges=True, + use_tspace=True, + use_triangles=False, + embed_textures=False, + use_custom_props=False, + bake_space_transform=False, + armature_nodetype='NULL', + colors_type='SRGB', + prioritize_active_color=False, + stellar_blade_fix=False, + stellar_blade_skeleton="EVE", + **kwargs + ): + + # Clear cached ObjectWrappers (just in case...). + ObjectWrapper.cache_clear() + + if object_types is None: + object_types = {'EMPTY', 'CAMERA', 'LIGHT', 'ARMATURE', 'MESH', 'OTHER'} + + if 'OTHER' in object_types: + object_types |= BLENDER_OTHER_OBJECT_TYPES + + # Default Blender unit is equivalent to meter, while FBX one is centimeter... + unit_scale = units_blender_to_fbx_factor(scene) if apply_unit_scale else 100.0 + if apply_scale_options == 'FBX_SCALE_NONE': + global_matrix = Matrix.Scale(unit_scale * global_scale, 4) @ global_matrix + unit_scale = 1.0 + elif apply_scale_options == 'FBX_SCALE_UNITS': + global_matrix = Matrix.Scale(global_scale, 4) @ global_matrix + elif apply_scale_options == 'FBX_SCALE_CUSTOM': + global_matrix = Matrix.Scale(unit_scale, 4) @ global_matrix + unit_scale = global_scale + else: # if apply_scale_options == 'FBX_SCALE_ALL': + unit_scale = global_scale * unit_scale + + global_scale = global_matrix.median_scale + global_matrix_inv = global_matrix.inverted() + # For transforming mesh normals. + global_matrix_inv_transposed = global_matrix_inv.transposed() + + # Only embed textures in COPY mode! + if embed_textures and path_mode != 'COPY': + embed_textures = False + + # Calculate bone correction matrix + bone_correction_matrix = None # Default is None = no change + bone_correction_matrix_inv = None + if (primary_bone_axis, secondary_bone_axis) != ('Y', 'X'): + from bpy_extras.io_utils import axis_conversion + bone_correction_matrix = axis_conversion(from_forward=secondary_bone_axis, + from_up=primary_bone_axis, + to_forward='X', + to_up='Y', + ).to_4x4() + bone_correction_matrix_inv = bone_correction_matrix.inverted() + + media_settings = FBXExportSettingsMedia( + path_mode, + os.path.dirname(bpy.data.filepath), # base_src + os.path.dirname(filepath), # base_dst + # Local dir where to put images (media), using FBX conventions. + os.path.splitext(os.path.basename(filepath))[0] + ".fbm", # subdir + embed_textures, + set(), # copy_set + set(), # embedded_set + ) + + settings = FBXExportSettings( + operator.report, (axis_up, axis_forward), global_matrix, global_scale, apply_unit_scale, unit_scale, + bake_space_transform, global_matrix_inv, global_matrix_inv_transposed, + context_objects, object_types, use_mesh_modifiers, use_mesh_modifiers_render, + mesh_smooth_type, use_subsurf, use_mesh_edges, use_tspace, use_triangles, + armature_nodetype, use_armature_deform_only, + add_leaf_bones, bone_correction_matrix, bone_correction_matrix_inv, + bake_anim, bake_anim_use_all_bones, bake_anim_use_nla_strips, bake_anim_use_all_actions, + bake_anim_step, bake_anim_simplify_factor, bake_anim_force_startend_keying, + False, media_settings, use_custom_props, colors_type, prioritize_active_color,stellar_blade_fix,stellar_blade_skeleton + ) + + import bpy_extras.io_utils + + print('\nFBX export starting... %r' % filepath) + start_time = time.process_time() + + # Generate some data about exported scene... + scene_data = fbx_data_from_scene(scene, depsgraph, settings) + + # Enable multithreaded array compression in FBXElem and wait until all threads are done before exiting the context + # manager. + with encode_bin.FBXElem.enable_multithreading_cm(): + # Writing elements into an FBX hierarchy can now begin. + root = elem_empty(None, b"") # Root element has no id, as it is not saved per se! + + # Mostly FBXHeaderExtension and GlobalSettings. + fbx_header_elements(root, scene_data) + + # Documents and References are pretty much void currently. + fbx_documents_elements(root, scene_data) + fbx_references_elements(root, scene_data) + + # Templates definitions. + fbx_definitions_elements(root, scene_data) + + # Actual data. + fbx_objects_elements(root, scene_data) + + # How data are inter-connected. + fbx_connections_elements(root, scene_data) + + # Animation. + fbx_takes_elements(root, scene_data) + + # Cleanup! + fbx_scene_data_cleanup(scene_data) + + # And we are done, all multithreaded tasks are complete, and we can write the whole thing to file! + encode_bin.write(filepath, root, FBX_VERSION) + + # Clear cached ObjectWrappers! + ObjectWrapper.cache_clear() + + # copy all collected files, if we did not embed them. + if not media_settings.embed_textures: + bpy_extras.io_utils.path_reference_copy(media_settings.copy_set) + + print('export finished in %.4f sec.' % (time.process_time() - start_time)) + return {'FINISHED'} + + +# defaults for applications, currently only unity but could add others. +def defaults_unity3d(): + return { + # These options seem to produce the same result as the old Ascii exporter in Unity3D: + "axis_up": 'Y', + "axis_forward": '-Z', + "global_matrix": Matrix.Rotation(-math.pi / 2.0, 4, 'X'), + # Should really be True, but it can cause problems if a model is already in a scene or prefab + # with the old transforms. + "bake_space_transform": False, + + "use_selection": False, + + "object_types": {'ARMATURE', 'EMPTY', 'MESH', 'OTHER'}, + "use_mesh_modifiers": True, + "use_mesh_modifiers_render": True, + "use_mesh_edges": False, + "mesh_smooth_type": 'FACE', + "colors_type": 'SRGB', + "use_subsurf": False, + "use_tspace": False, # XXX Why? Unity is expected to support tspace import... + "use_triangles": False, + + "use_armature_deform_only": True, + + "use_custom_props": True, + + "bake_anim": True, + "bake_anim_simplify_factor": 1.0, + "bake_anim_step": 1.0, + "bake_anim_use_nla_strips": True, + "bake_anim_use_all_actions": True, + "add_leaf_bones": False, # Avoid memory/performance cost for something only useful for modelling + "primary_bone_axis": 'Y', # Doesn't really matter for Unity, so leave unchanged + "secondary_bone_axis": 'X', + + "path_mode": 'AUTO', + "embed_textures": False, + "batch_mode": 'OFF', + } + + +def save(operator, context, + filepath="", + use_selection=False, + use_visible=False, + use_active_collection=False, + collection="", + batch_mode='OFF', + use_batch_own_dir=False, + **kwargs + ): + """ + This is a wrapper around save_single, which handles multi-scenes (or collections) cases, when batch-exporting + a whole .blend file. + """ + + ret = {'FINISHED'} + + active_object = context.view_layer.objects.active + + org_mode = None + if active_object and active_object.mode != 'OBJECT' and bpy.ops.object.mode_set.poll(): + org_mode = active_object.mode + bpy.ops.object.mode_set(mode='OBJECT') + + if batch_mode == 'OFF': + kwargs_mod = kwargs.copy() + + source_collection = None + if use_active_collection: + source_collection = context.view_layer.active_layer_collection.collection + elif collection: + local_collection = bpy.data.collections.get((collection, None)) + if local_collection: + source_collection = local_collection + else: + operator.report({'ERROR'}, "Collection '%s' was not found" % collection) + return {'CANCELLED'} + + if source_collection: + if use_selection: + ctx_objects = tuple(obj for obj in source_collection.all_objects if obj.select_get()) + else: + ctx_objects = source_collection.all_objects + else: + if use_selection: + ctx_objects = context.selected_objects + else: + ctx_objects = context.view_layer.objects + if use_visible: + ctx_objects = tuple(obj for obj in ctx_objects if obj.visible_get()) + + # Ensure no Objects are in Edit mode. + # Copy to a tuple for safety, to avoid the risk of modifying ctx_objects while iterating. + for obj in tuple(ctx_objects): + if not ensure_object_not_in_edit_mode(context, obj): + operator.report({'ERROR'}, "%s could not be set out of Edit Mode, so cannot be exported" % obj.name) + return {'CANCELLED'} + + kwargs_mod["context_objects"] = ctx_objects + + depsgraph = context.evaluated_depsgraph_get() + ret = save_single(operator, context.scene, depsgraph, filepath, **kwargs_mod) + else: + # XXX We need a way to generate a depsgraph for inactive view_layers first... + # XXX Also, what to do in case of batch-exporting scenes, when there is more than one view layer? + # Scenes have no concept of 'active' view layer, that's on window level... + fbxpath = filepath + + prefix = os.path.basename(fbxpath) + if prefix: + fbxpath = os.path.dirname(fbxpath) + + if batch_mode == 'COLLECTION': + data_seq = tuple((coll, coll.name, 'objects') for coll in bpy.data.collections if coll.objects) + elif batch_mode in {'SCENE_COLLECTION', 'ACTIVE_SCENE_COLLECTION'}: + scenes = [context.scene] if batch_mode == 'ACTIVE_SCENE_COLLECTION' else bpy.data.scenes + data_seq = [] + for scene in scenes: + if not scene.objects: + continue + # Needed to avoid having tens of 'Scene Collection' entries. + todo_collections = [(scene.collection, "_".join((scene.name, scene.collection.name)))] + while todo_collections: + coll, coll_name = todo_collections.pop() + todo_collections.extend(((c, c.name) for c in coll.children if c.all_objects)) + data_seq.append((coll, coll_name, 'all_objects')) + else: + data_seq = tuple((scene, scene.name, 'objects') for scene in bpy.data.scenes if scene.objects) + + # Ensure no Objects are in Edit mode. + for data, data_name, data_obj_propname in data_seq: + # Copy to a tuple for safety, to avoid the risk of modifying the data prop while iterating it. + for obj in tuple(getattr(data, data_obj_propname)): + if not ensure_object_not_in_edit_mode(context, obj): + operator.report({'ERROR'}, + "%s in %s could not be set out of Edit Mode, so cannot be exported" + % (obj.name, data_name)) + return {'CANCELLED'} + + # call this function within a loop with BATCH_ENABLE == False + + new_fbxpath = fbxpath # own dir option modifies, we need to keep an original + for data, data_name, data_obj_propname in data_seq: # scene or collection + newname = "_".join((prefix, bpy.path.clean_name(data_name))) if prefix else bpy.path.clean_name(data_name) + + if use_batch_own_dir: + new_fbxpath = os.path.join(fbxpath, newname) + # path may already exist... and be a file. + while os.path.isfile(new_fbxpath): + new_fbxpath = "_".join((new_fbxpath, "dir")) + if not os.path.exists(new_fbxpath): + os.makedirs(new_fbxpath) + + filepath = os.path.join(new_fbxpath, newname + '.fbx') + + print('\nBatch exporting %s as...\n\t%r' % (data, filepath)) + + if batch_mode in {'COLLECTION', 'SCENE_COLLECTION', 'ACTIVE_SCENE_COLLECTION'}: + # Collection, so that objects update properly, add a dummy scene. + scene = bpy.data.scenes.new(name="FBX_Temp") + src_scenes = {} # Count how much each 'source' scenes are used. + for obj in getattr(data, data_obj_propname): + for src_sce in obj.users_scene: + src_scenes[src_sce] = src_scenes.setdefault(src_sce, 0) + 1 + scene.collection.objects.link(obj) + + # Find the 'most used' source scene, and use its unit settings. This is somewhat weak, but should work + # fine in most cases, and avoids stupid issues like T41931. + best_src_scene = None + best_src_scene_users = -1 + for sce, nbr_users in src_scenes.items(): + if (nbr_users) > best_src_scene_users: + best_src_scene_users = nbr_users + best_src_scene = sce + scene.unit_settings.system = best_src_scene.unit_settings.system + scene.unit_settings.system_rotation = best_src_scene.unit_settings.system_rotation + scene.unit_settings.scale_length = best_src_scene.unit_settings.scale_length + + # new scene [only one viewlayer to update] + scene.view_layers[0].update() + # TODO - BUMMER! Armatures not in the group wont animate the mesh + else: + scene = data + + kwargs_batch = kwargs.copy() + kwargs_batch["context_objects"] = getattr(data, data_obj_propname) + + save_single(operator, scene, scene.view_layers[0].depsgraph, filepath, **kwargs_batch) + + if batch_mode in {'COLLECTION', 'SCENE_COLLECTION', 'ACTIVE_SCENE_COLLECTION'}: + # Remove temp collection scene. + bpy.data.scenes.remove(scene) + + if active_object and org_mode: + context.view_layer.objects.active = active_object + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode=org_mode) + + return ret diff --git a/4.5.2_LTS/io_scene_fbx/fbx2json.py b/4.5.2_LTS/io_scene_fbx/fbx2json.py new file mode 100644 index 0000000..b710228 --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/fbx2json.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2006-2012 assimp team +# SPDX-FileCopyrightText: 2013 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +""" +Usage +===== + + fbx2json [FILES]... + +This script will write a JSON file for each FBX argument given. + + +Output +====== + +The JSON data is formatted into a list of nested lists of 4 items: + + ``[id, [data, ...], "data_types", [subtree, ...]]`` + +Where each list may be empty, and the items in +the subtree are formatted the same way. + +data_types is a string, aligned with data that spesifies a type +for each property. + +The types are as follows: + +* 'Z': - INT8 +* 'Y': - INT16 +* 'B': - BOOL +* 'C': - CHAR +* 'I': - INT32 +* 'F': - FLOAT32 +* 'D': - FLOAT64 +* 'L': - INT64 +* 'R': - BYTES +* 'S': - STRING +* 'f': - FLOAT32_ARRAY +* 'i': - INT32_ARRAY +* 'd': - FLOAT64_ARRAY +* 'l': - INT64_ARRAY +* 'b': - BOOL ARRAY +* 'c': - BYTE ARRAY + +Note that key:value pairs aren't used since the id's are not +ensured to be unique. +""" + + +# ---------------------------------------------------------------------------- +# FBX Binary Parser + +from struct import unpack +import array +import zlib + +# at the end of each nested block, there is a NUL record to indicate +# that the sub-scope exists (i.e. to distinguish between P: and P : {}) +_BLOCK_SENTINEL_LENGTH = ... +_BLOCK_SENTINEL_DATA = ... +read_fbx_elem_uint = ... +_IS_BIG_ENDIAN = (__import__("sys").byteorder != 'little') +_HEAD_MAGIC = b'Kaydara FBX Binary\x20\x20\x00\x1a\x00' +from collections import namedtuple +FBXElem = namedtuple("FBXElem", ("id", "props", "props_type", "elems")) +del namedtuple + + +def read_uint(read): + return unpack(b'"TCDefinition" to control the FBX_KTIME opt-in in FBX version 7700. +FBX_HEADER_VERSION = 1003 +FBX_SCENEINFO_VERSION = 100 +FBX_TEMPLATES_VERSION = 100 + +FBX_MODELS_VERSION = 232 + +FBX_GEOMETRY_VERSION = 124 +# Revert back normals to 101 (simple 3D values) for now, 102 (4D + weights) seems not well supported by most apps +# currently, apart from some AD products. +FBX_GEOMETRY_NORMAL_VERSION = 101 +FBX_GEOMETRY_BINORMAL_VERSION = 101 +FBX_GEOMETRY_TANGENT_VERSION = 101 +FBX_GEOMETRY_SMOOTHING_VERSION = 102 +FBX_GEOMETRY_CREASE_VERSION = 101 +FBX_GEOMETRY_VCOLOR_VERSION = 101 +FBX_GEOMETRY_UV_VERSION = 101 +FBX_GEOMETRY_MATERIAL_VERSION = 101 +FBX_GEOMETRY_LAYER_VERSION = 100 +FBX_GEOMETRY_SHAPE_VERSION = 100 +FBX_DEFORMER_SHAPE_VERSION = 100 +FBX_DEFORMER_SHAPECHANNEL_VERSION = 100 +FBX_POSE_BIND_VERSION = 100 +FBX_DEFORMER_SKIN_VERSION = 101 +FBX_DEFORMER_CLUSTER_VERSION = 100 +FBX_MATERIAL_VERSION = 102 +FBX_TEXTURE_VERSION = 202 +FBX_ANIM_KEY_VERSION = 4008 + +FBX_NAME_CLASS_SEP = b"\x00\x01" +FBX_ANIM_PROPSGROUP_NAME = "d" + +FBX_KTIME_V7 = 46186158000 # This is the number of "ktimes" in one second (yep, precision over the nanosecond...) +# FBX 2019.5 (FBX version 7700) changed the number of "ktimes" per second, however, the new value is opt-in until FBX +# version 8000 where it will probably become opt-out. +FBX_KTIME_V8 = 141120000 +# To explicitly use the V7 value in FBX versions 7700-7XXX: fbx_root->"FBXHeaderExtension"->"OtherFlags"->"TCDefinition" +# is set to 127. +# To opt in to the V8 value in FBX version 7700-7XXX: "TCDefinition" is set to 0. +FBX_TIMECODE_DEFINITION_TO_KTIME_PER_SECOND = { + 0: FBX_KTIME_V8, + 127: FBX_KTIME_V7, +} +# The "ktimes" per second for Blender exported FBX is constant because the exported `FBX_VERSION` is constant. +FBX_KTIME = FBX_KTIME_V8 if FBX_VERSION >= 8000 else FBX_KTIME_V7 + + +MAT_CONVERT_LIGHT = Matrix.Rotation(math.pi / 2.0, 4, 'X') # Blender is -Z, FBX is -Y. +MAT_CONVERT_CAMERA = Matrix.Rotation(math.pi / 2.0, 4, 'Y') # Blender is -Z, FBX is +X. +# XXX I can't get this working :( +# MAT_CONVERT_BONE = Matrix.Rotation(math.pi / 2.0, 4, 'Z') # Blender is +Y, FBX is -X. +MAT_CONVERT_BONE = Matrix() + + +BLENDER_OTHER_OBJECT_TYPES = {'CURVE', 'SURFACE', 'FONT', 'META'} +BLENDER_OBJECT_TYPES_MESHLIKE = {'MESH'} | BLENDER_OTHER_OBJECT_TYPES + +SHAPE_KEY_SLIDER_HARD_MIN = bpy.types.ShapeKey.bl_rna.properties["slider_min"].hard_min +SHAPE_KEY_SLIDER_HARD_MAX = bpy.types.ShapeKey.bl_rna.properties["slider_max"].hard_max + + +# Lamps. +FBX_LIGHT_TYPES = { + 'POINT': 0, # Point. + 'SUN': 1, # Directional. + 'SPOT': 2, # Spot. + 'HEMI': 1, # Directional. + 'AREA': 3, # Area. +} +FBX_LIGHT_DECAY_TYPES = { + 'CONSTANT': 0, # None. + 'INVERSE_LINEAR': 1, # Linear. + 'INVERSE_SQUARE': 2, # Quadratic. + 'INVERSE_COEFFICIENTS': 2, # Quadratic... + 'CUSTOM_CURVE': 2, # Quadratic. + 'LINEAR_QUADRATIC_WEIGHTED': 2, # Quadratic. +} + + +RIGHT_HAND_AXES = { + # Up, Forward -> FBX values (tuples of (axis, sign), Up, Front, Coord). + ('X', '-Y'): ((0, 1), (1, 1), (2, 1)), + ('X', 'Y'): ((0, 1), (1, -1), (2, -1)), + ('X', '-Z'): ((0, 1), (2, 1), (1, -1)), + ('X', 'Z'): ((0, 1), (2, -1), (1, 1)), + ('-X', '-Y'): ((0, -1), (1, 1), (2, -1)), + ('-X', 'Y'): ((0, -1), (1, -1), (2, 1)), + ('-X', '-Z'): ((0, -1), (2, 1), (1, 1)), + ('-X', 'Z'): ((0, -1), (2, -1), (1, -1)), + ('Y', '-X'): ((1, 1), (0, 1), (2, -1)), + ('Y', 'X'): ((1, 1), (0, -1), (2, 1)), + ('Y', '-Z'): ((1, 1), (2, 1), (0, 1)), + ('Y', 'Z'): ((1, 1), (2, -1), (0, -1)), + ('-Y', '-X'): ((1, -1), (0, 1), (2, 1)), + ('-Y', 'X'): ((1, -1), (0, -1), (2, -1)), + ('-Y', '-Z'): ((1, -1), (2, 1), (0, -1)), + ('-Y', 'Z'): ((1, -1), (2, -1), (0, 1)), + ('Z', '-X'): ((2, 1), (0, 1), (1, 1)), + ('Z', 'X'): ((2, 1), (0, -1), (1, -1)), + ('Z', '-Y'): ((2, 1), (1, 1), (0, -1)), + ('Z', 'Y'): ((2, 1), (1, -1), (0, 1)), # Blender system! + ('-Z', '-X'): ((2, -1), (0, 1), (1, -1)), + ('-Z', 'X'): ((2, -1), (0, -1), (1, 1)), + ('-Z', '-Y'): ((2, -1), (1, 1), (0, 1)), + ('-Z', 'Y'): ((2, -1), (1, -1), (0, -1)), +} + + +# NOTE: Not fully in enum value order, since when exporting the first entry matching the framerate value is used +# (e.g. better have NTSC fullframe than NTSC drop frame for 29.97 framerate). +FBX_FRAMERATES = ( + # (-1.0, 0), # Default framerate. + (-1.0, 14), # Custom framerate. + (120.0, 1), + (100.0, 2), + (60.0, 3), + (50.0, 4), + (48.0, 5), + (30.0, 6), # BW NTSC, full frame. + (30.0, 7), # Drop frame. + (30.0 / 1.001, 9), # Color NTSC, full frame. + (30.0 / 1.001, 8), # Color NTSC, drop frame. + (25.0, 10), + (24.0, 11), + # (1.0, 12), # 1000 milli/s (use for date time?). + (24.0 / 1.001, 13), + (96.0, 15), + (72.0, 16), + (60.0 / 1.001, 17), + (120.0 / 1.001, 18), +) + + +# ##### Misc utilities ##### + +# Enable performance reports (measuring time used to perform various steps of importing or exporting). +DO_PERFMON = False + +if DO_PERFMON: + class PerfMon(): + def __init__(self): + self.level = -1 + self.ref_time = [] + + def level_up(self, message=""): + self.level += 1 + self.ref_time.append(None) + if message: + print("\t" * self.level, message, sep="") + + def level_down(self, message=""): + if not self.ref_time: + if message: + print(message) + return + ref_time = self.ref_time[self.level] + print("\t" * self.level, + "\tDone (%f sec)\n" % ((time.process_time() - ref_time) if ref_time is not None else 0.0), + sep="") + if message: + print("\t" * self.level, message, sep="") + del self.ref_time[self.level] + self.level -= 1 + + def step(self, message=""): + ref_time = self.ref_time[self.level] + curr_time = time.process_time() + if ref_time is not None: + print("\t" * self.level, "\tDone (%f sec)\n" % (curr_time - ref_time), sep="") + self.ref_time[self.level] = curr_time + print("\t" * self.level, message, sep="") +else: + class PerfMon(): + def __init__(self): + pass + + def level_up(self, message=""): + pass + + def level_down(self, message=""): + pass + + def step(self, message=""): + pass + + +# Scale/unit mess. FBX can store the 'reference' unit of a file in its UnitScaleFactor property +# (1.0 meaning centimeter, afaik). We use that to reflect user's default unit as set in Blender with scale_length. +# However, we always get values in BU (i.e. meters), so we have to reverse-apply that scale in global matrix... +# Note that when no default unit is available, we assume 'meters' (and hence scale by 100). +def units_blender_to_fbx_factor(scene): + return 100.0 if (scene.unit_settings.system == 'NONE') else (100.0 * scene.unit_settings.scale_length) + + +# Note: this could be in a utility (math.units e.g.)... + +UNITS = { + "meter": 1.0, # Ref unit! + "kilometer": 0.001, + "millimeter": 1000.0, + "foot": 1.0 / 0.3048, + "inch": 1.0 / 0.0254, + "turn": 1.0, # Ref unit! + "degree": 360.0, + "radian": math.pi * 2.0, + "second": 1.0, # Ref unit! + "ktime": FBX_KTIME, # For export use only because the imported "ktimes" per second may vary. +} + + +def units_convertor(u_from, u_to): + """Return a convertor between specified units.""" + conv = UNITS[u_to] / UNITS[u_from] + return lambda v: v * conv + + +def units_convertor_iter(u_from, u_to): + """Return an iterable convertor between specified units.""" + conv = units_convertor(u_from, u_to) + + def convertor(it): + for v in it: + yield(conv(v)) + + return convertor + + +def matrix4_to_array(mat): + """Concatenate matrix's columns into a single, flat tuple""" + # blender matrix is row major, fbx is col major so transpose on write + return tuple(f for v in mat.transposed() for f in v) + + +def array_to_matrix4(arr): + """Convert a single 16-len tuple into a valid 4D Blender matrix""" + # Blender matrix is row major, fbx is col major so transpose on read + return Matrix(tuple(zip(*[iter(arr)] * 4))).transposed() + + +def parray_as_ndarray(arr): + """Convert an array.array into an np.ndarray that shares the same memory""" + return np.frombuffer(arr, dtype=arr.typecode) + + +def similar_values(v1, v2, e=1e-6): + """Return True if v1 and v2 are nearly the same.""" + if v1 == v2: + return True + return ((abs(v1 - v2) / max(abs(v1), abs(v2))) <= e) + + +def similar_values_iter(v1, v2, e=1e-6): + """Return True if iterables v1 and v2 are nearly the same.""" + if v1 == v2: + return True + for v1, v2 in zip(v1, v2): + if (v1 != v2) and ((abs(v1 - v2) / max(abs(v1), abs(v2))) > e): + return False + return True + + +def shape_difference_exclude_similar(sv_cos, ref_cos, e=1e-6): + """Return a tuple of: + the difference between the vertex cos in sv_cos and ref_cos, excluding any that are nearly the same, + and the indices of the vertices that are not nearly the same""" + assert(sv_cos.size == ref_cos.size) + + # Create views of 1 co per row of the arrays, only making copies if needed. + sv_cos = sv_cos.reshape(-1, 3) + ref_cos = ref_cos.reshape(-1, 3) + + # Quick check for equality + if np.array_equal(sv_cos, ref_cos): + # There's no difference between the two arrays. + empty_cos = np.empty((0, 3), dtype=sv_cos.dtype) + empty_indices = np.empty(0, dtype=np.int32) + return empty_cos, empty_indices + + # Note that unlike math.isclose(a,b), np.isclose(a,b) is not symmetrical and the second argument 'b', is + # considered to be the reference value. + # Note that atol=0 will mean that if only one co component being compared is zero, they won't be considered close. + similar_mask = np.isclose(sv_cos, ref_cos, atol=0, rtol=e) + + # A co is only similar if every component in it is similar. + co_similar_mask = np.all(similar_mask, axis=1) + + # Get the indices of cos that are not similar. + not_similar_verts_idx = np.flatnonzero(~co_similar_mask) + + # Subtracting first over the entire arrays and then indexing seems faster than indexing both arrays first and then + # subtracting, until less than about 3% of the cos are being indexed. + difference_cos = (sv_cos - ref_cos)[not_similar_verts_idx] + return difference_cos, not_similar_verts_idx + + +def _mat4_vec3_array_multiply(mat4, vec3_array, dtype=None, return_4d=False): + """Multiply a 4d matrix by each 3d vector in an array and return as an array of either 3d or 4d vectors. + + A view of the input array is returned if return_4d=False, the dtype matches the input array and either the matrix is + None or, ignoring the last row, is a 3x3 identity matrix with no translation: + ┌1, 0, 0, 0┐ + │0, 1, 0, 0│ + └0, 0, 1, 0┘ + + When dtype=None, it defaults to the dtype of the input array.""" + return_dtype = dtype if dtype is not None else vec3_array.dtype + vec3_array = vec3_array.reshape(-1, 3) + + # Multiplying a 4d mathutils.Matrix by a 3d mathutils.Vector implicitly extends the Vector to 4d during the + # calculation by appending 1.0 to the Vector and then the 4d result is truncated back to 3d. + # Numpy does not do an implicit extension to 4d, so it would have to be done explicitly by extending the entire + # vec3_array to 4d. + # However, since the w component of the vectors is always 1.0, the last column can be excluded from the + # multiplication and then added to every multiplied vector afterwards, which avoids having to make a 4d copy of + # vec3_array beforehand. + # For a single column vector: + # ┌a, b, c, d┐ ┌x┐ ┌ax+by+cz+d┐ + # │e, f, g, h│ @ │y│ = │ex+fy+gz+h│ + # │i, j, k, l│ │z│ │ix+jy+kz+l│ + # └m, n, o, p┘ └1┘ └mx+ny+oz+p┘ + # ┌a, b, c┐ ┌x┐ ┌d┐ ┌ax+by+cz┐ ┌d┐ ┌ax+by+cz+d┐ + # │e, f, g│ @ │y│ + │h│ = │ex+fy+gz│ + │h│ = │ex+fy+gz+h│ + # │i, j, k│ └z┘ │l│ │ix+jy+kz│ │l│ │ix+jy+kz+l│ + # └m, n, o┘ └p┘ └mx+ny+oz┘ └p┘ └mx+ny+oz+p┘ + + # column_vector_multiplication in mathutils_Vector.c uses double precision math for Matrix @ Vector by casting the + # matrix's values to double precision and then casts back to single precision when returning the result, so at least + # double precision math is always be used to match standard Blender behaviour. + math_precision = np.result_type(np.double, vec3_array) + + to_multiply = None + to_add = None + w_to_set = 1.0 + if mat4 is not None: + mat_np = np.array(mat4, dtype=math_precision) + # Identity matrix is compared against to check if any matrix multiplication is required. + identity = np.identity(4, dtype=math_precision) + if not return_4d: + # If returning 3d, the entire last row of the matrix can be ignored because it only affects the w component. + mat_np = mat_np[:3] + identity = identity[:3] + + # Split mat_np into the columns to multiply and the column to add afterwards. + # First 3 columns + multiply_columns = mat_np[:, :3] + multiply_identity = identity[:, :3] + # Last column only + add_column = mat_np.T[3] + + # Analyze the split parts of the matrix to figure out if there is anything to multiply and anything to add. + if not np.array_equal(multiply_columns, multiply_identity): + to_multiply = multiply_columns + + if return_4d and to_multiply is None: + # When there's nothing to multiply, the w component of add_column can be set directly into the array because + # mx+ny+oz+p becomes 0x+0y+0z+p where p is add_column[3]. + w_to_set = add_column[3] + # Replace add_column with a view of only the translation. + add_column = add_column[:3] + + if add_column.any(): + to_add = add_column + + if to_multiply is None: + # If there's anything to add, ensure it's added using the precision being used for math. + array_dtype = math_precision if to_add is not None else return_dtype + if return_4d: + multiplied_vectors = np.empty((len(vec3_array), 4), dtype=array_dtype) + multiplied_vectors[:, :3] = vec3_array + multiplied_vectors[:, 3] = w_to_set + else: + # If there's anything to add, ensure a copy is made so that the input vec3_array isn't modified. + multiplied_vectors = vec3_array.astype(array_dtype, copy=to_add is not None) + else: + # Matrix multiplication has the signature (n,k) @ (k,m) -> (n,m). + # Where v is the number of vectors in vec3_array and d is the number of vector dimensions to return: + # to_multiply has shape (d,3), vec3_array has shape (v,3) and the result should have shape (v,d). + # Either vec3_array or to_multiply must be transposed: + # Can transpose vec3_array and then transpose the result: + # (v,3).T -> (3,v); (d,3) @ (3,v) -> (d,v); (d,v).T -> (v,d) + # Or transpose to_multiply and swap the order of multiplication: + # (d,3).T -> (3,d); (v,3) @ (3,d) -> (v,d) + # There's no, or negligible, performance difference between the two options, however, the result of the latter + # will be C contiguous in memory, making it faster to convert to flattened bytes with .tobytes(). + multiplied_vectors = vec3_array @ to_multiply.T + + if to_add is not None: + for axis, to_add_to_axis in zip(multiplied_vectors.T, to_add): + if to_add_to_axis != 0: + axis += to_add_to_axis + + # Cast to the desired return type before returning. + return multiplied_vectors.astype(return_dtype, copy=False) + + +def vcos_transformed(raw_cos, m=None, dtype=None): + return _mat4_vec3_array_multiply(m, raw_cos, dtype) + + +def nors_transformed(raw_nors, m=None, dtype=None): + # Great, now normals are also expected 4D! + # XXX Back to 3D normals for now! + # return _mat4_vec3_array_multiply(m, raw_nors, dtype, return_4d=True) + return _mat4_vec3_array_multiply(m, raw_nors, dtype) + + +def astype_view_signedness(arr, new_dtype): + """Unsafely views arr as new_dtype if the itemsize and byteorder of arr matches but the signedness does not. + + Safely views arr as new_dtype if both arr and new_dtype have the same itemsize, byteorder and signedness, but could + have a different character code, e.g. 'i' and 'l'. np.ndarray.astype with copy=False does not normally create this + view, but Blender can be picky about the character code used, so this function will create the view. + + Otherwise, calls np.ndarray.astype with copy=False. + + The benefit of copy=False is that if the array can be safely viewed as the new type, then a view is made, instead of + a copy with the new type. + + Unsigned types can't be viewed safely as signed or vice-versa, meaning that a copy would always be made by + .astype(..., copy=False). + + This is intended for viewing uintc data (a common Blender C type with variable itemsize, though usually 4 bytes, so + uint32) as int32 (a common FBX type), when the itemsizes match.""" + arr_dtype = arr.dtype + + if not isinstance(new_dtype, np.dtype): + # new_dtype could be a type instance or a string, but it needs to be a dtype to compare its itemsize, byteorder + # and kind. + new_dtype = np.dtype(new_dtype) + + # For simplicity, only dtypes of the same itemsize and byteorder, but opposite signedness, are handled. Everything + # else is left to .astype. + arr_kind = arr_dtype.kind + new_kind = new_dtype.kind + # Signed and unsigned int are opposite in terms of signedness. Other types don't have signedness. + integer_kinds = {'i', 'u'} + if ( + arr_kind in integer_kinds and new_kind in integer_kinds + and arr_dtype.itemsize == new_dtype.itemsize + and arr_dtype.byteorder == new_dtype.byteorder + ): + # arr and new_dtype have signedness and matching itemsize and byteorder, so return a view of the new type. + return arr.view(new_dtype) + else: + return arr.astype(new_dtype, copy=False) + + +def fast_first_axis_flat(ar): + """Get a flat view (or a copy if a view is not possible) of the input array whereby each element is a single element + of a dtype that is fast to sort, sorts according to individual bytes and contains the data for an entire row (and + any further dimensions) of the input array. + + Since the dtype of the view could sort in a different order to the dtype of the input array, this isn't typically + useful for actual sorting, but it is useful for sorting-based uniqueness, such as np.unique.""" + # If there are no rows, each element will be viewed as the new dtype. + elements_per_row = math.prod(ar.shape[1:]) + row_itemsize = ar.itemsize * elements_per_row + + # Get a dtype with itemsize that equals row_itemsize. + # Integer types sort the fastest, but are only available for specific itemsizes. + uint_dtypes_by_itemsize = {1: np.uint8, 2: np.uint16, 4: np.uint32, 8: np.uint64} + # Signed/unsigned makes no noticeable speed difference, but using unsigned will result in ordering according to + # individual bytes like the other, non-integer types. + if row_itemsize in uint_dtypes_by_itemsize: + entire_row_dtype = uint_dtypes_by_itemsize[row_itemsize] + else: + # When using kind='stable' sorting, numpy only uses radix sort with integer types, but it's still + # significantly faster to sort by a single item per row instead of multiple row elements or multiple structured + # type fields. + # Construct a flexible size dtype with matching itemsize. + # Should always be 4 because each character in a unicode string is UCS4. + str_itemsize = np.dtype((np.str_, 1)).itemsize + if row_itemsize % str_itemsize == 0: + # Unicode strings seem to be slightly faster to sort than bytes. + entire_row_dtype = np.dtype((np.str_, row_itemsize // str_itemsize)) + else: + # Bytes seem to be slightly faster to sort than raw bytes (np.void). + entire_row_dtype = np.dtype((np.bytes_, row_itemsize)) + + # View each element along the first axis as a single element. + # View (or copy if a view is not possible) as flat + ar = ar.reshape(-1) + # To view as a dtype of different size, the last axis (entire array in NumPy 1.22 and earlier) must be C-contiguous. + if row_itemsize != ar.itemsize and not ar.flags.c_contiguous: + ar = np.ascontiguousarray(ar) + return ar.view(entire_row_dtype) + + +def fast_first_axis_unique(ar, return_unique=True, return_index=False, return_inverse=False, return_counts=False): + """np.unique with axis=0 but optimised for when the input array has multiple elements per row, and the returned + unique array doesn't need to be sorted. + + Arrays with more than one element per row are more costly to sort in np.unique due to being compared one + row-element at a time, like comparing tuples. + + By viewing each entire row as a single non-structured element, much faster sorting can be achieved. Since the values + are viewed as a different type to their original, this means that the returned array of unique values may not be + sorted according to their original type. + + The array of unique values can be excluded from the returned tuple by specifying return_unique=False. + + Float type caveats: + All elements of -0.0 in the input array will be replaced with 0.0 to ensure that both values are collapsed into one. + NaN values can have lots of different byte representations (e.g. signalling/quiet and custom payloads). Only the + duplicates of each unique byte representation will be collapsed into one.""" + # At least something should always be returned. + assert(return_unique or return_index or return_inverse or return_counts) + # Only signed integer, unsigned integer and floating-point kinds of data are allowed. Other kinds of data have not + # been tested. + assert(ar.dtype.kind in "iuf") + + # Floating-point types have different byte representations for -0.0 and 0.0. Collapse them together by replacing all + # -0.0 in the input array with 0.0. + if ar.dtype.kind == 'f': + ar[ar == -0.0] = 0.0 + + # It's a bit annoying that the unique array is always calculated even when it might not be needed, but it is + # generally insignificant compared to the cost of sorting. + result = np.unique(fast_first_axis_flat(ar), return_index=return_index, + return_inverse=return_inverse, return_counts=return_counts) + + if return_unique: + unique = result[0] if isinstance(result, tuple) else result + # View in the original dtype. + unique = unique.view(ar.dtype) + # Return the same number of elements per row and any extra dimensions per row as the input array. + unique.shape = (-1, *ar.shape[1:]) + if isinstance(result, tuple): + return (unique,) + result[1:] + else: + return unique + else: + # Remove the first element, the unique array. + result = result[1:] + if len(result) == 1: + # Unpack single element tuples. + return result[0] + else: + return result + + +def ensure_object_not_in_edit_mode(context, obj): + """Objects in Edit mode usually cannot be exported because much of the API used when exporting is not available for + Objects in Edit mode. + + Exiting the currently active Object (and any other Objects opened in multi-editing) from Edit mode is simple and + should be done with `bpy.ops.mesh.mode_set(mode='OBJECT')` instead of using this function. + + This function is for the rare case where an Object is in Edit mode, but the current context mode is not Edit mode. + This can occur from a state where the current context mode is Edit mode, but then the active Object of the current + View Layer is changed to a different Object that is not in Edit mode. This changes the current context mode, but + leaves the other Object(s) in Edit mode. + """ + if obj.mode != 'EDIT': + return True + + # Get the active View Layer. + view_layer = context.view_layer + + # A View Layer belongs to a scene. + scene = view_layer.id_data + + # Get the current active Object of this View Layer, so we can restore it once done. + orig_active = view_layer.objects.active + + # Check if obj is in the View Layer. If obj is not in the View Layer, it cannot be set as the active Object. + # We don't use `obj.name in view_layer.objects` because an Object from a Library could have the same name. + is_in_view_layer = any(o == obj for o in view_layer.objects) + + do_unlink_from_scene_collection = False + try: + if not is_in_view_layer: + # There might not be any enabled collections in the View Layer, so link obj into the Scene Collection + # instead, which is always available to all View Layers of that Scene. + scene.collection.objects.link(obj) + do_unlink_from_scene_collection = True + view_layer.objects.active = obj + + # Now we're finally ready to attempt to change obj's mode. + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT') + if obj.mode == 'EDIT': + # The Object could not be set out of EDIT mode and therefore cannot be exported. + return False + finally: + # Always restore the original active Object and unlink obj from the Scene Collection if it had to be linked. + view_layer.objects.active = orig_active + if do_unlink_from_scene_collection: + scene.collection.objects.unlink(obj) + + return True + + +def expand_shape_key_range(shape_key, value_to_fit): + """Attempt to expand the slider_min/slider_max of a shape key to fit `value_to_fit` within the slider range, + expanding slightly beyond `value_to_fit` if possible, so that the new slider_min/slider_max is not the same as + `value_to_fit`. Blender has a hard minimum and maximum for slider values, so it may not be possible to fit the value + within the slider range. + + If `value_to_fit` is already within the slider range, no changes are made. + + First tries setting slider_min/slider_max to double `value_to_fit`, otherwise, expands the range in the direction of + `value_to_fit` by double the distance to `value_to_fit`. + + The new slider_min/slider_max is rounded down/up to the nearest whole number for a more visually pleasing result. + + Returns whether it was possible to expand the slider range to fit `value_to_fit`.""" + if value_to_fit < (slider_min := shape_key.slider_min): + if value_to_fit < 0.0: + # For the most common case, set slider_min to double value_to_fit. + target_slider_min = value_to_fit * 2.0 + else: + # Doubling value_to_fit would make it larger, so instead decrease slider_min by double the distance between + # slider_min and value_to_fit. + target_slider_min = slider_min - (slider_min - value_to_fit) * 2.0 + # Set slider_min to the first whole number less than or equal to target_slider_min. + shape_key.slider_min = math.floor(target_slider_min) + + return value_to_fit >= SHAPE_KEY_SLIDER_HARD_MIN + elif value_to_fit > (slider_max := shape_key.slider_max): + if value_to_fit > 0.0: + # For the most common case, set slider_max to double value_to_fit. + target_slider_max = value_to_fit * 2.0 + else: + # Doubling value_to_fit would make it smaller, so instead increase slider_max by double the distance between + # slider_max and value_to_fit. + target_slider_max = slider_max + (value_to_fit - slider_max) * 2.0 + # Set slider_max to the first whole number greater than or equal to target_slider_max. + shape_key.slider_max = math.ceil(target_slider_max) + + return value_to_fit <= SHAPE_KEY_SLIDER_HARD_MAX + else: + # Value is already within the range. + return True + + +# ##### Attribute utils. ##### +AttributeDataTypeInfo = namedtuple("AttributeDataTypeInfo", ["dtype", "foreach_attribute", "item_size"]) +_attribute_data_type_info_lookup = { + 'FLOAT': AttributeDataTypeInfo(np.single, "value", 1), + 'INT': AttributeDataTypeInfo(np.intc, "value", 1), + 'FLOAT_VECTOR': AttributeDataTypeInfo(np.single, "vector", 3), + 'FLOAT_COLOR': AttributeDataTypeInfo(np.single, "color", 4), # color_srgb is an alternative + 'BYTE_COLOR': AttributeDataTypeInfo(np.single, "color", 4), # color_srgb is an alternative + 'STRING': AttributeDataTypeInfo(None, "value", 1), # Not usable with foreach_get/set + 'BOOLEAN': AttributeDataTypeInfo(bool, "value", 1), + 'FLOAT2': AttributeDataTypeInfo(np.single, "vector", 2), + 'INT8': AttributeDataTypeInfo(np.intc, "value", 1), + 'INT32_2D': AttributeDataTypeInfo(np.intc, "value", 2), +} + + +def attribute_get(attributes, name, data_type, domain): + """Get an attribute by its name, data_type and domain. + + Returns None if no attribute with this name, data_type and domain exists.""" + attr = attributes.get(name) + if not attr: + return None + if attr.data_type == data_type and attr.domain == domain: + return attr + # It shouldn't normally happen, but it's possible there are multiple attributes with the same name, but different + # data_types or domains. + for attr in attributes: + if attr.name == name and attr.data_type == data_type and attr.domain == domain: + return attr + return None + + +def attribute_foreach_set(attribute, array_or_list, foreach_attribute=None): + """Set every value of an attribute with foreach_set.""" + if foreach_attribute is None: + foreach_attribute = _attribute_data_type_info_lookup[attribute.data_type].foreach_attribute + attribute.data.foreach_set(foreach_attribute, array_or_list) + + +def attribute_to_ndarray(attribute, foreach_attribute=None): + """Create a NumPy ndarray from an attribute.""" + data = attribute.data + data_type_info = _attribute_data_type_info_lookup[attribute.data_type] + ndarray = np.empty(len(data) * data_type_info.item_size, dtype=data_type_info.dtype) + if foreach_attribute is None: + foreach_attribute = data_type_info.foreach_attribute + data.foreach_get(foreach_attribute, ndarray) + return ndarray + + +@dataclass +class AttributeDescription: + """Helper class to reduce duplicate code for handling built-in Blender attributes.""" + name: str + # Valid identifiers can be found in bpy.types.Attribute.bl_rna.properties["data_type"].enum_items + data_type: str + # Valid identifiers can be found in bpy.types.Attribute.bl_rna.properties["domain"].enum_items + domain: str + # Some attributes are required to exist if certain conditions are met. If a required attribute does not exist when + # attempting to get it, an AssertionError is raised. + is_required_check: Callable[[Any], bool] = None + # NumPy dtype that matches the internal C data of this attribute. + dtype: np.dtype = field(init=False) + # The default attribute name to use with foreach_get and foreach_set. + foreach_attribute: str = field(init=False) + # The number of elements per value of the attribute when flattened into a 1-dimensional list/array. + item_size: int = field(init=False) + + def __post_init__(self): + data_type_info = _attribute_data_type_info_lookup[self.data_type] + self.dtype = data_type_info.dtype + self.foreach_attribute = data_type_info.foreach_attribute + self.item_size = data_type_info.item_size + + def is_required(self, attributes): + """Check if the attribute is required to exist in the provided attributes.""" + is_required_check = self.is_required_check + return is_required_check and is_required_check(attributes) + + def get(self, attributes): + """Get the attribute. + + If the attribute is required, but does not exist, an AssertionError is raised, otherwise None is returned.""" + attr = attribute_get(attributes, self.name, self.data_type, self.domain) + if not attr and self.is_required(attributes): + raise AssertionError("Required attribute '%s' with type '%s' and domain '%s' not found in %r" + % (self.name, self.data_type, self.domain, attributes)) + return attr + + def ensure(self, attributes): + """Get the attribute, creating it if it does not exist. + + Raises a RuntimeError if the attribute could not be created, which should only happen when attempting to create + an attribute with a reserved name, but with the wrong data_type or domain. See usage of + BuiltinCustomDataLayerProvider in Blender source for most reserved names. + + There is no guarantee that the returned attribute has the desired name because the name could already be in use + by another attribute with a different data_type and/or domain.""" + attr = self.get(attributes) + if attr: + return attr + + attr = attributes.new(self.name, self.data_type, self.domain) + if not attr: + raise RuntimeError("Could not create attribute '%s' with type '%s' and domain '%s' in %r" + % (self.name, self.data_type, self.domain, attributes)) + return attr + + def foreach_set(self, attributes, array_or_list, foreach_attribute=None): + """Get the attribute, creating it if it does not exist, and then set every value in the attribute.""" + attribute_foreach_set(self.ensure(attributes), array_or_list, foreach_attribute) + + def get_ndarray(self, attributes, foreach_attribute=None): + """Get the attribute and if it exists, return a NumPy ndarray containing its data, otherwise return None.""" + attr = self.get(attributes) + return attribute_to_ndarray(attr, foreach_attribute) if attr else None + + def to_ndarray(self, attributes, foreach_attribute=None): + """Get the attribute and if it exists, return a NumPy ndarray containing its data, otherwise return a + zero-length ndarray.""" + ndarray = self.get_ndarray(attributes, foreach_attribute) + return ndarray if ndarray is not None else np.empty(0, dtype=self.dtype) + + +# Built-in Blender attributes +# Only attributes used by the importer/exporter are included here. +# See usage of BuiltinCustomDataLayerProvider in Blender source to find most built-in attributes. +MESH_ATTRIBUTE_MATERIAL_INDEX = AttributeDescription("material_index", 'INT', 'FACE') +MESH_ATTRIBUTE_POSITION = AttributeDescription("position", 'FLOAT_VECTOR', 'POINT', + is_required_check=lambda attributes: bool(attributes.id_data.vertices)) +MESH_ATTRIBUTE_SHARP_EDGE = AttributeDescription("sharp_edge", 'BOOLEAN', 'EDGE') +MESH_ATTRIBUTE_EDGE_VERTS = AttributeDescription(".edge_verts", 'INT32_2D', 'EDGE', + is_required_check=lambda attributes: bool(attributes.id_data.edges)) +MESH_ATTRIBUTE_CORNER_VERT = AttributeDescription(".corner_vert", 'INT', 'CORNER', + is_required_check=lambda attributes: bool(attributes.id_data.loops)) +MESH_ATTRIBUTE_CORNER_EDGE = AttributeDescription(".corner_edge", 'INT', 'CORNER', + is_required_check=lambda attributes: bool(attributes.id_data.loops)) +MESH_ATTRIBUTE_SHARP_FACE = AttributeDescription("sharp_face", 'BOOLEAN', 'FACE') + + +# ##### UIDs code. ##### + +# ID class (mere int). +class UUID(int): + pass + + +# UIDs storage. +_keys_to_uuids = {} +_uuids_to_keys = {} + + +def _key_to_uuid(uuids, key): + # TODO: Check this is robust enough for our needs! + # Note: We assume we have already checked the related key wasn't yet in _keys_to_uids! + # As int64 is signed in FBX, we keep uids below 2**63... + if isinstance(key, int) and 0 <= key < 2**63: + # We can use value directly as id! + uuid = key + else: + uuid = hash(key) + if uuid < 0: + uuid = -uuid + if uuid >= 2**63: + uuid //= 2 + # Try to make our uid shorter! + if uuid > int(1e9): + t_uuid = uuid % int(1e9) + if t_uuid not in uuids: + uuid = t_uuid + # Make sure our uuid *is* unique. + if uuid in uuids: + inc = 1 if uuid < 2**62 else -1 + while uuid in uuids: + uuid += inc + if 0 > uuid >= 2**63: + # Note that this is more that unlikely, but does not harm anyway... + raise ValueError("Unable to generate an UUID for key {}".format(key)) + return UUID(uuid) + + +def get_fbx_uuid_from_key(key): + """ + Return an UUID for given key, which is assumed to be hashable. + """ + uuid = _keys_to_uuids.get(key, None) + if uuid is None: + uuid = _key_to_uuid(_uuids_to_keys, key) + _keys_to_uuids[key] = uuid + _uuids_to_keys[uuid] = key + return uuid + + +# XXX Not sure we'll actually need this one? +def get_key_from_fbx_uuid(uuid): + """ + Return the key which generated this uid. + """ + assert(uuid.__class__ == UUID) + return _uuids_to_keys.get(uuid, None) + + +# Blender-specific key generators +def get_bid_name(bid): + library = getattr(bid, "library", None) + if library is not None: + return "%s_L_%s" % (bid.name, library.name) + else: + return bid.name + + +def get_blenderID_key(bid): + if isinstance(bid, Iterable): + return "|".join("B" + e.rna_type.name + "#" + get_bid_name(e) for e in bid) + else: + return "B" + bid.rna_type.name + "#" + get_bid_name(bid) + + +def get_blenderID_name(bid): + if isinstance(bid, Iterable): + return "|".join(get_bid_name(e) for e in bid) + else: + return get_bid_name(bid) + + +def get_blender_empty_key(obj): + """Return bone's keys (Model and NodeAttribute).""" + return "|".join((get_blenderID_key(obj), "Empty")) + + +def get_blender_mesh_shape_key(me): + """Return main shape deformer's key.""" + return "|".join((get_blenderID_key(me), "Shape")) + + +def get_blender_mesh_shape_channel_key(me, shape): + """Return shape channel and geometry shape keys.""" + return ("|".join((get_blenderID_key(me), "Shape", get_blenderID_key(shape))), + "|".join((get_blenderID_key(me), "Geometry", get_blenderID_key(shape)))) + + +def get_blender_bone_key(armature, bone): + """Return bone's keys (Model and NodeAttribute).""" + return "|".join((get_blenderID_key((armature, bone)), "Data")) + + +def get_blender_bindpose_key(obj, mesh): + """Return object's bindpose key.""" + return "|".join((get_blenderID_key(obj), get_blenderID_key(mesh), "BindPose")) + + +def get_blender_armature_skin_key(armature, mesh): + """Return armature's skin key.""" + return "|".join((get_blenderID_key(armature), get_blenderID_key(mesh), "DeformerSkin")) + + +def get_blender_bone_cluster_key(armature, mesh, bone): + """Return bone's cluster key.""" + return "|".join((get_blenderID_key(armature), get_blenderID_key(mesh), + get_blenderID_key(bone), "SubDeformerCluster")) + + +def get_blender_anim_id_base(scene, ref_id): + if ref_id is not None: + return get_blenderID_key(scene) + "|" + get_blenderID_key(ref_id) + else: + return get_blenderID_key(scene) + + +def get_blender_anim_stack_key(scene, ref_id): + """Return single anim stack key.""" + return get_blender_anim_id_base(scene, ref_id) + "|AnimStack" + + +def get_blender_anim_layer_key(scene, ref_id): + """Return ID's anim layer key.""" + return get_blender_anim_id_base(scene, ref_id) + "|AnimLayer" + + +def get_blender_anim_curve_node_key(scene, ref_id, obj_key, fbx_prop_name): + """Return (stack/layer, ID, fbxprop) curve node key.""" + return "|".join((get_blender_anim_id_base(scene, ref_id), obj_key, fbx_prop_name, "AnimCurveNode")) + + +def get_blender_anim_curve_key(scene, ref_id, obj_key, fbx_prop_name, fbx_prop_item_name): + """Return (stack/layer, ID, fbxprop, item) curve key.""" + return "|".join((get_blender_anim_id_base(scene, ref_id), obj_key, fbx_prop_name, + fbx_prop_item_name, "AnimCurve")) + + +def get_blender_nodetexture_key(ma, socket_names): + return "|".join((get_blenderID_key(ma), *socket_names)) + + +# ##### Element generators. ##### + +# Note: elem may be None, in this case the element is not added to any parent. +def elem_empty(elem, name): + sub_elem = encode_bin.FBXElem(name) + if elem is not None: + elem.elems.append(sub_elem) + return sub_elem + + +def _elem_data_single(elem, name, value, func_name): + sub_elem = elem_empty(elem, name) + getattr(sub_elem, func_name)(value) + return sub_elem + + +def _elem_data_vec(elem, name, value, func_name): + sub_elem = elem_empty(elem, name) + func = getattr(sub_elem, func_name) + for v in value: + func(v) + return sub_elem + + +def elem_data_single_bool(elem, name, value): + return _elem_data_single(elem, name, value, "add_bool") + + +def elem_data_single_char(elem, name, value): + return _elem_data_single(elem, name, value, "add_char") + + +def elem_data_single_int8(elem, name, value): + return _elem_data_single(elem, name, value, "add_int8") + + +def elem_data_single_int16(elem, name, value): + return _elem_data_single(elem, name, value, "add_int16") + + +def elem_data_single_int32(elem, name, value): + return _elem_data_single(elem, name, value, "add_int32") + + +def elem_data_single_int64(elem, name, value): + return _elem_data_single(elem, name, value, "add_int64") + + +def elem_data_single_float32(elem, name, value): + return _elem_data_single(elem, name, value, "add_float32") + + +def elem_data_single_float64(elem, name, value): + return _elem_data_single(elem, name, value, "add_float64") + + +def elem_data_single_bytes(elem, name, value): + return _elem_data_single(elem, name, value, "add_bytes") + + +def elem_data_single_string(elem, name, value): + return _elem_data_single(elem, name, value, "add_string") + + +def elem_data_single_string_unicode(elem, name, value): + return _elem_data_single(elem, name, value, "add_string_unicode") + + +def elem_data_single_bool_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_bool_array") + + +def elem_data_single_int32_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_int32_array") + + +def elem_data_single_int64_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_int64_array") + + +def elem_data_single_float32_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_float32_array") + + +def elem_data_single_float64_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_float64_array") + + +def elem_data_single_byte_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_byte_array") + + +def elem_data_vec_float64(elem, name, value): + return _elem_data_vec(elem, name, value, "add_float64") + + +# ##### Generators for standard FBXProperties70 properties. ##### + +def elem_properties(elem): + return elem_empty(elem, b"Properties70") + + +# Properties definitions, format: (b"type_1", b"label(???)", "name_set_value_1", "name_set_value_2", ...) +# XXX Looks like there can be various variations of formats here... Will have to be checked ultimately! +# Also, those "custom" types like 'FieldOfView' or 'Lcl Translation' are pure nonsense, +# these are just Vector3D ultimately... *sigh* (again). +FBX_PROPERTIES_DEFINITIONS = { + # Generic types. + "p_bool": (b"bool", b"", "add_int32"), # Yes, int32 for a bool (and they do have a core bool type)!!! + "p_integer": (b"int", b"Integer", "add_int32"), + "p_ulonglong": (b"ULongLong", b"", "add_int64"), + "p_double": (b"double", b"Number", "add_float64"), # Non-animatable? + "p_number": (b"Number", b"", "add_float64"), # Animatable-only? + "p_enum": (b"enum", b"", "add_int32"), + "p_vector_3d": (b"Vector3D", b"Vector", "add_float64", "add_float64", "add_float64"), # Non-animatable? + "p_vector": (b"Vector", b"", "add_float64", "add_float64", "add_float64"), # Animatable-only? + "p_color_rgb": (b"ColorRGB", b"Color", "add_float64", "add_float64", "add_float64"), # Non-animatable? + "p_color": (b"Color", b"", "add_float64", "add_float64", "add_float64"), # Animatable-only? + "p_string": (b"KString", b"", "add_string_unicode"), + "p_string_url": (b"KString", b"Url", "add_string_unicode"), + "p_timestamp": (b"KTime", b"Time", "add_int64"), + "p_datetime": (b"DateTime", b"", "add_string_unicode"), + # Special types. + "p_object": (b"object", b""), # XXX Check this! No value for this prop??? Would really like to know how it works! + "p_compound": (b"Compound", b""), + # Specific types (sic). + # ## Objects (Models). + "p_lcl_translation": (b"Lcl Translation", b"", "add_float64", "add_float64", "add_float64"), + "p_lcl_rotation": (b"Lcl Rotation", b"", "add_float64", "add_float64", "add_float64"), + "p_lcl_scaling": (b"Lcl Scaling", b"", "add_float64", "add_float64", "add_float64"), + "p_visibility": (b"Visibility", b"", "add_float64"), + "p_visibility_inheritance": (b"Visibility Inheritance", b"", "add_int32"), + # ## Cameras!!! + "p_roll": (b"Roll", b"", "add_float64"), + "p_opticalcenterx": (b"OpticalCenterX", b"", "add_float64"), + "p_opticalcentery": (b"OpticalCenterY", b"", "add_float64"), + "p_fov": (b"FieldOfView", b"", "add_float64"), + "p_fov_x": (b"FieldOfViewX", b"", "add_float64"), + "p_fov_y": (b"FieldOfViewY", b"", "add_float64"), +} + + +def _elem_props_set(elem, ptype, name, value, flags): + p = elem_data_single_string(elem, b"P", name) + for t in ptype[:2]: + p.add_string(t) + p.add_string(flags) + if len(ptype) == 3: + getattr(p, ptype[2])(value) + elif len(ptype) > 3: + # We assume value is iterable, else it's a bug! + for callback, val in zip(ptype[2:], value): + getattr(p, callback)(val) + + +def _elem_props_flags(animatable, animated, custom): + # XXX: There are way more flags, see + # http://help.autodesk.com/view/FBX/2015/ENU/?guid=__cpp_ref_class_fbx_property_flags_html + # Unfortunately, as usual, no doc at all about their 'translation' in actual FBX file format. + # Curse you-know-who. + if animatable: + if animated: + if custom: + return b"A+U" + return b"A+" + if custom: + # Seems that customprops always need those 'flags', see T69554. Go figure... + return b"A+U" + return b"A" + if custom: + # Seems that customprops always need those 'flags', see T69554. Go figure... + return b"A+U" + return b"" + + +def elem_props_set(elem, ptype, name, value=None, animatable=False, animated=False, custom=False): + ptype = FBX_PROPERTIES_DEFINITIONS[ptype] + _elem_props_set(elem, ptype, name, value, _elem_props_flags(animatable, animated, custom)) + + +def elem_props_compound(elem, cmpd_name, custom=False): + def _setter(ptype, name, value, animatable=False, animated=False, custom=False): + name = cmpd_name + b"|" + name + elem_props_set(elem, ptype, name, value, animatable=animatable, animated=animated, custom=custom) + + elem_props_set(elem, "p_compound", cmpd_name, custom=custom) + return _setter + + +def elem_props_template_init(templates, template_type): + """ + Init a writing template of given type, for *one* element's properties. + """ + ret = {} + tmpl = templates.get(template_type) + if tmpl is not None: + written = tmpl.written[0] + props = tmpl.properties + ret = {name: [val, ptype, anim, written] for name, (val, ptype, anim) in props.items()} + return ret + + +def elem_props_template_set(template, elem, ptype_name, name, value, animatable=False, animated=False): + """ + Only add a prop if the same value is not already defined in given template. + Note it is important to not give iterators as value, here! + """ + ptype = FBX_PROPERTIES_DEFINITIONS[ptype_name] + if len(ptype) > 3: + value = tuple(value) + tmpl_val, tmpl_ptype, tmpl_animatable, tmpl_written = template.get(name, (None, None, False, False)) + # Note animatable flag from template takes precedence over given one, if applicable. + # However, animated properties are always written, since they cannot match their template! + if tmpl_ptype is not None and not animated: + if (tmpl_written and + ((len(ptype) == 3 and (tmpl_val, tmpl_ptype) == (value, ptype_name)) or + (len(ptype) > 3 and (tuple(tmpl_val), tmpl_ptype) == (value, ptype_name)))): + return # Already in template and same value. + _elem_props_set(elem, ptype, name, value, _elem_props_flags(tmpl_animatable, animated, False)) + template[name][3] = True + else: + _elem_props_set(elem, ptype, name, value, _elem_props_flags(animatable, animated, False)) + + +def elem_props_template_finalize(template, elem): + """ + Finalize one element's template/props. + Issue is, some templates might be "needed" by different types (e.g. NodeAttribute is for lights, cameras, etc.), + but values for only *one* subtype can be written as template. So we have to be sure we write those for the other + subtypes in each and every elements, if they are not overridden by that element. + Yes, hairy, FBX that is to say. When they could easily support several subtypes per template... :( + """ + for name, (value, ptype_name, animatable, written) in template.items(): + if written: + continue + ptype = FBX_PROPERTIES_DEFINITIONS[ptype_name] + _elem_props_set(elem, ptype, name, value, _elem_props_flags(animatable, False, False)) + + +# ##### Templates ##### +# TODO: check all those "default" values, they should match Blender's default as much as possible, I guess? + +FBXTemplate = namedtuple("FBXTemplate", ("type_name", "prop_type_name", "properties", "nbr_users", "written")) + + +def fbx_templates_generate(root, fbx_templates): + # We may have to gather different templates in the same node (e.g. NodeAttribute template gathers properties + # for Lights, Cameras, LibNodes, etc.). + ref_templates = {(tmpl.type_name, tmpl.prop_type_name): tmpl for tmpl in fbx_templates.values()} + + templates = {} + for type_name, prop_type_name, properties, nbr_users, _written in fbx_templates.values(): + tmpl = templates.setdefault(type_name, [{}, 0]) + tmpl[0][prop_type_name] = (properties, nbr_users) + tmpl[1] += nbr_users + + for type_name, (subprops, nbr_users) in templates.items(): + template = elem_data_single_string(root, b"ObjectType", type_name) + elem_data_single_int32(template, b"Count", nbr_users) + + if len(subprops) == 1: + prop_type_name, (properties, _nbr_sub_type_users) = next(iter(subprops.items())) + subprops = (prop_type_name, properties) + ref_templates[(type_name, prop_type_name)].written[0] = True + else: + # Ack! Even though this could/should work, looks like it is not supported. So we have to chose one. :| + max_users = max_props = -1 + written_prop_type_name = None + for prop_type_name, (properties, nbr_sub_type_users) in subprops.items(): + if nbr_sub_type_users > max_users or (nbr_sub_type_users == max_users and len(properties) > max_props): + max_users = nbr_sub_type_users + max_props = len(properties) + written_prop_type_name = prop_type_name + subprops = (written_prop_type_name, properties) + ref_templates[(type_name, written_prop_type_name)].written[0] = True + + prop_type_name, properties = subprops + if prop_type_name and properties: + elem = elem_data_single_string(template, b"PropertyTemplate", prop_type_name) + props = elem_properties(elem) + for name, (value, ptype, animatable) in properties.items(): + try: + elem_props_set(props, ptype, name, value, animatable=animatable) + except Exception as e: + print("Failed to write template prop (%r)" % e) + print(props, ptype, name, value, animatable) + + +# ##### FBX animation helpers. ##### + + +class AnimationCurveNodeWrapper: + """ + This class provides a same common interface for all (FBX-wise) AnimationCurveNode and AnimationCurve elements, + and easy API to handle those. + """ + __slots__ = ( + 'elem_keys', 'default_values', 'fbx_group', 'fbx_gname', 'fbx_props', + 'force_keying', 'force_startend_keying', + '_frame_times_array', '_frame_values_array', '_frame_write_mask_array', + ) + + kinds = { + 'LCL_TRANSLATION': ("Lcl Translation", "T", ("X", "Y", "Z")), + 'LCL_ROTATION': ("Lcl Rotation", "R", ("X", "Y", "Z")), + 'LCL_SCALING': ("Lcl Scaling", "S", ("X", "Y", "Z")), + 'SHAPE_KEY': ("DeformPercent", "DeformPercent", ("DeformPercent",)), + 'CAMERA_FOCAL': ("FocalLength", "FocalLength", ("FocalLength",)), + 'CAMERA_FOCUS_DISTANCE': ("FocusDistance", "FocusDistance", ("FocusDistance",)), + } + + def __init__(self, elem_key, kind, force_keying, force_startend_keying, default_values=...): + self.elem_keys = [elem_key] + assert(kind in self.kinds) + self.fbx_group = [self.kinds[kind][0]] + self.fbx_gname = [self.kinds[kind][1]] + self.fbx_props = [self.kinds[kind][2]] + self.force_keying = force_keying + self.force_startend_keying = force_startend_keying + self._frame_times_array = None + self._frame_values_array = None + self._frame_write_mask_array = None + if default_values is not ...: + assert(len(default_values) == len(self.fbx_props[0])) + self.default_values = default_values + else: + self.default_values = (0.0) * len(self.fbx_props[0]) + + def __bool__(self): + # We are 'True' if we do have some validated keyframes... + return self._frame_write_mask_array is not None and bool(np.any(self._frame_write_mask_array)) + + def add_group(self, elem_key, fbx_group, fbx_gname, fbx_props): + """ + Add another whole group stuff (curvenode, animated item/prop + curvnode/curve identifiers). + E.g. Shapes animations is written twice, houra! + """ + assert(len(fbx_props) == len(self.fbx_props[0])) + self.elem_keys.append(elem_key) + self.fbx_group.append(fbx_group) + self.fbx_gname.append(fbx_gname) + self.fbx_props.append(fbx_props) + + def set_keyframes(self, keyframe_times, keyframe_values): + """ + Set all keyframe times and values of the group. + Values can be a 2D array where each row is the values for a separate curve. + """ + # View 1D keyframe_values as 2D with a single row, so that the same code can be used for both 1D and + # 2D inputs. + if len(keyframe_values.shape) == 1: + keyframe_values = keyframe_values[np.newaxis] + # There must be a time for each column of values. + assert(len(keyframe_times) == keyframe_values.shape[1]) + # There must be as many rows of values as there are properties. + assert(len(self.fbx_props[0]) == len(keyframe_values)) + write_mask = np.full_like(keyframe_values, True, dtype=bool) # write everything by default + self._frame_times_array = keyframe_times + self._frame_values_array = keyframe_values + self._frame_write_mask_array = write_mask + + def simplify(self, fac, step, force_keep=False): + """ + Simplifies sampled curves by only enabling samples when: + * their values relatively differ from the previous sample ones. + """ + if self._frame_times_array is None: + # Keyframes have not been added yet. + return + + if fac == 0.0: + return + + # So that, with default factor and step values (1), we get: + min_reldiff_fac = fac * 1.0e-3 # min relative value evolution: 0.1% of current 'order of magnitude'. + min_absdiff_fac = 0.1 # A tenth of reldiff... + + # Initialise to no values enabled for writing. + self._frame_write_mask_array[:] = False + + # Values are enabled for writing if they differ enough from either of their adjacent values or if they differ + # enough from the closest previous value that is enabled due to either of these conditions. + for sampled_values, enabled_mask in zip(self._frame_values_array, self._frame_write_mask_array): + # Create overlapping views of the 'previous' (all but the last) and 'current' (all but the first) + # `sampled_values` and `enabled_mask`. + # Calculate absolute values from `sampled_values` so that the 'previous' and 'current' absolute arrays can + # be views into the same array instead of separately calculated arrays. + abs_sampled_values = np.abs(sampled_values) + # 'previous' views. + p_val_view = sampled_values[:-1] + p_abs_val_view = abs_sampled_values[:-1] + p_enabled_mask_view = enabled_mask[:-1] + # 'current' views. + c_val_view = sampled_values[1:] + c_abs_val_view = abs_sampled_values[1:] + c_enabled_mask_view = enabled_mask[1:] + + # If enough difference from previous sampled value, enable the current value *and* the previous one! + # The difference check is symmetrical, so this will compare each value to both of its adjacent values. + # Unless it is forcefully enabled later, this is the only way that the first value can be enabled. + # This is a contracted form of relative + absolute-near-zero difference: + # def is_different(a, b): + # abs_diff = abs(a - b) + # if abs_diff < min_reldiff_fac * min_absdiff_fac: + # return False + # return (abs_diff / ((abs(a) + abs(b)) / 2)) > min_reldiff_fac + # Note that we ignore the '/ 2' part here, since it's not much significant for us. + # Contracted form using only builtin Python functions: + # return abs(a - b) > (min_reldiff_fac * max(abs(a) + abs(b), min_absdiff_fac)) + abs_diff = np.abs(c_val_view - p_val_view) + different_if_greater_than = min_reldiff_fac * np.maximum(c_abs_val_view + p_abs_val_view, min_absdiff_fac) + enough_diff_p_val_mask = abs_diff > different_if_greater_than + # Enable both the current values *and* the previous values where `enough_diff_p_val_mask` is True. Some + # values may get set to True twice because the views overlap, but this is not a problem. + p_enabled_mask_view[enough_diff_p_val_mask] = True + c_enabled_mask_view[enough_diff_p_val_mask] = True + + # Else, if enough difference from previous enabled value, enable the current value only! + # For each 'current' value, get the index of the nearest previous enabled value in `sampled_values` (or + # itself if the value is enabled). + # Start with an array that is the index of the 'current' value in `sampled_values`. The 'current' values are + # all but the first value, so the indices will be from 1 to `len(sampled_values)` exclusive. + # Let len(sampled_values) == 9: + # [1, 2, 3, 4, 5, 6, 7, 8] + p_enabled_idx_in_sampled_values = np.arange(1, len(sampled_values)) + # Replace the indices of all disabled values with 0 in preparation of filling them in with the index of the + # nearest previous enabled value. We choose to replace with 0 so that if there is no nearest previous + # enabled value, we instead default to `sampled_values[0]`. + c_val_disabled_mask = ~c_enabled_mask_view + # Let `c_val_disabled_mask` be: + # [F, F, T, F, F, T, T, T] + # Set indices to 0 where `c_val_disabled_mask` is True: + # [1, 2, 3, 4, 5, 6, 7, 8] + # v v v v + # [1, 2, 0, 4, 5, 0, 0, 0] + p_enabled_idx_in_sampled_values[c_val_disabled_mask] = 0 + # Accumulative maximum travels across the array from left to right, filling in the zeroed indices with the + # maximum value so far, which will be the closest previous enabled index because the non-zero indices are + # strictly increasing. + # [1, 2, 0, 4, 5, 0, 0, 0] + # v v v v + # [1, 2, 2, 4, 5, 5, 5, 5] + p_enabled_idx_in_sampled_values = np.maximum.accumulate(p_enabled_idx_in_sampled_values) + # Only disabled values need to be checked against their nearest previous enabled values. + # We can additionally ignore all values which equal their immediately previous value because those values + # will never be enabled if they were not enabled by the earlier difference check against immediately + # previous values. + p_enabled_diff_to_check_mask = np.logical_and(c_val_disabled_mask, p_val_view != c_val_view) + # Convert from a mask to indices because we need the indices later and because the array of indices will + # usually be smaller than the mask array making it faster to index other arrays with. + p_enabled_diff_to_check_idx = np.flatnonzero(p_enabled_diff_to_check_mask) + # `p_enabled_idx_in_sampled_values` from earlier: + # [1, 2, 2, 4, 5, 5, 5, 5] + # `p_enabled_diff_to_check_mask` assuming no values equal their immediately previous value: + # [F, F, T, F, F, T, T, T] + # `p_enabled_diff_to_check_idx`: + # [ 2, 5, 6, 7] + # `p_enabled_idx_in_sampled_values_to_check`: + # [ 2, 5, 5, 5] + p_enabled_idx_in_sampled_values_to_check = p_enabled_idx_in_sampled_values[p_enabled_diff_to_check_idx] + # Get the 'current' disabled values that need to be checked. + c_val_to_check = c_val_view[p_enabled_diff_to_check_idx] + c_abs_val_to_check = c_abs_val_view[p_enabled_diff_to_check_idx] + # Get the nearest previous enabled value for each value to be checked. + nearest_p_enabled_val = sampled_values[p_enabled_idx_in_sampled_values_to_check] + abs_nearest_p_enabled_val = np.abs(nearest_p_enabled_val) + # Check the relative + absolute-near-zero difference again, but against the nearest previous enabled value + # this time. + abs_diff = np.abs(c_val_to_check - nearest_p_enabled_val) + different_if_greater_than = (min_reldiff_fac + * np.maximum(c_abs_val_to_check + abs_nearest_p_enabled_val, min_absdiff_fac)) + enough_diff_p_enabled_val_mask = abs_diff > different_if_greater_than + # If there are any that are different enough from the previous enabled value, then we have to check them all + # iteratively because enabling a new value can change the nearest previous enabled value of some elements, + # which changes their relative + absolute-near-zero difference: + # `p_enabled_diff_to_check_idx`: + # [2, 5, 6, 7] + # `p_enabled_idx_in_sampled_values_to_check`: + # [2, 5, 5, 5] + # Let `enough_diff_p_enabled_val_mask` be: + # [F, F, T, T] + # The first index that is newly enabled is 6: + # [2, 5,>6<,5] + # But 6 > 5, so the next value's nearest previous enabled index is also affected: + # [2, 5, 6,>6<] + # We had calculated a newly enabled index of 7 too, but that was calculated against the old nearest previous + # enabled index of 5, which has now been updated to 6, so whether 7 is enabled or not needs to be + # recalculated: + # [F, F, T, ?] + if np.any(enough_diff_p_enabled_val_mask): + # Accessing .data, the memoryview of the array, iteratively or by individual index is faster than doing + # the same with the array itself. + zipped = zip(p_enabled_diff_to_check_idx.data, + c_val_to_check.data, + c_abs_val_to_check.data, + p_enabled_idx_in_sampled_values_to_check.data, + enough_diff_p_enabled_val_mask.data) + # While iterating, we could set updated values into `enough_diff_p_enabled_val_mask` as we go and then + # update `enabled_mask` in bulk after the iteration, but if we're going to update an array while + # iterating, we may as well update `enabled_mask` directly instead and skip the bulk update. + # Additionally, the number of `True` writes to `enabled_mask` is usually much less than the number of + # updates that would be required to `enough_diff_p_enabled_val_mask`. + c_enabled_mask_view_mv = c_enabled_mask_view.data + + # While iterating, keep track of the most recent newly enabled index, so we can tell when we need to + # recalculate whether the current value needs to be enabled. + new_p_enabled_idx = -1 + # Keep track of its value too for performance. + new_p_enabled_val = -1 + new_abs_p_enabled_val = -1 + for cur_idx, c_val, c_abs_val, old_p_enabled_idx, enough_diff in zipped: + if new_p_enabled_idx > old_p_enabled_idx: + # The nearest previous enabled value is newly enabled and was not included when + # `enough_diff_p_enabled_val_mask` was calculated, so whether the current value is different + # enough needs to be recalculated using the newly enabled value. + # Check if the relative + absolute-near-zero difference is enough to enable this value. + enough_diff = (abs(c_val - new_p_enabled_val) + > (min_reldiff_fac * max(c_abs_val + new_abs_p_enabled_val, min_absdiff_fac))) + if enough_diff: + # The current value needs to be enabled. + c_enabled_mask_view_mv[cur_idx] = True + # Update the index and values for this newly enabled value. + new_p_enabled_idx = cur_idx + new_p_enabled_val = c_val + new_abs_p_enabled_val = c_abs_val + + # If we write nothing (action doing nothing) and are in 'force_keep' mode, we key everything! :P + # See T41766. + # Also, it seems some importers (e.g. UE4) do not handle correctly armatures where some bones + # are not animated, but are children of animated ones, so added an option to systematically force writing + # one key in this case. + # See T41719, T41605, T41254... + if self.force_keying or (force_keep and not self): + are_keyed = [True] * len(self._frame_write_mask_array) + else: + are_keyed = np.any(self._frame_write_mask_array, axis=1) + + # If we did key something, ensure first and last sampled values are keyed as well. + if self.force_startend_keying: + for is_keyed, frame_write_mask in zip(are_keyed, self._frame_write_mask_array): + if is_keyed: + frame_write_mask[:1] = True + frame_write_mask[-1:] = True + + def get_final_data(self, scene, ref_id, force_keep=False): + """ + Yield final anim data for this 'curvenode' (for all curvenodes defined). + force_keep is to force to keep a curve even if it only has one valid keyframe. + """ + curves = [ + (self._frame_times_array[write_mask], values[write_mask]) + for values, write_mask in zip(self._frame_values_array, self._frame_write_mask_array) + ] + + force_keep = force_keep or self.force_keying + for elem_key, fbx_group, fbx_gname, fbx_props in \ + zip(self.elem_keys, self.fbx_group, self.fbx_gname, self.fbx_props): + group_key = get_blender_anim_curve_node_key(scene, ref_id, elem_key, fbx_group) + group = {} + for c, def_val, fbx_item in zip(curves, self.default_values, fbx_props): + fbx_item = FBX_ANIM_PROPSGROUP_NAME + "|" + fbx_item + curve_key = get_blender_anim_curve_key(scene, ref_id, elem_key, fbx_group, fbx_item) + # (curve key, default value, keyframes, write flag). + times = c[0] + write_flag = len(times) > (0 if force_keep else 1) + group[fbx_item] = (curve_key, def_val, c, write_flag) + yield elem_key, group_key, group, fbx_group, fbx_gname + + +# ##### FBX objects generators. ##### + +# FBX Model-like data (i.e. Blender objects, depsgraph instances and bones) are wrapped in ObjectWrapper. +# This allows us to have a (nearly) same code FBX-wise for all those types. +# The wrapper tries to stay as small as possible, by mostly using callbacks (property(get...)) +# to actual Blender data it contains. +# Note it caches its instances, so that you may call several times ObjectWrapper(your_object) +# with a minimal cost (just re-computing the key). + +class MetaObjectWrapper(type): + def __call__(cls, bdata, armature=None): + if bdata is None: + return None + dup_mat = None + if isinstance(bdata, Object): + key = get_blenderID_key(bdata) + elif isinstance(bdata, DepsgraphObjectInstance): + if bdata.is_instance: + key = "|".join((get_blenderID_key((bdata.parent.original, bdata.instance_object.original)), + cls._get_dup_num_id(bdata))) + dup_mat = bdata.matrix_world.copy() + else: + key = get_blenderID_key(bdata.object.original) + else: # isinstance(bdata, (Bone, PoseBone)): + if isinstance(bdata, PoseBone): + bdata = armature.data.bones[bdata.name] + key = get_blenderID_key((armature, bdata)) + + cache = getattr(cls, "_cache", None) + if cache is None: + cache = cls._cache = {} + instance = cache.get(key) + if instance is not None: + # Duplis hack: since dupli instances are not persistent in Blender (we have to re-create them to get updated + # info like matrix...), we *always* need to reset that matrix when calling ObjectWrapper() (all + # other data is supposed valid during whole cache live span, so we can skip resetting it). + instance._dupli_matrix = dup_mat + return instance + + instance = cls.__new__(cls, bdata, armature) + instance.__init__(bdata, armature) + instance.key = key + instance._dupli_matrix = dup_mat + cache[key] = instance + return instance + + +class ObjectWrapper(metaclass=MetaObjectWrapper): + """ + This class provides a same common interface for all (FBX-wise) object-like elements: + * Blender Object + * Blender Bone and PoseBone + * Blender DepsgraphObjectInstance (for dulis). + Note since a same Blender object might be 'mapped' to several FBX models (esp. with duplis), + we need to use a key to identify each. + """ + __slots__ = ( + 'name', 'key', 'bdata', 'parented_to_armature', 'override_materials', + '_tag', '_ref', '_dupli_matrix' + ) + + @classmethod + def cache_clear(cls): + if hasattr(cls, "_cache"): + del cls._cache + + @staticmethod + def _get_dup_num_id(bdata): + INVALID_IDS = {2147483647, 0} + pids = tuple(bdata.persistent_id) + idx_valid = 0 + prev_i = ... + for idx, i in enumerate(pids[::-1]): + if i not in INVALID_IDS or (idx == len(pids) and i == 0 and prev_i != 0): + idx_valid = len(pids) - idx + break + prev_i = i + return ".".join(str(i) for i in pids[:idx_valid]) + + def __init__(self, bdata, armature=None): + """ + bdata might be an Object (deprecated), DepsgraphObjectInstance, Bone or PoseBone. + If Bone or PoseBone, armature Object must be provided. + """ + # Note: DepsgraphObjectInstance are purely runtime data, they become invalid as soon as we step to the next item! + # Hence we have to immediately copy *all* needed data... + if isinstance(bdata, Object): # DEPRECATED + self._tag = 'OB' + self.name = get_blenderID_name(bdata) + self.bdata = bdata + self._ref = None + elif isinstance(bdata, DepsgraphObjectInstance): + if bdata.is_instance: + # Note that dupli instance matrix is set by meta-class initialization. + self._tag = 'DP' + self.name = "|".join((get_blenderID_name((bdata.parent.original, bdata.instance_object.original)), + "Dupli", self._get_dup_num_id(bdata))) + self.bdata = bdata.instance_object.original + self._ref = bdata.parent.original + else: + self._tag = 'OB' + self.name = get_blenderID_name(bdata) + self.bdata = bdata.object.original + self._ref = None + else: # isinstance(bdata, (Bone, PoseBone)): + if isinstance(bdata, PoseBone): + bdata = armature.data.bones[bdata.name] + self._tag = 'BO' + self.name = get_blenderID_name(bdata) + self.bdata = bdata + self._ref = armature + self.parented_to_armature = False + self.override_materials = None + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.key == other.key + + def __hash__(self): + return hash(self.key) + + def __repr__(self): + return self.key + + # #### Common to all _tag values. + def get_fbx_uuid(self): + return get_fbx_uuid_from_key(self.key) + fbx_uuid = property(get_fbx_uuid) + + # XXX Not sure how much that’s useful now... :/ + def get_hide(self): + return self.bdata.hide_viewport if self._tag in {'OB', 'DP'} else self.bdata.hide + hide = property(get_hide) + + def get_parent(self): + if self._tag == 'OB': + if (self.bdata.parent and self.bdata.parent.type == 'ARMATURE' and + self.bdata.parent_type == 'BONE' and self.bdata.parent_bone): + # Try to parent to a bone. + bo_par = self.bdata.parent.pose.bones.get(self.bdata.parent_bone, None) + if (bo_par): + return ObjectWrapper(bo_par, self.bdata.parent) + else: # Fallback to mere object parenting. + return ObjectWrapper(self.bdata.parent) + else: + # Mere object parenting. + return ObjectWrapper(self.bdata.parent) + elif self._tag == 'DP': + return ObjectWrapper(self._ref) + else: # self._tag == 'BO' + return ObjectWrapper(self.bdata.parent, self._ref) or ObjectWrapper(self._ref) + parent = property(get_parent) + + def get_bdata_pose_bone(self): + if self._tag == 'BO': + return self._ref.pose.bones[self.bdata.name] + return None + bdata_pose_bone = property(get_bdata_pose_bone) + + def get_matrix_local(self): + if self._tag == 'OB': + return self.bdata.matrix_local.copy() + elif self._tag == 'DP': + return self._ref.matrix_world.inverted_safe() @ self._dupli_matrix + else: # 'BO', current pose + # PoseBone.matrix is in armature space, bring in back in real local one! + par = self.bdata.parent + par_mat_inv = self._ref.pose.bones[par.name].matrix.inverted_safe() if par else Matrix() + return par_mat_inv @ self._ref.pose.bones[self.bdata.name].matrix + matrix_local = property(get_matrix_local) + + def get_matrix_global(self): + if self._tag == 'OB': + return self.bdata.matrix_world.copy() + elif self._tag == 'DP': + return self._dupli_matrix + else: # 'BO', current pose + return self._ref.matrix_world @ self._ref.pose.bones[self.bdata.name].matrix + matrix_global = property(get_matrix_global) + + def get_matrix_rest_local(self): + if self._tag == 'BO': + # Bone.matrix_local is in armature space, bring in back in real local one! + par = self.bdata.parent + par_mat_inv = par.matrix_local.inverted_safe() if par else Matrix() + return par_mat_inv @ self.bdata.matrix_local + else: + return self.matrix_local.copy() + matrix_rest_local = property(get_matrix_rest_local) + + def get_matrix_rest_global(self): + if self._tag == 'BO': + return self._ref.matrix_world @ self.bdata.matrix_local + else: + return self.matrix_global.copy() + matrix_rest_global = property(get_matrix_rest_global) + + # #### Transform and helpers + def has_valid_parent(self, objects): + par = self.parent + if par in objects: + if self._tag == 'OB': + par_type = self.bdata.parent_type + if par_type in {'OBJECT', 'BONE'}: + return True + else: + print("Sorry, “{}” parenting type is not supported".format(par_type)) + return False + return True + return False + + def use_bake_space_transform(self, scene_data): + # NOTE: Only applies to object types supporting this!!! Currently, only meshes and the like... + # TODO: Check whether this can work for bones too... + return (scene_data.settings.bake_space_transform and self._tag in {'OB', 'DP'} and + self.bdata.type in BLENDER_OBJECT_TYPES_MESHLIKE | {'EMPTY'}) + + def fbx_object_matrix(self, scene_data, rest=False, local_space=False, global_space=False): + """ + Generate object transform matrix (*always* in matching *FBX* space!). + If local_space is True, returned matrix is *always* in local space. + Else if global_space is True, returned matrix is always in world space. + If both local_space and global_space are False, returned matrix is in parent space if parent is valid, + else in world space. + Note local_space has precedence over global_space. + If rest is True and object is a Bone, returns matching rest pose transform instead of current pose one. + Applies specific rotation to bones, lamps and cameras (conversion Blender -> FBX). + """ + # Objects which are not bones and do not have any parent are *always* in global space + # (unless local_space is True!). + is_global = (not local_space and + (global_space or not (self._tag in {'DP', 'BO'} or self.has_valid_parent(scene_data.objects)))) + + # Objects (meshes!) parented to armature are not parented to anything in FBX, hence we need them + # in global space, which is their 'virtual' local space... + is_global = is_global or self.parented_to_armature + + # Since we have to apply corrections to some types of object, we always need local Blender space here... + matrix = self.matrix_rest_local if rest else self.matrix_local + parent = self.parent + + # Bones, lamps and cameras need to be rotated (in local space!). + if self._tag == 'BO': + # If we have a bone parent we need to undo the parent correction. + if not is_global and scene_data.settings.bone_correction_matrix_inv and parent and parent.is_bone: + matrix = scene_data.settings.bone_correction_matrix_inv @ matrix + # Apply the bone correction. + if scene_data.settings.bone_correction_matrix: + matrix = matrix @ scene_data.settings.bone_correction_matrix + elif self.bdata.type == 'LIGHT': + matrix = matrix @ MAT_CONVERT_LIGHT + elif self.bdata.type == 'CAMERA': + matrix = matrix @ MAT_CONVERT_CAMERA + + if self._tag in {'DP', 'OB'} and parent: + if parent._tag == 'BO': + # In bone parent case, we get transformation in **bone tip** space (sigh). + # Have to bring it back into bone root, which is FBX expected value. + matrix = Matrix.Translation((0, (parent.bdata.tail - parent.bdata.head).length, 0)) @ matrix + + # Our matrix is in local space, time to bring it in its final desired space. + if parent: + if is_global: + # Move matrix to global Blender space. + matrix = (parent.matrix_rest_global if rest else parent.matrix_global) @ matrix + elif parent.use_bake_space_transform(scene_data): + # Blender's and FBX's local space of parent may differ if we use bake_space_transform... + # Apply parent's *Blender* local space... + matrix = (parent.matrix_rest_local if rest else parent.matrix_local) @ matrix + # ...and move it back into parent's *FBX* local space. + par_mat = parent.fbx_object_matrix(scene_data, rest=rest, local_space=True) + matrix = par_mat.inverted_safe() @ matrix + + if self.use_bake_space_transform(scene_data): + # If we bake the transforms we need to post-multiply inverse global transform. + # This means that the global transform will not apply to children of this transform. + matrix = matrix @ scene_data.settings.global_matrix_inv + if is_global: + # In any case, pre-multiply the global matrix to get it in FBX global space! + matrix = scene_data.settings.global_matrix @ matrix + + return matrix + + def fbx_object_tx(self, scene_data, rest=False, rot_euler_compat=None): + """ + Generate object transform data (always in local space when possible). + """ + matrix = self.fbx_object_matrix(scene_data, rest=rest) + loc, rot, scale = matrix.decompose() + matrix_rot = rot.to_matrix() + # quat -> euler, we always use 'XYZ' order, use ref rotation if given. + if rot_euler_compat is not None: + rot = rot.to_euler('XYZ', rot_euler_compat) + else: + rot = rot.to_euler('XYZ') + return loc, rot, scale, matrix, matrix_rot + + # #### _tag dependent... + def get_is_object(self): + return self._tag == 'OB' + is_object = property(get_is_object) + + def get_is_dupli(self): + return self._tag == 'DP' + is_dupli = property(get_is_dupli) + + def get_is_bone(self): + return self._tag == 'BO' + is_bone = property(get_is_bone) + + def get_type(self): + if self._tag in {'OB', 'DP'}: + return self.bdata.type + return ... + type = property(get_type) + + def get_armature(self): + if self._tag == 'BO': + return ObjectWrapper(self._ref) + return None + armature = property(get_armature) + + def get_bones(self): + if self._tag == 'OB' and self.bdata.type == 'ARMATURE': + return (ObjectWrapper(bo, self.bdata) for bo in self.bdata.data.bones) + return () + bones = property(get_bones) + + def get_materials(self): + override_materials = self.override_materials + if override_materials is not None: + return override_materials + if self._tag in {'OB', 'DP'}: + return tuple(slot.material for slot in self.bdata.material_slots) + return () + materials = property(get_materials) + + def is_deformed_by_armature(self, arm_obj): + if not (self.is_object and self.type == 'MESH'): + return False + if self.parent == arm_obj and self.bdata.parent_type == 'ARMATURE': + return True + for mod in self.bdata.modifiers: + if mod.type == 'ARMATURE' and mod.object == arm_obj.bdata: + return True + + # #### Duplis... + def dupli_list_gen(self, depsgraph): + if self._tag == 'OB' and self.bdata.is_instancer: + return (ObjectWrapper(dup) for dup in depsgraph.object_instances + if dup.parent and ObjectWrapper(dup.parent.original) == self) + return () + + +def fbx_name_class(name, cls): + return FBX_NAME_CLASS_SEP.join((name, cls)) + + +# ##### Top-level FBX data container. ##### + +# Helper sub-container gathering all exporter settings related to media (texture files). +FBXExportSettingsMedia = namedtuple("FBXExportSettingsMedia", ( + "path_mode", "base_src", "base_dst", "subdir", + "embed_textures", "copy_set", "embedded_set", +)) + +# Helper container gathering all exporter settings. +FBXExportSettings = namedtuple("FBXExportSettings", ( + "report", "to_axes", "global_matrix", "global_scale", "apply_unit_scale", "unit_scale", + "bake_space_transform", "global_matrix_inv", "global_matrix_inv_transposed", + "context_objects", "object_types", "use_mesh_modifiers", "use_mesh_modifiers_render", + "mesh_smooth_type", "use_subsurf", "use_mesh_edges", "use_tspace", "use_triangles", + "armature_nodetype", "use_armature_deform_only", "add_leaf_bones", + "bone_correction_matrix", "bone_correction_matrix_inv", + "bake_anim", "bake_anim_use_all_bones", "bake_anim_use_nla_strips", "bake_anim_use_all_actions", + "bake_anim_step", "bake_anim_simplify_factor", "bake_anim_force_startend_keying", + "use_metadata", "media_settings", "use_custom_props", "colors_type", "prioritize_active_color", "stellar_blade_fix", "stellar_blade_skeleton" +)) + +# Helper container gathering some data we need multiple times: +# * templates. +# * settings, scene. +# * objects. +# * object data. +# * skinning data (binding armature/mesh). +# * animations. +FBXExportData = namedtuple("FBXExportData", ( + "templates", "templates_users", "connections", + "settings", "scene", "depsgraph", "objects", "animations", "animated", "frame_start", "frame_end", + "data_empties", "data_lights", "data_cameras", "data_meshes", "mesh_material_indices", + "data_bones", "data_leaf_bones", "data_deformers_skin", "data_deformers_shape", + "data_world", "data_materials", "data_textures", "data_videos", +)) + +# Helper container gathering all importer settings. +FBXImportSettings = namedtuple("FBXImportSettings", ( + "report", "to_axes", "global_matrix", "global_scale", + "bake_space_transform", "global_matrix_inv", "global_matrix_inv_transposed", + "use_custom_normals", "use_image_search", + "use_alpha_decals", "decal_offset", + "use_anim", "anim_offset", + "use_subsurf", + "use_custom_props", "use_custom_props_enum_as_string", + "nodal_material_wrap_map", "image_cache", + "ignore_leaf_bones", "force_connect_children", "automatic_bone_orientation", "bone_correction_matrix", + "use_prepost_rot", "colors_type", +)) diff --git a/4.5.2_LTS/io_scene_fbx/fbx_utils_threading.py b/4.5.2_LTS/io_scene_fbx/fbx_utils_threading.py new file mode 100644 index 0000000..bf7631b --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/fbx_utils_threading.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from contextlib import contextmanager, nullcontext +import os +from queue import SimpleQueue + +# Note: `bpy` cannot be imported here because this module is also used by the fbx2json.py and json2fbx.py scripts. + +# For debugging/profiling purposes, can be modified at runtime to force single-threaded execution. +_MULTITHREADING_ENABLED = True +# The concurrent.futures module may not work or may not be available on WebAssembly platforms wasm32-emscripten and +# wasm32-wasi. +try: + from concurrent.futures import ThreadPoolExecutor +except ModuleNotFoundError: + _MULTITHREADING_ENABLED = False + ThreadPoolExecutor = None +else: + try: + # The module may be available, but not be fully functional. An error may be raised when attempting to start a + # new thread. + with ThreadPoolExecutor() as tpe: + # Attempt to start a thread by submitting a callable. + tpe.submit(lambda: None) + except Exception: + # Assume that multithreading is not supported and fall back to single-threaded execution. + _MULTITHREADING_ENABLED = False + + +def get_cpu_count(): + """Get the number of cpus assigned to the current process if that information is available on this system. + If not available, get the total number of cpus. + If the cpu count is indeterminable, it is assumed that there is only 1 cpu available.""" + sched_getaffinity = getattr(os, "sched_getaffinity", None) + if sched_getaffinity is not None: + # Return the number of cpus assigned to the current process. + return len(sched_getaffinity(0)) + count = os.cpu_count() + return count if count is not None else 1 + + +class MultiThreadedTaskConsumer: + """Helper class that encapsulates everything needed to run a function on separate threads, with a single-threaded + fallback if multithreading is not available. + + Lower overhead than typical use of ThreadPoolExecutor because no Future objects are returned, which makes this class + more suitable to running many smaller tasks. + + As with any threaded parallelization, because of Python's Global Interpreter Lock, only one thread can execute + Python code at a time, so threaded parallelization is only useful when the functions used release the GIL, such as + many IO related functions.""" + # A special task value used to signal task consumer threads to shut down. + _SHUT_DOWN_THREADS = object() + + __slots__ = ("_consumer_function", "_shared_task_queue", "_task_consumer_futures", "_executor", + "_max_consumer_threads", "_shutting_down", "_max_queue_per_consumer") + + def __init__(self, consumer_function, max_consumer_threads, max_queue_per_consumer=5): + # It's recommended to use MultiThreadedTaskConsumer.new_cpu_bound_cm() instead of creating new instances + # directly. + # __init__ should only be called after checking _MULTITHREADING_ENABLED. + assert(_MULTITHREADING_ENABLED) + # The function that will be called on separate threads to consume tasks. + self._consumer_function = consumer_function + # All the threads share a single queue. This is a simplistic approach, but it is unlikely to be problematic + # unless the main thread is expected to wait a long time for the consumer threads to finish. + self._shared_task_queue = SimpleQueue() + # Reference to each thread is kept through the returned Future objects. This is used as part of determining when + # new threads should be started and is used to be able to receive and handle exceptions from the threads. + self._task_consumer_futures = [] + # Create the executor. + self._executor = ThreadPoolExecutor(max_workers=max_consumer_threads) + # Technically the max workers of the executor is accessible through its `._max_workers`, but since it's private, + # meaning it could be changed without warning, we'll store the max workers/consumers ourselves. + self._max_consumer_threads = max_consumer_threads + # The maximum task queue size (before another consumer thread is started) increases by this amount with every + # additional consumer thread. + self._max_queue_per_consumer = max_queue_per_consumer + # When shutting down the threads, this is set to True as an extra safeguard to prevent new tasks being + # scheduled. + self._shutting_down = False + + @classmethod + def new_cpu_bound_cm(cls, consumer_function, other_cpu_bound_threads_in_use=1, hard_max_threads=32): + """Return a context manager that, when entered, returns a wrapper around `consumer_function` that schedules + `consumer_function` to be run on a separate thread. + + If the system can't use multithreading, then the context manager's returned function will instead be the input + `consumer_function` argument, causing tasks to be run immediately on the calling thread. + + When exiting the context manager, it waits for all scheduled tasks to complete and prevents the creation of new + tasks, similar to calling ThreadPoolExecutor.shutdown(). For these reasons, the wrapped function should only be + called from the thread that entered the context manager, otherwise there is no guarantee that all tasks will get + scheduled before the context manager exits. + + Any task that fails with an exception will cause all task consumer threads to stop. + + The maximum number of threads used matches the number of cpus available up to a maximum of `hard_max_threads`. + `hard_max_threads`'s default of 32 matches ThreadPoolExecutor's default behaviour. + + The maximum number of threads used is decreased by `other_cpu_bound_threads_in_use`. Defaulting to `1`, assuming + that the calling thread will also be doing CPU-bound work. + + Most IO-bound tasks can probably use a ThreadPoolExecutor directly instead because there will typically be fewer + tasks and, on average, each individual task will take longer. + If needed, `cls.new_cpu_bound_cm(consumer_function, -4)` could be suitable for lots of small IO-bound tasks, + because it ensures a minimum of 5 threads, like the default ThreadPoolExecutor.""" + if _MULTITHREADING_ENABLED: + max_threads = get_cpu_count() - other_cpu_bound_threads_in_use + max_threads = min(max_threads, hard_max_threads) + if max_threads > 0: + return cls(consumer_function, max_threads)._wrap_executor_cm() + # Fall back to single-threaded. + return nullcontext(consumer_function) + + def _task_consumer_callable(self): + """Callable that is run by each task consumer thread. + Signals the other task consumer threads to stop when stopped intentionally or when an exception occurs.""" + try: + while True: + # Blocks until it can get a task. + task_args = self._shared_task_queue.get() + + if task_args is self._SHUT_DOWN_THREADS: + # This special value signals that it's time for all the threads to stop. + break + else: + # Call the task consumer function. + self._consumer_function(*task_args) + finally: + # Either the thread has been told to shut down because it received _SHUT_DOWN_THREADS or an exception has + # occurred. + # Add _SHUT_DOWN_THREADS to the queue so that the other consumer threads will also shut down. + self._shared_task_queue.put(self._SHUT_DOWN_THREADS) + + def _schedule_task(self, *args): + """Task consumer threads are only started as tasks are added. + + To mitigate starting lots of threads if many tasks are scheduled in quick succession, new threads are only + started if the number of queued tasks grows too large. + + This function is a slight misuse of ThreadPoolExecutor. Normally each task to be scheduled would be submitted + through ThreadPoolExecutor.submit, but doing so is noticeably slower for small tasks. We could start new Thread + instances manually without using ThreadPoolExecutor, but ThreadPoolExecutor gives us a higher level API for + waiting for threads to finish and handling exceptions without having to implement an API using Thread ourselves. + """ + if self._shutting_down: + # Shouldn't occur through normal usage. + raise RuntimeError("Cannot schedule new tasks after shutdown") + # Schedule the task by adding it to the task queue. + self._shared_task_queue.put(args) + # Check if more consumer threads need to be added to account for the rate at which tasks are being scheduled + # compared to the rate at which tasks are being consumed. + current_consumer_count = len(self._task_consumer_futures) + if current_consumer_count < self._max_consumer_threads: + # The max queue size increases as new threads are added, otherwise, by the time the next task is added, it's + # likely that the queue size will still be over the max, causing another new thread to be added immediately. + # Increasing the max queue size whenever a new thread is started gives some time for the new thread to start + # up and begin consuming tasks before it's determined that another thread is needed. + max_queue_size_for_current_consumers = self._max_queue_per_consumer * current_consumer_count + + if self._shared_task_queue.qsize() > max_queue_size_for_current_consumers: + # Add a new consumer thread because the queue has grown too large. + self._task_consumer_futures.append(self._executor.submit(self._task_consumer_callable)) + + @contextmanager + def _wrap_executor_cm(self): + """Wrap the executor's context manager to instead return self._schedule_task and such that the threads + automatically start shutting down before the executor itself starts shutting down.""" + # .__enter__() + # Exiting the context manager of the executor will wait for all threads to finish and prevent new + # threads from being created, as if its shutdown() method had been called. + with self._executor: + try: + yield self._schedule_task + finally: + # .__exit__() + self._shutting_down = True + # Signal all consumer threads to finish up and shut down so that the executor can shut down. + # When this is run on the same thread that schedules new tasks, this guarantees that no more tasks will + # be scheduled after the consumer threads start to shut down. + self._shared_task_queue.put(self._SHUT_DOWN_THREADS) + + # Because `self._executor` was entered with a context manager, it will wait for all the consumer threads + # to finish even if we propagate an exception from one of the threads here. + for future in self._task_consumer_futures: + # .exception() waits for the future to finish and returns its raised exception or None. + ex = future.exception() + if ex is not None: + # If one of the threads raised an exception, propagate it to the main thread. + # Only the first exception will be propagated if there were multiple. + raise ex diff --git a/4.5.2_LTS/io_scene_fbx/import_fbx.py b/4.5.2_LTS/io_scene_fbx/import_fbx.py new file mode 100644 index 0000000..0c95352 --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/import_fbx.py @@ -0,0 +1,4033 @@ +# SPDX-FileCopyrightText: 2013-2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +# FBX 7.1.0 -> 7.4.0 loader for Blender + +# Not totally pep8 compliant. +# pep8 import_fbx.py --ignore=E501,E123,E702,E125 + +if "bpy" in locals(): + import importlib + if "parse_fbx" in locals(): + importlib.reload(parse_fbx) + if "fbx_utils" in locals(): + importlib.reload(fbx_utils) + +import bpy +from bpy.app.translations import pgettext_tip as tip_ +from mathutils import Matrix, Euler, Vector, Quaternion + +# Also imported in .fbx_utils, so importing here is unlikely to further affect Blender startup time. +import numpy as np + +# ----- +# Utils +from . import parse_fbx, fbx_utils + +from .parse_fbx import ( + data_types, + FBXElem, +) +from .fbx_utils import ( + PerfMon, + units_blender_to_fbx_factor, + units_convertor_iter, + array_to_matrix4, + similar_values, + similar_values_iter, + FBXImportSettings, + vcos_transformed, + nors_transformed, + parray_as_ndarray, + astype_view_signedness, + MESH_ATTRIBUTE_MATERIAL_INDEX, + MESH_ATTRIBUTE_POSITION, + MESH_ATTRIBUTE_EDGE_VERTS, + MESH_ATTRIBUTE_CORNER_VERT, + MESH_ATTRIBUTE_SHARP_FACE, + MESH_ATTRIBUTE_SHARP_EDGE, + expand_shape_key_range, + FBX_KTIME_V7, + FBX_KTIME_V8, + FBX_TIMECODE_DEFINITION_TO_KTIME_PER_SECOND, +) + +LINEAR_INTERPOLATION_VALUE = bpy.types.Keyframe.bl_rna.properties['interpolation'].enum_items['LINEAR'].value + +# global singleton, assign on execution +fbx_elem_nil = None + +# Units converters... +convert_deg_to_rad_iter = units_convertor_iter("degree", "radian") + +MAT_CONVERT_BONE = fbx_utils.MAT_CONVERT_BONE.inverted() +MAT_CONVERT_LIGHT = fbx_utils.MAT_CONVERT_LIGHT.inverted() +MAT_CONVERT_CAMERA = fbx_utils.MAT_CONVERT_CAMERA.inverted() + + +def validate_blend_names(name): + assert(type(name) == bytes) + # Blender typically does not accept names over 63 bytes... + if len(name) > 63: + import hashlib + h = hashlib.sha1(name).hexdigest() + n = 55 + name_utf8 = name[:n].decode('utf-8', 'replace') + "_" + h[:7] + while len(name_utf8.encode()) > 63: + n -= 1 + name_utf8 = name[:n].decode('utf-8', 'replace') + "_" + h[:7] + return name_utf8 + else: + # We use 'replace' even though FBX 'specs' say it should always be utf8, see T53841. + return name.decode('utf-8', 'replace') + + +def elem_find_first(elem, id_search, default=None): + for fbx_item in elem.elems: + if fbx_item.id == id_search: + return fbx_item + return default + + +def elem_find_iter(elem, id_search): + for fbx_item in elem.elems: + if fbx_item.id == id_search: + yield fbx_item + + +def elem_find_first_string(elem, id_search): + fbx_item = elem_find_first(elem, id_search) + if fbx_item is not None and fbx_item.props: # Do not error on complete empty properties (see T45291). + assert(len(fbx_item.props) == 1) + assert(fbx_item.props_type[0] == data_types.STRING) + return fbx_item.props[0].decode('utf-8', 'replace') + return None + + +def elem_find_first_string_as_bytes(elem, id_search): + fbx_item = elem_find_first(elem, id_search) + if fbx_item is not None and fbx_item.props: # Do not error on complete empty properties (see T45291). + assert(len(fbx_item.props) == 1) + assert(fbx_item.props_type[0] == data_types.STRING) + return fbx_item.props[0] # Keep it as bytes as requested... + return None + + +def elem_find_first_bytes(elem, id_search, decode=True): + fbx_item = elem_find_first(elem, id_search) + if fbx_item is not None and fbx_item.props: # Do not error on complete empty properties (see T45291). + assert(len(fbx_item.props) == 1) + assert(fbx_item.props_type[0] == data_types.BYTES) + return fbx_item.props[0] + return None + + +def elem_repr(elem): + return "%s: props[%d=%r], elems=(%r)" % ( + elem.id, + len(elem.props), + ", ".join([repr(p) for p in elem.props]), + # elem.props_type, + b", ".join([e.id for e in elem.elems]), + ) + + +def elem_split_name_class(elem): + assert(elem.props_type[-2] == data_types.STRING) + elem_name, elem_class = elem.props[-2].split(b'\x00\x01') + return elem_name, elem_class + + +def elem_name_ensure_class(elem, clss=...): + elem_name, elem_class = elem_split_name_class(elem) + if clss is not ...: + assert(elem_class == clss) + return validate_blend_names(elem_name) + + +def elem_name_ensure_classes(elem, clss=...): + elem_name, elem_class = elem_split_name_class(elem) + if clss is not ...: + assert(elem_class in clss) + return validate_blend_names(elem_name) + + +def elem_split_name_class_nodeattr(elem): + assert(elem.props_type[-2] == data_types.STRING) + elem_name, elem_class = elem.props[-2].split(b'\x00\x01') + assert(elem_class == b'NodeAttribute') + assert(elem.props_type[-1] == data_types.STRING) + elem_class = elem.props[-1] + return elem_name, elem_class + + +def elem_uuid(elem): + assert(elem.props_type[0] == data_types.INT64) + return elem.props[0] + + +def elem_prop_first(elem, default=None): + return elem.props[0] if (elem is not None) and elem.props else default + + +# ---- +# Support for +# Properties70: { ... P: +# Custom properties ("user properties" in FBX) are ignored here and get handled separately (see #104773). +def elem_props_find_first(elem, elem_prop_id): + if elem is None: + # When properties are not found... Should never happen, but happens - as usual. + return None + # support for templates (tuple of elems) + if type(elem) is not FBXElem: + assert(type(elem) is tuple) + for e in elem: + result = elem_props_find_first(e, elem_prop_id) + if result is not None: + return result + assert(len(elem) > 0) + return None + + for subelem in elem.elems: + assert(subelem.id == b'P') + # 'U' flag indicates that the property has been defined by the user. + if subelem.props[0] == elem_prop_id and b'U' not in subelem.props[3]: + return subelem + return None + + +def elem_props_get_color_rgb(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert(elem_prop.props[0] == elem_prop_id) + if elem_prop.props[1] == b'Color': + # FBX version 7300 + assert(elem_prop.props[1] == b'Color') + assert(elem_prop.props[2] == b'') + else: + assert(elem_prop.props[1] == b'ColorRGB') + assert(elem_prop.props[2] == b'Color') + assert(elem_prop.props_type[4:7] == bytes((data_types.FLOAT64,)) * 3) + return elem_prop.props[4:7] + return default + + +def elem_props_get_vector_3d(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert(elem_prop.props_type[4:7] == bytes((data_types.FLOAT64,)) * 3) + return elem_prop.props[4:7] + return default + + +def elem_props_get_number(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert(elem_prop.props[0] == elem_prop_id) + if elem_prop.props[1] == b'double': + assert(elem_prop.props[1] == b'double') + assert(elem_prop.props[2] == b'Number') + else: + assert(elem_prop.props[1] == b'Number') + assert(elem_prop.props[2] == b'') + + # we could allow other number types + assert(elem_prop.props_type[4] == data_types.FLOAT64) + + return elem_prop.props[4] + return default + + +def elem_props_get_integer(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert(elem_prop.props[0] == elem_prop_id) + if elem_prop.props[1] == b'int': + assert(elem_prop.props[1] == b'int') + assert(elem_prop.props[2] == b'Integer') + elif elem_prop.props[1] == b'ULongLong': + assert(elem_prop.props[1] == b'ULongLong') + assert(elem_prop.props[2] == b'') + + # we could allow other number types + assert(elem_prop.props_type[4] in {data_types.INT32, data_types.INT64}) + + return elem_prop.props[4] + return default + + +def elem_props_get_bool(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert(elem_prop.props[0] == elem_prop_id) + # b'Bool' with a capital seems to be used for animated property... go figure... + assert(elem_prop.props[1] in {b'bool', b'Bool'}) + assert(elem_prop.props[2] == b'') + + # we could allow other number types + assert(elem_prop.props_type[4] == data_types.INT32) + assert(elem_prop.props[4] in {0, 1}) + + return bool(elem_prop.props[4]) + return default + + +def elem_props_get_enum(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert(elem_prop.props[0] == elem_prop_id) + assert(elem_prop.props[1] == b'enum') + assert(elem_prop.props[2] == b'') + assert(elem_prop.props[3] == b'') + + # we could allow other number types + assert(elem_prop.props_type[4] == data_types.INT32) + + return elem_prop.props[4] + return default + + +def elem_props_get_visibility(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert(elem_prop.props[0] == elem_prop_id) + assert(elem_prop.props[1] == b'Visibility') + assert(elem_prop.props[2] == b'') + + # we could allow other number types + assert(elem_prop.props_type[4] == data_types.FLOAT64) + + return elem_prop.props[4] + return default + + +# ---------------------------------------------------------------------------- +# Blender + +# ------ +# Object +from collections import namedtuple + + +FBXTransformData = namedtuple("FBXTransformData", ( + "loc", "geom_loc", + "rot", "rot_ofs", "rot_piv", "pre_rot", "pst_rot", "rot_ord", "rot_alt_mat", "geom_rot", + "sca", "sca_ofs", "sca_piv", "geom_sca", +)) + + +def blen_read_custom_properties(fbx_obj, blen_obj, settings): + # There doesn't seem to be a way to put user properties into templates, so this only get the object properties: + fbx_obj_props = elem_find_first(fbx_obj, b'Properties70') + if fbx_obj_props: + for fbx_prop in fbx_obj_props.elems: + assert(fbx_prop.id == b'P') + + if b'U' in fbx_prop.props[3]: + if fbx_prop.props[0] == b'UDP3DSMAX': + # Special case for 3DS Max user properties: + try: + assert(fbx_prop.props[1] == b'KString') + except AssertionError as exc: + print(exc) + assert(fbx_prop.props_type[4] == data_types.STRING) + items = fbx_prop.props[4].decode('utf-8', 'replace') + for item in items.split('\r\n'): + if item: + split_item = item.split('=', 1) + if len(split_item) != 2: + split_item = item.split(':', 1) + if len(split_item) != 2: + print("cannot parse UDP3DSMAX custom property '%s', ignoring..." % item) + else: + prop_name, prop_value = split_item + prop_name = validate_blend_names(prop_name.strip().encode('utf-8')) + blen_obj[prop_name] = prop_value.strip() + else: + prop_name = validate_blend_names(fbx_prop.props[0]) + prop_type = fbx_prop.props[1] + if prop_type in {b'Vector', b'Vector3D', b'Color', b'ColorRGB'}: + assert(fbx_prop.props_type[4:7] == bytes((data_types.FLOAT64,)) * 3) + blen_obj[prop_name] = fbx_prop.props[4:7] + elif prop_type in {b'Vector4', b'ColorRGBA'}: + assert(fbx_prop.props_type[4:8] == bytes((data_types.FLOAT64,)) * 4) + blen_obj[prop_name] = fbx_prop.props[4:8] + elif prop_type == b'Vector2D': + assert(fbx_prop.props_type[4:6] == bytes((data_types.FLOAT64,)) * 2) + blen_obj[prop_name] = fbx_prop.props[4:6] + elif prop_type in {b'Integer', b'int'}: + assert(fbx_prop.props_type[4] == data_types.INT32) + blen_obj[prop_name] = fbx_prop.props[4] + elif prop_type == b'KString': + assert(fbx_prop.props_type[4] == data_types.STRING) + blen_obj[prop_name] = fbx_prop.props[4].decode('utf-8', 'replace') + elif prop_type in {b'Number', b'double', b'Double'}: + assert(fbx_prop.props_type[4] == data_types.FLOAT64) + blen_obj[prop_name] = fbx_prop.props[4] + elif prop_type in {b'Float', b'float'}: + assert(fbx_prop.props_type[4] == data_types.FLOAT32) + blen_obj[prop_name] = fbx_prop.props[4] + elif prop_type in {b'Bool', b'bool'}: + assert(fbx_prop.props_type[4] == data_types.INT32) + blen_obj[prop_name] = fbx_prop.props[4] != 0 + elif prop_type in {b'Enum', b'enum'}: + assert(fbx_prop.props_type[4:6] == bytes((data_types.INT32, data_types.STRING))) + val = fbx_prop.props[4] + if settings.use_custom_props_enum_as_string and fbx_prop.props[5]: + enum_items = fbx_prop.props[5].decode('utf-8', 'replace').split('~') + if val >= 0 and val < len(enum_items): + blen_obj[prop_name] = enum_items[val] + else: + print("WARNING: User property '%s' has wrong enum value, skipped" % prop_name) + else: + blen_obj[prop_name] = val + else: + print( + "WARNING: User property type '%s' is not supported" % + prop_type.decode( + 'utf-8', 'replace')) + + +def blen_read_object_transform_do(transform_data): + # This is a nightmare. FBX SDK uses Maya way to compute the transformation matrix of a node - utterly simple: + # + # WorldTransform = ParentWorldTransform @ T @ Roff @ Rp @ Rpre @ R @ Rpost-1 @ Rp-1 @ Soff @ Sp @ S @ Sp-1 + # + # Where all those terms are 4 x 4 matrices that contain: + # WorldTransform: Transformation matrix of the node in global space. + # ParentWorldTransform: Transformation matrix of the parent node in global space. + # T: Translation + # Roff: Rotation offset + # Rp: Rotation pivot + # Rpre: Pre-rotation + # R: Rotation + # Rpost-1: Inverse of the post-rotation (FBX 2011 documentation incorrectly specifies this without inversion) + # Rp-1: Inverse of the rotation pivot + # Soff: Scaling offset + # Sp: Scaling pivot + # S: Scaling + # Sp-1: Inverse of the scaling pivot + # + # But it was still too simple, and FBX notion of compatibility is... quite specific. So we also have to + # support 3DSMax way: + # + # WorldTransform = ParentWorldTransform @ T @ R @ S @ OT @ OR @ OS + # + # Where all those terms are 4 x 4 matrices that contain: + # WorldTransform: Transformation matrix of the node in global space + # ParentWorldTransform: Transformation matrix of the parent node in global space + # T: Translation + # R: Rotation + # S: Scaling + # OT: Geometric transform translation + # OR: Geometric transform rotation + # OS: Geometric transform scale + # + # Notes: + # Geometric transformations ***are not inherited***: ParentWorldTransform does not contain the OT, OR, OS + # of WorldTransform's parent node. + # The R matrix takes into account the rotation order. Other rotation matrices are always 'XYZ' order. + # + # Taken from https://help.autodesk.com/view/FBX/2020/ENU/ + # ?guid=FBX_Developer_Help_nodes_and_scene_graph_fbx_nodes_computing_transformation_matrix_html + + # translation + lcl_translation = Matrix.Translation(transform_data.loc) + geom_loc = Matrix.Translation(transform_data.geom_loc) + + # rotation + def to_rot(rot, rot_ord): return Euler(convert_deg_to_rad_iter(rot), rot_ord).to_matrix().to_4x4() + lcl_rot = to_rot(transform_data.rot, transform_data.rot_ord) @ transform_data.rot_alt_mat + pre_rot = to_rot(transform_data.pre_rot, 'XYZ') + pst_rot = to_rot(transform_data.pst_rot, 'XYZ') + geom_rot = to_rot(transform_data.geom_rot, 'XYZ') + + rot_ofs = Matrix.Translation(transform_data.rot_ofs) + rot_piv = Matrix.Translation(transform_data.rot_piv) + sca_ofs = Matrix.Translation(transform_data.sca_ofs) + sca_piv = Matrix.Translation(transform_data.sca_piv) + + # scale + lcl_scale = Matrix() + lcl_scale[0][0], lcl_scale[1][1], lcl_scale[2][2] = transform_data.sca + geom_scale = Matrix() + geom_scale[0][0], geom_scale[1][1], geom_scale[2][2] = transform_data.geom_sca + + base_mat = ( + lcl_translation @ + rot_ofs @ + rot_piv @ + pre_rot @ + lcl_rot @ + pst_rot.inverted_safe() @ + rot_piv.inverted_safe() @ + sca_ofs @ + sca_piv @ + lcl_scale @ + sca_piv.inverted_safe() + ) + geom_mat = geom_loc @ geom_rot @ geom_scale + # We return mat without 'geometric transforms' too, because it is to be used for children, sigh... + return (base_mat @ geom_mat, base_mat, geom_mat) + + +# XXX This might be weak, now that we can add vgroups from both bones and shapes, name collisions become +# more likely, will have to make this more robust!!! +def add_vgroup_to_objects(vg_indices, vg_weights, vg_name, objects): + assert(len(vg_indices) == len(vg_weights)) + if vg_indices: + for obj in objects: + # We replace/override here... + vg = obj.vertex_groups.get(vg_name) + if vg is None: + vg = obj.vertex_groups.new(name=vg_name) + vg_add = vg.add + for i, w in zip(vg_indices, vg_weights): + vg_add((i,), w, 'REPLACE') + + +def blen_read_object_transform_preprocess(fbx_props, fbx_obj, rot_alt_mat, use_prepost_rot): + # This is quite involved, 'fbxRNode.cpp' from openscenegraph used as a reference + const_vector_zero_3d = 0.0, 0.0, 0.0 + const_vector_one_3d = 1.0, 1.0, 1.0 + + loc = list(elem_props_get_vector_3d(fbx_props, b'Lcl Translation', const_vector_zero_3d)) + rot = list(elem_props_get_vector_3d(fbx_props, b'Lcl Rotation', const_vector_zero_3d)) + sca = list(elem_props_get_vector_3d(fbx_props, b'Lcl Scaling', const_vector_one_3d)) + + geom_loc = list(elem_props_get_vector_3d(fbx_props, b'GeometricTranslation', const_vector_zero_3d)) + geom_rot = list(elem_props_get_vector_3d(fbx_props, b'GeometricRotation', const_vector_zero_3d)) + geom_sca = list(elem_props_get_vector_3d(fbx_props, b'GeometricScaling', const_vector_one_3d)) + + rot_ofs = elem_props_get_vector_3d(fbx_props, b'RotationOffset', const_vector_zero_3d) + rot_piv = elem_props_get_vector_3d(fbx_props, b'RotationPivot', const_vector_zero_3d) + sca_ofs = elem_props_get_vector_3d(fbx_props, b'ScalingOffset', const_vector_zero_3d) + sca_piv = elem_props_get_vector_3d(fbx_props, b'ScalingPivot', const_vector_zero_3d) + + is_rot_act = elem_props_get_bool(fbx_props, b'RotationActive', False) + + if is_rot_act: + if use_prepost_rot: + pre_rot = elem_props_get_vector_3d(fbx_props, b'PreRotation', const_vector_zero_3d) + pst_rot = elem_props_get_vector_3d(fbx_props, b'PostRotation', const_vector_zero_3d) + else: + pre_rot = const_vector_zero_3d + pst_rot = const_vector_zero_3d + rot_ord = { + 0: 'XYZ', + 1: 'XZY', + 2: 'YZX', + 3: 'YXZ', + 4: 'ZXY', + 5: 'ZYX', + 6: 'XYZ', # XXX eSphericXYZ, not really supported... + }.get(elem_props_get_enum(fbx_props, b'RotationOrder', 0)) + else: + pre_rot = const_vector_zero_3d + pst_rot = const_vector_zero_3d + rot_ord = 'XYZ' + + return FBXTransformData(loc, geom_loc, + rot, rot_ofs, rot_piv, pre_rot, pst_rot, rot_ord, rot_alt_mat, geom_rot, + sca, sca_ofs, sca_piv, geom_sca) + + +# --------- +# Animation +def _blen_read_object_transform_do_anim(transform_data, lcl_translation_mat, lcl_rot_euler, lcl_scale_mat, + extra_pre_matrix, extra_post_matrix): + """Specialized version of blen_read_object_transform_do for animation that pre-calculates the non-animated matrices + and returns a function that calculates (base_mat @ geom_mat). See the comments in blen_read_object_transform_do for + a full description of what this function is doing. + + The lcl_translation_mat, lcl_rot_euler and lcl_scale_mat arguments should have their values updated each frame and + then calling the returned function will calculate the matrix for the current frame. + + extra_pre_matrix and extra_post_matrix are any extra matrices to multiply first/last.""" + # Translation + geom_loc = Matrix.Translation(transform_data.geom_loc) + + # Rotation + def to_rot_xyz(rot): + # All the rotations that can be precalculated have a fixed XYZ order. + return Euler(convert_deg_to_rad_iter(rot), 'XYZ').to_matrix().to_4x4() + pre_rot = to_rot_xyz(transform_data.pre_rot) + pst_rot_inv = to_rot_xyz(transform_data.pst_rot).inverted_safe() + geom_rot = to_rot_xyz(transform_data.geom_rot) + + # Offsets and pivots + rot_ofs = Matrix.Translation(transform_data.rot_ofs) + rot_piv = Matrix.Translation(transform_data.rot_piv) + rot_piv_inv = rot_piv.inverted_safe() + sca_ofs = Matrix.Translation(transform_data.sca_ofs) + sca_piv = Matrix.Translation(transform_data.sca_piv) + sca_piv_inv = sca_piv.inverted_safe() + + # Scale + geom_scale = Matrix() + geom_scale[0][0], geom_scale[1][1], geom_scale[2][2] = transform_data.geom_sca + + # Some matrices can be combined in advance, using the associative property of matrix multiplication, so that less + # matrix multiplication is required each frame. + geom_mat = geom_loc @ geom_rot @ geom_scale + post_lcl_translation = rot_ofs @ rot_piv @ pre_rot + post_lcl_rotation = transform_data.rot_alt_mat @ pst_rot_inv @ rot_piv_inv @ sca_ofs @ sca_piv + post_lcl_scaling = sca_piv_inv @ geom_mat @ extra_post_matrix + + # Get the bound to_matrix method to avoid re-binding it on each call. + lcl_rot_euler_to_matrix_3x3 = lcl_rot_euler.to_matrix + # Get the unbound Matrix.to_4x4 method to avoid having to look it up again on each call. + matrix_to_4x4 = Matrix.to_4x4 + + if extra_pre_matrix == Matrix(): + # There aren't any other matrices that must be multiplied before lcl_translation_mat that extra_pre_matrix can + # be combined with, so skip extra_pre_matrix when it's the identity matrix. + return lambda: (lcl_translation_mat @ + post_lcl_translation @ + matrix_to_4x4(lcl_rot_euler_to_matrix_3x3()) @ + post_lcl_rotation @ + lcl_scale_mat @ + post_lcl_scaling) + else: + return lambda: (extra_pre_matrix @ + lcl_translation_mat @ + post_lcl_translation @ + matrix_to_4x4(lcl_rot_euler_to_matrix_3x3()) @ + post_lcl_rotation @ + lcl_scale_mat @ + post_lcl_scaling) + + +def _transformation_curves_gen(item, values_arrays, channel_keys): + """Yields flattened location/rotation/scaling values for imported PoseBone/Object Lcl Translation/Rotation/Scaling + animation curve values. + + The value arrays must have the same lengths, where each index of each array corresponds to a single keyframe. + + Each value array must have a corresponding channel key tuple that identifies the fbx property + (b'Lcl Translation'/b'Lcl Rotation'/b'Lcl Scaling') and the channel (x/y/z as 0/1/2) of that property.""" + from operator import setitem + from functools import partial + + if item.is_bone: + bl_obj = item.bl_obj.pose.bones[item.bl_bone] + else: + bl_obj = item.bl_obj + + rot_mode = bl_obj.rotation_mode + transform_data = item.fbx_transform_data + rot_eul_prev = bl_obj.rotation_euler.copy() + rot_quat_prev = bl_obj.rotation_quaternion.copy() + + # Pre-compute combined pre-matrix + # Remove that rest pose matrix from current matrix (also in parent space) by computing the inverted local rest + # matrix of the bone, if relevant. + combined_pre_matrix = item.get_bind_matrix().inverted_safe() if item.is_bone else Matrix() + # item.pre_matrix will contain any correction for a parent's correction matrix or the global matrix + if item.pre_matrix: + combined_pre_matrix @= item.pre_matrix + + # Pre-compute combined post-matrix + # Compensate for changes in the local matrix during processing + combined_post_matrix = item.anim_compensation_matrix.copy() if item.anim_compensation_matrix else Matrix() + # item.post_matrix will contain any correction for lights, camera and bone orientation + if item.post_matrix: + combined_post_matrix @= item.post_matrix + + # Create matrices/euler from the initial transformation values of this item. + # These variables will be updated in-place as we iterate through each frame. + lcl_translation_mat = Matrix.Translation(transform_data.loc) + lcl_rotation_eul = Euler(convert_deg_to_rad_iter(transform_data.rot), transform_data.rot_ord) + lcl_scaling_mat = Matrix() + lcl_scaling_mat[0][0], lcl_scaling_mat[1][1], lcl_scaling_mat[2][2] = transform_data.sca + + # Create setters into lcl_translation_mat, lcl_rotation_eul and lcl_scaling_mat for each values_array and convert + # any rotation values into radians. + lcl_setters = [] + values_arrays_converted = [] + for values_array, (fbx_prop, channel) in zip(values_arrays, channel_keys): + if fbx_prop == b'Lcl Translation': + # lcl_translation_mat.translation[channel] = value + setter = partial(setitem, lcl_translation_mat.translation, channel) + elif fbx_prop == b'Lcl Rotation': + # FBX rotations are in degrees, but Blender uses radians, so convert all rotation values in advance. + values_array = np.deg2rad(values_array) + # lcl_rotation_eul[channel] = value + setter = partial(setitem, lcl_rotation_eul, channel) + else: + assert(fbx_prop == b'Lcl Scaling') + # lcl_scaling_mat[channel][channel] = value + setter = partial(setitem, lcl_scaling_mat[channel], channel) + lcl_setters.append(setter) + values_arrays_converted.append(values_array) + + # Create an iterator that gets one value from each array. Each iterated tuple will be all the imported + # Lcl Translation/Lcl Rotation/Lcl Scaling values for a single frame, in that order. + # Note that an FBX animation does not have to animate all the channels, so only the animated channels of each + # property will be present. + # .data, the memoryview of an np.ndarray, is faster to iterate than the ndarray itself. + frame_values_it = zip(*(arr.data for arr in values_arrays_converted)) + + # Getting the unbound methods in advance avoids having to look them up again on each call within the loop. + mat_decompose = Matrix.decompose + quat_to_axis_angle = Quaternion.to_axis_angle + quat_to_euler = Quaternion.to_euler + quat_dot = Quaternion.dot + + calc_mat = _blen_read_object_transform_do_anim(transform_data, + lcl_translation_mat, lcl_rotation_eul, lcl_scaling_mat, + combined_pre_matrix, combined_post_matrix) + + # Iterate through the values for each frame. + for frame_values in frame_values_it: + # Set each value into its corresponding lcl matrix/euler. + for lcl_setter, value in zip(lcl_setters, frame_values): + lcl_setter(value) + + # Calculate the updated matrix for this frame. + mat = calc_mat() + + # Now we have a virtual matrix of transform from AnimCurves, we can yield keyframe values! + loc, rot, sca = mat_decompose(mat) + if rot_mode == 'QUATERNION': + if quat_dot(rot_quat_prev, rot) < 0.0: + rot = -rot + rot_quat_prev = rot + elif rot_mode == 'AXIS_ANGLE': + vec, ang = quat_to_axis_angle(rot) + rot = ang, vec.x, vec.y, vec.z + else: # Euler + rot = quat_to_euler(rot, rot_mode, rot_eul_prev) + rot_eul_prev = rot + + # Yield order matches the order that the location/rotation/scale FCurves are created in. + yield from loc + yield from rot + yield from sca + + +def _combine_curve_keyframe_times(times_and_values_tuples, initial_values): + """Combine multiple parsed animation curves, that affect different channels, such that every animation curve + contains the keyframes from every other curve, interpolating the values for the newly inserted keyframes in each + curve. + + Currently, linear interpolation is assumed, but FBX does store how keyframes should be interpolated, so correctly + interpolating the keyframe values is a TODO.""" + if len(times_and_values_tuples) == 1: + # Nothing to do when there is only a single curve. + times, values = times_and_values_tuples[0] + return times, [values] + + all_times = [t[0] for t in times_and_values_tuples] + + # Get the combined sorted unique times of all the curves. + sorted_all_times = np.unique(np.concatenate(all_times)) + + values_arrays = [] + for (times, values), initial_value in zip(times_and_values_tuples, initial_values): + if sorted_all_times.size == times.size: + # `sorted_all_times` will always contain all values in `times` and both `times` and `sorted_all_times` must + # be strictly increasing, so if both arrays have the same size, they must be identical. + extended_values = values + else: + # For now, linear interpolation is assumed. NumPy conveniently has a fast C-compiled function for this. + # Efficiently implementing other FBX supported interpolation will most likely be much more complicated. + extended_values = np.interp(sorted_all_times, times, values, left=initial_value) + values_arrays.append(extended_values) + return sorted_all_times, values_arrays + + +def blen_read_invalid_animation_curve(key_times, key_values): + """FBX will parse animation curves even when their keyframe times are invalid (not strictly increasing). It's + unclear exactly how FBX handles invalid curves, but this matches in some cases and is how the FBX IO addon has been + handling invalid keyframe times for a long time. + + Notably, this function will also correctly parse valid animation curves, though is much slower than the trivial, + regular way. + + The returned keyframe times are guaranteed to be strictly increasing.""" + sorted_unique_times = np.unique(key_times) + + # Unsure if this can be vectorized with numpy, so using iteration for now. + def index_gen(): + idx = 0 + key_times_data = key_times.data + key_times_len = len(key_times) + # Iterating .data, the memoryview of the array, is faster than iterating the array directly. + for curr_fbxktime in sorted_unique_times.data: + if key_times_data[idx] < curr_fbxktime: + if idx >= 0: + idx += 1 + if idx >= key_times_len: + # We have reached our last element for this curve, stay on it from now on... + idx = -1 + yield idx + + indices = np.fromiter(index_gen(), dtype=np.int64, count=len(sorted_unique_times)) + indexed_times = key_times[indices] + indexed_values = key_values[indices] + + # Linear interpolate the value for each time in sorted_unique_times according to the times and values at each index + # and the previous index. + interpolated_values = np.empty_like(indexed_values) + + # Where the index is 0, there's no previous value to interpolate from, so we set the value without interpolating. + # Because the indices are in increasing order, all zeroes must be at the start, so we can find the index of the last + # zero and use that to index with a slice instead of a boolean array for performance. + # Equivalent to, but as a slice: + # idx_zero_mask = indices == 0 + # idx_nonzero_mask = ~idx_zero_mask + first_nonzero_idx = np.searchsorted(indices, 0, side='right') + idx_zero_slice = slice(0, first_nonzero_idx) # [:first_nonzero_idx] + idx_nonzero_slice = slice(first_nonzero_idx, None) # [first_nonzero_idx:] + + interpolated_values[idx_zero_slice] = indexed_values[idx_zero_slice] + + indexed_times_nonzero_idx = indexed_times[idx_nonzero_slice] + indexed_values_nonzero_idx = indexed_values[idx_nonzero_slice] + indices_nonzero = indices[idx_nonzero_slice] + + prev_indices_nonzero = indices_nonzero - 1 + prev_indexed_times_nonzero_idx = key_times[prev_indices_nonzero] + prev_indexed_values_nonzero_idx = key_values[prev_indices_nonzero] + + ifac_a = sorted_unique_times[idx_nonzero_slice] - prev_indexed_times_nonzero_idx + ifac_b = indexed_times_nonzero_idx - prev_indexed_times_nonzero_idx + # If key_times contains two (or more) duplicate times in a row, then values in `ifac_b` can be zero which would + # result in division by zero. + # Use the `np.errstate` context manager to suppress printing the RuntimeWarning to the system console. + with np.errstate(divide='ignore'): + ifac = ifac_a / ifac_b + interpolated_values[idx_nonzero_slice] = ((indexed_values_nonzero_idx - prev_indexed_values_nonzero_idx) * ifac + + prev_indexed_values_nonzero_idx) + + # If the time to interpolate at is larger than the time in indexed_times, then the value has been extrapolated. + # Extrapolated values are excluded. + valid_mask = indexed_times >= sorted_unique_times + + key_times = sorted_unique_times[valid_mask] + key_values = interpolated_values[valid_mask] + + return key_times, key_values + + +def _convert_fbx_time_to_blender_time(key_times, blen_start_offset, fbx_start_offset, fps, fbx_ktime): + timefac = fps / fbx_ktime + + # Convert from FBX timing to Blender timing. + # Cannot subtract in-place because key_times could be read directly from FBX and could be used by multiple Actions. + key_times = key_times - fbx_start_offset + # FBX times are integers and timefac is a Python float, so the new array will be a np.float64 array. + key_times = key_times * timefac + + key_times += blen_start_offset + + return key_times + + +def blen_read_animation_curve(fbx_curve): + """Read an animation curve from FBX data. + + The parsed keyframe times are guaranteed to be strictly increasing.""" + key_times = parray_as_ndarray(elem_prop_first(elem_find_first(fbx_curve, b'KeyTime'))) + key_values = parray_as_ndarray(elem_prop_first(elem_find_first(fbx_curve, b'KeyValueFloat'))) + + assert(len(key_values) == len(key_times)) + + # The FBX SDK specifies that only one key per time is allowed and that the keys are sorted in time order. + # https://help.autodesk.com/view/FBX/2020/ENU/?guid=FBX_Developer_Help_cpp_ref_class_fbx_anim_curve_html + all_times_strictly_increasing = (key_times[1:] > key_times[:-1]).all() + + if all_times_strictly_increasing: + return key_times, key_values + else: + # FBX will still read animation curves even if they are invalid. + return blen_read_invalid_animation_curve(key_times, key_values) + + +def blen_store_keyframes(fbx_key_times, blen_fcurve, key_values, blen_start_offset, fps, fbx_ktime, fbx_start_offset=0): + """Set all keyframe times and values for a newly created FCurve. + Linear interpolation is currently assumed. + + This is a convenience function for calling blen_store_keyframes_multi with only a single fcurve and values array.""" + blen_store_keyframes_multi(fbx_key_times, [(blen_fcurve, key_values)], blen_start_offset, fps, fbx_ktime, + fbx_start_offset) + + +def blen_store_keyframes_multi(fbx_key_times, fcurve_and_key_values_pairs, blen_start_offset, fps, fbx_ktime, + fbx_start_offset=0): + """Set all keyframe times and values for multiple pairs of newly created FCurves and keyframe values arrays, where + each pair has the same keyframe times. + Linear interpolation is currently assumed.""" + bl_key_times = _convert_fbx_time_to_blender_time(fbx_key_times, blen_start_offset, fbx_start_offset, fps, fbx_ktime) + num_keys = len(bl_key_times) + + # Compatible with C float type + bl_keyframe_dtype = np.single + # Compatible with C char type + bl_enum_dtype = np.ubyte + + # The keyframe_points 'co' are accessed as flattened pairs of (time, value). + # The key times are the same for each (blen_fcurve, key_values) pair, so only the values need to be updated for each + # array of values. + keyframe_points_co = np.empty(len(bl_key_times) * 2, dtype=bl_keyframe_dtype) + # Even indices are times. + keyframe_points_co[0::2] = bl_key_times + + interpolation_array = np.full(num_keys, LINEAR_INTERPOLATION_VALUE, dtype=bl_enum_dtype) + + for blen_fcurve, key_values in fcurve_and_key_values_pairs: + # The fcurve must be newly created and thus have no keyframe_points. + assert(len(blen_fcurve.keyframe_points) == 0) + + # Odd indices are values. + keyframe_points_co[1::2] = key_values + + # Add the keyframe points to the FCurve and then set the 'co' and 'interpolation' of each point. + blen_fcurve.keyframe_points.add(num_keys) + blen_fcurve.keyframe_points.foreach_set('co', keyframe_points_co) + blen_fcurve.keyframe_points.foreach_set('interpolation', interpolation_array) + + # Since we inserted our keyframes in 'ultra-fast' mode, we have to update the fcurves now. + blen_fcurve.update() + + +def blen_read_animations_action_item(action, item, cnodes, fps, anim_offset, global_scale, shape_key_deforms, + fbx_ktime): + """ + 'Bake' loc/rot/scale into the action, + taking any pre_ and post_ matrix into account to transform from fbx into blender space. + """ + from bpy.types import Object, PoseBone, ShapeKey, Material, Camera + + fbx_curves: dict[bytes, dict[int, FBXElem]] = {} + for curves, fbxprop in cnodes.values(): + channels_dict = fbx_curves.setdefault(fbxprop, {}) + for (fbx_acdata, _blen_data), channel in curves.values(): + if channel in channels_dict: + # Ignore extra curves when one has already been found for this channel because FBX's default animation + # system implementation only uses the first curve assigned to a channel. + # Additional curves per channel are allowed by the FBX specification, but the handling of these curves + # is considered the responsibility of the application that created them. Note that each curve node is + # expected to have a unique set of channels, so these additional curves with the same channel would have + # to belong to separate curve nodes. See the FBX SDK documentation for FbxAnimCurveNode. + continue + channels_dict[channel] = fbx_acdata + + # Leave if no curves are attached (if a blender curve is attached to scale but without keys it defaults to 0). + if len(fbx_curves) == 0: + return + + if isinstance(item, Material): + grpname = item.name + props = [("diffuse_color", 3, grpname or "Diffuse Color")] + elif isinstance(item, ShapeKey): + props = [(item.path_from_id("value"), 1, "Key")] + elif isinstance(item, Camera): + props = [(item.path_from_id("lens"), 1, "Camera"), (item.dof.path_from_id("focus_distance"), 1, "Camera")] + else: # Object or PoseBone: + if item.is_bone: + bl_obj = item.bl_obj.pose.bones[item.bl_bone] + else: + bl_obj = item.bl_obj + + # We want to create actions for objects, but for bones we 'reuse' armatures' actions! + grpname = bl_obj.name + + # Since we might get other channels animated in the end, due to all FBX transform magic, + # we need to add curves for whole loc/rot/scale in any case. + props = [(bl_obj.path_from_id("location"), 3, grpname or "Location"), + None, + (bl_obj.path_from_id("scale"), 3, grpname or "Scale")] + rot_mode = bl_obj.rotation_mode + if rot_mode == 'QUATERNION': + props[1] = (bl_obj.path_from_id("rotation_quaternion"), 4, grpname or "Quaternion Rotation") + elif rot_mode == 'AXIS_ANGLE': + props[1] = (bl_obj.path_from_id("rotation_axis_angle"), 4, grpname or "Axis Angle Rotation") + else: # Euler + props[1] = (bl_obj.path_from_id("rotation_euler"), 3, grpname or "Euler Rotation") + + blen_curves = [action.fcurves.new(prop, index=channel, action_group=grpname) + for prop, nbr_channels, grpname in props for channel in range(nbr_channels)] + + if isinstance(item, Material): + for fbxprop, channel_to_curve in fbx_curves.items(): + assert(fbxprop == b'DiffuseColor') + for channel, curve in channel_to_curve.items(): + assert(channel in {0, 1, 2}) + blen_curve = blen_curves[channel] + fbx_key_times, values = blen_read_animation_curve(curve) + blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps, fbx_ktime) + + elif isinstance(item, ShapeKey): + for fbxprop, channel_to_curve in fbx_curves.items(): + assert(fbxprop == b'DeformPercent') + for channel, curve in channel_to_curve.items(): + assert(channel == 0) + blen_curve = blen_curves[channel] + + fbx_key_times, values = blen_read_animation_curve(curve) + # A fully activated shape key in FBX DeformPercent is 100.0 whereas it is 1.0 in Blender. + values = values / 100.0 + blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps, fbx_ktime) + + # Store the minimum and maximum shape key values, so that the shape key's slider range can be expanded + # if necessary after reading all animations. + if values.size: + deform_values = shape_key_deforms.setdefault(item, []) + deform_values.append(values.min()) + deform_values.append(values.max()) + + elif isinstance(item, Camera): + for fbxprop, channel_to_curve in fbx_curves.items(): + is_focus_distance = fbxprop == b'FocusDistance' + assert(fbxprop == b'FocalLength' or is_focus_distance) + for channel, curve in channel_to_curve.items(): + assert(channel == 0) + # The indices are determined by the creation of the `props` list above. + blen_curve = blen_curves[1 if is_focus_distance else 0] + + fbx_key_times, values = blen_read_animation_curve(curve) + if is_focus_distance: + # Remap the imported values from FBX to Blender. + values = values / 1000.0 + values *= global_scale + blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps, fbx_ktime) + + else: # Object or PoseBone: + transform_data = item.fbx_transform_data + + # Each transformation curve needs to have keyframes at the times of every other transformation curve + # (interpolating missing values), so that we can construct a matrix at every keyframe. + transform_prop_to_attr = { + b'Lcl Translation': transform_data.loc, + b'Lcl Rotation': transform_data.rot, + b'Lcl Scaling': transform_data.sca, + } + + times_and_values_tuples = [] + initial_values = [] + channel_keys = [] + for fbxprop, channel_to_curve in fbx_curves.items(): + if fbxprop not in transform_prop_to_attr: + # Currently, we only care about transformation curves. + continue + for channel, curve in channel_to_curve.items(): + assert(channel in {0, 1, 2}) + fbx_key_times, values = blen_read_animation_curve(curve) + + channel_keys.append((fbxprop, channel)) + + initial_values.append(transform_prop_to_attr[fbxprop][channel]) + + times_and_values_tuples.append((fbx_key_times, values)) + if not times_and_values_tuples: + # If `times_and_values_tuples` is empty, all the imported animation curves are for properties other than + # transformation (e.g. animated custom properties), so there is nothing to do until support for those other + # properties is added. + return + + # Combine the keyframe times of all the transformation curves so that each curve has a value at every time. + combined_fbx_times, values_arrays = _combine_curve_keyframe_times(times_and_values_tuples, initial_values) + + # Convert from FBX Lcl Translation/Lcl Rotation/Lcl Scaling to the Blender location/rotation/scaling properties + # of this Object/PoseBone. + # The number of fcurves for the Blender properties varies depending on the rotation mode. + num_loc_channels = 3 + num_rot_channels = 4 if rot_mode in {'QUATERNION', 'AXIS_ANGLE'} else 3 # Variations of EULER are all 3 + num_sca_channels = 3 + num_channels = num_loc_channels + num_rot_channels + num_sca_channels + num_frames = len(combined_fbx_times) + full_length = num_channels * num_frames + + # Do the conversion. + flattened_channel_values_gen = _transformation_curves_gen(item, values_arrays, channel_keys) + flattened_channel_values = np.fromiter(flattened_channel_values_gen, dtype=np.single, count=full_length) + + # Reshape to one row per frame and then view the transpose so that each row corresponds to a single channel. + # e.g. + # loc_channels = channel_values[:num_loc_channels] + # rot_channels = channel_values[num_loc_channels:num_loc_channels + num_rot_channels] + # sca_channels = channel_values[num_loc_channels + num_rot_channels:] + channel_values = flattened_channel_values.reshape(num_frames, num_channels).T + + # Each channel has the same keyframe times, so the combined times can be passed once along with all the curves + # and values arrays. + blen_store_keyframes_multi(combined_fbx_times, zip(blen_curves, channel_values), anim_offset, fps, fbx_ktime) + + +def blen_read_animations(fbx_tmpl_astack, fbx_tmpl_alayer, stacks, scene, anim_offset, global_scale, fbx_ktime): + """ + Recreate an action per stack/layer/object combinations. + Only the first found action is linked to objects, more complex setups are not handled, + it's up to user to reproduce them! + """ + from bpy.types import ShapeKey, Material, Camera + + shape_key_values = {} + actions = {} + for as_uuid, ((fbx_asdata, _blen_data), alayers) in stacks.items(): + stack_name = elem_name_ensure_class(fbx_asdata, b'AnimStack') + for al_uuid, ((fbx_aldata, _blen_data), items) in alayers.items(): + layer_name = elem_name_ensure_class(fbx_aldata, b'AnimLayer') + for item, cnodes in items.items(): + if isinstance(item, Material): + id_data = item + elif isinstance(item, ShapeKey): + id_data = item.id_data + elif isinstance(item, Camera): + id_data = item + else: + id_data = item.bl_obj + # XXX Ignore rigged mesh animations - those are a nightmare to handle, see note about it in + # FbxImportHelperNode class definition. + if id_data and id_data.type == 'MESH' and id_data.parent and id_data.parent.type == 'ARMATURE': + continue + if id_data is None: + continue + + # Create new action if needed (should always be needed, except for keyblocks from shapekeys cases). + key = (as_uuid, al_uuid, id_data) + action = actions.get(key) + if action is None: + if stack_name == layer_name: + action_name = "|".join((id_data.name, stack_name)) + else: + action_name = "|".join((id_data.name, stack_name, layer_name)) + actions[key] = action = bpy.data.actions.new(action_name) + action.use_fake_user = True + # If none yet assigned, assign this action to id_data. + if not id_data.animation_data: + id_data.animation_data_create() + if not id_data.animation_data.action: + id_data.animation_data.action = action + # And actually populate the action! + blen_read_animations_action_item(action, item, cnodes, scene.render.fps, anim_offset, global_scale, + shape_key_values, fbx_ktime) + + # If the minimum/maximum animated value is outside the slider range of the shape key, attempt to expand the slider + # range until the animated range fits and has extra room to be decreased or increased further. + # Shape key slider_min and slider_max have hard min/max values, if an imported animation uses a value outside that + # range, a warning message will be printed to the console and the slider_min/slider_max values will end up clamped. + shape_key_values_in_range = True + for shape_key, deform_values in shape_key_values.items(): + min_animated_deform = min(deform_values) + max_animated_deform = max(deform_values) + shape_key_values_in_range &= expand_shape_key_range(shape_key, min_animated_deform) + shape_key_values_in_range &= expand_shape_key_range(shape_key, max_animated_deform) + if not shape_key_values_in_range: + print("WARNING: The imported animated Value of a Shape Key is beyond the minimum/maximum allowed and will be" + " clamped during playback.") + + +# ---- +# Mesh + +def blen_read_geom_layerinfo(fbx_layer): + return ( + validate_blend_names(elem_find_first_string_as_bytes(fbx_layer, b'Name')), + elem_find_first_string_as_bytes(fbx_layer, b'MappingInformationType'), + elem_find_first_string_as_bytes(fbx_layer, b'ReferenceInformationType'), + ) + + +def blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size): + """Validate blen_data when it's not a bpy_prop_collection. + Returns whether blen_data is a bpy_prop_collection""" + blen_data_is_collection = isinstance(blen_data, bpy.types.bpy_prop_collection) + if not blen_data_is_collection: + if item_size > 1: + assert(len(blen_data.shape) == 2) + assert(blen_data.shape[1] == item_size) + assert(blen_data.dtype == blen_dtype) + return blen_data_is_collection + + +def blen_read_geom_parse_fbx_data(fbx_data, stride, item_size): + """Parse fbx_data as an array.array into a 2d np.ndarray that shares the same memory, where each row is a single + item""" + # Technically stride < item_size could be supported, but there's probably not a use case for it since it would + # result in a view of the data with self-overlapping memory. + assert(stride >= item_size) + # View the array.array as an np.ndarray. + fbx_data_np = parray_as_ndarray(fbx_data) + + if stride == item_size: + if item_size > 1: + # Need to make sure fbx_data_np has a whole number of items to be able to view item_size elements per row. + items_remainder = len(fbx_data_np) % item_size + if items_remainder: + print("ERROR: not a whole number of items in this FBX layer, skipping the partial item!") + fbx_data_np = fbx_data_np[:-items_remainder] + fbx_data_np = fbx_data_np.reshape(-1, item_size) + else: + # Create a view of fbx_data_np that is only the first item_size elements of each stride. Note that the view will + # not be C-contiguous. + stride_remainder = len(fbx_data_np) % stride + if stride_remainder: + if stride_remainder < item_size: + print("ERROR: not a whole number of items in this FBX layer, skipping the partial item!") + # Not enough in the remainder for a full item, so cut off the partial stride + fbx_data_np = fbx_data_np[:-stride_remainder] + # Reshape to one stride per row and then create a view that includes only the first item_size elements + # of each stride. + fbx_data_np = fbx_data_np.reshape(-1, stride)[:, :item_size] + else: + print("ERROR: not a whole number of strides in this FBX layer! There are a whole number of items, but" + " this could indicate an error!") + # There is not a whole number of strides, but there is a whole number of items. + # This is a pain to deal with because fbx_data_np.reshape(-1, stride) is not possible. + # A view of just the items can be created using stride_tricks.as_strided by specifying the shape and + # strides of the view manually. + # Extreme care must be taken when using stride_tricks.as_strided because improper usage can result in + # a view that gives access to memory outside the array. + from numpy.lib import stride_tricks + + # fbx_data_np should always start off as flat and C-contiguous. + assert(fbx_data_np.strides == (fbx_data_np.itemsize,)) + + num_whole_strides = len(fbx_data_np) // stride + # Plus the one partial stride that is enough elements for a complete item. + num_items = num_whole_strides + 1 + shape = (num_items, item_size) + + # strides are the number of bytes to step to get to the next element, for each axis. + step_per_item = fbx_data_np.itemsize * stride + step_per_item_element = fbx_data_np.itemsize + strides = (step_per_item, step_per_item_element) + + fbx_data_np = stride_tricks.as_strided(fbx_data_np, shape, strides) + else: + # There's a whole number of strides, so first reshape to one stride per row and then create a view that + # includes only the first item_size elements of each stride. + fbx_data_np = fbx_data_np.reshape(-1, stride)[:, :item_size] + + return fbx_data_np + + +def blen_read_geom_check_fbx_data_length(blen_data, fbx_data_np, is_indices=False): + """Check that there are the same number of items in blen_data and fbx_data_np. + + Returns a tuple of two elements: + 0: fbx_data_np or, if fbx_data_np contains more items than blen_data, a view of fbx_data_np with the excess + items removed + 1: Whether the returned fbx_data_np contains enough items to completely fill blen_data""" + bl_num_items = len(blen_data) + fbx_num_items = len(fbx_data_np) + enough_data = fbx_num_items >= bl_num_items + if not enough_data: + if is_indices: + print("ERROR: not enough indices in this FBX layer, missing data will be left as default!") + else: + print("ERROR: not enough data in this FBX layer, missing data will be left as default!") + elif fbx_num_items > bl_num_items: + if is_indices: + print("ERROR: too many indices in this FBX layer, skipping excess!") + else: + print("ERROR: too much data in this FBX layer, skipping excess!") + fbx_data_np = fbx_data_np[:bl_num_items] + + return fbx_data_np, enough_data + + +def blen_read_geom_xform(fbx_data_np, xform): + """xform is either None, or a function that takes fbx_data_np as its only positional argument and returns an + np.ndarray with the same total number of elements as fbx_data_np. + It is acceptable for xform to return an array with a different dtype to fbx_data_np. + + Returns xform(fbx_data_np) when xform is not None and ensures the result of xform(fbx_data_np) has the same shape as + fbx_data_np before returning it. + When xform is None, fbx_data_np is returned as is.""" + if xform is not None: + item_size = fbx_data_np.shape[1] + fbx_total_data = fbx_data_np.size + fbx_data_np = xform(fbx_data_np) + # The amount of data should not be changed by xform + assert(fbx_data_np.size == fbx_total_data) + # Ensure fbx_data_np is still item_size elements per row + if len(fbx_data_np.shape) != 2 or fbx_data_np.shape[1] != item_size: + fbx_data_np = fbx_data_np.reshape(-1, item_size) + return fbx_data_np + + +def blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_data, stride, item_size, descr, + xform): + """Generic fbx_layer to blen_data foreach setter for Direct layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array.""" + fbx_data_np = blen_read_geom_parse_fbx_data(fbx_data, stride, item_size) + fbx_data_np, enough_data = blen_read_geom_check_fbx_data_length(blen_data, fbx_data_np) + fbx_data_np = blen_read_geom_xform(fbx_data_np, xform) + + blen_data_is_collection = blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size) + + if blen_data_is_collection: + if not enough_data: + blen_total_data = len(blen_data) * item_size + buffer = np.empty(blen_total_data, dtype=blen_dtype) + # It's not clear what values should be used for the missing data, so read the current values into a buffer. + blen_data.foreach_get(blen_attr, buffer) + + # Change the buffer shape to one item per row + buffer.shape = (-1, item_size) + + # Copy the fbx data into the start of the buffer + buffer[:len(fbx_data_np)] = fbx_data_np + else: + # Convert the buffer to the Blender C type of blen_attr + buffer = astype_view_signedness(fbx_data_np, blen_dtype) + + # Set blen_attr of blen_data. The buffer must be flat and C-contiguous, which ravel() ensures + blen_data.foreach_set(blen_attr, buffer.ravel()) + else: + assert(blen_data.size % item_size == 0) + blen_data = blen_data.view() + blen_data.shape = (-1, item_size) + blen_data[:len(fbx_data_np)] = fbx_data_np + + +def blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_data, fbx_layer_index, stride, + item_size, descr, xform): + """Generic fbx_layer to blen_data foreach setter for IndexToDirect layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array or a 1d np.ndarray.""" + fbx_data_np = blen_read_geom_parse_fbx_data(fbx_data, stride, item_size) + fbx_data_np = blen_read_geom_xform(fbx_data_np, xform) + + # fbx_layer_index is allowed to be a 1d np.ndarray for use with blen_read_geom_array_foreach_set_looptovert. + if not isinstance(fbx_layer_index, np.ndarray): + fbx_layer_index = parray_as_ndarray(fbx_layer_index) + + fbx_layer_index, enough_indices = blen_read_geom_check_fbx_data_length(blen_data, fbx_layer_index, is_indices=True) + + blen_data_is_collection = blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size) + + blen_data_items_len = len(blen_data) + blen_data_len = blen_data_items_len * item_size + fbx_num_items = len(fbx_data_np) + + # Find all indices that are out of bounds of fbx_data_np. + min_index_inclusive = -fbx_num_items + max_index_inclusive = fbx_num_items - 1 + valid_index_mask = np.equal(fbx_layer_index, fbx_layer_index.clip(min_index_inclusive, max_index_inclusive)) + indices_invalid = not valid_index_mask.all() + + fbx_data_items = fbx_data_np.reshape(-1, item_size) + + if indices_invalid or not enough_indices: + if blen_data_is_collection: + buffer = np.empty(blen_data_len, dtype=blen_dtype) + buffer_item_view = buffer.view() + buffer_item_view.shape = (-1, item_size) + # Since we don't know what the default values should be for the missing data, read the current values into a + # buffer. + blen_data.foreach_get(blen_attr, buffer) + else: + buffer_item_view = blen_data + + if not enough_indices: + # Reduce the length of the view to the same length as the number of indices. + buffer_item_view = buffer_item_view[:len(fbx_layer_index)] + + # Copy the result of indexing fbx_data_items by each element in fbx_layer_index into the buffer. + if indices_invalid: + print("ERROR: indices in this FBX layer out of bounds of the FBX data, skipping invalid indices!") + buffer_item_view[valid_index_mask] = fbx_data_items[fbx_layer_index[valid_index_mask]] + else: + buffer_item_view[:] = fbx_data_items[fbx_layer_index] + + if blen_data_is_collection: + blen_data.foreach_set(blen_attr, buffer.ravel()) + else: + if blen_data_is_collection: + # Cast the buffer to the Blender C type of blen_attr + fbx_data_items = astype_view_signedness(fbx_data_items, blen_dtype) + buffer_items = fbx_data_items[fbx_layer_index] + blen_data.foreach_set(blen_attr, buffer_items.ravel()) + else: + blen_data[:] = fbx_data_items[fbx_layer_index] + + +def blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_data, stride, item_size, descr, + xform): + """Generic fbx_layer to blen_data foreach setter for AllSame layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array.""" + fbx_data_np = blen_read_geom_parse_fbx_data(fbx_data, stride, item_size) + fbx_data_np = blen_read_geom_xform(fbx_data_np, xform) + blen_data_is_collection = blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size) + fbx_items_len = len(fbx_data_np) + blen_items_len = len(blen_data) + + if fbx_items_len < 1: + print("ERROR: not enough data in this FBX layer, skipping!") + return + + if blen_data_is_collection: + # Create an array filled with the value from fbx_data_np + buffer = np.full((blen_items_len, item_size), fbx_data_np[0], dtype=blen_dtype) + + blen_data.foreach_set(blen_attr, buffer.ravel()) + else: + blen_data[:] = fbx_data_np[0] + + +def blen_read_geom_array_foreach_set_looptovert(mesh, blen_data, blen_attr, blen_dtype, fbx_data, stride, item_size, + descr, xform): + """Generic fbx_layer to blen_data foreach setter for face corner ByVertice layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array""" + # The fbx_data is mapped to vertices. To expand fbx_data to face corners, get an array of the vertex index of each + # face corner that will then be used to index fbx_data. + corner_vertex_indices = MESH_ATTRIBUTE_CORNER_VERT.to_ndarray(mesh.attributes) + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_data, corner_vertex_indices, stride, + item_size, descr, xform) + + +# generic error printers. +def blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet=False): + if not quiet: + print("warning layer %r mapping type unsupported: %r" % (descr, fbx_layer_mapping)) + + +def blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet=False): + if not quiet: + print("warning layer %r ref type unsupported: %r" % (descr, fbx_layer_ref)) + + +def blen_read_geom_array_mapped_vert( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByVertice': + if fbx_layer_ref == b'IndexToDirect': + # XXX Looks like we often get no fbx_layer_index in this case, shall not happen but happens... + # We fallback to 'Direct' mapping in this case. + # ~ assert(fbx_layer_index is not None) + if fbx_layer_index is None: + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + else: + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_layer_data, + fbx_layer_index, stride, item_size, descr, xform) + return True + elif fbx_layer_ref == b'Direct': + assert(fbx_layer_index is None) + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert(fbx_layer_index is None) + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_array_mapped_edge( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByEdge': + if fbx_layer_ref == b'Direct': + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert(fbx_layer_index is None) + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_array_mapped_polygon( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByPolygon': + if fbx_layer_ref == b'IndexToDirect': + # XXX Looks like we often get no fbx_layer_index in this case, shall not happen but happens... + # We fallback to 'Direct' mapping in this case. + # ~ assert(fbx_layer_index is not None) + if fbx_layer_index is None: + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + else: + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_layer_data, + fbx_layer_index, stride, item_size, descr, xform) + return True + elif fbx_layer_ref == b'Direct': + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert(fbx_layer_index is None) + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_array_mapped_polyloop( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByPolygonVertex': + if fbx_layer_ref == b'IndexToDirect': + # XXX Looks like we often get no fbx_layer_index in this case, shall not happen but happens... + # We fallback to 'Direct' mapping in this case. + # ~ assert(fbx_layer_index is not None) + if fbx_layer_index is None: + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + else: + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_layer_data, + fbx_layer_index, stride, item_size, descr, xform) + return True + elif fbx_layer_ref == b'Direct': + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'ByVertice': + if fbx_layer_ref == b'Direct': + assert(fbx_layer_index is None) + blen_read_geom_array_foreach_set_looptovert(mesh, blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert(fbx_layer_index is None) + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_layer_material(fbx_obj, mesh): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementMaterial') + + if fbx_layer is None: + return + + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + layer_id = b'Materials' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + + blen_data = MESH_ATTRIBUTE_MATERIAL_INDEX.ensure(mesh.attributes).data + fbx_item_size = 1 + assert(fbx_item_size == MESH_ATTRIBUTE_MATERIAL_INDEX.item_size) + blen_read_geom_array_mapped_polygon( + mesh, blen_data, MESH_ATTRIBUTE_MATERIAL_INDEX.foreach_attribute, MESH_ATTRIBUTE_MATERIAL_INDEX.dtype, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, fbx_item_size, layer_id, + ) + + +def blen_read_geom_layer_uv(fbx_obj, mesh): + for layer_id in (b'LayerElementUV',): + for fbx_layer in elem_find_iter(fbx_obj, layer_id): + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, b'UV')) + fbx_layer_index = elem_prop_first(elem_find_first(fbx_layer, b'UVIndex')) + + # Always init our new layers with (0, 0) UVs. + uv_lay = mesh.uv_layers.new(name=fbx_layer_name, do_init=False) + if uv_lay is None: + print("Failed to add {%r %r} UVLayer to %r (probably too many of them?)" + "" % (layer_id, fbx_layer_name, mesh.name)) + continue + + blen_data = uv_lay.uv + + # some valid files omit this data + if fbx_layer_data is None: + print("%r %r missing data" % (layer_id, fbx_layer_name)) + continue + + blen_read_geom_array_mapped_polyloop( + mesh, blen_data, "vector", np.single, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + 2, 2, layer_id, + ) + + +def blen_read_geom_layer_color(fbx_obj, mesh, colors_type): + if colors_type == 'NONE': + return + use_srgb = colors_type == 'SRGB' + layer_type = 'BYTE_COLOR' if use_srgb else 'FLOAT_COLOR' + color_prop_name = "color_srgb" if use_srgb else "color" + # almost same as UVs + for layer_id in (b'LayerElementColor',): + for fbx_layer in elem_find_iter(fbx_obj, layer_id): + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, b'Colors')) + fbx_layer_index = elem_prop_first(elem_find_first(fbx_layer, b'ColorIndex')) + + color_lay = mesh.color_attributes.new(name=fbx_layer_name, type=layer_type, domain='CORNER') + + if color_lay is None: + print("Failed to add {%r %r} vertex color layer to %r (probably too many of them?)" + "" % (layer_id, fbx_layer_name, mesh.name)) + continue + + blen_data = color_lay.data + + # some valid files omit this data + if fbx_layer_data is None: + print("%r %r missing data" % (layer_id, fbx_layer_name)) + continue + + blen_read_geom_array_mapped_polyloop( + mesh, blen_data, color_prop_name, np.single, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + 4, 4, layer_id, + ) + + +def blen_read_geom_layer_smooth(fbx_obj, mesh): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementSmoothing') + + if fbx_layer is None: + return + + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + layer_id = b'Smoothing' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + + # udk has 'Direct' mapped, with no Smoothing, not sure why, but ignore these + if fbx_layer_data is None: + return + + if fbx_layer_mapping == b'ByEdge': + # some models have bad edge data, we can't use this info... + if not mesh.edges: + print("warning skipping sharp edges data, no valid edges...") + return + + blen_data = MESH_ATTRIBUTE_SHARP_EDGE.ensure(mesh.attributes).data + fbx_item_size = 1 + assert(fbx_item_size == MESH_ATTRIBUTE_SHARP_EDGE.item_size) + blen_read_geom_array_mapped_edge( + mesh, blen_data, MESH_ATTRIBUTE_SHARP_EDGE.foreach_attribute, MESH_ATTRIBUTE_SHARP_EDGE.dtype, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, fbx_item_size, layer_id, + xform=np.logical_not, # in FBX, 0 (False) is sharp, but in Blender True is sharp. + ) + elif fbx_layer_mapping == b'ByPolygon': + sharp_face = MESH_ATTRIBUTE_SHARP_FACE.ensure(mesh.attributes) + blen_data = sharp_face.data + fbx_item_size = 1 + assert(fbx_item_size == MESH_ATTRIBUTE_SHARP_FACE.item_size) + sharp_face_set_successfully = blen_read_geom_array_mapped_polygon( + mesh, blen_data, MESH_ATTRIBUTE_SHARP_FACE.foreach_attribute, MESH_ATTRIBUTE_SHARP_FACE.dtype, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, fbx_item_size, layer_id, + xform=lambda s: (s == 0), # smoothgroup bitflags, treat as booleans for now + ) + if not sharp_face_set_successfully: + mesh.attributes.remove(sharp_face) + else: + print("warning layer %r mapping type unsupported: %r" % (fbx_layer.id, fbx_layer_mapping)) + + +def blen_read_geom_layer_edge_crease(fbx_obj, mesh): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementEdgeCrease') + + if fbx_layer is None: + return False + + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + if fbx_layer_mapping != b'ByEdge': + return False + + layer_id = b'EdgeCrease' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + + # some models have bad edge data, we can't use this info... + if not mesh.edges: + print("warning skipping edge crease data, no valid edges...") + return False + + if fbx_layer_mapping == b'ByEdge': + # some models have bad edge data, we can't use this info... + if not mesh.edges: + print("warning skipping edge crease data, no valid edges...") + return False + + blen_data = mesh.edge_creases_ensure().data + return blen_read_geom_array_mapped_edge( + mesh, blen_data, "value", np.single, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, 1, layer_id, + # Blender squares those values before sending them to OpenSubdiv, when other software don't, + # so we need to compensate that to get similar results through FBX... + xform=np.sqrt, + ) + else: + print("warning layer %r mapping type unsupported: %r" % (fbx_layer.id, fbx_layer_mapping)) + return False + + +def blen_read_geom_layer_normal(fbx_obj, mesh, xform=None): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementNormal') + + if fbx_layer is None: + return False + + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + layer_id = b'Normals' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + fbx_layer_index = elem_prop_first(elem_find_first(fbx_layer, b'NormalsIndex')) + + if fbx_layer_data is None: + print("warning %r %r missing data" % (layer_id, fbx_layer_name)) + return False + + # Normals are temporarily set here so that they can be retrieved again after a call to Mesh.validate(). + bl_norm_dtype = np.single + item_size = 3 + # try loops, then polygons, then vertices. + tries = ((mesh.attributes["temp_custom_normals"].data, "Loops", False, blen_read_geom_array_mapped_polyloop), + (mesh.polygons, "Polygons", True, blen_read_geom_array_mapped_polygon), + (mesh.vertices, "Vertices", True, blen_read_geom_array_mapped_vert)) + for blen_data, blen_data_type, is_fake, func in tries: + bdata = np.zeros((len(blen_data), item_size), dtype=bl_norm_dtype) if is_fake else blen_data + if func(mesh, bdata, "vector", bl_norm_dtype, + fbx_layer_data, fbx_layer_index, fbx_layer_mapping, fbx_layer_ref, 3, item_size, layer_id, xform, True): + if blen_data_type == "Polygons": + # To expand to per-loop normals, repeat each per-polygon normal by the number of loops of each polygon. + poly_loop_totals = np.empty(len(mesh.polygons), dtype=np.uintc) + mesh.polygons.foreach_get("loop_total", poly_loop_totals) + loop_normals = np.repeat(bdata, poly_loop_totals, axis=0) + mesh.attributes["temp_custom_normals"].data.foreach_set("vector", loop_normals.ravel()) + elif blen_data_type == "Vertices": + # We have to copy vnors to lnors! Far from elegant, but simple. + loop_vertex_indices = MESH_ATTRIBUTE_CORNER_VERT.to_ndarray(mesh.attributes) + mesh.attributes["temp_custom_normals"].data.foreach_set("vector", bdata[loop_vertex_indices].ravel()) + return True + + blen_read_geom_array_error_mapping("normal", fbx_layer_mapping) + blen_read_geom_array_error_ref("normal", fbx_layer_ref) + return False + + +def normalize_vecs(vectors): + norms = np.linalg.norm(vectors, axis=1, keepdims=True) + np.divide(vectors, norms, out=vectors, where=norms != 0) + + +def blen_read_geom(fbx_tmpl, fbx_obj, settings): + # Vertices are in object space, but we are post-multiplying all transforms with the inverse of the + # global matrix, so we need to apply the global matrix to the vertices to get the correct result. + geom_mat_co = settings.global_matrix if settings.bake_space_transform else None + # We need to apply the inverse transpose of the global matrix when transforming normals. + geom_mat_no = Matrix(settings.global_matrix_inv_transposed) if settings.bake_space_transform else None + if geom_mat_no is not None: + # Remove translation & scaling! + geom_mat_no.translation = Vector() + geom_mat_no.normalize() + + # TODO, use 'fbx_tmpl' + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'Geometry') + + fbx_verts = elem_prop_first(elem_find_first(fbx_obj, b'Vertices')) + fbx_polys = elem_prop_first(elem_find_first(fbx_obj, b'PolygonVertexIndex')) + fbx_edges = elem_prop_first(elem_find_first(fbx_obj, b'Edges')) + + # The dtypes when empty don't matter, but are set to what the fbx arrays are expected to be. + fbx_verts = parray_as_ndarray(fbx_verts) if fbx_verts else np.empty(0, dtype=data_types.ARRAY_FLOAT64) + fbx_polys = parray_as_ndarray(fbx_polys) if fbx_polys else np.empty(0, dtype=data_types.ARRAY_INT32) + fbx_edges = parray_as_ndarray(fbx_edges) if fbx_edges else np.empty(0, dtype=data_types.ARRAY_INT32) + + # Each vert is a 3d vector so is made of 3 components. + tot_verts = len(fbx_verts) // 3 + if tot_verts * 3 != len(fbx_verts): + print("ERROR: Not a whole number of vertices. Ignoring the partial vertex!") + # Remove any remainder. + fbx_verts = fbx_verts[:tot_verts * 3] + + tot_loops = len(fbx_polys) + tot_edges = len(fbx_edges) + + mesh = bpy.data.meshes.new(name=elem_name_utf8) + attributes = mesh.attributes + + if tot_verts: + if geom_mat_co is not None: + fbx_verts = vcos_transformed(fbx_verts, geom_mat_co, MESH_ATTRIBUTE_POSITION.dtype) + else: + fbx_verts = fbx_verts.astype(MESH_ATTRIBUTE_POSITION.dtype, copy=False) + + mesh.vertices.add(tot_verts) + MESH_ATTRIBUTE_POSITION.foreach_set(attributes, fbx_verts.ravel()) + + if tot_loops: + bl_loop_start_dtype = np.uintc + + mesh.loops.add(tot_loops) + # The end of each polygon is specified by an inverted index. + fbx_loop_end_idx = np.flatnonzero(fbx_polys < 0) + + tot_polys = len(fbx_loop_end_idx) + + # Un-invert the loop ends. + fbx_polys[fbx_loop_end_idx] ^= -1 + # Set loop vertex indices, casting to the Blender C type first for performance. + MESH_ATTRIBUTE_CORNER_VERT.foreach_set( + attributes, astype_view_signedness(fbx_polys, MESH_ATTRIBUTE_CORNER_VERT.dtype)) + + poly_loop_starts = np.empty(tot_polys, dtype=bl_loop_start_dtype) + # The first loop is always a loop start. + poly_loop_starts[0] = 0 + # Ignoring the last loop end, the indices after every loop end are the remaining loop starts. + poly_loop_starts[1:] = fbx_loop_end_idx[:-1] + 1 + + mesh.polygons.add(tot_polys) + mesh.polygons.foreach_set("loop_start", poly_loop_starts) + + blen_read_geom_layer_material(fbx_obj, mesh) + blen_read_geom_layer_uv(fbx_obj, mesh) + blen_read_geom_layer_color(fbx_obj, mesh, settings.colors_type) + + if tot_edges: + # edges in fact index the polygons (NOT the vertices) + + # The first vertex index of each edge is the vertex index of the corresponding loop in fbx_polys. + edges_a = fbx_polys[fbx_edges] + + # The second vertex index of each edge is the vertex index of the next loop in the same polygon. The + # complexity here is that if the first vertex index was the last loop of that polygon in fbx_polys, the next + # loop in the polygon is the first loop of that polygon, which is not the next loop in fbx_polys. + + # Copy fbx_polys, but rolled backwards by 1 so that indexing the result by [fbx_edges] will get the next + # loop of the same polygon unless the first vertex index was the last loop of the polygon. + fbx_polys_next = np.roll(fbx_polys, -1) + # Get the first loop of each polygon and set them into fbx_polys_next at the same indices as the last loop + # of each polygon in fbx_polys. + fbx_polys_next[fbx_loop_end_idx] = fbx_polys[poly_loop_starts] + + # Indexing fbx_polys_next by fbx_edges now gets the vertex index of the next loop in fbx_polys. + edges_b = fbx_polys_next[fbx_edges] + + # edges_a and edges_b need to be combined so that the first vertex index of each edge is immediately + # followed by the second vertex index of that same edge. + # Stack edges_a and edges_b as individual columns like np.column_stack((edges_a, edges_b)). + # np.concatenate is used because np.column_stack doesn't allow specifying the dtype of the returned array. + edges_conv = np.concatenate((edges_a.reshape(-1, 1), edges_b.reshape(-1, 1)), + axis=1, dtype=MESH_ATTRIBUTE_EDGE_VERTS.dtype, casting='unsafe') + + # Add the edges and set their vertex indices. + mesh.edges.add(len(edges_conv)) + # ravel() because edges_conv must be flat and C-contiguous when passed to foreach_set. + MESH_ATTRIBUTE_EDGE_VERTS.foreach_set(attributes, edges_conv.ravel()) + elif tot_edges: + print("ERROR: No polygons, but edges exist. Ignoring the edges!") + + # must be after edge, face loading. + blen_read_geom_layer_smooth(fbx_obj, mesh) + + blen_read_geom_layer_edge_crease(fbx_obj, mesh) + + ok_normals = False + if settings.use_custom_normals: + # Note: we store 'temp' normals in loops, since validate() may alter final mesh, + # we can only set custom lnors *after* calling it. + mesh.attributes.new("temp_custom_normals", 'FLOAT_VECTOR', 'CORNER') + if geom_mat_no is None: + ok_normals = blen_read_geom_layer_normal(fbx_obj, mesh) + else: + ok_normals = blen_read_geom_layer_normal(fbx_obj, mesh, + lambda v_array: nors_transformed(v_array, geom_mat_no)) + + mesh.validate(clean_customdata=False) # *Very* important to not remove lnors here! + + if ok_normals: + bl_nors_dtype = np.single + clnors = np.empty(len(mesh.loops) * 3, dtype=bl_nors_dtype) + mesh.attributes["temp_custom_normals"].data.foreach_get("vector", clnors) + + clnors = clnors.reshape(len(mesh.loops), 3) + normalize_vecs(clnors) + clnors = clnors.reshape(len(mesh.loops) * 3) + + # Iterating clnors into a nested tuple first is faster than passing clnors.reshape(-1, 3) directly into + # normals_split_custom_set. We use clnors.data since it is a memoryview, which is faster to iterate than clnors. + mesh.normals_split_custom_set(tuple(zip(*(iter(clnors.data),) * 3))) + if settings.use_custom_normals: + mesh.attributes.remove(mesh.attributes["temp_custom_normals"]) + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, mesh, settings) + + return mesh + + +def blen_read_shapes(fbx_tmpl, fbx_data, objects, me, scene): + if not fbx_data: + # No shape key data. Nothing to do. + return + + me_vcos = MESH_ATTRIBUTE_POSITION.to_ndarray(me.attributes) + me_vcos_vector_view = me_vcos.reshape(-1, 3) + + objects = list({node.bl_obj for node in objects}) + assert(objects) + + # Blender has a hard minimum and maximum shape key Value. If an imported shape key has a value outside this range it + # will be clamped, and we'll print a warning message to the console. + shape_key_values_in_range = True + bc_uuid_to_keyblocks = {} + for bc_uuid, fbx_sdata, fbx_bcdata, shapes_assigned_to_channel in fbx_data: + num_shapes_assigned_to_channel = len(shapes_assigned_to_channel) + if num_shapes_assigned_to_channel > 1: + # Relevant design task: #104698 + raise RuntimeError("FBX in-between Shapes are not currently supported") # See bug report #84111 + elem_name_utf8 = elem_name_ensure_class(fbx_sdata, b'Geometry') + indices = elem_prop_first(elem_find_first(fbx_sdata, b'Indexes')) + dvcos = elem_prop_first(elem_find_first(fbx_sdata, b'Vertices')) + + indices = parray_as_ndarray(indices) if indices else np.empty(0, dtype=data_types.ARRAY_INT32) + dvcos = parray_as_ndarray(dvcos) if dvcos else np.empty(0, dtype=data_types.ARRAY_FLOAT64) + + # If there's not a whole number of vectors, trim off the remainder. + # 3 components per vector. + remainder = len(dvcos) % 3 + if remainder: + dvcos = dvcos[:-remainder] + dvcos = dvcos.reshape(-1, 3) + + # There must be the same number of indices as vertex coordinate differences. + assert(len(indices) == len(dvcos)) + + # We completely ignore normals here! + weight = elem_prop_first(elem_find_first(fbx_bcdata, b'DeformPercent'), default=100.0) / 100.0 + + # The FullWeights array stores the deformation percentages of the BlendShapeChannel that fully activate each + # Shape assigned to the BlendShapeChannel. Blender also uses this array to store Vertex Group weights, but this + # is not part of the FBX standard. + full_weights = elem_prop_first(elem_find_first(fbx_bcdata, b'FullWeights')) + full_weights = parray_as_ndarray(full_weights) if full_weights else np.empty(0, dtype=data_types.ARRAY_FLOAT64) + + # Special case for Blender exported Shape Keys with a Vertex Group assigned. The Vertex Group weights are stored + # in the FullWeights array. + # XXX - It's possible, though very rare, to get a false positive here and create a Vertex Group when we + # shouldn't. This should only be possible when there are extraneous FullWeights or when there is a single + # FullWeight and its value is not 100.0. + if ( + # Blender exported Shape Keys only ever export as 1 Shape per BlendShapeChannel. + num_shapes_assigned_to_channel == 1 + # There should be one vertex weight for each vertex moved by the Shape. + and len(full_weights) == len(indices) + # Skip creating a Vertex Group when all the weights are 100.0 because such a Vertex Group has no effect. + # This also avoids creating a Vertex Group for imported Shapes that only move a single vertex because + # their BlendShapeChannel's singular FullWeight is expected to always be 100.0. + and not np.all(full_weights == 100.0) + # Blender vertex weights are always within the [0.0, 1.0] range (scaled to [0.0, 100.0] when saving to + # FBX). This can eliminate imported BlendShapeChannels from Unreal that have extraneous FullWeights + # because the extraneous values are usually negative. + and np.all((full_weights >= 0.0) & (full_weights <= 100.0)) + ): + # Not doing the division in-place because it's technically possible for FBX BlendShapeChannels to be used by + # more than one FBX BlendShape, though this shouldn't be the case for Blender exported Shape Keys. + vgweights = full_weights / 100.0 + else: + vgweights = None + # There must be a FullWeight for each Shape. Any extra FullWeights are ignored. + assert(len(full_weights) >= num_shapes_assigned_to_channel) + + # To add shape keys to the mesh, an Object using the mesh is needed. + if me.shape_keys is None: + objects[0].shape_key_add(name="Basis", from_mix=False) + kb = objects[0].shape_key_add(name=elem_name_utf8, from_mix=False) + me.shape_keys.use_relative = True # Should already be set as such. + + # Only need to set the shape key co if there are any non-zero dvcos. + if dvcos.any(): + shape_cos = me_vcos_vector_view.copy() + shape_cos[indices] += dvcos + kb.points.foreach_set("co", shape_cos.ravel()) + + shape_key_values_in_range &= expand_shape_key_range(kb, weight) + + kb.value = weight + + # Add vgroup if necessary. + if vgweights is not None: + # VertexGroup.add only allows sequences of int indices, but iterating the indices array directly would + # produce numpy scalars of types such as np.int32. The underlying memoryview of the indices array, however, + # does produce standard Python ints when iterated, so pass indices.data to add_vgroup_to_objects instead of + # indices. + # memoryviews tend to be faster to iterate than numpy arrays anyway, so vgweights.data is passed too. + add_vgroup_to_objects(indices.data, vgweights.data, kb.name, objects) + kb.vertex_group = kb.name + + bc_uuid_to_keyblocks.setdefault(bc_uuid, []).append(kb) + + if not shape_key_values_in_range: + print("WARNING: The imported Value of a Shape Key on the Mesh '%s' is beyond the minimum/maximum allowed and" + " has been clamped." % me.name) + + return bc_uuid_to_keyblocks + + +# -------- +# Material + +def blen_read_material(fbx_tmpl, fbx_obj, settings): + from bpy_extras import node_shader_utils + from math import sqrt + + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'Material') + + nodal_material_wrap_map = settings.nodal_material_wrap_map + ma = bpy.data.materials.new(name=elem_name_utf8) + + const_color_white = 1.0, 1.0, 1.0 + const_color_black = 0.0, 0.0, 0.0 + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + fbx_props_no_template = (fbx_props[0], fbx_elem_nil) + + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True) + ma_wrap.base_color = elem_props_get_color_rgb(fbx_props, b'DiffuseColor', const_color_white) + # No specular color in Principled BSDF shader, assumed to be either white or take some tint from diffuse one... + # TODO: add way to handle tint option (guesstimate from spec color + intensity...)? + ma_wrap.specular = elem_props_get_number(fbx_props, b'SpecularFactor', 0.25) * 2.0 + # XXX Totally empirical conversion, trying to adapt it (and protect against invalid negative values, see T96076): + # From [1.0 - 0.0] Principled BSDF range to [0.0 - 100.0] FBX shininess range)... + fbx_shininess = max(elem_props_get_number(fbx_props, b'Shininess', 20.0), 0.0) + ma_wrap.roughness = 1.0 - (sqrt(fbx_shininess) / 10.0) + # Sweetness... Looks like we are not the only ones to not know exactly how FBX is supposed to work (see T59850). + # According to one of its developers, Unity uses that formula to extract alpha value: + # + # alpha = 1 - TransparencyFactor + # if (alpha == 1 or alpha == 0): + # alpha = 1 - TransparentColor.r + # + # Until further info, let's assume this is correct way to do, hence the following code for TransparentColor. + # However, there are some cases (from 3DSMax, see T65065), where we do have TransparencyFactor only defined + # in the template to 0.0, and then materials defining TransparentColor to pure white (1.0, 1.0, 1.0), + # and setting alpha value in Opacity... try to cope with that too. :(((( + alpha = 1.0 - elem_props_get_number(fbx_props, b'TransparencyFactor', 0.0) + if (alpha == 1.0 or alpha == 0.0): + alpha = elem_props_get_number(fbx_props_no_template, b'Opacity', None) + if alpha is None: + alpha = 1.0 - elem_props_get_color_rgb(fbx_props, b'TransparentColor', const_color_black)[0] + ma_wrap.alpha = alpha + ma_wrap.metallic = elem_props_get_number(fbx_props, b'ReflectionFactor', 0.0) + # We have no metallic (a.k.a. reflection) color... + # elem_props_get_color_rgb(fbx_props, b'ReflectionColor', const_color_white) + ma_wrap.normalmap_strength = elem_props_get_number(fbx_props, b'BumpFactor', 1.0) + # Emission strength and color + ma_wrap.emission_strength = elem_props_get_number(fbx_props, b'EmissiveFactor', 1.0) + ma_wrap.emission_color = elem_props_get_color_rgb(fbx_props, b'EmissiveColor', const_color_black) + + nodal_material_wrap_map[ma] = ma_wrap + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, ma, settings) + + return ma + + +# ------- +# Image & Texture + +def blen_read_texture_image(fbx_tmpl, fbx_obj, basedir, settings): + import os + from bpy_extras import image_utils + + def pack_data_from_content(image, fbx_obj): + data = elem_find_first_bytes(fbx_obj, b'Content') + if (data): + data_len = len(data) + if (data_len): + image.pack(data=data, data_len=data_len) + + elem_name_utf8 = elem_name_ensure_classes(fbx_obj, {b'Texture', b'Video'}) + + image_cache = settings.image_cache + + # Yet another beautiful logic demonstration by Master FBX: + # * RelativeFilename in both Video and Texture nodes. + # * FileName in texture nodes. + # * Filename in video nodes. + # Aaaaaaaarrrrrrrrgggggggggggg!!!!!!!!!!!!!! + filepath = elem_find_first_string(fbx_obj, b'RelativeFilename') + if filepath: + # Make sure we do handle a relative path, and not an absolute one (see D5143). + filepath = filepath.lstrip(os.path.sep).lstrip(os.path.altsep) + filepath = os.path.join(basedir, filepath) + else: + filepath = elem_find_first_string(fbx_obj, b'FileName') + if not filepath: + filepath = elem_find_first_string(fbx_obj, b'Filename') + if not filepath: + print("Error, could not find any file path in ", fbx_obj) + print(" Falling back to: ", elem_name_utf8) + filepath = elem_name_utf8 + else: + filepath = filepath.replace('\\', '/') if (os.sep == '/') else filepath.replace('/', '\\') + + image = image_cache.get(filepath) + if image is not None: + # Data is only embedded once, we may have already created the image but still be missing its data! + if not image.has_data: + pack_data_from_content(image, fbx_obj) + return image + + image = image_utils.load_image( + filepath, + dirname=basedir, + place_holder=True, + recursive=settings.use_image_search, + ) + + # Try to use embedded data, if available! + pack_data_from_content(image, fbx_obj) + + image_cache[filepath] = image + # name can be ../a/b/c + image.name = os.path.basename(elem_name_utf8) + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, image, settings) + + return image + + +def blen_read_camera(fbx_tmpl, fbx_obj, settings): + # meters to inches + M2I = 0.0393700787 + + global_scale = settings.global_scale + + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'NodeAttribute') + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + camera = bpy.data.cameras.new(name=elem_name_utf8) + + camera.type = 'ORTHO' if elem_props_get_enum(fbx_props, b'CameraProjectionType', 0) == 1 else 'PERSP' + + camera.dof.focus_distance = elem_props_get_number(fbx_props, b'FocusDistance', 10) * global_scale + if (elem_props_get_bool(fbx_props, b'UseDepthOfField', False)): + camera.dof.use_dof = True + + camera.lens = elem_props_get_number(fbx_props, b'FocalLength', 35.0) + camera.sensor_width = elem_props_get_number(fbx_props, b'FilmWidth', 32.0 * M2I) / M2I + camera.sensor_height = elem_props_get_number(fbx_props, b'FilmHeight', 32.0 * M2I) / M2I + + camera.ortho_scale = elem_props_get_number(fbx_props, b'OrthoZoom', 1.0) + + filmaspect = camera.sensor_width / camera.sensor_height + # film offset + camera.shift_x = elem_props_get_number(fbx_props, b'FilmOffsetX', 0.0) / (M2I * camera.sensor_width) + camera.shift_y = elem_props_get_number(fbx_props, b'FilmOffsetY', 0.0) / (M2I * camera.sensor_height * filmaspect) + + camera.clip_start = elem_props_get_number(fbx_props, b'NearPlane', 0.01) * global_scale + camera.clip_end = elem_props_get_number(fbx_props, b'FarPlane', 100.0) * global_scale + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, camera, settings) + + return camera + + +def blen_read_light(fbx_tmpl, fbx_obj, settings): + import math + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'NodeAttribute') + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + light_type = { + 0: 'POINT', + 1: 'SUN', + 2: 'SPOT'}.get(elem_props_get_enum(fbx_props, b'LightType', 0), 'POINT') + + lamp = bpy.data.lights.new(name=elem_name_utf8, type=light_type) + + if light_type == 'SPOT': + spot_size = elem_props_get_number(fbx_props, b'OuterAngle', None) + if spot_size is None: + # Deprecated. + spot_size = elem_props_get_number(fbx_props, b'Cone angle', 45.0) + lamp.spot_size = math.radians(spot_size) + + spot_blend = elem_props_get_number(fbx_props, b'InnerAngle', None) + if spot_blend is None: + # Deprecated. + spot_blend = elem_props_get_number(fbx_props, b'HotSpot', 45.0) + lamp.spot_blend = 1.0 - (spot_blend / spot_size) + + # TODO, cycles nodes??? + lamp.color = elem_props_get_color_rgb(fbx_props, b'Color', (1.0, 1.0, 1.0)) + lamp.energy = elem_props_get_number(fbx_props, b'Intensity', 100.0) / 100.0 + lamp.use_shadow = elem_props_get_bool(fbx_props, b'CastShadow', True) + if hasattr(lamp, "cycles"): + lamp.cycles.cast_shadow = lamp.use_shadow + # Keeping this for now, but this is not used nor exposed anymore afaik... + lamp.shadow_color = elem_props_get_color_rgb(fbx_props, b'ShadowColor', (0.0, 0.0, 0.0)) + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, lamp, settings) + + return lamp + + +# ### Import Utility class +class FbxImportHelperNode: + """ + Temporary helper node to store a hierarchy of fbxNode objects before building Objects, Armatures and Bones. + It tries to keep the correction data in one place so it can be applied consistently to the imported data. + """ + + __slots__ = ( + '_parent', 'anim_compensation_matrix', 'is_global_animation', 'armature_setup', 'armature', 'bind_matrix', + 'bl_bone', 'bl_data', 'bl_obj', 'bone_child_matrix', 'children', 'clusters', + 'fbx_elem', 'fbx_data_elem', 'fbx_name', 'fbx_transform_data', 'fbx_type', + 'is_armature', 'has_bone_children', 'is_bone', 'is_root', 'is_leaf', + 'matrix', 'matrix_as_parent', 'matrix_geom', 'meshes', 'post_matrix', 'pre_matrix') + + def __init__(self, fbx_elem, bl_data, fbx_transform_data, is_bone): + self.fbx_name = elem_name_ensure_class(fbx_elem, b'Model') if fbx_elem else 'Unknown' + self.fbx_type = fbx_elem.props[2] if fbx_elem else None + self.fbx_elem = fbx_elem + # FBX elem of a connected NodeAttribute/Geometry for helpers whose bl_data + # does not exist or is yet to be created. + self.fbx_data_elem = None + self.bl_obj = None + self.bl_data = bl_data + # Name of bone if this is a bone (this may be different to fbx_name if there was a name conflict in Blender!) + self.bl_bone = None + self.fbx_transform_data = fbx_transform_data + self.is_root = False + self.is_bone = is_bone + self.is_armature = False + self.armature = None # For bones only, relevant armature node. + # True if the hierarchy below this node contains bones, important to support mixed hierarchies. + self.has_bone_children = False + # True for leaf-bones added to the end of some bone chains to set the lengths. + self.is_leaf = False + self.pre_matrix = None # correction matrix that needs to be applied before the FBX transform + self.bind_matrix = None # for bones this is the matrix used to bind to the skin + if fbx_transform_data: + self.matrix, self.matrix_as_parent, self.matrix_geom = blen_read_object_transform_do(fbx_transform_data) + else: + self.matrix, self.matrix_as_parent, self.matrix_geom = (None, None, None) + self.post_matrix = None # correction matrix that needs to be applied after the FBX transform + self.bone_child_matrix = None # Objects attached to a bone end not the beginning, this matrix corrects for that + + # XXX Those two are to handle the fact that rigged meshes are not linked to their armature in FBX, which implies + # that their animation is in global space (afaik...). + # This is actually not really solvable currently, since anim_compensation_matrix is not valid if armature + # itself is animated (we'd have to recompute global-to-local anim_compensation_matrix for each frame, + # and for each armature action... beyond being an insane work). + # Solution for now: do not read rigged meshes animations at all! sic... + # a mesh moved in the hierarchy may have a different local matrix. This compensates animations for this. + self.anim_compensation_matrix = None + self.is_global_animation = False + + self.meshes = None # List of meshes influenced by this bone. + self.clusters = [] # Deformer Cluster nodes + self.armature_setup = {} # mesh and armature matrix when the mesh was bound + + self._parent = None + self.children = [] + + @property + def parent(self): + return self._parent + + @parent.setter + def parent(self, value): + if self._parent is not None: + self._parent.children.remove(self) + self._parent = value + if self._parent is not None: + self._parent.children.append(self) + + @property + def ignore(self): + # Separating leaf status from ignore status itself. + # Currently they are equivalent, but this may change in future. + return self.is_leaf + + def __repr__(self): + if self.fbx_elem: + return self.fbx_elem.props[1].decode() + else: + return "None" + + def print_info(self, indent=0): + print(" " * indent + (self.fbx_name if self.fbx_name else "(Null)") + + ("[root]" if self.is_root else "") + + ("[leaf]" if self.is_leaf else "") + + ("[ignore]" if self.ignore else "") + + ("[armature]" if self.is_armature else "") + + ("[bone]" if self.is_bone else "") + + ("[HBC]" if self.has_bone_children else "") + ) + for c in self.children: + c.print_info(indent + 1) + + def mark_leaf_bones(self): + if self.is_bone and len(self.children) == 1: + child = self.children[0] + if child.is_bone and len(child.children) == 0: + child.is_leaf = True + for child in self.children: + child.mark_leaf_bones() + + def do_bake_transform(self, settings): + return (settings.bake_space_transform and self.fbx_type in (b'Mesh', b'Null') and + not self.is_armature and not self.is_bone) + + def find_correction_matrix(self, settings, parent_correction_inv=None): + from bpy_extras.io_utils import axis_conversion + + if self.parent and (self.parent.is_root or self.parent.do_bake_transform(settings)): + self.pre_matrix = settings.global_matrix + + if parent_correction_inv: + self.pre_matrix = parent_correction_inv @ (self.pre_matrix if self.pre_matrix else Matrix()) + + correction_matrix = None + + if self.is_bone: + if settings.automatic_bone_orientation: + # find best orientation to align bone with + bone_children = tuple(child for child in self.children if child.is_bone) + if len(bone_children) == 0: + # no children, inherit the correction from parent (if possible) + if self.parent and self.parent.is_bone: + correction_matrix = parent_correction_inv.inverted() if parent_correction_inv else None + else: + # else find how best to rotate the bone to align the Y axis with the children + best_axis = (1, 0, 0) + if len(bone_children) == 1: + vec = bone_children[0].get_bind_matrix().to_translation() + best_axis = Vector((0, 0, 1 if vec[2] >= 0 else -1)) + if abs(vec[0]) > abs(vec[1]): + if abs(vec[0]) > abs(vec[2]): + best_axis = Vector((1 if vec[0] >= 0 else -1, 0, 0)) + elif abs(vec[1]) > abs(vec[2]): + best_axis = Vector((0, 1 if vec[1] >= 0 else -1, 0)) + else: + # get the child directions once because they may be checked several times + child_locs = (child.get_bind_matrix().to_translation() for child in bone_children) + child_locs = tuple(loc.normalized() for loc in child_locs if loc.magnitude > 0.0) + + # I'm not sure which one I like better... + if False: + best_angle = -1.0 + for i in range(6): + a = i // 2 + s = -1 if i % 2 == 1 else 1 + test_axis = Vector((s if a == 0 else 0, s if a == 1 else 0, s if a == 2 else 0)) + + # find max angle to children + max_angle = 1.0 + for loc in child_locs: + max_angle = min(max_angle, test_axis.dot(loc)) + + # is it better than the last one? + if best_angle < max_angle: + best_angle = max_angle + best_axis = test_axis + else: + best_angle = -1.0 + for vec in child_locs: + test_axis = Vector((0, 0, 1 if vec[2] >= 0 else -1)) + if abs(vec[0]) > abs(vec[1]): + if abs(vec[0]) > abs(vec[2]): + test_axis = Vector((1 if vec[0] >= 0 else -1, 0, 0)) + elif abs(vec[1]) > abs(vec[2]): + test_axis = Vector((0, 1 if vec[1] >= 0 else -1, 0)) + + # find max angle to children + max_angle = 1.0 + for loc in child_locs: + max_angle = min(max_angle, test_axis.dot(loc)) + + # is it better than the last one? + if best_angle < max_angle: + best_angle = max_angle + best_axis = test_axis + + # convert best_axis to axis string + to_up = 'Z' if best_axis[2] >= 0 else '-Z' + if abs(best_axis[0]) > abs(best_axis[1]): + if abs(best_axis[0]) > abs(best_axis[2]): + to_up = 'X' if best_axis[0] >= 0 else '-X' + elif abs(best_axis[1]) > abs(best_axis[2]): + to_up = 'Y' if best_axis[1] >= 0 else '-Y' + to_forward = 'X' if to_up not in {'X', '-X'} else 'Y' + + # Build correction matrix + if (to_up, to_forward) != ('Y', 'X'): + correction_matrix = axis_conversion(from_forward='X', + from_up='Y', + to_forward=to_forward, + to_up=to_up, + ).to_4x4() + else: + correction_matrix = settings.bone_correction_matrix + else: + # camera and light can be hard wired + if self.fbx_type == b'Camera': + correction_matrix = MAT_CONVERT_CAMERA + elif self.fbx_type == b'Light': + correction_matrix = MAT_CONVERT_LIGHT + + self.post_matrix = correction_matrix + + if self.do_bake_transform(settings): + self.post_matrix = settings.global_matrix_inv @ (self.post_matrix if self.post_matrix else Matrix()) + + # process children + correction_matrix_inv = correction_matrix.inverted_safe() if correction_matrix else None + for child in self.children: + child.find_correction_matrix(settings, correction_matrix_inv) + + def find_armature_bones(self, armature): + for child in self.children: + if child.is_bone: + child.armature = armature + child.find_armature_bones(armature) + + def find_armatures(self): + needs_armature = False + for child in self.children: + if child.is_bone: + needs_armature = True + break + if needs_armature: + if self.fbx_type in {b'Null', b'Root'}: + # if empty then convert into armature + self.is_armature = True + armature = self + else: + # otherwise insert a new node + # XXX Maybe in case self is virtual FBX root node, we should instead add one armature per bone child? + armature = FbxImportHelperNode(None, None, None, False) + armature.fbx_name = "Armature" + armature.is_armature = True + + for child in tuple(self.children): + if child.is_bone: + child.parent = armature + + armature.parent = self + + armature.find_armature_bones(armature) + + for child in self.children: + if child.is_armature or child.is_bone: + continue + child.find_armatures() + + def find_bone_children(self): + has_bone_children = False + for child in self.children: + has_bone_children |= child.find_bone_children() + self.has_bone_children = has_bone_children + return self.is_bone or has_bone_children + + def find_fake_bones(self, in_armature=False): + if in_armature and not self.is_bone and self.has_bone_children: + self.is_bone = True + # if we are not a null node we need an intermediate node for the data + if self.fbx_type not in {b'Null', b'Root'}: + node = FbxImportHelperNode(self.fbx_elem, self.bl_data, None, False) + self.fbx_elem = None + self.bl_data = None + + # transfer children + for child in self.children: + if child.is_bone or child.has_bone_children: + continue + child.parent = node + + # attach to parent + node.parent = self + + if self.is_armature: + in_armature = True + for child in self.children: + child.find_fake_bones(in_armature) + + def get_world_matrix_as_parent(self): + matrix = self.parent.get_world_matrix_as_parent() if self.parent else Matrix() + if self.matrix_as_parent: + matrix = matrix @ self.matrix_as_parent + return matrix + + def get_world_matrix(self): + matrix = self.parent.get_world_matrix_as_parent() if self.parent else Matrix() + if self.matrix: + matrix = matrix @ self.matrix + return matrix + + def get_matrix(self): + matrix = self.matrix if self.matrix else Matrix() + if self.pre_matrix: + matrix = self.pre_matrix @ matrix + if self.post_matrix: + matrix = matrix @ self.post_matrix + return matrix + + def get_bind_matrix(self): + matrix = self.bind_matrix if self.bind_matrix else Matrix() + if self.pre_matrix: + matrix = self.pre_matrix @ matrix + if self.post_matrix: + matrix = matrix @ self.post_matrix + return matrix + + def make_bind_pose_local(self, parent_matrix=None): + if parent_matrix is None: + parent_matrix = Matrix() + + if self.bind_matrix: + bind_matrix = parent_matrix.inverted_safe() @ self.bind_matrix + else: + bind_matrix = self.matrix.copy() if self.matrix else None + + self.bind_matrix = bind_matrix + if bind_matrix: + parent_matrix = parent_matrix @ bind_matrix + + for child in self.children: + child.make_bind_pose_local(parent_matrix) + + def collect_skeleton_meshes(self, meshes): + for _, m in self.clusters: + meshes.update(m) + for child in self.children: + if not child.meshes: + child.collect_skeleton_meshes(meshes) + + def collect_armature_meshes(self): + if self.is_armature: + armature_matrix_inv = self.get_world_matrix().inverted_safe() + + meshes = set() + for child in self.children: + # Children meshes may be linked to children armatures, in which case we do not want to link them + # to a parent one. See T70244. + child.collect_armature_meshes() + if not child.meshes: + child.collect_skeleton_meshes(meshes) + for m in meshes: + old_matrix = m.matrix + m.matrix = armature_matrix_inv @ m.get_world_matrix() + m.anim_compensation_matrix = old_matrix.inverted_safe() @ m.matrix + m.is_global_animation = True + m.parent = self + self.meshes = meshes + else: + for child in self.children: + child.collect_armature_meshes() + + def build_skeleton(self, arm, parent_matrix, settings, parent_bone_size=1): + def child_connect(par_bone, child_bone, child_head, connect_ctx): + # child_bone or child_head may be None. + force_connect_children, connected = connect_ctx + if child_bone is not None: + child_bone.parent = par_bone + child_head = child_bone.head + + if similar_values_iter(par_bone.tail, child_head): + if child_bone is not None: + child_bone.use_connect = True + # Disallow any force-connection at this level from now on, since that child was 'really' + # connected, we do not want to move current bone's tail anymore! + connected = None + elif force_connect_children and connected is not None: + # We only store position where tail of par_bone should be in the end. + # Actual tail moving and force connection of compatible child bones will happen + # once all have been checked. + if connected is ...: + connected = ([child_head.copy(), 1], [child_bone] if child_bone is not None else []) + else: + connected[0][0] += child_head + connected[0][1] += 1 + if child_bone is not None: + connected[1].append(child_bone) + connect_ctx[1] = connected + + def child_connect_finalize(par_bone, connect_ctx): + force_connect_children, connected = connect_ctx + # Do nothing if force connection is not enabled! + if force_connect_children and connected is not None and connected is not ...: + # Here again we have to be wary about zero-length bones!!! + par_tail = connected[0][0] / connected[0][1] + if (par_tail - par_bone.head).magnitude < 1e-2: + par_bone_vec = (par_bone.tail - par_bone.head).normalized() + par_tail = par_bone.head + par_bone_vec * 0.01 + par_bone.tail = par_tail + for child_bone in connected[1]: + if similar_values_iter(par_tail, child_bone.head): + child_bone.use_connect = True + + # Create the (edit)bone. + bone = arm.bl_data.edit_bones.new(name=self.fbx_name) + bone.select = True + self.bl_obj = arm.bl_obj + self.bl_data = arm.bl_data + self.bl_bone = bone.name # Could be different from the FBX name! + # Read EditBone custom props the NodeAttribute + if settings.use_custom_props and self.fbx_data_elem: + blen_read_custom_properties(self.fbx_data_elem, bone, settings) + + # get average distance to children + bone_size = 0.0 + bone_count = 0 + for child in self.children: + if child.is_bone: + bone_size += child.get_bind_matrix().to_translation().magnitude + bone_count += 1 + if bone_count > 0: + bone_size /= bone_count + else: + bone_size = parent_bone_size + + # So that our bone gets its final length, but still Y-aligned in armature space. + # 0-length bones are automatically collapsed into their parent when you leave edit mode, + # so this enforces a minimum length. + bone_tail = Vector((0.0, 1.0, 0.0)) * max(0.01, bone_size) + bone.tail = bone_tail + + # And rotate/move it to its final "rest pose". + bone_matrix = parent_matrix @ self.get_bind_matrix().normalized() + + bone.matrix = bone_matrix + + force_connect_children = settings.force_connect_children + + connect_ctx = [force_connect_children, ...] + for child in self.children: + if child.is_leaf and force_connect_children: + # Arggggggggggggggggg! We do not want to create this bone, but we need its 'virtual head' location + # to orient current one!!! + child_head = (bone_matrix @ child.get_bind_matrix().normalized()).translation + child_connect(bone, None, child_head, connect_ctx) + elif child.is_bone and not child.ignore: + child_bone = child.build_skeleton(arm, bone_matrix, settings, bone_size) + # Connection to parent. + child_connect(bone, child_bone, None, connect_ctx) + + child_connect_finalize(bone, connect_ctx) + + # Correction for children attached to a bone. FBX expects to attach to the head of a bone, while Blender + # attaches to the tail. + if force_connect_children: + # When forcefully connecting, the bone's tail position may be changed, which can change both the bone's + # rotation and its length. + # Set the correction matrix such that it transforms the current tail transformation back to the original + # head transformation. + head_to_origin = bone.matrix.inverted_safe() + tail_to_head = Matrix.Translation(bone.head - bone.tail) + origin_to_original_head = bone_matrix + tail_to_original_head = head_to_origin @ tail_to_head @ origin_to_original_head + self.bone_child_matrix = tail_to_original_head + else: + self.bone_child_matrix = Matrix.Translation(-bone_tail) + + return bone + + def build_node_obj(self, fbx_tmpl, settings): + if self.bl_obj: + return self.bl_obj + + if self.is_bone or not self.fbx_elem: + return None + + # create when linking since we need object data + elem_name_utf8 = self.fbx_name + + # Object data must be created already + self.bl_obj = obj = bpy.data.objects.new(name=elem_name_utf8, object_data=self.bl_data) + + fbx_props = (elem_find_first(self.fbx_elem, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + # ---- + # Misc Attributes + + obj.color[0:3] = elem_props_get_color_rgb(fbx_props, b'Color', (0.8, 0.8, 0.8)) + obj.hide_viewport = not bool(elem_props_get_visibility(fbx_props, b'Visibility', 1.0)) + + obj.matrix_basis = self.get_matrix() + + if settings.use_custom_props: + blen_read_custom_properties(self.fbx_elem, obj, settings) + + return obj + + def build_skeleton_children(self, fbx_tmpl, settings, scene, view_layer): + if self.is_bone: + for child in self.children: + if child.ignore: + continue + child.build_skeleton_children(fbx_tmpl, settings, scene, view_layer) + return None + else: + # child is not a bone + obj = self.build_node_obj(fbx_tmpl, settings) + + if obj is None: + return None + + for child in self.children: + if child.ignore: + continue + child.build_skeleton_children(fbx_tmpl, settings, scene, view_layer) + + # instance in scene + view_layer.active_layer_collection.collection.objects.link(obj) + obj.select_set(True) + + return obj + + def link_skeleton_children(self, fbx_tmpl, settings, scene): + if self.is_bone: + for child in self.children: + if child.ignore: + continue + child_obj = child.bl_obj + if child_obj and child_obj != self.bl_obj: + child_obj.parent = self.bl_obj # get the armature the bone belongs to + child_obj.parent_bone = self.bl_bone + child_obj.parent_type = 'BONE' + child_obj.matrix_parent_inverse = Matrix() + + # Blender attaches to the end of a bone, while FBX attaches to the start. + # bone_child_matrix corrects for that. + if child.pre_matrix: + child.pre_matrix = self.bone_child_matrix @ child.pre_matrix + else: + child.pre_matrix = self.bone_child_matrix + + child_obj.matrix_basis = child.get_matrix() + child.link_skeleton_children(fbx_tmpl, settings, scene) + return None + else: + obj = self.bl_obj + + for child in self.children: + if child.ignore: + continue + child_obj = child.link_skeleton_children(fbx_tmpl, settings, scene) + if child_obj: + child_obj.parent = obj + + return obj + + def set_pose_matrix_and_custom_props(self, arm, settings): + pose_bone = arm.bl_obj.pose.bones[self.bl_bone] + pose_bone.matrix_basis = self.get_bind_matrix().inverted_safe() @ self.get_matrix() + + # `self.fbx_elem` can be `None` in cases where the imported hierarchy contains a mix of bone and non-bone FBX + # Nodes parented to one another, e.g. "bone1"->"mesh1"->"bone2". In Blender, an Armature can only consist of + # bones, so to maintain the imported hierarchy, a placeholder bone with the same name as "mesh1" is inserted + # into the Armature and then the imported "mesh1" Object is parented to the placeholder bone. The placeholder + # bone won't have a `self.fbx_elem` because it belongs to the "mesh1" Object instead. + # See FbxImportHelperNode.find_fake_bones(). + if settings.use_custom_props and self.fbx_elem: + blen_read_custom_properties(self.fbx_elem, pose_bone, settings) + + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.set_pose_matrix_and_custom_props(arm, settings) + + def merge_weights(self, combined_weights, fbx_cluster): + indices = elem_prop_first(elem_find_first(fbx_cluster, b'Indexes', default=None), default=()) + weights = elem_prop_first(elem_find_first(fbx_cluster, b'Weights', default=None), default=()) + + for index, weight in zip(indices, weights): + w = combined_weights.get(index) + if w is None: + combined_weights[index] = [weight] + else: + w.append(weight) + + def set_bone_weights(self): + ignored_children = tuple(child for child in self.children + if child.is_bone and child.ignore and len(child.clusters) > 0) + + if len(ignored_children) > 0: + # If we have an ignored child bone we need to merge their weights into the current bone weights. + # This can happen both intentionally and accidentally when skinning a model. Either way, they + # need to be moved into a parent bone or they cause animation glitches. + for fbx_cluster, meshes in self.clusters: + combined_weights = {} + self.merge_weights(combined_weights, fbx_cluster) + + for child in ignored_children: + for child_cluster, child_meshes in child.clusters: + if not meshes.isdisjoint(child_meshes): + self.merge_weights(combined_weights, child_cluster) + + # combine child weights + indices = [] + weights = [] + for i, w in combined_weights.items(): + indices.append(i) + if len(w) > 1: + # Add ignored child weights to the current bone's weight. + # XXX - Weights that sum to more than 1.0 get clamped to 1.0 when set in the vertex group. + weights.append(sum(w)) + else: + weights.append(w[0]) + + add_vgroup_to_objects(indices, weights, self.bl_bone, [node.bl_obj for node in meshes]) + + # clusters that drive meshes not included in a parent don't need to be merged + all_meshes = set().union(*[meshes for _, meshes in self.clusters]) + for child in ignored_children: + for child_cluster, child_meshes in child.clusters: + if all_meshes.isdisjoint(child_meshes): + indices = elem_prop_first(elem_find_first(child_cluster, b'Indexes', default=None), default=()) + weights = elem_prop_first(elem_find_first(child_cluster, b'Weights', default=None), default=()) + add_vgroup_to_objects(indices, weights, self.bl_bone, [node.bl_obj for node in child_meshes]) + else: + # set the vertex weights on meshes + for fbx_cluster, meshes in self.clusters: + indices = elem_prop_first(elem_find_first(fbx_cluster, b'Indexes', default=None), default=()) + weights = elem_prop_first(elem_find_first(fbx_cluster, b'Weights', default=None), default=()) + add_vgroup_to_objects(indices, weights, self.bl_bone, [node.bl_obj for node in meshes]) + + for child in self.children: + if child.is_bone and not child.ignore: + child.set_bone_weights() + + def build_hierarchy(self, fbx_tmpl, settings, scene, view_layer): + if self.is_armature: + # create when linking since we need object data + elem_name_utf8 = self.fbx_name + + self.bl_data = arm_data = bpy.data.armatures.new(name=elem_name_utf8) + + # Object data must be created already + self.bl_obj = arm = bpy.data.objects.new(name=elem_name_utf8, object_data=arm_data) + + arm.matrix_basis = self.get_matrix() + + if self.fbx_elem: + fbx_props = (elem_find_first(self.fbx_elem, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + if settings.use_custom_props: + # Read Armature Object custom props from the Node + blen_read_custom_properties(self.fbx_elem, arm, settings) + + if self.fbx_data_elem: + # Read Armature Data custom props from the NodeAttribute + blen_read_custom_properties(self.fbx_data_elem, arm_data, settings) + + # instance in scene + view_layer.active_layer_collection.collection.objects.link(arm) + arm.select_set(True) + + # Add bones: + + # Switch to Edit mode. + view_layer.objects.active = arm + is_hidden = arm.hide_viewport + arm.hide_viewport = False # Can't switch to Edit mode hidden objects... + bpy.ops.object.mode_set(mode='EDIT') + + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.build_skeleton(self, Matrix(), settings) + + bpy.ops.object.mode_set(mode='OBJECT') + + arm.hide_viewport = is_hidden + + # Set pose matrix and PoseBone custom properties + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.set_pose_matrix_and_custom_props(self, settings) + + # Add bone children: + for child in self.children: + if child.ignore: + continue + child_obj = child.build_skeleton_children(fbx_tmpl, settings, scene, view_layer) + + return arm + elif self.fbx_elem and not self.is_bone: + obj = self.build_node_obj(fbx_tmpl, settings) + + # walk through children + for child in self.children: + child.build_hierarchy(fbx_tmpl, settings, scene, view_layer) + + # instance in scene + view_layer.active_layer_collection.collection.objects.link(obj) + obj.select_set(True) + + return obj + else: + for child in self.children: + child.build_hierarchy(fbx_tmpl, settings, scene, view_layer) + + return None + + def link_hierarchy(self, fbx_tmpl, settings, scene): + if self.is_armature: + arm = self.bl_obj + + # Link bone children: + for child in self.children: + if child.ignore: + continue + child_obj = child.link_skeleton_children(fbx_tmpl, settings, scene) + if child_obj: + child_obj.parent = arm + + # Add armature modifiers to the meshes + if self.meshes: + for mesh in self.meshes: + (mmat, amat) = mesh.armature_setup[self] + me_obj = mesh.bl_obj + + # bring global armature & mesh matrices into *Blender* global space. + # Note: Usage of matrix_geom (local 'diff' transform) here is quite brittle. + # Among other things, why in hell isn't it taken into account by bindpose & co??? + # Probably because org app (max) handles it completely aside from any parenting stuff, + # which we obviously cannot do in Blender. :/ + if amat is None: + amat = self.bind_matrix + amat = settings.global_matrix @ (Matrix() if amat is None else amat) + if self.matrix_geom: + amat = amat @ self.matrix_geom + mmat = settings.global_matrix @ mmat + if mesh.matrix_geom: + mmat = mmat @ mesh.matrix_geom + + # Now that we have armature and mesh in there (global) bind 'state' (matrix), + # we can compute inverse parenting matrix of the mesh. + me_obj.matrix_parent_inverse = amat.inverted_safe() @ mmat @ me_obj.matrix_basis.inverted_safe() + + mod = mesh.bl_obj.modifiers.new(arm.name, 'ARMATURE') + mod.object = arm + + # Add bone weights to the deformers + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.set_bone_weights() + + return arm + elif self.bl_obj: + obj = self.bl_obj + + # walk through children + for child in self.children: + child_obj = child.link_hierarchy(fbx_tmpl, settings, scene) + if child_obj: + child_obj.parent = obj + + return obj + else: + for child in self.children: + child.link_hierarchy(fbx_tmpl, settings, scene) + + return None + + +def load(operator, context, filepath="", + use_manual_orientation=False, + axis_forward='-Z', + axis_up='Y', + global_scale=1.0, + bake_space_transform=False, + use_custom_normals=True, + use_image_search=False, + use_alpha_decals=False, + decal_offset=0.0, + use_anim=True, + anim_offset=1.0, + use_subsurf=False, + use_custom_props=True, + use_custom_props_enum_as_string=True, + ignore_leaf_bones=False, + force_connect_children=False, + automatic_bone_orientation=False, + primary_bone_axis='Y', + secondary_bone_axis='X', + use_prepost_rot=True, + colors_type='SRGB'): + + global fbx_elem_nil + fbx_elem_nil = FBXElem('', (), (), ()) + + import os + import time + from bpy_extras.io_utils import axis_conversion + + from . import parse_fbx + from .fbx_utils import RIGHT_HAND_AXES, FBX_FRAMERATES + + start_time_proc = time.process_time() + start_time_sys = time.time() + + perfmon = PerfMon() + perfmon.level_up() + perfmon.step("FBX Import: start importing %s" % filepath) + perfmon.level_up() + + # Detect ASCII files. + + # Typically it's bad practice to fail silently on any error, + # however the file may fail to read for many reasons, + # and this situation is handled later in the code, + # right now we only want to know if the file successfully reads as ascii. + try: + with open(filepath, 'r', encoding="utf-8") as fh: + fh.read(24) + is_ascii = True + except Exception: + is_ascii = False + + if is_ascii: + operator.report({'ERROR'}, tip_("ASCII FBX files are not supported %r") % filepath) + return {'CANCELLED'} + del is_ascii + # End ascii detection. + + try: + elem_root, version = parse_fbx.parse(filepath) + except Exception as e: + import traceback + traceback.print_exc() + + operator.report({'ERROR'}, tip_("Couldn't open file %r (%s)") % (filepath, e)) + return {'CANCELLED'} + + if version < 7100: + operator.report({'ERROR'}, tip_("Version %r unsupported, must be %r or later") % (version, 7100)) + return {'CANCELLED'} + + print("FBX version: %r" % version) + + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT', toggle=False) + + # deselect all + if bpy.ops.object.select_all.poll(): + bpy.ops.object.select_all(action='DESELECT') + + basedir = os.path.dirname(filepath) + + nodal_material_wrap_map = {} + image_cache = {} + + # Tables: (FBX_byte_id -> [FBX_data, None or Blender_datablock]) + fbx_table_nodes = {} + + if use_alpha_decals: + material_decals = set() + else: + material_decals = None + + scene = context.scene + view_layer = context.view_layer + + # #### Get some info from GlobalSettings. + + perfmon.step("FBX import: Prepare...") + + fbx_settings = elem_find_first(elem_root, b'GlobalSettings') + fbx_settings_props = elem_find_first(fbx_settings, b'Properties70') + if fbx_settings is None or fbx_settings_props is None: + operator.report({'ERROR'}, tip_("No 'GlobalSettings' found in file %r") % filepath) + return {'CANCELLED'} + + # FBX default base unit seems to be the centimeter, while raw Blender Unit is equivalent to the meter... + unit_scale = elem_props_get_number(fbx_settings_props, b'UnitScaleFactor', 1.0) + unit_scale_org = elem_props_get_number(fbx_settings_props, b'OriginalUnitScaleFactor', 1.0) + global_scale *= (unit_scale / units_blender_to_fbx_factor(context.scene)) + # Compute global matrix and scale. + if not use_manual_orientation: + axis_forward = (elem_props_get_integer(fbx_settings_props, b'FrontAxis', 1), + elem_props_get_integer(fbx_settings_props, b'FrontAxisSign', 1)) + axis_up = (elem_props_get_integer(fbx_settings_props, b'UpAxis', 2), + elem_props_get_integer(fbx_settings_props, b'UpAxisSign', 1)) + axis_coord = (elem_props_get_integer(fbx_settings_props, b'CoordAxis', 0), + elem_props_get_integer(fbx_settings_props, b'CoordAxisSign', 1)) + axis_key = (axis_up, axis_forward, axis_coord) + axis_up, axis_forward = {v: k for k, v in RIGHT_HAND_AXES.items()}.get(axis_key, ('Z', 'Y')) + global_matrix = (Matrix.Scale(global_scale, 4) @ + axis_conversion(from_forward=axis_forward, from_up=axis_up).to_4x4()) + + # To cancel out unwanted rotation/scale on nodes. + global_matrix_inv = global_matrix.inverted() + # For transforming mesh normals. + global_matrix_inv_transposed = global_matrix_inv.transposed() + + # Compute bone correction matrix + bone_correction_matrix = None # None means no correction/identity + if not automatic_bone_orientation: + if (primary_bone_axis, secondary_bone_axis) != ('Y', 'X'): + bone_correction_matrix = axis_conversion(from_forward='X', + from_up='Y', + to_forward=secondary_bone_axis, + to_up=primary_bone_axis, + ).to_4x4() + + # Compute framerate settings. + custom_fps = elem_props_get_number(fbx_settings_props, b'CustomFrameRate', 25.0) + time_mode = elem_props_get_enum(fbx_settings_props, b'TimeMode') + real_fps = {eid: val for val, eid in FBX_FRAMERATES[1:]}.get(time_mode, custom_fps) + if real_fps <= 0.0: + real_fps = 25.0 + scene.render.fps = round(real_fps) + scene.render.fps_base = scene.render.fps / real_fps + + # store global settings that need to be accessed during conversion + settings = FBXImportSettings( + operator.report, (axis_up, axis_forward), global_matrix, global_scale, + bake_space_transform, global_matrix_inv, global_matrix_inv_transposed, + use_custom_normals, use_image_search, + use_alpha_decals, decal_offset, + use_anim, anim_offset, + use_subsurf, + use_custom_props, use_custom_props_enum_as_string, + nodal_material_wrap_map, image_cache, + ignore_leaf_bones, force_connect_children, automatic_bone_orientation, bone_correction_matrix, + use_prepost_rot, colors_type, + ) + + # #### And now, the "real" data. + + perfmon.step("FBX import: Templates...") + + fbx_defs = elem_find_first(elem_root, b'Definitions') # can be None + fbx_nodes = elem_find_first(elem_root, b'Objects') + fbx_connections = elem_find_first(elem_root, b'Connections') + + if fbx_nodes is None: + operator.report({'ERROR'}, tip_("No 'Objects' found in file %r") % filepath) + return {'CANCELLED'} + if fbx_connections is None: + operator.report({'ERROR'}, tip_("No 'Connections' found in file %r") % filepath) + return {'CANCELLED'} + + # ---- + # First load property templates + # Load 'PropertyTemplate' values. + # Key is a tuple, (ObjectType, FBXNodeType) + # eg, (b'Texture', b'KFbxFileTexture') + # (b'Geometry', b'KFbxMesh') + fbx_templates = {} + + def _(): + if fbx_defs is not None: + for fbx_def in fbx_defs.elems: + if fbx_def.id == b'ObjectType': + for fbx_subdef in fbx_def.elems: + if fbx_subdef.id == b'PropertyTemplate': + assert(fbx_def.props_type == b'S') + assert(fbx_subdef.props_type == b'S') + # (b'Texture', b'KFbxFileTexture') - eg. + key = fbx_def.props[0], fbx_subdef.props[0] + fbx_templates[key] = fbx_subdef + _() + del _ + + def fbx_template_get(key): + ret = fbx_templates.get(key, fbx_elem_nil) + if ret is fbx_elem_nil: + # Newest FBX (7.4 and above) use no more 'K' in their type names... + key = (key[0], key[1][1:]) + return fbx_templates.get(key, fbx_elem_nil) + return ret + + perfmon.step("FBX import: Nodes...") + + # ---- + # Build FBX node-table + def _(): + for fbx_obj in fbx_nodes.elems: + # TODO, investigate what other items after first 3 may be + assert(fbx_obj.props_type[:3] == b'LSS') + fbx_uuid = elem_uuid(fbx_obj) + fbx_table_nodes[fbx_uuid] = [fbx_obj, None] + _() + del _ + + # ---- + # Load in the data + # http://download.autodesk.com/us/fbx/20112/FBX_SDK_HELP/index.html?url= + # WS73099cc142f487551fea285e1221e4f9ff8-7fda.htm,topicNumber=d0e6388 + + perfmon.step("FBX import: Connections...") + + fbx_connection_map = {} + fbx_connection_map_reverse = {} + + def _(): + for fbx_link in fbx_connections.elems: + c_type = fbx_link.props[0] + if fbx_link.props_type[1:3] == b'LL': + c_src, c_dst = fbx_link.props[1:3] + fbx_connection_map.setdefault(c_src, []).append((c_dst, fbx_link)) + fbx_connection_map_reverse.setdefault(c_dst, []).append((c_src, fbx_link)) + _() + del _ + + perfmon.step("FBX import: Meshes...") + + # ---- + # Load mesh data + def _(): + fbx_tmpl = fbx_template_get((b'Geometry', b'KFbxMesh')) + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Geometry': + continue + if fbx_obj.props[-1] == b'Mesh': + assert(blen_data is None) + fbx_item[1] = blen_read_geom(fbx_tmpl, fbx_obj, settings) + _() + del _ + + perfmon.step("FBX import: Materials & Textures...") + + # ---- + # Load material data + def _(): + fbx_tmpl = fbx_template_get((b'Material', b'KFbxSurfacePhong')) + # b'KFbxSurfaceLambert' + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Material': + continue + assert(blen_data is None) + fbx_item[1] = blen_read_material(fbx_tmpl, fbx_obj, settings) + _() + del _ + + # ---- + # Load image & textures data + def _(): + fbx_tmpl_tex = fbx_template_get((b'Texture', b'KFbxFileTexture')) + fbx_tmpl_img = fbx_template_get((b'Video', b'KFbxVideo')) + + # Important to run all 'Video' ones first, embedded images are stored in those nodes. + # XXX Note we simplify things here, assuming both matching Video and Texture will use same file path, + # this may be a bit weak, if issue arise we'll fallback to plain connection stuff... + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Video': + continue + fbx_item[1] = blen_read_texture_image(fbx_tmpl_img, fbx_obj, basedir, settings) + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Texture': + continue + fbx_item[1] = blen_read_texture_image(fbx_tmpl_tex, fbx_obj, basedir, settings) + _() + del _ + + perfmon.step("FBX import: Cameras & Lamps...") + + # ---- + # Load camera data + def _(): + fbx_tmpl = fbx_template_get((b'NodeAttribute', b'KFbxCamera')) + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'NodeAttribute': + continue + if fbx_obj.props[-1] == b'Camera': + assert(blen_data is None) + fbx_item[1] = blen_read_camera(fbx_tmpl, fbx_obj, settings) + _() + del _ + + # ---- + # Load lamp data + def _(): + fbx_tmpl = fbx_template_get((b'NodeAttribute', b'KFbxLight')) + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'NodeAttribute': + continue + if fbx_obj.props[-1] == b'Light': + assert(blen_data is None) + fbx_item[1] = blen_read_light(fbx_tmpl, fbx_obj, settings) + _() + del _ + + # ---- + # Connections + def connection_filter_ex(fbx_uuid, fbx_id, dct): + return [(c_found[0], c_found[1], c_type) + for (c_uuid, c_type) in dct.get(fbx_uuid, ()) + # 0 is used for the root node, which isn't in fbx_table_nodes + for c_found in (() if c_uuid == 0 else (fbx_table_nodes.get(c_uuid, (None, None)),)) + if (fbx_id is None) or (c_found[0] and c_found[0].id == fbx_id)] + + def connection_filter_forward(fbx_uuid, fbx_id): + return connection_filter_ex(fbx_uuid, fbx_id, fbx_connection_map) + + def connection_filter_reverse(fbx_uuid, fbx_id): + return connection_filter_ex(fbx_uuid, fbx_id, fbx_connection_map_reverse) + + perfmon.step("FBX import: Objects & Armatures...") + + # -- temporary helper hierarchy to build armatures and objects from + # lookup from uuid to helper node. Used to build parent-child relations and later to look up animated nodes. + fbx_helper_nodes = {} + + def _(): + # We build an intermediate hierarchy used to: + # - Calculate and store bone orientation correction matrices. The same matrices will be reused for animation. + # - Find/insert armature nodes. + # - Filter leaf bones. + + # create scene root + fbx_helper_nodes[0] = root_helper = FbxImportHelperNode(None, None, None, False) + root_helper.is_root = True + + # add fbx nodes + fbx_tmpl = fbx_template_get((b'Model', b'KFbxNode')) + for a_uuid, a_item in fbx_table_nodes.items(): + fbx_obj, bl_data = a_item + if fbx_obj is None or fbx_obj.id != b'Model': + continue + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + transform_data = blen_read_object_transform_preprocess(fbx_props, fbx_obj, Matrix(), use_prepost_rot) + # Note: 'Root' "bones" are handled as (armature) objects. + # Note: See T46912 for first FBX file I ever saw with 'Limb' bones - thought those were totally deprecated. + is_bone = fbx_obj.props[2] in {b'LimbNode', b'Limb'} + fbx_helper_nodes[a_uuid] = FbxImportHelperNode(fbx_obj, bl_data, transform_data, is_bone) + + # add parent-child relations and add blender data to the node + for fbx_link in fbx_connections.elems: + if fbx_link.props[0] != b'OO': + continue + if fbx_link.props_type[1:3] == b'LL': + c_src, c_dst = fbx_link.props[1:3] + parent = fbx_helper_nodes.get(c_dst) + if parent is None: + continue + + child = fbx_helper_nodes.get(c_src) + if child is None: + # add blender data (meshes, lights, cameras, etc.) to a helper node + fbx_sdata, bl_data = p_item = fbx_table_nodes.get(c_src, (None, None)) + if fbx_sdata is None: + continue + if fbx_sdata.id not in {b'Geometry', b'NodeAttribute'}: + continue + parent.bl_data = bl_data + if bl_data is None: + # If there's no bl_data, add the fbx_sdata so that it can be read when creating the bl_data/bone + parent.fbx_data_elem = fbx_sdata + else: + # set parent + child.parent = parent + + # find armatures (either an empty below a bone or a new node inserted at the bone + root_helper.find_armatures() + + # mark nodes that have bone children + root_helper.find_bone_children() + + # mark nodes that need a bone to attach child-bones to + root_helper.find_fake_bones() + + # mark leaf nodes that are only required to mark the end of their parent bone + if settings.ignore_leaf_bones: + root_helper.mark_leaf_bones() + + # What a mess! Some bones have several BindPoses, some have none, clusters contain a bind pose as well, + # and you can have several clusters per bone! + # Maybe some conversion can be applied to put them all into the same frame of reference? + + # get the bind pose from pose elements + for a_uuid, a_item in fbx_table_nodes.items(): + fbx_obj, bl_data = a_item + if fbx_obj is None: + continue + if fbx_obj.id != b'Pose': + continue + if fbx_obj.props[2] != b'BindPose': + continue + for fbx_pose_node in fbx_obj.elems: + if fbx_pose_node.id != b'PoseNode': + continue + node_elem = elem_find_first(fbx_pose_node, b'Node') + node = elem_uuid(node_elem) + matrix_elem = elem_find_first(fbx_pose_node, b'Matrix') + matrix = array_to_matrix4(matrix_elem.props[0]) if matrix_elem else None + bone = fbx_helper_nodes.get(node) + if bone and matrix: + # Store the matrix in the helper node. + # There may be several bind pose matrices for the same node, but in tests they seem to be identical. + bone.bind_matrix = matrix # global space + + # get clusters and bind pose + for helper_uuid, helper_node in fbx_helper_nodes.items(): + if not helper_node.is_bone: + continue + for cluster_uuid, cluster_link in fbx_connection_map.get(helper_uuid, ()): + if cluster_link.props[0] != b'OO': + continue + fbx_cluster, _ = fbx_table_nodes.get(cluster_uuid, (None, None)) + if fbx_cluster is None or fbx_cluster.id != b'Deformer' or fbx_cluster.props[2] != b'Cluster': + continue + + # Get the bind pose from the cluster: + tx_mesh_elem = elem_find_first(fbx_cluster, b'Transform', default=None) + tx_mesh = array_to_matrix4(tx_mesh_elem.props[0]) if tx_mesh_elem else Matrix() + + tx_bone_elem = elem_find_first(fbx_cluster, b'TransformLink', default=None) + tx_bone = array_to_matrix4(tx_bone_elem.props[0]) if tx_bone_elem else None + + tx_arm_elem = elem_find_first(fbx_cluster, b'TransformAssociateModel', default=None) + tx_arm = array_to_matrix4(tx_arm_elem.props[0]) if tx_arm_elem else None + + mesh_matrix = tx_mesh + armature_matrix = tx_arm + + if tx_bone: + mesh_matrix = tx_bone @ mesh_matrix + helper_node.bind_matrix = tx_bone # overwrite the bind matrix + + # Get the meshes driven by this cluster: (Shouldn't that be only one?) + meshes = set() + for skin_uuid, skin_link in fbx_connection_map.get(cluster_uuid): + if skin_link.props[0] != b'OO': + continue + fbx_skin, _ = fbx_table_nodes.get(skin_uuid, (None, None)) + if fbx_skin is None or fbx_skin.id != b'Deformer' or fbx_skin.props[2] != b'Skin': + continue + skin_connection = fbx_connection_map.get(skin_uuid) + if skin_connection is None: + continue + for mesh_uuid, mesh_link in skin_connection: + if mesh_link.props[0] != b'OO': + continue + fbx_mesh, _ = fbx_table_nodes.get(mesh_uuid, (None, None)) + if fbx_mesh is None or fbx_mesh.id != b'Geometry' or fbx_mesh.props[2] != b'Mesh': + continue + for object_uuid, object_link in fbx_connection_map.get(mesh_uuid): + if object_link.props[0] != b'OO': + continue + mesh_node = fbx_helper_nodes[object_uuid] + if mesh_node: + # ---- + # If we get a valid mesh matrix (in bone space), store armature and + # mesh global matrices, we need them to compute mesh's matrix_parent_inverse + # when actually binding them via the modifier. + # Note we assume all bones were bound with the same mesh/armature (global) matrix, + # we do not support otherwise in Blender anyway! + mesh_node.armature_setup[helper_node.armature] = (mesh_matrix, armature_matrix) + meshes.add(mesh_node) + + helper_node.clusters.append((fbx_cluster, meshes)) + + # convert bind poses from global space into local space + root_helper.make_bind_pose_local() + + # collect armature meshes + root_helper.collect_armature_meshes() + + # find the correction matrices to align FBX objects with their Blender equivalent + root_helper.find_correction_matrix(settings) + + # build the Object/Armature/Bone hierarchy + root_helper.build_hierarchy(fbx_tmpl, settings, scene, view_layer) + + # Link the Object/Armature/Bone hierarchy + root_helper.link_hierarchy(fbx_tmpl, settings, scene) + + # root_helper.print_info(0) + _() + del _ + + perfmon.step("FBX import: ShapeKeys...") + + # We can handle shapes. + blend_shape_channels = {} # We do not need Shapes themselves, but keyblocks, for anim. + + def _(): + fbx_tmpl = fbx_template_get((b'Geometry', b'KFbxShape')) + + # - FBX | - Blender equivalent + # Mesh | `Mesh` + # BlendShape | `Key` + # BlendShapeChannel | `ShapeKey`, but without its `.data`. + # Shape | `ShapeKey.data`, but also includes normals and the values are relative to the base Mesh + # | instead of being absolute. The data is sparse, so each Shape has an "Indexes" array too. + # | FBX 2020 introduced 'Modern Style' Shapes that also support tangents, binormals, vertex + # | colors and UVs, and can be absolute values instead of relative, but 'Modern Style' Shapes + # | are not currently supported. + # + # The FBX connections between Shapes and Meshes form multiple many-many relationships: + # Mesh >-< BlendShape >-< BlendShapeChannel >-< Shape + # In practice, the relationships are almost never many-many and are more typically 1-many or 1-1: + # Mesh --- BlendShape: + # usually 1-1 and the FBX SDK might enforce that each BlendShape is connected to at most one Mesh. + # BlendShape --< BlendShapeChannel: + # usually 1-many. + # BlendShapeChannel --- or uncommonly --< Shape: + # usually 1-1, but 1-many is a documented feature. + + def connections_gen(c_src_uuid, fbx_id, fbx_type): + """Helper to reduce duplicate code""" + # Rarely, an imported FBX file will have duplicate connections. For Shape Key related connections, FBX + # appears to ignore the duplicates, or overwrite the existing duplicates such that the end result is the + # same as ignoring them, so keep a set of the seen connections and ignore any duplicates. + seen_connections = set() + for c_dst_uuid, ctype in fbx_connection_map.get(c_src_uuid, ()): + if ctype.props[0] != b'OO': + # 'Object-Object' connections only. + continue + fbx_data, bl_data = fbx_table_nodes.get(c_dst_uuid, (None, None)) + if fbx_data is None or fbx_data.id != fbx_id or fbx_data.props[2] != fbx_type: + # Either `c_dst_uuid` doesn't exist, or it has a different id or type. + continue + connection_key = (c_src_uuid, c_dst_uuid) + if connection_key in seen_connections: + # The connection is a duplicate, skip it. + continue + seen_connections.add(connection_key) + yield c_dst_uuid, fbx_data, bl_data + + # XXX - Multiple Shapes can be assigned to a single BlendShapeChannel to create a progressive blend between the + # base mesh and the assigned Shapes, with the percentage at which each Shape is fully blended being stored + # in the BlendShapeChannel's FullWeights array. This is also known as 'in-between shapes'. + # We don't have any support for in-between shapes currently. + blend_shape_channel_to_shapes = {} + mesh_to_shapes = {} + for s_uuid, (fbx_sdata, _bl_sdata) in fbx_table_nodes.items(): + if fbx_sdata is None or fbx_sdata.id != b'Geometry' or fbx_sdata.props[2] != b'Shape': + continue + + # shape -> blendshapechannel -> blendshape -> mesh. + for bc_uuid, fbx_bcdata, _bl_bcdata in connections_gen(s_uuid, b'Deformer', b'BlendShapeChannel'): + # Track the Shapes connected to each BlendShapeChannel. + shapes_assigned_to_channel = blend_shape_channel_to_shapes.setdefault(bc_uuid, []) + shapes_assigned_to_channel.append(s_uuid) + for bs_uuid, _fbx_bsdata, _bl_bsdata in connections_gen(bc_uuid, b'Deformer', b'BlendShape'): + for m_uuid, _fbx_mdata, bl_mdata in connections_gen(bs_uuid, b'Geometry', b'Mesh'): + # Blenmeshes are assumed already created at that time! + assert(isinstance(bl_mdata, bpy.types.Mesh)) + # Group shapes by mesh so that each mesh only needs to be processed once for all of its shape + # keys. + if bl_mdata not in mesh_to_shapes: + # And we have to find all objects using this mesh! + objects = [] + for o_uuid, o_ctype in fbx_connection_map.get(m_uuid, ()): + if o_ctype.props[0] != b'OO': + continue + node = fbx_helper_nodes[o_uuid] + if node: + objects.append(node) + shapes_list = [] + mesh_to_shapes[bl_mdata] = (objects, shapes_list) + else: + shapes_list = mesh_to_shapes[bl_mdata][1] + # Only the number of shapes assigned to each BlendShapeChannel needs to be passed through to + # `blen_read_shapes`, but that number isn't known until all the connections have been + # iterated, so pass the `shapes_assigned_to_channel` list instead. + shapes_list.append((bc_uuid, fbx_sdata, fbx_bcdata, shapes_assigned_to_channel)) + # BlendShape deformers are only here to connect BlendShapeChannels to meshes, nothing else to do. + + # Iterate through each mesh and create its shape keys + for bl_mdata, (objects, shapes) in mesh_to_shapes.items(): + for bc_uuid, keyblocks in blen_read_shapes(fbx_tmpl, shapes, objects, bl_mdata, scene).items(): + # keyblocks is a list of tuples (mesh, keyblock) matching that shape/blendshapechannel, for animation. + blend_shape_channels.setdefault(bc_uuid, []).extend(keyblocks) + _() + del _ + + if settings.use_subsurf: + perfmon.step("FBX import: Subdivision surfaces") + + # Look through connections for subsurf in meshes and add it to the parent object + def _(): + for fbx_link in fbx_connections.elems: + if fbx_link.props[0] != b'OO': + continue + if fbx_link.props_type[1:3] == b'LL': + c_src, c_dst = fbx_link.props[1:3] + parent = fbx_helper_nodes.get(c_dst) + if parent is None: + continue + + child = fbx_helper_nodes.get(c_src) + if child is None: + fbx_sdata, bl_data = fbx_table_nodes.get(c_src, (None, None)) + if fbx_sdata.id != b'Geometry': + continue + + preview_levels = elem_prop_first(elem_find_first(fbx_sdata, b'PreviewDivisionLevels')) + render_levels = elem_prop_first(elem_find_first(fbx_sdata, b'RenderDivisionLevels')) + if isinstance(preview_levels, int) and isinstance(render_levels, int): + mod = parent.bl_obj.modifiers.new('subsurf', 'SUBSURF') + mod.levels = preview_levels + mod.render_levels = render_levels + boundary_rule = elem_prop_first(elem_find_first(fbx_sdata, b'BoundaryRule'), default=1) + if boundary_rule == 1: + mod.boundary_smooth = "PRESERVE_CORNERS" + else: + mod.boundary_smooth = "ALL" + + _() + del _ + + if use_anim: + perfmon.step("FBX import: Animations...") + + # Animation! + def _(): + # Find the number of "ktimes" per second for this file. + # Start with the default for this FBX version. + fbx_ktime = FBX_KTIME_V8 if version >= 8000 else FBX_KTIME_V7 + # Try to find the value of the nested elem_root->'FBXHeaderExtension'->'OtherFlags'->'TCDefinition' element + # and look up the "ktimes" per second for its value. + if header := elem_find_first(elem_root, b'FBXHeaderExtension'): + # The header version that added TCDefinition support is 1004. + if elem_prop_first(elem_find_first(header, b'FBXHeaderVersion'), default=0) >= 1004: + if other_flags := elem_find_first(header, b'OtherFlags'): + if timecode_definition := elem_find_first(other_flags, b'TCDefinition'): + timecode_definition_value = elem_prop_first(timecode_definition) + # If its value is unknown or missing, default to FBX_KTIME_V8. + fbx_ktime = FBX_TIMECODE_DEFINITION_TO_KTIME_PER_SECOND.get(timecode_definition_value, + FBX_KTIME_V8) + + fbx_tmpl_astack = fbx_template_get((b'AnimationStack', b'FbxAnimStack')) + fbx_tmpl_alayer = fbx_template_get((b'AnimationLayer', b'FbxAnimLayer')) + stacks = {} + + # AnimationStacks. + for as_uuid, fbx_asitem in fbx_table_nodes.items(): + fbx_asdata, _blen_data = fbx_asitem + if fbx_asdata.id != b'AnimationStack' or fbx_asdata.props[2] != b'': + continue + stacks[as_uuid] = (fbx_asitem, {}) + + # AnimationLayers + # (mixing is completely ignored for now, each layer results in an independent set of actions). + def get_astacks_from_alayer(al_uuid): + for as_uuid, as_ctype in fbx_connection_map.get(al_uuid, ()): + if as_ctype.props[0] != b'OO': + continue + fbx_asdata, _bl_asdata = fbx_table_nodes.get(as_uuid, (None, None)) + if (fbx_asdata is None or fbx_asdata.id != b'AnimationStack' or + fbx_asdata.props[2] != b'' or as_uuid not in stacks): + continue + yield as_uuid + for al_uuid, fbx_alitem in fbx_table_nodes.items(): + fbx_aldata, _blen_data = fbx_alitem + if fbx_aldata.id != b'AnimationLayer' or fbx_aldata.props[2] != b'': + continue + for as_uuid in get_astacks_from_alayer(al_uuid): + _fbx_asitem, alayers = stacks[as_uuid] + alayers[al_uuid] = (fbx_alitem, {}) + + # AnimationCurveNodes (also the ones linked to actual animated data!). + curvenodes = {} + for acn_uuid, fbx_acnitem in fbx_table_nodes.items(): + fbx_acndata, _blen_data = fbx_acnitem + if fbx_acndata.id != b'AnimationCurveNode' or fbx_acndata.props[2] != b'': + continue + cnode = curvenodes[acn_uuid] = {} + items = [] + for n_uuid, n_ctype in fbx_connection_map.get(acn_uuid, ()): + if n_ctype.props[0] != b'OP': + continue + lnk_prop = n_ctype.props[3] + if lnk_prop in {b'Lcl Translation', b'Lcl Rotation', b'Lcl Scaling'}: + # n_uuid can (????) be linked to root '0' node, instead of a mere object node... See T41712. + ob = fbx_helper_nodes.get(n_uuid, None) + if ob is None or ob.is_root: + continue + items.append((ob, lnk_prop)) + elif lnk_prop == b'DeformPercent': # Shape keys. + keyblocks = blend_shape_channels.get(n_uuid, None) + if keyblocks is None: + continue + items += [(kb, lnk_prop) for kb in keyblocks] + elif lnk_prop == b'FocalLength': # Camera lens. + from bpy.types import Camera + fbx_item = fbx_table_nodes.get(n_uuid, None) + if fbx_item is None or not isinstance(fbx_item[1], Camera): + continue + cam = fbx_item[1] + items.append((cam, lnk_prop)) + elif lnk_prop == b'FocusDistance': # Camera focus. + from bpy.types import Camera + fbx_item = fbx_table_nodes.get(n_uuid, None) + if fbx_item is None or not isinstance(fbx_item[1], Camera): + continue + cam = fbx_item[1] + items.append((cam, lnk_prop)) + elif lnk_prop == b'DiffuseColor': + from bpy.types import Material + fbx_item = fbx_table_nodes.get(n_uuid, None) + if fbx_item is None or not isinstance(fbx_item[1], Material): + continue + mat = fbx_item[1] + items.append((mat, lnk_prop)) + print("WARNING! Importing material's animation is not supported for Nodal materials...") + for al_uuid, al_ctype in fbx_connection_map.get(acn_uuid, ()): + if al_ctype.props[0] != b'OO': + continue + fbx_aldata, _blen_aldata = fbx_alitem = fbx_table_nodes.get(al_uuid, (None, None)) + if fbx_aldata is None or fbx_aldata.id != b'AnimationLayer' or fbx_aldata.props[2] != b'': + continue + for as_uuid in get_astacks_from_alayer(al_uuid): + _fbx_alitem, anim_items = stacks[as_uuid][1][al_uuid] + assert(_fbx_alitem == fbx_alitem) + for item, item_prop in items: + # No need to keep curvenode FBX data here, contains nothing useful for us. + anim_items.setdefault(item, {})[acn_uuid] = (cnode, item_prop) + + # AnimationCurves (real animation data). + for ac_uuid, fbx_acitem in fbx_table_nodes.items(): + fbx_acdata, _blen_data = fbx_acitem + if fbx_acdata.id != b'AnimationCurve' or fbx_acdata.props[2] != b'': + continue + for acn_uuid, acn_ctype in fbx_connection_map.get(ac_uuid, ()): + if acn_ctype.props[0] != b'OP': + continue + fbx_acndata, _bl_acndata = fbx_table_nodes.get(acn_uuid, (None, None)) + if (fbx_acndata is None or fbx_acndata.id != b'AnimationCurveNode' or + fbx_acndata.props[2] != b'' or acn_uuid not in curvenodes): + continue + # Note this is an infamous simplification of the compound props stuff, + # seems to be standard naming but we'll probably have to be smarter to handle more exotic files? + channel = { + b'd|X': 0, b'd|Y': 1, b'd|Z': 2, + b'd|DeformPercent': 0, + b'd|FocalLength': 0, + b'd|FocusDistance': 0 + }.get(acn_ctype.props[3], None) + if channel is None: + continue + curvenodes[acn_uuid][ac_uuid] = (fbx_acitem, channel) + + # And now that we have sorted all this, apply animations! + blen_read_animations(fbx_tmpl_astack, fbx_tmpl_alayer, stacks, scene, settings.anim_offset, global_scale, + fbx_ktime) + + _() + del _ + + perfmon.step("FBX import: Assign materials...") + + def _(): + # link Material's to Geometry (via Model's) + processed_meshes = set() + for helper_uuid, helper_node in fbx_helper_nodes.items(): + obj = helper_node.bl_obj + if not obj or obj.type != 'MESH': + continue + + # Get the Mesh corresponding to the Geometry used by this Model. + mesh = obj.data + processed_meshes.add(mesh) + + # Get the Materials from the Model's connections. + material_connections = connection_filter_reverse(helper_uuid, b'Material') + if not material_connections: + continue + + mesh_mats = mesh.materials + num_mesh_mats = len(mesh_mats) + + if num_mesh_mats == 0: + # This is the first (or only) model to use this Geometry. This is the most common case when importing. + # All the Materials can trivially be appended to the Mesh's Materials. + mats_to_append = material_connections + mats_to_compare = () + elif num_mesh_mats == len(material_connections): + # Another Model uses the same Geometry and has already appended its Materials to the Mesh. This is the + # second most common case when importing. + # It's also possible that a Model could share the same Geometry and have the same number of Materials, + # but have different Materials, though this is less common. + # The Model Materials will need to be compared with the Mesh Materials at the same indices to check if + # they are different. + mats_to_append = () + mats_to_compare = material_connections + else: + # Under the assumption that only used Materials are connected to the Model, the number of Materials of + # each Model using a specific Geometry should be the same, otherwise the Material Indices of the + # Geometry will be out-of-bounds of the Materials of at least one of the Models using that Geometry. + # We wouldn't expect this case to happen, but there's nothing to say it can't. + # We'll handle a differing number of Materials by appending any extra Materials and comparing the rest. + mats_to_append = material_connections[num_mesh_mats:] + mats_to_compare = material_connections[:num_mesh_mats] + + for _fbx_lnk_material, material, _fbx_lnk_material_type in mats_to_append: + mesh_mats.append(material) + + mats_to_compare_and_slots = zip(mats_to_compare, obj.material_slots) + for (_fbx_lnk_material, material, _fbx_lnk_material_type), mat_slot in mats_to_compare_and_slots: + if material != mat_slot.material: + # Material Slots default to being linked to the Mesh, so a previously processed Object is also using + # this Mesh, but the Mesh uses a different Material for this Material Slot. + # To have a different Material for this Material Slot on this Object only, the Material Slot must be + # linked to the Object rather than the Mesh. + # TODO: add an option to link all materials to objects in Blender instead? + mat_slot.link = 'OBJECT' + mat_slot.material = material + + # We have to validate mesh polygons' ma_idx, see #41015! + # Some FBX seem to have an extra 'default' material which is not defined in FBX file. + for mesh in processed_meshes: + if mesh.validate_material_indices(): + print("WARNING: mesh '%s' had invalid material indices, those were reset to first material" % mesh.name) + _() + del _ + + perfmon.step("FBX import: Assign textures...") + + def _(): + material_images = {} + + fbx_tmpl = fbx_template_get((b'Material', b'KFbxSurfacePhong')) + # b'KFbxSurfaceLambert' + + def texture_mapping_set(fbx_obj, node_texture): + assert(fbx_obj.id == b'Texture') + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + loc = elem_props_get_vector_3d(fbx_props, b'Translation', (0.0, 0.0, 0.0)) + rot = tuple(-r for r in elem_props_get_vector_3d(fbx_props, b'Rotation', (0.0, 0.0, 0.0))) + scale = tuple(((1.0 / s) if s != 0.0 else 1.0) + for s in elem_props_get_vector_3d(fbx_props, b'Scaling', (1.0, 1.0, 1.0))) + clamp = (bool(elem_props_get_enum(fbx_props, b'WrapModeU', 0)) or + bool(elem_props_get_enum(fbx_props, b'WrapModeV', 0))) + + if (loc == (0.0, 0.0, 0.0) and + rot == (0.0, 0.0, 0.0) and + scale == (1.0, 1.0, 1.0) and + clamp == False): + return + + node_texture.translation = loc + node_texture.rotation = rot + node_texture.scale = scale + if clamp: + node_texture.extension = 'EXTEND' + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Material': + continue + + material = fbx_table_nodes.get(fbx_uuid, (None, None))[1] + for (fbx_lnk, + image, + fbx_lnk_type) in connection_filter_reverse(fbx_uuid, b'Texture'): + + if fbx_lnk_type.props[0] == b'OP': + lnk_type = fbx_lnk_type.props[3] + + ma_wrap = nodal_material_wrap_map[material] + + if lnk_type in {b'DiffuseColor', b'3dsMax|maps|texmap_diffuse'}: + ma_wrap.base_color_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.base_color_texture) + elif lnk_type in {b'SpecularColor', b'SpecularFactor'}: + # Intensity actually, not color... + ma_wrap.specular_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.specular_texture) + elif lnk_type in {b'ReflectionColor', b'ReflectionFactor', b'3dsMax|maps|texmap_reflection'}: + # Intensity actually, not color... + ma_wrap.metallic_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.metallic_texture) + elif lnk_type in {b'TransparentColor', b'TransparencyFactor'}: + ma_wrap.alpha_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.alpha_texture) + if use_alpha_decals: + material_decals.add(material) + elif lnk_type == b'ShininessExponent': + # That is probably reversed compared to expected results? TODO... + ma_wrap.roughness_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.roughness_texture) + # XXX, applications abuse bump! + elif lnk_type in {b'NormalMap', b'Bump', b'3dsMax|maps|texmap_bump'}: + ma_wrap.normalmap_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.normalmap_texture) + """ + elif lnk_type == b'Bump': + # TODO displacement... + """ + elif lnk_type in {b'EmissiveColor'}: + ma_wrap.emission_color_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.emission_color_texture) + elif lnk_type in {b'EmissiveFactor'}: + ma_wrap.emission_strength_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.emission_strength_texture) + else: + print("WARNING: material link %r ignored" % lnk_type) + + material_images.setdefault(material, {})[lnk_type] = image + + # Check if the diffuse image has an alpha channel, + # if so, use the alpha channel. + + # Note: this could be made optional since images may have alpha but be entirely opaque + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Material': + continue + material = fbx_table_nodes.get(fbx_uuid, (None, None))[1] + image = material_images.get(material, {}).get(b'DiffuseColor', None) + # do we have alpha? + if image and image.depth == 32: + if use_alpha_decals: + material_decals.add(material) + + ma_wrap = nodal_material_wrap_map[material] + ma_wrap.alpha_texture.use_alpha = True + ma_wrap.alpha_texture.copy_from(ma_wrap.base_color_texture) + + # Propagate mapping from diffuse to all other channels which have none defined. + # XXX Commenting for now, I do not really understand the logic here, why should diffuse mapping + # be applied to all others if not defined for them??? + # ~ ma_wrap = nodal_material_wrap_map[material] + # ~ ma_wrap.mapping_set_from_diffuse() + + _() + del _ + + perfmon.step("FBX import: Cycles z-offset workaround...") + + def _(): + # Annoying workaround for cycles having no z-offset + if material_decals and use_alpha_decals: + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Geometry': + continue + if fbx_obj.props[-1] == b'Mesh': + mesh = fbx_item[1] + + num_verts = len(mesh.vertices) + if decal_offset != 0.0 and num_verts > 0: + for material in mesh.materials: + if material in material_decals: + blen_norm_dtype = np.single + vcos = MESH_ATTRIBUTE_POSITION.to_ndarray(mesh.attributes) + vnorm = np.empty(num_verts * 3, dtype=blen_norm_dtype) + mesh.vertex_normals.foreach_get("vector", vnorm) + + vcos += vnorm * decal_offset + + MESH_ATTRIBUTE_POSITION.foreach_set(mesh.attributes, vcos) + break + + for obj in (obj for obj in bpy.data.objects if obj.data == mesh): + obj.visible_shadow = False + _() + del _ + + perfmon.level_down() + + perfmon.level_down("Import finished.") + return {'FINISHED'} diff --git a/4.5.2_LTS/io_scene_fbx/json2fbx.py b/4.5.2_LTS/io_scene_fbx/json2fbx.py new file mode 100644 index 0000000..8cbea51 --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/json2fbx.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2014-2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +""" +Usage +===== + + json2fbx [FILES]... + +This script will write a binary FBX file for each JSON argument given. + + +Input +====== + +The JSON data is formatted into a list of nested lists of 4 items: + + ``[id, [data, ...], "data_types", [subtree, ...]]`` + +Where each list may be empty, and the items in +the subtree are formatted the same way. + +data_types is a string, aligned with data that spesifies a type +for each property. + +The types are as follows: + +* 'Z': - INT8 +* 'Y': - INT16 +* 'B': - BOOL +* 'C': - CHAR +* 'I': - INT32 +* 'F': - FLOAT32 +* 'D': - FLOAT64 +* 'L': - INT64 +* 'R': - BYTES +* 'S': - STRING +* 'f': - FLOAT32_ARRAY +* 'i': - INT32_ARRAY +* 'd': - FLOAT64_ARRAY +* 'l': - INT64_ARRAY +* 'b': - BOOL ARRAY +* 'c': - BYTE ARRAY + +Note that key:value pairs aren't used since the id's are not +ensured to be unique. +""" + + +def elem_empty(elem, name): + import encode_bin + sub_elem = encode_bin.FBXElem(name) + if elem is not None: + elem.elems.append(sub_elem) + return sub_elem + + +def parse_json_rec(fbx_root, json_node): + name, data, data_types, children = json_node + ver = 0 + + assert(len(data_types) == len(data)) + + e = elem_empty(fbx_root, name.encode()) + for d, dt in zip(data, data_types): + if dt == "B": + e.add_bool(d) + elif dt == "C": + d = eval('b"""' + d + '"""') + e.add_char(d) + elif dt == "Z": + e.add_int8(d) + elif dt == "Y": + e.add_int16(d) + elif dt == "I": + e.add_int32(d) + elif dt == "L": + e.add_int64(d) + elif dt == "F": + e.add_float32(d) + elif dt == "D": + e.add_float64(d) + elif dt == "R": + d = eval('b"""' + d + '"""') + e.add_bytes(d) + elif dt == "S": + d = d.encode().replace(b"::", b"\x00\x01") + e.add_string(d) + elif dt == "i": + e.add_int32_array(d) + elif dt == "l": + e.add_int64_array(d) + elif dt == "f": + e.add_float32_array(d) + elif dt == "d": + e.add_float64_array(d) + elif dt == "b": + e.add_bool_array(d) + elif dt == "c": + e.add_byte_array(d) + + if name == "FBXVersion": + assert(data_types == "I") + ver = int(data[0]) + + for child in children: + _ver = parse_json_rec(e, child) + if _ver: + ver = _ver + + return ver + + +def parse_json(json_root): + root = elem_empty(None, b"") + ver = 0 + + for n in json_root: + _ver = parse_json_rec(root, n) + if _ver: + ver = _ver + + return root, ver + + +def json2fbx(fn): + import os + import json + + import encode_bin + + fn_fbx = "%s.fbx" % os.path.splitext(fn)[0] + print("Writing: %r " % fn_fbx, end="") + with open(fn) as f_json: + json_root = json.load(f_json) + with encode_bin.FBXElem.enable_multithreading_cm(): + fbx_root, fbx_version = parse_json(json_root) + print("(Version %d) ..." % fbx_version) + encode_bin.write(fn_fbx, fbx_root, fbx_version) + + +# ---------------------------------------------------------------------------- +# Command Line + +def main(): + import sys + + if "--help" in sys.argv: + print(__doc__) + return + + for arg in sys.argv[1:]: + try: + json2fbx(arg) + except: + print("Failed to convert %r, error:" % arg) + + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/4.5.2_LTS/io_scene_fbx/parse_fbx.py b/4.5.2_LTS/io_scene_fbx/parse_fbx.py new file mode 100644 index 0000000..948f538 --- /dev/null +++ b/4.5.2_LTS/io_scene_fbx/parse_fbx.py @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: 2006-2012 assimp team +# SPDX-FileCopyrightText: 2013 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +__all__ = ( + "parse", + "data_types", + "parse_version", + "FBXElem", +) + +from struct import unpack +import array +import zlib +from io import BytesIO + +from . import data_types +from .fbx_utils_threading import MultiThreadedTaskConsumer + +# at the end of each nested block, there is a NUL record to indicate +# that the sub-scope exists (i.e. to distinguish between P: and P : {}) +_BLOCK_SENTINEL_LENGTH = ... +_BLOCK_SENTINEL_DATA = ... +read_fbx_elem_start = ... +_IS_BIG_ENDIAN = (__import__("sys").byteorder != 'little') +_HEAD_MAGIC = b'Kaydara FBX Binary\x20\x20\x00\x1a\x00' +from collections import namedtuple +FBXElem = namedtuple("FBXElem", ("id", "props", "props_type", "elems")) +del namedtuple + + +def read_uint(read): + return unpack(b' Import-Export", + "description": "FBX IO meshes, UVs, vertex colors, materials, textures, cameras, lamps and actions", + "warning": "", + "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/scene_fbx.html", + "support": 'OFFICIAL', + "category": "Import-Export", +} + + +if "bpy" in locals(): + import importlib + if "import_fbx" in locals(): + importlib.reload(import_fbx) + if "export_fbx_bin" in locals(): + importlib.reload(export_fbx_bin) + if "export_fbx" in locals(): + importlib.reload(export_fbx) + + +import bpy +from bpy.props import ( + StringProperty, + BoolProperty, + FloatProperty, + EnumProperty, + CollectionProperty, +) +from bpy_extras.io_utils import ( + ImportHelper, + ExportHelper, + orientation_helper, + path_reference_mode, + axis_conversion, + poll_file_object_drop, +) + + +@orientation_helper(axis_forward='-Z', axis_up='Y') +class ImportFBX(bpy.types.Operator, ImportHelper): + """Load a FBX file""" + bl_idname = "import_scene.fbx" + bl_label = "Import FBX" + bl_options = {'UNDO', 'PRESET'} + + directory: StringProperty( + subtype='DIR_PATH', + options={'HIDDEN', 'SKIP_PRESET'}, + ) + + filename_ext = ".fbx" + filter_glob: StringProperty(default="*.fbx", options={'HIDDEN'}) + + files: CollectionProperty( + name="File Path", + type=bpy.types.OperatorFileListElement, + options={'HIDDEN', 'SKIP_PRESET'}, + ) + + ui_tab: EnumProperty( + items=(('MAIN', "Main", "Main basic settings"), + ('ARMATURE', "Armatures", "Armature-related settings"), + ), + name="ui_tab", + description="Import options categories", + ) + + use_manual_orientation: BoolProperty( + name="Manual Orientation", + description="Specify orientation and scale, instead of using embedded data in FBX file", + default=False, + ) + global_scale: FloatProperty( + name="Scale", + min=0.001, max=1000.0, + default=1.0, + ) + bake_space_transform: BoolProperty( + name="Apply Transform", + description="Bake space transform into object data, avoids getting unwanted rotations to objects when " + "target space is not aligned with Blender's space " + "(WARNING! experimental option, use at own risk, known to be broken with armatures/animations)", + default=False, + ) + + use_custom_normals: BoolProperty( + name="Custom Normals", + description="Import custom normals, if available (otherwise Blender will recompute them)", + default=True, + ) + colors_type: EnumProperty( + name="Vertex Colors", + items=(('NONE', "None", "Do not import color attributes"), + ('SRGB', "sRGB", "Expect file colors in sRGB color space"), + ('LINEAR', "Linear", "Expect file colors in linear color space"), + ), + description="Import vertex color attributes", + default='SRGB', + ) + + use_image_search: BoolProperty( + name="Image Search", + description="Search subdirs for any associated images (WARNING: may be slow)", + default=True, + ) + + use_alpha_decals: BoolProperty( + name="Alpha Decals", + description="Treat materials with alpha as decals (no shadow casting)", + default=False, + ) + decal_offset: FloatProperty( + name="Decal Offset", + description="Displace geometry of alpha meshes", + min=0.0, max=1.0, + default=0.0, + ) + + use_anim: BoolProperty( + name="Import Animation", + description="Import FBX animation", + default=True, + ) + anim_offset: FloatProperty( + name="Animation Offset", + description="Offset to apply to animation during import, in frames", + default=1.0, + ) + + use_subsurf: BoolProperty( + name="Subdivision Data", + description="Import FBX subdivision information as subdivision surface modifiers", + default=False, + ) + + use_custom_props: BoolProperty( + name="Custom Properties", + description="Import user properties as custom properties", + default=True, + ) + use_custom_props_enum_as_string: BoolProperty( + name="Import Enums As Strings", + description="Store enumeration values as strings", + default=True, + ) + + ignore_leaf_bones: BoolProperty( + name="Ignore Leaf Bones", + description="Ignore the last bone at the end of each chain (used to mark the length of the previous bone)", + default=False, + ) + force_connect_children: BoolProperty( + name="Force Connect Children", + description="Force connection of children bones to their parent, even if their computed head/tail " + "positions do not match (can be useful with pure-joints-type armatures)", + default=False, + ) + automatic_bone_orientation: BoolProperty( + name="Automatic Bone Orientation", + description="Try to align the major bone axis with the bone children", + default=False, + ) + primary_bone_axis: EnumProperty( + name="Primary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='Y', + ) + secondary_bone_axis: EnumProperty( + name="Secondary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='X', + ) + + use_prepost_rot: BoolProperty( + name="Use Pre/Post Rotation", + description="Use pre/post rotation from FBX transform (you may have to disable that in some cases)", + default=True, + ) + mtl_name_collision_mode: EnumProperty( + name="Material Name Collision", + items=(("MAKE_UNIQUE", "Make Unique", "Import each FBX material as a unique Blender material"), + ("REFERENCE_EXISTING", "Reference Existing", + "If a material with the same name already exists, reference that instead of importing"), + ), + default='MAKE_UNIQUE', + description="Behavior when the name of an imported material conflicts with an existing material", + ) + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False # No animation. + + import_panel_include(layout, self) + import_panel_transform(layout, self) + import_panel_materials(layout, self) + import_panel_animation(layout, self) + import_panel_armature(layout, self) + + def execute(self, context): + keywords = self.as_keywords(ignore=("filter_glob", "directory", "ui_tab", "filepath", "files")) + + from . import import_fbx + import os + + if self.files: + ret = {'CANCELLED'} + for file in self.files: + path = os.path.join(self.directory, file.name) + if import_fbx.load(self, context, filepath=path, **keywords) == {'FINISHED'}: + ret = {'FINISHED'} + return ret + else: + return import_fbx.load(self, context, filepath=self.filepath, **keywords) + + def invoke(self, context, event): + return self.invoke_popup(context) + + +def import_panel_include(layout, operator): + header, body = layout.panel("FBX_import_include", default_closed=False) + header.label(text="Include") + if body: + body.prop(operator, "use_custom_normals") + body.prop(operator, "use_subsurf") + body.prop(operator, "use_custom_props") + sub = body.row() + sub.enabled = operator.use_custom_props + sub.prop(operator, "use_custom_props_enum_as_string") + body.prop(operator, "use_image_search") + body.prop(operator, "colors_type") + + +def import_panel_transform(layout, operator): + header, body = layout.panel("FBX_import_transform", default_closed=False) + header.label(text="Transform") + if body: + body.prop(operator, "global_scale") + body.prop(operator, "decal_offset") + row = body.row() + row.prop(operator, "bake_space_transform") + row.label(text="", icon='ERROR') + body.prop(operator, "use_prepost_rot") + + import_panel_transform_orientation(body, operator) + + +def import_panel_transform_orientation(layout, operator): + header, body = layout.panel("FBX_import_transform_manual_orientation", default_closed=False) + header.use_property_split = False + header.prop(operator, "use_manual_orientation", text="") + header.label(text="Manual Orientation") + if body: + body.enabled = operator.use_manual_orientation + body.prop(operator, "axis_forward") + body.prop(operator, "axis_up") + + +def import_panel_materials(layout, operator): + header, body = layout.panel("FBX_import_material", default_closed=True) + header.label(text="Materials") + if body: + body.prop(operator, "mtl_name_collision_mode") + + +def import_panel_animation(layout, operator): + header, body = layout.panel("FBX_import_animation", default_closed=True) + header.use_property_split = False + header.prop(operator, "use_anim", text="") + header.label(text="Animation") + if body: + body.enabled = operator.use_anim + body.prop(operator, "anim_offset") + + +def import_panel_armature(layout, operator): + header, body = layout.panel("FBX_import_armature", default_closed=True) + header.label(text="Armature") + if body: + body.prop(operator, "ignore_leaf_bones") + body.prop(operator, "force_connect_children"), + body.prop(operator, "automatic_bone_orientation"), + sub = body.column() + sub.enabled = not operator.automatic_bone_orientation + sub.prop(operator, "primary_bone_axis") + sub.prop(operator, "secondary_bone_axis") + + +@orientation_helper(axis_forward='-Z', axis_up='Y') +class ExportFBX(bpy.types.Operator, ExportHelper): + """Write a FBX file""" + bl_idname = "export_scene.fbx" + bl_label = "Export FBX" + bl_options = {'UNDO', 'PRESET'} + + filename_ext = ".fbx" + filter_glob: StringProperty(default="*.fbx", options={'HIDDEN'}) + + # List of operator properties, the attributes will be assigned + # to the class instance from the operator settings before calling. + + use_selection: BoolProperty( + name="Selected Objects", + description="Export selected and visible objects only", + default=False, + ) + use_visible: BoolProperty( + name='Visible Objects', + description='Export visible objects only', + default=False + ) + use_active_collection: BoolProperty( + name="Active Collection", + description="Export only objects from the active collection (and its children)", + default=False, + ) + collection: StringProperty( + name="Source Collection", + description="Export only objects from this collection (and its children)", + default="", + ) + global_scale: FloatProperty( + name="Scale", + description="Scale all data (Some importers do not support scaled armatures!)", + min=0.001, max=1000.0, + soft_min=0.01, soft_max=1000.0, + default=1.0, + ) + apply_unit_scale: BoolProperty( + name="Apply Unit", + description=( + "Take into account current Blender units settings " + "(if unset, raw Blender Units values are used as-is)" + ), + default=True, + ) + apply_scale_options: EnumProperty( + items=(('FBX_SCALE_NONE', "All Local", + "Apply custom scaling and units scaling to each object transformation, FBX scale remains at 1.0"), + ('FBX_SCALE_UNITS', "FBX Units Scale", + "Apply custom scaling to each object transformation, and units scaling to FBX scale"), + ('FBX_SCALE_CUSTOM', "FBX Custom Scale", + "Apply custom scaling to FBX scale, and units scaling to each object transformation"), + ('FBX_SCALE_ALL', "FBX All", + "Apply custom scaling and units scaling to FBX scale"), + ), + name="Apply Scalings", + description="How to apply custom and units scalings in generated FBX file " + "(Blender uses FBX scale to detect units on import, " + "but many other applications do not handle the same way)", + ) + + use_space_transform: BoolProperty( + name="Use Space Transform", + description="Apply global space transform to the object rotations. When disabled " + "only the axis space is written to the file and all object transforms are left as-is", + default=True, + ) + bake_space_transform: BoolProperty( + name="Apply Transform", + description="Bake space transform into object data, avoids getting unwanted rotations to objects when " + "target space is not aligned with Blender's space " + "(WARNING! experimental option, use at own risk, known to be broken with armatures/animations)", + default=False, + ) + + object_types: EnumProperty( + name="Object Types", + options={'ENUM_FLAG'}, + items=(('EMPTY', "Empty", ""), + ('CAMERA', "Camera", ""), + ('LIGHT', "Lamp", ""), + ('ARMATURE', "Armature", "WARNING: not supported in dupli/group instances"), + ('MESH', "Mesh", ""), + ('OTHER', "Other", "Other geometry types, like curve, meta-ball, etc. (converted to meshes)"), + ), + description="Which kind of object to export", + default={'EMPTY', 'CAMERA', 'LIGHT', 'ARMATURE', 'MESH', 'OTHER'}, + ) + + use_mesh_modifiers: BoolProperty( + name="Apply Modifiers", + description="Apply modifiers to mesh objects (except Armature ones) - " + "WARNING: prevents exporting shape keys", + default=True, + ) + use_mesh_modifiers_render: BoolProperty( + name="Use Modifiers Render Setting", + description="Use render settings when applying modifiers to mesh objects (DISABLED in Blender 2.8)", + default=True, + ) + mesh_smooth_type: EnumProperty( + name="Smoothing", + items=(('OFF', "Normals Only", "Export only normals instead of writing edge or face smoothing data"), + ('FACE', "Face", "Write face smoothing"), + ('EDGE', "Edge", "Write edge smoothing"), + ('SMOOTH_GROUP', "Smoothing Groups", "Write face smoothing groups"), + ), + description="Export smoothing information " + "(prefer 'Normals Only' option if your target importer understands custom normals)", + default='OFF', + ) + colors_type: EnumProperty( + name="Vertex Colors", + items=(('NONE', "None", "Do not export color attributes"), + ('SRGB', "sRGB", "Export colors in sRGB color space"), + ('LINEAR', "Linear", "Export colors in linear color space"), + ), + description="Export vertex color attributes", + default='SRGB', + ) + prioritize_active_color: BoolProperty( + name="Prioritize Active Color", + description="Make sure active color will be exported first. Could be important " + "since some other software can discard other color attributes besides the first one", + default=False, + ) + use_subsurf: BoolProperty( + name="Export Subdivision Surface", + description="Export the last Catmull-Rom subdivision modifier as FBX subdivision " + "(does not apply the modifier even if 'Apply Modifiers' is enabled)", + default=False, + ) + use_mesh_edges: BoolProperty( + name="Loose Edges", + description="Export loose edges (as two-vertices polygons)", + default=False, + ) + use_tspace: BoolProperty( + name="Tangent Space", + description="Add binormal and tangent vectors, together with normal they form the tangent space " + "(will only work correctly with tris/quads only meshes!)", + default=False, + ) + use_triangles: BoolProperty( + name="Triangulate Faces", + description="Convert all faces to triangles", + default=False, + ) + use_custom_props: BoolProperty( + name="Custom Properties", + description="Export custom properties", + default=False, + ) + add_leaf_bones: BoolProperty( + name="Add Leaf Bones", + description="Append a final bone to the end of each chain to specify last bone length " + "(use this when you intend to edit the armature from exported data)", + default=True # False for commit! + ) + primary_bone_axis: EnumProperty( + name="Primary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='Y', + ) + secondary_bone_axis: EnumProperty( + name="Secondary Bone Axis", + items=(('X', "X Axis", ""), + ('Y', "Y Axis", ""), + ('Z', "Z Axis", ""), + ('-X', "-X Axis", ""), + ('-Y', "-Y Axis", ""), + ('-Z', "-Z Axis", ""), + ), + default='X', + ) + use_armature_deform_only: BoolProperty( + name="Only Deform Bones", + description="Only write deforming bones (and non-deforming ones when they have deforming children)", + default=False, + ) + armature_nodetype: EnumProperty( + name="Armature FBXNode Type", + items=(('NULL', "Null", "'Null' FBX node, similar to Blender's Empty (default)"), + ('ROOT', "Root", "'Root' FBX node, supposed to be the root of chains of bones..."), + ('LIMBNODE', "LimbNode", "'LimbNode' FBX node, a regular joint between two bones..."), + ), + description="FBX type of node (object) used to represent Blender's armatures " + "(use the Null type unless you experience issues with the other app, " + "as other choices may not import back perfectly into Blender...)", + default='NULL', + ) + bake_anim: BoolProperty( + name="Baked Animation", + description="Export baked keyframe animation", + default=True, + ) + bake_anim_use_all_bones: BoolProperty( + name="Key All Bones", + description="Force exporting at least one key of animation for all bones " + "(needed with some target applications, like UE4)", + default=True, + ) + bake_anim_use_nla_strips: BoolProperty( + name="NLA Strips", + description="Export each non-muted NLA strip as a separated FBX's AnimStack, if any, " + "instead of global scene animation", + default=True, + ) + bake_anim_use_all_actions: BoolProperty( + name="All Actions", + description="Export each action as a separated FBX's AnimStack, instead of global scene animation " + "(note that animated objects will get all actions compatible with them, " + "others will get no animation at all)", + default=True, + ) + bake_anim_force_startend_keying: BoolProperty( + name="Force Start/End Keying", + description="Always add a keyframe at start and end of actions for animated channels", + default=True, + ) + bake_anim_step: FloatProperty( + name="Sampling Rate", + description="How often to evaluate animated values (in frames)", + min=0.01, max=100.0, + soft_min=0.1, soft_max=10.0, + default=1.0, + ) + bake_anim_simplify_factor: FloatProperty( + name="Simplify", + description="How much to simplify baked values (0.0 to disable, the higher the more simplified)", + min=0.0, max=100.0, # No simplification to up to 10% of current magnitude tolerance. + soft_min=0.0, soft_max=10.0, + default=1.0, # default: min slope: 0.005, max frame step: 10. + ) + path_mode: path_reference_mode + embed_textures: BoolProperty( + name="Embed Textures", + description="Embed textures in FBX binary file (only for \"Copy\" path mode!)", + default=False, + ) + batch_mode: EnumProperty( + name="Batch Mode", + items=(('OFF', "Off", "Active scene to file"), + ('SCENE', "Scene", "Each scene as a file"), + ('COLLECTION', "Collection", + "Each collection (data-block ones) as a file, does not include content of children collections"), + ('SCENE_COLLECTION', "Scene Collections", + "Each collection (including master, non-data-block ones) of each scene as a file, " + "including content from children collections"), + ('ACTIVE_SCENE_COLLECTION', "Active Scene Collections", + "Each collection (including master, non-data-block one) of the active scene as a file, " + "including content from children collections"), + ), + ) + use_batch_own_dir: BoolProperty( + name="Batch Own Dir", + description="Create a dir for each exported file", + default=True, + ) + use_metadata: BoolProperty( + name="Use Metadata", + default=True, + options={'HIDDEN'}, + ) + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False # No animation. + + # Are we inside the File browser + is_file_browser = context.space_data.type == 'FILE_BROWSER' + + export_main(layout, self, is_file_browser) + export_panel_include(layout, self, is_file_browser) + export_panel_transform(layout, self) + export_panel_geometry(layout, self) + export_panel_armature(layout, self) + export_panel_animation(layout, self) + + @property + def check_extension(self): + return self.batch_mode == 'OFF' + + def execute(self, context): + from mathutils import Matrix + if not self.filepath: + raise Exception("filepath not set") + + global_matrix = (axis_conversion(to_forward=self.axis_forward, + to_up=self.axis_up, + ).to_4x4() + if self.use_space_transform else Matrix()) + + keywords = self.as_keywords(ignore=("check_existing", + "filter_glob", + "ui_tab", + )) + + keywords["global_matrix"] = global_matrix + + from . import export_fbx_bin + return export_fbx_bin.save(self, context, **keywords) + + +def export_main(layout, operator, is_file_browser): + row = layout.row(align=True) + row.prop(operator, "path_mode") + sub = row.row(align=True) + sub.enabled = (operator.path_mode == 'COPY') + sub.prop(operator, "embed_textures", text="", icon='PACKAGE' if operator.embed_textures else 'UGLYPACKAGE') + if is_file_browser: + row = layout.row(align=True) + row.prop(operator, "batch_mode") + sub = row.row(align=True) + sub.prop(operator, "use_batch_own_dir", text="", icon='NEWFOLDER') + + +def export_panel_include(layout, operator, is_file_browser): + header, body = layout.panel("FBX_export_include", default_closed=False) + header.label(text="Include") + if body: + sublayout = body.column(heading="Limit to") + sublayout.enabled = (operator.batch_mode == 'OFF') + if is_file_browser: + sublayout.prop(operator, "use_selection") + sublayout.prop(operator, "use_visible") + sublayout.prop(operator, "use_active_collection") + + body.column().prop(operator, "object_types") + body.prop(operator, "use_custom_props") + + +def export_panel_transform(layout, operator): + header, body = layout.panel("FBX_export_transform", default_closed=False) + header.label(text="Transform") + if body: + body.prop(operator, "global_scale") + body.prop(operator, "apply_scale_options") + + body.prop(operator, "axis_forward") + body.prop(operator, "axis_up") + + body.prop(operator, "apply_unit_scale") + body.prop(operator, "use_space_transform") + row = body.row() + row.prop(operator, "bake_space_transform") + row.label(text="", icon='ERROR') + + +def export_panel_geometry(layout, operator): + header, body = layout.panel("FBX_export_geometry", default_closed=True) + header.label(text="Geometry") + if body: + body.prop(operator, "mesh_smooth_type") + body.prop(operator, "use_subsurf") + body.prop(operator, "use_mesh_modifiers") + # sub = body.row() + # sub.enabled = operator.use_mesh_modifiers and False # disabled in 2.8... + # sub.prop(operator, "use_mesh_modifiers_render") + body.prop(operator, "use_mesh_edges") + body.prop(operator, "use_triangles") + sub = body.row() + # ~ sub.enabled = operator.mesh_smooth_type in {'OFF'} + sub.prop(operator, "use_tspace") + body.prop(operator, "colors_type") + body.prop(operator, "prioritize_active_color") + + +def export_panel_armature(layout, operator): + header, body = layout.panel("FBX_export_armature", default_closed=True) + header.label(text="Armature") + if body: + body.prop(operator, "primary_bone_axis") + body.prop(operator, "secondary_bone_axis") + body.prop(operator, "armature_nodetype") + body.prop(operator, "use_armature_deform_only") + body.prop(operator, "add_leaf_bones") + + +def export_panel_animation(layout, operator): + header, body = layout.panel("FBX_export_bake_animation", default_closed=True) + header.use_property_split = False + header.prop(operator, "bake_anim", text="") + header.label(text="Animation") + if body: + body.enabled = operator.bake_anim + body.prop(operator, "bake_anim_use_all_bones") + body.prop(operator, "bake_anim_use_nla_strips") + body.prop(operator, "bake_anim_use_all_actions") + body.prop(operator, "bake_anim_force_startend_keying") + body.prop(operator, "bake_anim_step") + body.prop(operator, "bake_anim_simplify_factor") + + +def menu_func_import(self, context): + self.layout.operator(ImportFBX.bl_idname, text="FBX (.fbx) (Legacy)") + + +def menu_func_export(self, context): + self.layout.operator(ExportFBX.bl_idname, text="FBX (.fbx)") + + +classes = ( + ImportFBX, + ExportFBX +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + + bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + bpy.types.TOPBAR_MT_file_export.append(menu_func_export) + + +def unregister(): + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) + bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) + + for cls in classes: + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() diff --git a/5.1/io_scene_fbx/data_types.py b/5.1/io_scene_fbx/data_types.py new file mode 100644 index 0000000..328ba3a --- /dev/null +++ b/5.1/io_scene_fbx/data_types.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2006-2012 assimp team +# SPDX-FileCopyrightText: 2013 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +BOOL = b'B'[0] +CHAR = b'C'[0] +INT8 = b'Z'[0] +INT16 = b'Y'[0] +INT32 = b'I'[0] +INT64 = b'L'[0] +FLOAT32 = b'F'[0] +FLOAT64 = b'D'[0] +BYTES = b'R'[0] +STRING = b'S'[0] +INT32_ARRAY = b'i'[0] +INT64_ARRAY = b'l'[0] +FLOAT32_ARRAY = b'f'[0] +FLOAT64_ARRAY = b'd'[0] +BOOL_ARRAY = b'b'[0] +BYTE_ARRAY = b'c'[0] + +# Some other misc defines +# Known combinations so far - supposed meaning: A = animatable, A+ = animated, U = UserProp +# VALID_NUMBER_FLAGS = {b'A', b'A+', b'AU', b'A+U'} # Not used... + +# array types - actual length may vary (depending on underlying C implementation)! +import array + +# For now, bytes and bool are assumed always 1byte. +ARRAY_BOOL = 'b' +ARRAY_BYTE = 'B' + +ARRAY_INT32 = None +ARRAY_INT64 = None +for _t in 'ilq': + size = array.array(_t).itemsize + if size == 4: + ARRAY_INT32 = _t + elif size == 8: + ARRAY_INT64 = _t + if ARRAY_INT32 and ARRAY_INT64: + break +if not ARRAY_INT32: + raise Exception("Impossible to get a 4-bytes integer type for array!") +if not ARRAY_INT64: + raise Exception("Impossible to get an 8-bytes integer type for array!") + +ARRAY_FLOAT32 = None +ARRAY_FLOAT64 = None +for _t in 'fd': + size = array.array(_t).itemsize + if size == 4: + ARRAY_FLOAT32 = _t + elif size == 8: + ARRAY_FLOAT64 = _t + if ARRAY_FLOAT32 and ARRAY_FLOAT64: + break +if not ARRAY_FLOAT32: + raise Exception("Impossible to get a 4-bytes float type for array!") +if not ARRAY_FLOAT64: + raise Exception("Impossible to get an 8-bytes float type for array!") diff --git a/5.1/io_scene_fbx/encode_bin.py b/5.1/io_scene_fbx/encode_bin.py new file mode 100644 index 0000000..f15fd9f --- /dev/null +++ b/5.1/io_scene_fbx/encode_bin.py @@ -0,0 +1,434 @@ +# SPDX-FileCopyrightText: 2013 Campbell Barton +# +# SPDX-License-Identifier: GPL-2.0-or-later + +try: + from . import data_types + from .fbx_utils_threading import MultiThreadedTaskConsumer +except: + import data_types + from fbx_utils_threading import MultiThreadedTaskConsumer + +from struct import pack +from contextlib import contextmanager +import array +import numpy as np +import zlib + +_BLOCK_SENTINEL_LENGTH = ... +_BLOCK_SENTINEL_DATA = ... +_ELEM_META_FORMAT = ... +_ELEM_META_SIZE = ... +_IS_BIG_ENDIAN = (__import__("sys").byteorder != 'little') +_HEAD_MAGIC = b'Kaydara FBX Binary\x20\x20\x00\x1a\x00' + +# fbx has very strict CRC rules, all based on file timestamp +# until we figure these out, write files at a fixed time. (workaround!) + +# Assumes: CreationTime +_TIME_ID = b'1970-01-01 10:00:00:000' +_FILE_ID = b'\x28\xb3\x2a\xeb\xb6\x24\xcc\xc2\xbf\xc8\xb0\x2a\xa9\x2b\xfc\xf1' +_FOOT_ID = b'\xfa\xbc\xab\x09\xd0\xc8\xd4\x66\xb1\x76\xfb\x83\x1c\xf7\x26\x7e' + +# Awful exceptions: those "classes" of elements seem to need block sentinel even when having no children and some props. +_ELEMS_ID_ALWAYS_BLOCK_SENTINEL = {b"AnimationStack", b"AnimationLayer"} + + +class FBXElem: + __slots__ = ( + "id", + "props", + "props_type", + "elems", + + "_props_length", # combine length of props + "_end_offset", # byte offset from the start of the file. + ) + + def __init__(self, id): + assert len(id) < 256 # length must fit in a uint8 + self.id = id + self.props = [] + self.props_type = bytearray() + self.elems = [] + self._end_offset = -1 + self._props_length = -1 + + @classmethod + @contextmanager + def enable_multithreading_cm(cls): + """Temporarily enable multithreaded array compression. + + The context manager handles starting up and shutting down the threads. + + Only exits once all the threads are done (either all tasks were completed or an error occurred and the threads + were stopped prematurely). + + Writing to a file is temporarily disabled as a safeguard.""" + # __enter__() + orig_func = cls._add_compressed_array_helper + orig_write = cls._write + + def insert_compressed_array(props, insert_at, data, length): + # zlib.compress releases the GIL, so can be multithreaded. + data = zlib.compress(data, 1) + comp_len = len(data) + + encoding = 1 + data = pack('<3I', length, encoding, comp_len) + data + props[insert_at] = data + + with MultiThreadedTaskConsumer.new_cpu_bound_cm(insert_compressed_array) as wrapped_func: + try: + def _add_compressed_array_helper_multi(self, data, length): + # Append a dummy value that will be replaced with the compressed array data later. + self.props.append(...) + # The index to insert the compressed array into. + insert_at = len(self.props) - 1 + # Schedule the array to be compressed on a separate thread and then inserted into the hierarchy at + # `insert_at`. + wrapped_func(self.props, insert_at, data, length) + + # As an extra safeguard, temporarily replace the `_write` function to raise an error if called. + def temp_write(*_args, **_kwargs): + raise RuntimeError("Writing is not allowed until multithreaded array compression has been disabled") + + cls._add_compressed_array_helper = _add_compressed_array_helper_multi + cls._write = temp_write + + # Return control back to the caller of __enter__(). + yield + finally: + # __exit__() + # Restore the original functions. + cls._add_compressed_array_helper = orig_func + cls._write = orig_write + # Exiting the MultiThreadedTaskConsumer context manager will wait for all scheduled tasks to complete. + + def add_bool(self, data): + assert isinstance(data, bool) + data = pack('?', data) + + self.props_type.append(data_types.BOOL) + self.props.append(data) + + def add_char(self, data): + assert isinstance(data, bytes) + assert len(data) == 1 + data = pack('vertex-indices array by the loop->edge-index array. + t_pvi_edge_keys = t_ev_pair_view[t_lei] + + # Sort each [edge_start_n, edge_end_n] pair to get edge keys. + # Heap-sort seems to be the fastest for this specific use case. + t_pvi_edge_keys.sort(axis=1, kind='heapsort') + + # Note that finding unique edge keys means that if there are multiple edges that share the same vertices (which + # shouldn't normally happen), only the first edge found in loops will be exported along with its per-edge data. + # To export separate edges that share the same vertices, fast_first_axis_unique can be replaced with np.unique + # with t_lei as the first argument, finding unique edges rather than unique edge keys. + # + # Since we want the unique values in their original order, the only part we care about is the indices of the + # first occurrence of the unique elements in t_pvi_edge_keys, so we can use our fast uniqueness helper function. + t_eli = fast_first_axis_unique(t_pvi_edge_keys, return_unique=False, return_index=True) + + # To get the indices of the elements in t_pvi_edge_keys that produce unique values, but in the original order of + # t_pvi_edge_keys, t_eli must be sorted. + # Due to loops and their edge keys tending to have a partial ordering within meshes, sorting with kind='stable' + # with radix sort tends to be faster than the default of `kind='quicksort'` with `introsort`. + t_eli.sort(kind='stable') + + # Edge index of each element in unique t_pvi_edge_keys, used to map per-edge data such as sharp and creases. + t_pvi_edge_indices = t_lei[t_eli] + + # We have to ^-1 last index of each loop. + # Ensure t_pvi is the correct number of bits before inverting. + # t_lvi may be used again later, so always create a copy to avoid modifying it in the next step. + t_pvi = t_lvi.astype(pvi_fbx_dtype) + # The index of the end of each loop is one before the index of the start of the next loop. + t_pvi[t_ls[1:] - 1] ^= -1 + # The index of the end of the last loop will be the very last index. + t_pvi[-1] ^= -1 + del t_pvi_edge_keys + else: + # Should be empty, but make sure it's the correct type. + t_pvi = np.empty(0, dtype=pvi_fbx_dtype) + t_eli = np.empty(0, dtype=eli_fbx_dtype) + + # And finally we can write data! + t_pvi = astype_view_signedness(t_pvi, pvi_fbx_dtype) + t_eli = astype_view_signedness(t_eli, eli_fbx_dtype) + elem_data_single_int32_array(geom, b"PolygonVertexIndex", t_pvi) + elem_data_single_int32_array(geom, b"Edges", t_eli) + del t_pvi + del t_eli + del t_ev + del t_ev_pair_view + + # And now, layers! + + # Smoothing. + if smooth_type in {'FACE', 'EDGE', 'SMOOTH_GROUP'}: + ps_fbx_dtype = np.int32 + _map = b"" + if smooth_type == 'FACE': + # The FBX integer values are usually interpreted as boolean where 0 is False (sharp) and 1 is True + # (smooth). + # The values may also be used to represent smoothing group bitflags, but this does not seem well-supported. + t_ps = MESH_ATTRIBUTE_SHARP_FACE.get_ndarray(attributes) + if t_ps is not None: + # FBX sharp is False, but Blender sharp is True, so invert. + t_ps = np.logical_not(t_ps) + else: + # The mesh has no "sharp_face" attribute, so every face is smooth. + t_ps = np.ones(len(me.polygons), dtype=ps_fbx_dtype) + _map = b"ByPolygon" + elif smooth_type == 'SMOOTH_GROUP': + smoothing_groups = me.calc_smooth_groups(use_bitflags=True, use_boundary_vertices_for_bitflags=True)[0] + t_ps = np.asarray(smoothing_groups, dtype=ps_fbx_dtype) + _map = b"ByPolygon" + else: # EDGE + _map = b"ByEdge" + if t_pvi_edge_indices.size: + # Write Edge Smoothing. + # Note edge is sharp also if it's used by more than two faces, or one of its faces is flat. + mesh_poly_nbr = len(me.polygons) + mesh_edge_nbr = len(me.edges) + mesh_loop_nbr = len(me.loops) + # t_ls and t_lei may contain extra polygons or loops added for loose edges that are not present in the + # mesh data, so create views that exclude the extra data added for loose edges. + mesh_t_ls_view = t_ls[:mesh_poly_nbr] + mesh_t_lei_view = t_lei[:mesh_loop_nbr] + + # - Get sharp edges from edges used by more than two loops (and therefore more than two faces) + e_more_than_two_faces_mask = np.bincount(mesh_t_lei_view, minlength=mesh_edge_nbr) > 2 + + # - Get sharp edges from the "sharp_edge" attribute. The attribute may not exist, in which case, there + # are no edges marked as sharp. + e_use_sharp_mask = MESH_ATTRIBUTE_SHARP_EDGE.get_ndarray(attributes) + if e_use_sharp_mask is not None: + # - Combine with edges that are sharp because they're in more than two faces + e_use_sharp_mask = np.logical_or(e_use_sharp_mask, e_more_than_two_faces_mask, out=e_use_sharp_mask) + else: + e_use_sharp_mask = e_more_than_two_faces_mask + + # - Get sharp edges from flat shaded faces + p_flat_mask = MESH_ATTRIBUTE_SHARP_FACE.get_ndarray(attributes) + if p_flat_mask is not None: + # Convert flat shaded polygons to flat shaded loops by repeating each element by the number of sides + # of that polygon. + # Polygon sides can be calculated from the element-wise difference of loop starts appended by the + # number of loops. Alternatively, polygon sides can be retrieved directly from the 'loop_total' + # attribute of polygons, but since we already have t_ls, it tends to be quicker to calculate from + # t_ls. + polygon_sides = np.diff(mesh_t_ls_view, append=mesh_loop_nbr) + p_flat_loop_mask = np.repeat(p_flat_mask, polygon_sides) + # Convert flat shaded loops to flat shaded (sharp) edge indices. + # Note that if an edge is in multiple loops that are part of flat shaded faces, its edge index will + # end up in sharp_edge_indices_from_polygons multiple times. + sharp_edge_indices_from_polygons = mesh_t_lei_view[p_flat_loop_mask] + + # - Combine with edges that are sharp because a polygon they're in has flat shading + e_use_sharp_mask[sharp_edge_indices_from_polygons] = True + del sharp_edge_indices_from_polygons + del p_flat_loop_mask + del polygon_sides + del p_flat_mask + + # - Convert sharp edges to sharp edge keys (t_pvi) + ek_use_sharp_mask = e_use_sharp_mask[t_pvi_edge_indices] + + # - Sharp edges are indicated in FBX as zero (False), so invert + t_ps = np.invert(ek_use_sharp_mask, out=ek_use_sharp_mask) + del ek_use_sharp_mask + del e_use_sharp_mask + del mesh_t_lei_view + del mesh_t_ls_view + else: + t_ps = np.empty(0, dtype=ps_fbx_dtype) + t_ps = t_ps.astype(ps_fbx_dtype, copy=False) + lay_smooth = elem_data_single_int32(geom, b"LayerElementSmoothing", 0) + elem_data_single_int32(lay_smooth, b"Version", FBX_GEOMETRY_SMOOTHING_VERSION) + elem_data_single_string(lay_smooth, b"Name", b"") + elem_data_single_string(lay_smooth, b"MappingInformationType", _map) + elem_data_single_string(lay_smooth, b"ReferenceInformationType", b"Direct") + elem_data_single_int32_array(lay_smooth, b"Smoothing", t_ps) # Sight, int32 for bool... + del t_ps + del t_ls + del t_lei + + # Edge crease for subdivision + if write_crease: + ec_fbx_dtype = np.float64 + if t_pvi_edge_indices.size: + ec_bl_dtype = np.single + edge_creases = me.edge_creases + if edge_creases: + t_ec_raw = np.empty(len(me.edges), dtype=ec_bl_dtype) + edge_creases.data.foreach_get("value", t_ec_raw) + + # Convert to t_pvi edge-keys. + t_ec_ek_raw = t_ec_raw[t_pvi_edge_indices] + + # Blender squares those values before sending them to OpenSubdiv, when other software don't, + # so we need to compensate that to get similar results through FBX... + # Use the precision of the fbx dtype for the calculation since it's usually higher precision. + t_ec_ek_raw = t_ec_ek_raw.astype(ec_fbx_dtype, copy=False) + t_ec = np.square(t_ec_ek_raw, out=t_ec_ek_raw) + del t_ec_ek_raw + del t_ec_raw + else: + # todo: Blender edge creases are optional now, we may be able to avoid writing the array to FBX when + # there are no edge creases. + t_ec = np.zeros(t_pvi_edge_indices.shape, dtype=ec_fbx_dtype) + else: + t_ec = np.empty(0, dtype=ec_fbx_dtype) + + lay_crease = elem_data_single_int32(geom, b"LayerElementEdgeCrease", 0) + elem_data_single_int32(lay_crease, b"Version", FBX_GEOMETRY_CREASE_VERSION) + elem_data_single_string(lay_crease, b"Name", b"") + elem_data_single_string(lay_crease, b"MappingInformationType", b"ByEdge") + elem_data_single_string(lay_crease, b"ReferenceInformationType", b"Direct") + elem_data_single_float64_array(lay_crease, b"EdgeCrease", t_ec) + del t_ec + + # And we are done with edges! + del t_pvi_edge_indices + + # Loop normals. + tspacenumber = 0 + if write_normals: + normal_bl_dtype = np.single + normal_fbx_dtype = np.float64 + match me.normals_domain: + case 'POINT': + # All faces are smooth shaded, so we can get normals from the vertices. + normal_source = me.vertex_normals + normal_mapping = b"ByVertice" + # External software support for b"ByPolygon" normals does not seem to be as widely available as the other + # mappings. See blender/blender#117470. + # case 'FACE': + # # Either all faces or all edges are sharp, so we can get normals from the faces. + # normal_source = me.polygon_normals + # normal_mapping = b"ByPolygon" + case 'CORNER' | 'FACE': + # We have a mix of sharp/smooth edges/faces or custom normals, so need to get normals from corners. + normal_source = me.corner_normals + normal_mapping = b"ByPolygonVertex" + case _: + # Unreachable + raise AssertionError("Unexpected normals domain '%s'" % me.normals_domain) + # Each normal has 3 components, so the length is multiplied by 3. + t_normal = np.empty(len(normal_source) * 3, dtype=normal_bl_dtype) + normal_source.foreach_get("vector", t_normal) + t_normal = nors_transformed(t_normal, geom_mat_no, normal_fbx_dtype) + normal_idx_fbx_dtype = np.int32 + lay_nor = elem_data_single_int32(geom, b"LayerElementNormal", 0) + elem_data_single_int32(lay_nor, b"Version", FBX_GEOMETRY_NORMAL_VERSION) + elem_data_single_string(lay_nor, b"Name", b"") + elem_data_single_string(lay_nor, b"MappingInformationType", normal_mapping) + # FBX SDK documentation says that normals should use IndexToDirect. + elem_data_single_string(lay_nor, b"ReferenceInformationType", b"IndexToDirect") + + # Workaround for Unity FBX import issue where the normals are considered invalid if any normals are + # deduplicated. See #123088. + # Unity FBX also has issues with importing blend shape normals with deduplicated normals, so skip + # deduplication if the mesh has shape keys. See !126491. + skip_normal_deduplication = (normal_mapping == b"ByVertice") or \ + (me in scene_data.data_deformers_shape) + + if skip_normal_deduplication: + # Write every normal without any deduplication, so the indices array will be [0, 1, 2, ..., n]. + t_normal_idx = np.arange(len(t_normal.reshape(-1, 3)), dtype=normal_idx_fbx_dtype) + else: + # Tuple of unique sorted normals and then the index in the unique sorted normals of each normal in t_normal. + # Since we don't care about how the normals are sorted, only that they're unique, we can use the fast unique + # helper function. + t_normal, t_normal_idx = fast_first_axis_unique(t_normal.reshape(-1, 3), return_inverse=True) + + # Convert to the type for fbx + t_normal_idx = astype_view_signedness(t_normal_idx, normal_idx_fbx_dtype) + + elem_data_single_float64_array(lay_nor, b"Normals", t_normal) + # Normal weights, no idea what it is. + # t_normal_w = np.zeros(len(t_normal), dtype=np.float64) + # elem_data_single_float64_array(lay_nor, b"NormalsW", t_normal_w) + + elem_data_single_int32_array(lay_nor, b"NormalsIndex", t_normal_idx) + + del t_normal_idx + # del t_normal_w + del t_normal + + # tspace + if scene_data.settings.use_tspace: + tspacenumber = len(me.uv_layers) + if tspacenumber: + # We can only compute tspace on tessellated meshes, need to check that here... + lt_bl_dtype = np.uintc + t_lt = np.empty(len(me.polygons), dtype=lt_bl_dtype) + me.polygons.foreach_get("loop_total", t_lt) + if (t_lt > 4).any(): + del t_lt + scene_data.settings.report( + {'WARNING'}, + tip_("Mesh '%s' has polygons with more than 4 vertices, " + "cannot compute/export tangent space for it") % me.name) + else: + del t_lt + num_loops = len(me.loops) + t_ln = np.empty(num_loops * 3, dtype=normal_bl_dtype) + # `t_lnw = np.zeros(len(me.loops), dtype=np.float64)` + # WARNING: Since tangent layers are recomputed inside the loop, do not directly iterate over the + # UV-layers. Instead, cache their keys (names), and use this cached data inside the loop to compute + # the tangent layers. + uvlayer_names = [uvl.name for uvl in me.uv_layers] + for idx, name in enumerate(uvlayer_names): + # Annoying, `me.calc_tangent` errors in case there is no geometry... + if num_loops > 0: + me.calc_tangents(uvmap=name) + + # Loop bitangents (aka binormals). + # NOTE: this is not supported by importer currently. + me.loops.foreach_get("bitangent", t_ln) + lay_nor = elem_data_single_int32(geom, b"LayerElementBinormal", idx) + elem_data_single_int32(lay_nor, b"Version", FBX_GEOMETRY_BINORMAL_VERSION) + elem_data_single_string_unicode(lay_nor, b"Name", name) + elem_data_single_string(lay_nor, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_nor, b"ReferenceInformationType", b"Direct") + elem_data_single_float64_array(lay_nor, b"Binormals", + nors_transformed(t_ln, geom_mat_no, normal_fbx_dtype)) + # Binormal weights, no idea what it is. + # elem_data_single_float64_array(lay_nor, b"BinormalsW", t_lnw) + + # Loop tangents. + # NOTE: this is not supported by importer currently. + me.loops.foreach_get("tangent", t_ln) + lay_nor = elem_data_single_int32(geom, b"LayerElementTangent", idx) + elem_data_single_int32(lay_nor, b"Version", FBX_GEOMETRY_TANGENT_VERSION) + elem_data_single_string_unicode(lay_nor, b"Name", name) + elem_data_single_string(lay_nor, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_nor, b"ReferenceInformationType", b"Direct") + elem_data_single_float64_array(lay_nor, b"Tangents", + nors_transformed(t_ln, geom_mat_no, normal_fbx_dtype)) + # Tangent weights, no idea what it is. + # elem_data_single_float64_array(lay_nor, b"TangentsW", t_lnw) + + del t_ln + # del t_lnw + me.free_tangents() + + # Write VertexColor Layers. + colors_type = scene_data.settings.colors_type + vcolnumber = 0 if colors_type == 'NONE' else len(me.color_attributes) + if vcolnumber: + color_prop_name = "color_srgb" if colors_type == 'SRGB' else "color" + # ByteColorAttribute color also gets returned by the API as single precision float + bl_lc_dtype = np.single + fbx_lc_dtype = np.float64 + fbx_lcidx_dtype = np.int32 + + color_attributes = me.color_attributes + if scene_data.settings.prioritize_active_color: + active_color = me.color_attributes.active_color + color_attributes = sorted(color_attributes, key=lambda x: x == active_color, reverse=True) + + for colindex, collayer in enumerate(color_attributes): + is_point = collayer.domain == "POINT" + vcollen = len(me.vertices if is_point else me.loops) + # Each rgba component is flattened in the array + t_lc = np.empty(vcollen * 4, dtype=bl_lc_dtype) + collayer.data.foreach_get(color_prop_name, t_lc) + lay_vcol = elem_data_single_int32(geom, b"LayerElementColor", colindex) + elem_data_single_int32(lay_vcol, b"Version", FBX_GEOMETRY_VCOLOR_VERSION) + elem_data_single_string_unicode(lay_vcol, b"Name", collayer.name) + elem_data_single_string(lay_vcol, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_vcol, b"ReferenceInformationType", b"IndexToDirect") + + # Use the fast uniqueness helper function since we don't care about sorting. + t_lc, col_indices = fast_first_axis_unique(t_lc.reshape(-1, 4), return_inverse=True) + + if is_point: + # for "point" domain colors, we could directly emit them + # with a "ByVertex" mapping type, but some software does not + # properly understand that. So expand to full "ByPolygonVertex" + # index map. + # Ignore loops added for loose edges. + col_indices = col_indices[t_lvi[:len(me.loops)]] + + t_lc = t_lc.astype(fbx_lc_dtype, copy=False) + col_indices = astype_view_signedness(col_indices, fbx_lcidx_dtype) + + elem_data_single_float64_array(lay_vcol, b"Colors", t_lc) + elem_data_single_int32_array(lay_vcol, b"ColorIndex", col_indices) + + del t_lc + del col_indices + + # Write UV layers. + # Note: LayerElementTexture is deprecated since FBX 2011 - luckily! + # Textures are now only related to materials, in FBX! + uvnumber = len(me.uv_layers) + if uvnumber: + luv_bl_dtype = np.single + luv_fbx_dtype = np.float64 + lv_idx_fbx_dtype = np.int32 + + t_luv = np.empty(len(me.loops) * 2, dtype=luv_bl_dtype) + # Fast view for sort-based uniqueness of pairs. + t_luv_fast_pair_view = fast_first_axis_flat(t_luv.reshape(-1, 2)) + # It must be a view of t_luv otherwise it won't update when t_luv is updated. + assert t_luv_fast_pair_view.base is t_luv + + # Looks like this mapping is also expected to convey UV islands (arg..... :((((( ). + # So we need to generate unique triplets (uv, vertex_idx) here, not only just based on UV values. + # Ignore loops added for loose edges. + t_lvidx = t_lvi[:len(me.loops)] + + # If we were to create a combined array of (uv, vertex_idx) elements, we could find unique triplets by sorting + # that array by first sorting by the vertex_idx column and then sorting by the uv column using a stable sorting + # algorithm. + # This is exactly what we'll do, but without creating the combined array, because only the uv elements are + # included in the export and the vertex_idx column is the same for every uv layer. + + # Because the vertex_idx column is the same for every uv layer, the vertex_idx column can be sorted in advance. + # argsort gets the indices that sort the array, which are needed to be able to sort the array of uv pairs in the + # same way to create the indices that recreate the full uvs from the unique uvs. + # Loops and vertices tend to naturally have a partial ordering, which makes sorting with kind='stable' + # (radix sort) faster than the default of `kind='quicksort'` (`introsort`) in most cases. + perm_vidx = t_lvidx.argsort(kind='stable') + + # Mask and uv indices arrays will be modified and re-used by each uv layer. + unique_mask = np.empty(len(me.loops), dtype=np.bool_) + unique_mask[:1] = True + uv_indices = np.empty(len(me.loops), dtype=lv_idx_fbx_dtype) + + for uvindex, uvlayer in enumerate(me.uv_layers): + lay_uv = elem_data_single_int32(geom, b"LayerElementUV", uvindex) + elem_data_single_int32(lay_uv, b"Version", FBX_GEOMETRY_UV_VERSION) + elem_data_single_string_unicode(lay_uv, b"Name", uvlayer.name) + elem_data_single_string(lay_uv, b"MappingInformationType", b"ByPolygonVertex") + elem_data_single_string(lay_uv, b"ReferenceInformationType", b"IndexToDirect") + + uvlayer.uv.foreach_get("vector", t_luv) + + # t_luv_fast_pair_view is a view in a dtype that compares elements by individual bytes, but float types have + # separate byte representations of positive and negative zero. For uniqueness, these should be considered + # the same, so replace all -0.0 with 0.0 in advance. + t_luv[t_luv == -0.0] = 0.0 + + # These steps to create unique_uv_pairs are the same as how np.unique would find unique values by sorting a + # structured array where each element is a triplet of (uv, vertex_idx), except uv and vertex_idx are + # separate arrays here and vertex_idx has already been sorted in advance. + + # Sort according to the `vertex_idx` column, using the pre-calculated indices that sort it. + sorted_t_luv_fast = t_luv_fast_pair_view[perm_vidx] + + # Get the indices that would sort the sorted uv pairs. Stable sorting must be used to maintain the sorting + # of the vertex indices. + perm_uv_pairs = sorted_t_luv_fast.argsort(kind='stable') + # Use the indices to sort both the uv pairs and the vertex_idx columns. + perm_combined = perm_vidx[perm_uv_pairs] + sorted_vidx = t_lvidx[perm_combined] + sorted_t_luv_fast = sorted_t_luv_fast[perm_uv_pairs] + + # Create a mask where either the uv pair doesn't equal the previous value in the array, or the vertex index + # doesn't equal the previous value, these will be the unique uv-vidx triplets. + # For an imaginary triplet array: + # ... + # [(0.4, 0.2), 0] + # [(0.4, 0.2), 1] -> Unique because vertex index different from previous + # [(0.4, 0.2), 2] -> Unique because vertex index different from previous + # [(0.7, 0.6), 2] -> Unique because uv different from previous + # [(0.7, 0.6), 2] + # ... + # Output the result into unique_mask. + np.logical_or(sorted_t_luv_fast[1:] != sorted_t_luv_fast[:-1], sorted_vidx[1:] != sorted_vidx[:-1], + out=unique_mask[1:]) + + # Get each uv pair marked as unique by the unique_mask and then view as the original dtype. + unique_uvs = sorted_t_luv_fast[unique_mask].view(luv_bl_dtype) + + # NaN values are considered invalid and indicate a bug somewhere else in Blender or in an addon, we want + # these bugs to be reported instead of hiding them by allowing the export to continue. + if np.isnan(unique_uvs).any(): + raise RuntimeError("UV layer %s on %r has invalid UVs containing NaN values" % (uvlayer.name, me)) + + # Convert to the type needed for fbx + unique_uvs = unique_uvs.astype(luv_fbx_dtype, copy=False) + + # Set the indices of pairs in unique_uvs that reconstruct the pairs in t_luv into uv_indices. + # uv_indices will then be the same as an inverse array returned by np.unique with return_inverse=True. + uv_indices[perm_combined] = np.cumsum(unique_mask, dtype=uv_indices.dtype) - 1 + + elem_data_single_float64_array(lay_uv, b"UV", unique_uvs) + elem_data_single_int32_array(lay_uv, b"UVIndex", uv_indices) + del unique_uvs + del sorted_t_luv_fast + del sorted_vidx + del perm_uv_pairs + del perm_combined + del uv_indices + del unique_mask + del perm_vidx + del t_lvidx + del t_luv + del t_luv_fast_pair_view + del t_lvi + + # Face's materials. + me_fbxmaterials_idx = scene_data.mesh_material_indices.get(me) + if me_fbxmaterials_idx is not None: + # We cannot use me.materials here, as this array is filled with None in case materials are linked to object... + me_blmaterials = me_obj.materials + if me_fbxmaterials_idx and me_blmaterials: + lay_ma = elem_data_single_int32(geom, b"LayerElementMaterial", 0) + elem_data_single_int32(lay_ma, b"Version", FBX_GEOMETRY_MATERIAL_VERSION) + elem_data_single_string(lay_ma, b"Name", b"") + nbr_mats = len(me_fbxmaterials_idx) + multiple_fbx_mats = nbr_mats > 1 + # If a mesh does not have more than one material its material_index attribute can be ignored. + # If a mesh has multiple materials but all its polygons are assigned to the first material, its + # material_index attribute may not exist. + t_pm = None if not multiple_fbx_mats else MESH_ATTRIBUTE_MATERIAL_INDEX.get_ndarray(attributes) + if t_pm is not None: + fbx_pm_dtype = np.int32 + + # We have to validate mat indices, and map them to FBX indices. + # Note a mat might not be in me_fbxmaterials_idx (e.g. node mats are ignored). + + # The first valid material will be used for materials out of bounds of me_blmaterials or materials not + # in me_fbxmaterials_idx. + def_me_blmaterial_idx, def_ma = next( + (i, me_fbxmaterials_idx[m]) for i, m in enumerate(me_blmaterials) if m in me_fbxmaterials_idx) + + # Set material indices that are out of bounds to the default material index + mat_idx_limit = len(me_blmaterials) + # Material indices shouldn't be negative, but they technically could be. Viewing as unsigned before + # checking for indices that are too large means that a single >= check will pick up both negative + # indices and indices that are too large. + t_pm[t_pm.view("u%i" % t_pm.itemsize) >= mat_idx_limit] = def_me_blmaterial_idx + + # Map to FBX indices. Materials not in me_fbxmaterials_idx will be set to the default material index. + blmat_fbx_idx = np.fromiter((me_fbxmaterials_idx.get(m, def_ma) for m in me_blmaterials), + dtype=fbx_pm_dtype) + t_pm = blmat_fbx_idx[t_pm] + + elem_data_single_string(lay_ma, b"MappingInformationType", b"ByPolygon") + # XXX Logically, should be "Direct" reference type, since we do not have any index array, and have one + # value per polygon... + # But looks like FBX expects it to be IndexToDirect here (maybe because materials are already + # indices??? *sigh*). + elem_data_single_string(lay_ma, b"ReferenceInformationType", b"IndexToDirect") + elem_data_single_int32_array(lay_ma, b"Materials", t_pm) + else: + elem_data_single_string(lay_ma, b"MappingInformationType", b"AllSame") + elem_data_single_string(lay_ma, b"ReferenceInformationType", b"IndexToDirect") + if multiple_fbx_mats: + # There's no material_index attribute, so every material index is effectively zero. + # In the order of the mesh's materials, get the FBX index of the first material that is exported. + all_same_idx = next(me_fbxmaterials_idx[m] for m in me_blmaterials if m in me_fbxmaterials_idx) + else: + # There's only one fbx material, so the index will always be zero. + all_same_idx = 0 + elem_data_single_int32_array(lay_ma, b"Materials", [all_same_idx]) + del t_pm + + # And the "layer TOC"... + + layer = elem_data_single_int32(geom, b"Layer", 0) + elem_data_single_int32(layer, b"Version", FBX_GEOMETRY_LAYER_VERSION) + if write_normals: + lay_nor = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_nor, b"Type", b"LayerElementNormal") + elem_data_single_int32(lay_nor, b"TypedIndex", 0) + if tspacenumber: + lay_binor = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_binor, b"Type", b"LayerElementBinormal") + elem_data_single_int32(lay_binor, b"TypedIndex", 0) + lay_tan = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_tan, b"Type", b"LayerElementTangent") + elem_data_single_int32(lay_tan, b"TypedIndex", 0) + if smooth_type in {'FACE', 'EDGE', 'SMOOTH_GROUP'}: + lay_smooth = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_smooth, b"Type", b"LayerElementSmoothing") + elem_data_single_int32(lay_smooth, b"TypedIndex", 0) + if write_crease: + lay_crease = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_crease, b"Type", b"LayerElementEdgeCrease") + elem_data_single_int32(lay_crease, b"TypedIndex", 0) + if vcolnumber: + lay_vcol = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_vcol, b"Type", b"LayerElementColor") + elem_data_single_int32(lay_vcol, b"TypedIndex", 0) + if uvnumber: + lay_uv = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_uv, b"Type", b"LayerElementUV") + elem_data_single_int32(lay_uv, b"TypedIndex", 0) + if me_fbxmaterials_idx is not None: + lay_ma = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_ma, b"Type", b"LayerElementMaterial") + elem_data_single_int32(lay_ma, b"TypedIndex", 0) + + # Add other uv and/or vcol layers... + for vcolidx, uvidx, tspaceidx in zip_longest(range(1, vcolnumber), range(1, uvnumber), range(1, tspacenumber), + fillvalue=0): + layer = elem_data_single_int32(geom, b"Layer", max(vcolidx, uvidx)) + elem_data_single_int32(layer, b"Version", FBX_GEOMETRY_LAYER_VERSION) + if vcolidx: + lay_vcol = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_vcol, b"Type", b"LayerElementColor") + elem_data_single_int32(lay_vcol, b"TypedIndex", vcolidx) + if uvidx: + lay_uv = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_uv, b"Type", b"LayerElementUV") + elem_data_single_int32(lay_uv, b"TypedIndex", uvidx) + if tspaceidx: + lay_binor = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_binor, b"Type", b"LayerElementBinormal") + elem_data_single_int32(lay_binor, b"TypedIndex", tspaceidx) + lay_tan = elem_empty(layer, b"LayerElement") + elem_data_single_string(lay_tan, b"Type", b"LayerElementTangent") + elem_data_single_int32(lay_tan, b"TypedIndex", tspaceidx) + + # Shape keys... + fbx_data_mesh_shapes_elements(root, me_obj, me, scene_data, tmpl, props) + + elem_props_template_finalize(tmpl, props) + done_meshes.add(me_key) + + +def fbx_data_material_elements(root, ma, scene_data): + """ + Write the Material data block. + """ + + ambient_color = (0.0, 0.0, 0.0) + if scene_data.data_world: + ambient_color = next(iter(scene_data.data_world.keys())).color + + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=True) + ma_key, _objs = scene_data.data_materials[ma] + ma_type = b"Phong" + + fbx_ma = elem_data_single_int64(root, b"Material", get_fbx_uuid_from_key(ma_key)) + fbx_ma.add_string(fbx_name_class(ma.name.encode(), b"Material")) + fbx_ma.add_string(b"") + + elem_data_single_int32(fbx_ma, b"Version", FBX_MATERIAL_VERSION) + # those are not yet properties, it seems... + elem_data_single_string(fbx_ma, b"ShadingModel", ma_type) + elem_data_single_int32(fbx_ma, b"MultiLayer", 0) # Should be bool... + + tmpl = elem_props_template_init(scene_data.templates, b"Material") + props = elem_properties(fbx_ma) + + elem_props_template_set(tmpl, props, "p_string", b"ShadingModel", ma_type.decode()) + elem_props_template_set(tmpl, props, "p_color", b"DiffuseColor", ma_wrap.base_color) + # Not in Principled BSDF, so assuming always 1 + elem_props_template_set(tmpl, props, "p_number", b"DiffuseFactor", 1.0) + # Principled BSDF only has an emissive color, so we assume factor to be always 1.0. + elem_props_template_set(tmpl, props, "p_color", b"EmissiveColor", ma_wrap.emission_color) + elem_props_template_set(tmpl, props, "p_number", b"EmissiveFactor", ma_wrap.emission_strength) + # Not in Principled BSDF, so assuming always 0 + elem_props_template_set(tmpl, props, "p_color", b"AmbientColor", ambient_color) + elem_props_template_set(tmpl, props, "p_number", b"AmbientFactor", 0.0) + # Sweetness... Looks like we are not the only ones to not know exactly how FBX is supposed to work (see T59850). + # According to one of its developers, Unity uses that formula to extract alpha value: + # + # alpha = 1 - TransparencyFactor + # if (alpha == 1 or alpha == 0): + # alpha = 1 - TransparentColor.r + # + # Until further info, let's assume this is correct way to do, hence the following code for TransparentColor. + if ma_wrap.alpha < 1.0e-5 or ma_wrap.alpha > (1.0 - 1.0e-5): + elem_props_template_set(tmpl, props, "p_color", b"TransparentColor", (1.0 - ma_wrap.alpha,) * 3) + else: + elem_props_template_set(tmpl, props, "p_color", b"TransparentColor", ma_wrap.base_color) + elem_props_template_set(tmpl, props, "p_number", b"TransparencyFactor", 1.0 - ma_wrap.alpha) + elem_props_template_set(tmpl, props, "p_number", b"Opacity", ma_wrap.alpha) + elem_props_template_set(tmpl, props, "p_vector_3d", b"NormalMap", (0.0, 0.0, 0.0)) + elem_props_template_set(tmpl, props, "p_double", b"BumpFactor", ma_wrap.normalmap_strength) + # Not sure about those... + """ + b"Bump": ((0.0, 0.0, 0.0), "p_vector_3d"), + b"DisplacementColor": ((0.0, 0.0, 0.0), "p_color_rgb"), + b"DisplacementFactor": (0.0, "p_double"), + """ + # TODO: use specular tint? + elem_props_template_set(tmpl, props, "p_color", b"SpecularColor", ma_wrap.base_color) + elem_props_template_set(tmpl, props, "p_number", b"SpecularFactor", ma_wrap.specular / 2.0) + # See Material template about those two! + # XXX Totally empirical conversion, trying to adapt it + # (from 0.0 - 100.0 FBX shininess range to 1.0 - 0.0 Principled BSDF range)... + shininess = (1.0 - ma_wrap.roughness) * 10 + shininess *= shininess + elem_props_template_set(tmpl, props, "p_number", b"Shininess", shininess) + elem_props_template_set(tmpl, props, "p_number", b"ShininessExponent", shininess) + elem_props_template_set(tmpl, props, "p_color", b"ReflectionColor", ma_wrap.base_color) + elem_props_template_set(tmpl, props, "p_number", b"ReflectionFactor", ma_wrap.metallic) + + elem_props_template_finalize(tmpl, props) + + # Custom properties. + if scene_data.settings.use_custom_props: + fbx_data_element_custom_properties(props, ma) + + +def _get_image_filepath(img): + if len(img.filepath) > 0: + return img.filepath + + # It's possible to have a packed image without a filepath. Pick a filepath + # that is unlikely to conflict. + filepath = os.path.join("textures", "packed") + if img.library: + filepath = os.path.join(filepath, bpy.path.clean_name(img.library.name)) + return "//" + os.path.join(filepath, bpy.path.clean_name(img.name)) + + +def _gen_vid_path(img, scene_data): + msetts = scene_data.settings.media_settings + img_filepath = _get_image_filepath(img) + fname_rel = bpy_extras.io_utils.path_reference(img_filepath, msetts.base_src, msetts.base_dst, msetts.path_mode, + msetts.subdir, msetts.copy_set, img.library) + fname_abs = os.path.normpath(os.path.abspath(os.path.join(msetts.base_dst, fname_rel))) + return fname_abs, fname_rel + + +def fbx_data_texture_file_elements(root, blender_tex_key, scene_data): + """ + Write the (file) Texture data block. + """ + # XXX All this is very fuzzy to me currently... + # Textures do not seem to use properties as much as they could. + # For now assuming most logical and simple stuff. + + ma, sock_name = blender_tex_key + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=True) + tex_key, _fbx_prop = scene_data.data_textures[blender_tex_key] + tex = getattr(ma_wrap, sock_name) + img = tex.image + fname_abs, fname_rel = _gen_vid_path(img, scene_data) + + fbx_tex = elem_data_single_int64(root, b"Texture", get_fbx_uuid_from_key(tex_key)) + fbx_tex.add_string(fbx_name_class(sock_name.encode(), b"Texture")) + fbx_tex.add_string(b"") + + elem_data_single_string(fbx_tex, b"Type", b"TextureVideoClip") + elem_data_single_int32(fbx_tex, b"Version", FBX_TEXTURE_VERSION) + elem_data_single_string(fbx_tex, b"TextureName", fbx_name_class(sock_name.encode(), b"Texture")) + elem_data_single_string(fbx_tex, b"Media", fbx_name_class(img.name.encode(), b"Video")) + elem_data_single_string_unicode(fbx_tex, b"FileName", fname_abs) + elem_data_single_string_unicode(fbx_tex, b"RelativeFilename", fname_rel) + + alpha_source = 0 # None + if img.alpha_mode != 'NONE': + # ~ if tex.texture.use_calculate_alpha: + # ~ alpha_source = 1 # RGBIntensity as alpha. + # ~ else: + # ~ alpha_source = 2 # Black, i.e. alpha channel. + alpha_source = 2 # Black, i.e. alpha channel. + # BlendMode not useful for now, only affects layered textures AFAICS. + mapping = 0 # UV. + uvset = None + if tex.texcoords == 'ORCO': # XXX Others? + if tex.projection == 'FLAT': + mapping = 1 # Planar + elif tex.projection == 'CUBE': + mapping = 4 # Box + elif tex.projection == 'TUBE': + mapping = 3 # Cylindrical + elif tex.projection == 'SPHERE': + mapping = 2 # Spherical + elif tex.texcoords == 'UV': + mapping = 0 # UV + # Yuck, UVs are linked by mere names it seems... :/ + # XXX TODO how to get that now??? + # uvset = tex.uv_layer + wrap_mode = 1 # Clamp + if tex.extension == 'REPEAT': + wrap_mode = 0 # Repeat + + tmpl = elem_props_template_init(scene_data.templates, b"TextureFile") + props = elem_properties(fbx_tex) + elem_props_template_set(tmpl, props, "p_enum", b"AlphaSource", alpha_source) + elem_props_template_set(tmpl, props, "p_bool", b"PremultiplyAlpha", + img.alpha_mode in {'STRAIGHT'}) # Or is it PREMUL? + elem_props_template_set(tmpl, props, "p_enum", b"CurrentMappingType", mapping) + if uvset is not None: + elem_props_template_set(tmpl, props, "p_string", b"UVSet", uvset) + elem_props_template_set(tmpl, props, "p_enum", b"WrapModeU", wrap_mode) + elem_props_template_set(tmpl, props, "p_enum", b"WrapModeV", wrap_mode) + elem_props_template_set(tmpl, props, "p_vector_3d", b"Translation", tex.translation) + elem_props_template_set(tmpl, props, "p_vector_3d", b"Rotation", (-r for r in tex.rotation)) + elem_props_template_set(tmpl, props, "p_vector_3d", b"Scaling", + (((1.0 / s) if s != 0.0 else 1.0) for s in tex.scale)) + # UseMaterial should always be ON IMHO. + elem_props_template_set(tmpl, props, "p_bool", b"UseMaterial", True) + elem_props_template_set(tmpl, props, "p_bool", b"UseMipMap", False) + elem_props_template_finalize(tmpl, props) + + # No custom properties, since that's not a data-block anymore. + + +def fbx_data_video_elements(root, vid, scene_data): + """ + Write the actual image data block. + """ + msetts = scene_data.settings.media_settings + + vid_key, _texs = scene_data.data_videos[vid] + fname_abs, fname_rel = _gen_vid_path(vid, scene_data) + + fbx_vid = elem_data_single_int64(root, b"Video", get_fbx_uuid_from_key(vid_key)) + fbx_vid.add_string(fbx_name_class(vid.name.encode(), b"Video")) + fbx_vid.add_string(b"Clip") + + elem_data_single_string(fbx_vid, b"Type", b"Clip") + # XXX No Version??? + + tmpl = elem_props_template_init(scene_data.templates, b"Video") + props = elem_properties(fbx_vid) + elem_props_template_set(tmpl, props, "p_string_url", b"Path", fname_abs) + elem_props_template_finalize(tmpl, props) + + elem_data_single_int32(fbx_vid, b"UseMipMap", 0) + elem_data_single_string_unicode(fbx_vid, b"Filename", fname_abs) + elem_data_single_string_unicode(fbx_vid, b"RelativeFilename", fname_rel) + + if scene_data.settings.media_settings.embed_textures: + if vid.packed_file is not None: + # We only ever embed a given file once! + if fname_abs not in msetts.embedded_set: + elem_data_single_bytes(fbx_vid, b"Content", vid.packed_file.data) + msetts.embedded_set.add(fname_abs) + else: + filepath = bpy.path.abspath(vid.filepath) + # We only ever embed a given file once! + if filepath not in msetts.embedded_set: + try: + with open(filepath, 'br') as f: + elem_data_single_bytes(fbx_vid, b"Content", f.read()) + except Exception as e: + print("WARNING: embedding file {:s} failed ({:s})".format(filepath, str(e))) + elem_data_single_bytes(fbx_vid, b"Content", b"") + msetts.embedded_set.add(filepath) + # Looks like we'd rather not write any 'Content' element in this case (see T44442). + # Sounds suspect, but let's try it! + # ~ else: + # ~ elem_data_single_bytes(fbx_vid, b"Content", b"") + + # Blender currently has no UI for editing custom properties on Images, but the importer will import Image custom + # properties from either a Video Node or a Texture Node, preferring a Video node if one exists. We'll propagate + # these custom properties only to Video Nodes because that is most likely where they were imported from, and Texture + # Nodes are more like Blender's Shader Nodes than Images, which is what we're exporting here. + if scene_data.settings.use_custom_props: + fbx_data_element_custom_properties(props, vid) + + +def fbx_data_armature_elements(root, arm_obj, scene_data): + """ + Write: + * Bones "data" (NodeAttribute::LimbNode, contains pretty much nothing!). + * Deformers (i.e. Skin), bind between an armature and a mesh. + ** SubDeformers (i.e. Cluster), one per bone/vgroup pair. + * BindPose. + Note armature itself has no data, it is a mere "Null" Model... + """ + mat_world_arm = arm_obj.fbx_object_matrix(scene_data, global_space=True) + bones = tuple(bo_obj for bo_obj in arm_obj.bones if bo_obj in scene_data.objects) + + bone_radius_scale = 33.0 + + # Bones "data". + for bo_obj in bones: + bo = bo_obj.bdata + bo_data_key = scene_data.data_bones[bo_obj] + fbx_bo = elem_data_single_int64(root, b"NodeAttribute", get_fbx_uuid_from_key(bo_data_key)) + fbx_bo.add_string(fbx_name_class(bo.name.encode(), b"NodeAttribute")) + fbx_bo.add_string(b"LimbNode") + elem_data_single_string(fbx_bo, b"TypeFlags", b"Skeleton") + + tmpl = elem_props_template_init(scene_data.templates, b"Bone") + props = elem_properties(fbx_bo) + elem_props_template_set(tmpl, props, "p_double", b"Size", bo.head_radius * bone_radius_scale) + elem_props_template_finalize(tmpl, props) + + # Custom properties. + if scene_data.settings.use_custom_props: + fbx_data_element_custom_properties(props, bo) + + # Store Blender bone length - XXX Not much useful actually :/ + # (LimbLength can't be used because it is a scale factor 0-1 for the parent-child distance: + # http://docs.autodesk.com/FBX/2014/ENU/FBX-SDK-Documentation/cpp_ref/class_fbx_skeleton.html#a9bbe2a70f4ed82cd162620259e649f0f ) + # elem_props_set( + # props, "p_double", "BlenderBoneLength".encode(), (bo.tail_local - bo.head_local).length, custom=True, + # ) + + # Skin deformers and BindPoses. + # Note: we might also use Deformers for our "parent to vertex" stuff??? + deformer = scene_data.data_deformers_skin.get(arm_obj, None) + if deformer is not None: + for me, (skin_key, ob_obj, clusters) in deformer.items(): + # BindPose. + mat_world_obj, mat_world_bones = fbx_data_bindpose_element(root, ob_obj, me, scene_data, + arm_obj, mat_world_arm, bones) + + # Deformer. + fbx_skin = elem_data_single_int64(root, b"Deformer", get_fbx_uuid_from_key(skin_key)) + fbx_skin.add_string(fbx_name_class(arm_obj.name.encode(), b"Deformer")) + fbx_skin.add_string(b"Skin") + + elem_data_single_int32(fbx_skin, b"Version", FBX_DEFORMER_SKIN_VERSION) + elem_data_single_float64(fbx_skin, b"Link_DeformAcuracy", 50.0) # Only vague idea what it is... + + # Pre-process vertex weights so that the vertices only need to be iterated once. + ob = ob_obj.bdata + bo_vg_idx = {bo_obj.bdata.name: ob.vertex_groups[bo_obj.bdata.name].index + for bo_obj in clusters.keys() if bo_obj.bdata.name in ob.vertex_groups} + valid_idxs = set(bo_vg_idx.values()) + vgroups = {vg.index: {} for vg in ob.vertex_groups} + for idx, v in enumerate(me.vertices): + for vg in v.groups: + if (w := vg.weight) and (vg_idx := vg.group) in valid_idxs: + vgroups[vg_idx][idx] = w + + for bo_obj, clstr_key in clusters.items(): + bo = bo_obj.bdata + # Find which vertices are affected by this bone/vgroup pair, and matching weights. + # Note we still write a cluster for bones not affecting the mesh, to get 'rest pose' data + # (the TransformBlah matrices). + vg_idx = bo_vg_idx.get(bo.name, None) + indices, weights = ((), ()) if vg_idx is None or not vgroups[vg_idx] else zip(*vgroups[vg_idx].items()) + + # Create the cluster. + fbx_clstr = elem_data_single_int64(root, b"Deformer", get_fbx_uuid_from_key(clstr_key)) + fbx_clstr.add_string(fbx_name_class(bo.name.encode(), b"SubDeformer")) + fbx_clstr.add_string(b"Cluster") + + elem_data_single_int32(fbx_clstr, b"Version", FBX_DEFORMER_CLUSTER_VERSION) + # No idea what that user data might be... + fbx_userdata = elem_data_single_string(fbx_clstr, b"UserData", b"") + fbx_userdata.add_string(b"") + if indices: + elem_data_single_int32_array(fbx_clstr, b"Indexes", indices) + elem_data_single_float64_array(fbx_clstr, b"Weights", weights) + # Transform, TransformLink and TransformAssociateModel matrices... + # They seem to be duplicates of BindPose ones??? Have armature (associatemodel) in addition, though. + # WARNING! Even though official FBX API presents Transform in global space, + # **it is stored in bone space in FBX data!** See: + # http://area.autodesk.com/forum/autodesk-fbx/fbx-sdk/why-the-values-return- + # by-fbxcluster-gettransformmatrix-x-not-same-with-the-value-in-ascii-fbx-file/ + elem_data_single_float64_array( + fbx_clstr, b"Transform", matrix4_to_array( + mat_world_bones[bo_obj].inverted_safe() @ mat_world_obj)) + elem_data_single_float64_array(fbx_clstr, b"TransformLink", matrix4_to_array(mat_world_bones[bo_obj])) + elem_data_single_float64_array(fbx_clstr, b"TransformAssociateModel", matrix4_to_array(mat_world_arm)) + + +def fbx_data_leaf_bone_elements(root, scene_data): + # Write a dummy leaf bone that is used by applications to show the length of the last bone in a chain + for (node_name, _par_uuid, node_uuid, attr_uuid, matrix, hide, size) in scene_data.data_leaf_bones: + # Bone 'data'... + fbx_bo = elem_data_single_int64(root, b"NodeAttribute", attr_uuid) + fbx_bo.add_string(fbx_name_class(node_name.encode(), b"NodeAttribute")) + fbx_bo.add_string(b"LimbNode") + elem_data_single_string(fbx_bo, b"TypeFlags", b"Skeleton") + + tmpl = elem_props_template_init(scene_data.templates, b"Bone") + props = elem_properties(fbx_bo) + elem_props_template_set(tmpl, props, "p_double", b"Size", size) + elem_props_template_finalize(tmpl, props) + + # And bone object. + model = elem_data_single_int64(root, b"Model", node_uuid) + model.add_string(fbx_name_class(node_name.encode(), b"Model")) + model.add_string(b"LimbNode") + + elem_data_single_int32(model, b"Version", FBX_MODELS_VERSION) + + # Object transform info. + loc, rot, scale = matrix.decompose() + rot = rot.to_euler('XYZ') + rot = tuple(convert_rad_to_deg_iter(rot)) + + tmpl = elem_props_template_init(scene_data.templates, b"Model") + # For now add only loc/rot/scale... + props = elem_properties(model) + # Generated leaf bones are obviously never animated! + elem_props_template_set(tmpl, props, "p_lcl_translation", b"Lcl Translation", loc) + elem_props_template_set(tmpl, props, "p_lcl_rotation", b"Lcl Rotation", rot) + elem_props_template_set(tmpl, props, "p_lcl_scaling", b"Lcl Scaling", scale) + elem_props_template_set(tmpl, props, "p_visibility", b"Visibility", float(not hide)) + + # Absolutely no idea what this is, but seems mandatory for validity of the file, and defaults to + # invalid -1 value... + elem_props_template_set(tmpl, props, "p_integer", b"DefaultAttributeIndex", 0) + + elem_props_template_set(tmpl, props, "p_enum", b"InheritType", 1) # RSrs + + # Those settings would obviously need to be edited in a complete version of the exporter, may depends on + # object type, etc. + elem_data_single_int32(model, b"MultiLayer", 0) + elem_data_single_int32(model, b"MultiTake", 0) + # Probably the FbxNode.EShadingMode enum. Full description in fbx_data_object_elements. + elem_data_single_char(model, b"Shading", b"\x01") + elem_data_single_string(model, b"Culling", b"CullingOff") + + elem_props_template_finalize(tmpl, props) + + +def fbx_data_object_elements(root, ob_obj, scene_data): + """ + Write the Object (Model) data blocks. + Note this "Model" can also be bone or dupli! + """ + obj_type = b"Null" # default, sort of empty... + if ob_obj.is_bone: + obj_type = b"LimbNode" + elif (ob_obj.type == 'ARMATURE'): + if scene_data.settings.armature_nodetype == 'ROOT': + obj_type = b"Root" + elif scene_data.settings.armature_nodetype == 'LIMBNODE': + obj_type = b"LimbNode" + else: # Default, preferred option... + obj_type = b"Null" + elif (ob_obj.type in BLENDER_OBJECT_TYPES_MESHLIKE): + obj_type = b"Mesh" + elif (ob_obj.type == 'LIGHT'): + obj_type = b"Light" + elif (ob_obj.type == 'CAMERA'): + obj_type = b"Camera" + model = elem_data_single_int64(root, b"Model", ob_obj.fbx_uuid) + model.add_string(fbx_name_class(ob_obj.name.encode(), b"Model")) + model.add_string(obj_type) + + elem_data_single_int32(model, b"Version", FBX_MODELS_VERSION) + + # Object transform info. + loc, rot, scale, matrix, matrix_rot = ob_obj.fbx_object_tx(scene_data) + rot = tuple(convert_rad_to_deg_iter(rot)) + + tmpl = elem_props_template_init(scene_data.templates, b"Model") + # For now add only loc/rot/scale... + props = elem_properties(model) + elem_props_template_set(tmpl, props, "p_lcl_translation", b"Lcl Translation", loc, + animatable=True, animated=((ob_obj.key, "Lcl Translation") in scene_data.animated)) + elem_props_template_set(tmpl, props, "p_lcl_rotation", b"Lcl Rotation", rot, + animatable=True, animated=((ob_obj.key, "Lcl Rotation") in scene_data.animated)) + elem_props_template_set(tmpl, props, "p_lcl_scaling", b"Lcl Scaling", scale, + animatable=True, animated=((ob_obj.key, "Lcl Scaling") in scene_data.animated)) + elem_props_template_set(tmpl, props, "p_visibility", b"Visibility", float(not ob_obj.hide)) + + # Absolutely no idea what this is, but seems mandatory for validity of the file, and defaults to + # invalid -1 value... + elem_props_template_set(tmpl, props, "p_integer", b"DefaultAttributeIndex", 0) + + elem_props_template_set(tmpl, props, "p_enum", b"InheritType", 1) # RSrs + + # Custom properties. + if scene_data.settings.use_custom_props: + # Here we want customprops from the 'pose' bone, not the 'edit' bone... + bdata = ob_obj.bdata_pose_bone if ob_obj.is_bone else ob_obj.bdata + fbx_data_element_custom_properties(props, bdata) + + # Those settings would obviously need to be edited in a complete version of the exporter, may depends on + # object type, etc. + elem_data_single_int32(model, b"MultiLayer", 0) + elem_data_single_int32(model, b"MultiTake", 0) + # This is probably the FbxNode.EShadingMode enum. Not directly used by the FBX SDK, but the SDK guarantees that the + # value will be passed through from an imported file to an exported one. Common values are 'Y' and 'T'. 'U' and 'W' + # have also been seen in older FBX files. It's not clear which enum member each of these values corresponds to or if + # these values are actually application specific. Blender had been exporting this as a `True` bool for a long time + # seemingly without issue. The '\x01' char is the same value as `True` in raw bytes. + elem_data_single_char(model, b"Shading", b"\x01") + elem_data_single_string(model, b"Culling", b"CullingOff") + + if obj_type == b"Camera": + # Why, oh why are FBX cameras such a mess??? + # And WHY add camera data HERE??? Not even sure this is needed... + render = scene_data.scene.render + width = render.resolution_x * 1.0 + height = render.resolution_y * 1.0 + elem_props_template_set(tmpl, props, "p_enum", b"ResolutionMode", 0) # Don't know what it means + elem_props_template_set(tmpl, props, "p_double", b"AspectW", width) + elem_props_template_set(tmpl, props, "p_double", b"AspectH", height) + elem_props_template_set(tmpl, props, "p_bool", b"ViewFrustum", True) + elem_props_template_set(tmpl, props, "p_enum", b"BackgroundMode", 0) # Don't know what it means + elem_props_template_set(tmpl, props, "p_bool", b"ForegroundTransparent", True) + + elem_props_template_finalize(tmpl, props) + + +def fbx_data_animation_elements(root, scene_data): + """ + Write animation data. + """ + animations = scene_data.animations + if not animations: + return + + # Animation stacks. + for astack_key, alayers, alayer_key, name, f_start, f_end in animations: + astack = elem_data_single_int64(root, b"AnimationStack", get_fbx_uuid_from_key(astack_key)) + astack.add_string(fbx_name_class(name, b"AnimStack")) + astack.add_string(b"") + + astack_tmpl = elem_props_template_init(scene_data.templates, b"AnimationStack") + astack_props = elem_properties(astack) + r = scene_data.scene.render + fps = r.fps / r.fps_base + start = int(convert_sec_to_ktime(f_start / fps)) + end = int(convert_sec_to_ktime(f_end / fps)) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"LocalStart", start) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"LocalStop", end) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"ReferenceStart", start) + elem_props_template_set(astack_tmpl, astack_props, "p_timestamp", b"ReferenceStop", end) + elem_props_template_finalize(astack_tmpl, astack_props) + + # For now, only one layer for all animations. + alayer = elem_data_single_int64(root, b"AnimationLayer", get_fbx_uuid_from_key(alayer_key)) + alayer.add_string(fbx_name_class(name, b"AnimLayer")) + alayer.add_string(b"") + + for ob_obj, (alayer_key, acurvenodes) in alayers.items(): + # Animation layer. + # alayer = elem_data_single_int64(root, b"AnimationLayer", get_fbx_uuid_from_key(alayer_key)) + # alayer.add_string(fbx_name_class(ob_obj.name.encode(), b"AnimLayer")) + # alayer.add_string(b"") + + for fbx_prop, (acurvenode_key, acurves, acurvenode_name) in acurvenodes.items(): + # Animation curve node. + acurvenode = elem_data_single_int64(root, b"AnimationCurveNode", get_fbx_uuid_from_key(acurvenode_key)) + acurvenode.add_string(fbx_name_class(acurvenode_name.encode(), b"AnimCurveNode")) + acurvenode.add_string(b"") + + acn_tmpl = elem_props_template_init(scene_data.templates, b"AnimationCurveNode") + acn_props = elem_properties(acurvenode) + + for fbx_item, (acurve_key, def_value, (keys, values), _acurve_valid) in acurves.items(): + elem_props_template_set(acn_tmpl, acn_props, "p_number", fbx_item.encode(), + def_value, animatable=True) + + # Only create Animation curve if needed! + nbr_keys = len(keys) + if nbr_keys: + acurve = elem_data_single_int64(root, b"AnimationCurve", get_fbx_uuid_from_key(acurve_key)) + acurve.add_string(fbx_name_class(b"", b"AnimCurve")) + acurve.add_string(b"") + + # key attributes... + # flags... + keyattr_flags = ( + 1 << 2 | # interpolation mode, 1 = constant, 2 = linear, 3 = cubic. + 1 << 8 | # tangent mode, 8 = auto, 9 = TCB, 10 = user, 11 = generic break, + 1 << 13 | # tangent mode, 12 = generic clamp, 13 = generic time independent, + 1 << 14 | # tangent mode, 13 + 14 = generic clamp progressive. + 0, + ) + # Maybe values controlling TCB & co??? + keyattr_datafloat = (0.0, 0.0, 9.419963346924634e-30, 0.0) + + # And now, the *real* data! + elem_data_single_float64(acurve, b"Default", def_value) + elem_data_single_int32(acurve, b"KeyVer", FBX_ANIM_KEY_VERSION) + elem_data_single_int64_array(acurve, b"KeyTime", astype_view_signedness(keys, np.int64)) + elem_data_single_float32_array(acurve, b"KeyValueFloat", values.astype(np.float32, copy=False)) + elem_data_single_int32_array(acurve, b"KeyAttrFlags", keyattr_flags) + elem_data_single_float32_array(acurve, b"KeyAttrDataFloat", keyattr_datafloat) + elem_data_single_int32_array(acurve, b"KeyAttrRefCount", (nbr_keys,)) + + elem_props_template_finalize(acn_tmpl, acn_props) + + +# ##### Top-level FBX data container. ##### + +# Mapping Blender -> FBX (principled_socket_name, fbx_name). +PRINCIPLED_TEXTURE_SOCKETS_TO_FBX = ( + # ("diffuse", "diffuse", b"DiffuseFactor"), + ("base_color_texture", b"DiffuseColor"), + ("alpha_texture", b"TransparencyFactor"), # Will be inverted in fact, not much we can do really... + # ("base_color_texture", b"TransparentColor"), # Uses diffuse color in Blender! + ("emission_strength_texture", b"EmissiveFactor"), + ("emission_color_texture", b"EmissiveColor"), + # ("ambient", "ambient", b"AmbientFactor"), + # ("", "", b"AmbientColor"), # World stuff in Blender, for now ignore... + ("normalmap_texture", b"NormalMap"), + # Note: unsure about those... :/ + # ("", "", b"Bump"), + # ("", "", b"BumpFactor"), + # ("", "", b"DisplacementColor"), + # ("", "", b"DisplacementFactor"), + ("specular_texture", b"SpecularFactor"), + # ("base_color", b"SpecularColor"), # TODO: use tint? + # See Material template about those two! + ("roughness_texture", b"Shininess"), + ("roughness_texture", b"ShininessExponent"), + # ("mirror", "mirror", b"ReflectionColor"), + ("metallic_texture", b"ReflectionFactor"), +) + + +def fbx_skeleton_from_armature(scene, settings, arm_obj, objects, data_meshes, + data_bones, data_deformers_skin, data_empties, arm_parents): + """ + Create skeleton from armature/bones (NodeAttribute/LimbNode and Model/LimbNode), and for each deformed mesh, + create Pose/BindPose(with sub PoseNode) and Deformer/Skin(with Deformer/SubDeformer/Cluster). + Also supports "parent to bone" (simple parent to Model/LimbNode). + arm_parents is a set of tuples (armature, object) for all successful armature bindings. + """ + # We need some data for our armature 'object' too!!! + data_empties[arm_obj] = get_blender_empty_key(arm_obj.bdata) + + arm_data = arm_obj.bdata.data + bones = {} + for bo in arm_obj.bones: + if settings.use_armature_deform_only: + if bo.bdata.use_deform: + bones[bo] = True + bo_par = bo.parent + while bo_par.is_bone: + bones[bo_par] = True + bo_par = bo_par.parent + elif bo not in bones: # Do not override if already set in the loop above! + bones[bo] = False + else: + bones[bo] = True + + bones = {bo: None for bo, use in bones.items() if use} + + if not bones: + return + + data_bones.update((bo, get_blender_bone_key(arm_obj.bdata, bo.bdata)) for bo in bones) + + for ob_obj in objects: + if not ob_obj.is_deformed_by_armature(arm_obj): + continue + + # Always handled by an Armature modifier... + found = False + for mod in ob_obj.bdata.modifiers: + if mod.type not in {'ARMATURE'} or not mod.object: + continue + # We only support vertex groups binding method, not bone envelopes one! + if mod.object == arm_obj.bdata and mod.use_vertex_groups: + found = True + break + + if not found: + continue + + # Now we have a mesh using this armature. + # Note: bind-pose have no relations at all (no connections), so no need for any preprocess for them. + # Create skin & clusters relations (note skins are connected to geometry, *not* model!). + _key, me, _free = data_meshes[ob_obj] + clusters = {bo: get_blender_bone_cluster_key(arm_obj.bdata, me, bo.bdata) for bo in bones} + data_deformers_skin.setdefault(arm_obj, {})[me] = (get_blender_armature_skin_key(arm_obj.bdata, me), + ob_obj, clusters) + + # We don't want a regular parent relationship for those in FBX... + arm_parents.add((arm_obj, ob_obj)) + # Needed to handle matrices/spaces (since we do not parent them to 'armature' in FBX :/ ). + ob_obj.parented_to_armature = True + + objects.update(bones) + + +def fbx_generate_leaf_bones(settings, data_bones): + # Find which bones have no children. + child_count = {bo: 0 for bo in data_bones.keys()} + for bo in data_bones.keys(): + if bo.parent and bo.parent.is_bone: + child_count[bo.parent] += 1 + + bone_radius_scale = settings.global_scale * 33.0 + + # generate bone data + leaf_parents = [bo for bo, count in child_count.items() if count == 0] + leaf_bones = [] + for parent in leaf_parents: + node_name = parent.name + "_end" + parent_uuid = parent.fbx_uuid + parent_key = parent.key + node_uuid = get_fbx_uuid_from_key(parent_key + "_end_node") + attr_uuid = get_fbx_uuid_from_key(parent_key + "_end_nodeattr") + + hide = parent.hide + size = parent.bdata.head_radius * bone_radius_scale + bone_length = (parent.bdata.tail_local - parent.bdata.head_local).length + matrix = Matrix.Translation((0, bone_length, 0)) + if settings.bone_correction_matrix_inv: + matrix = settings.bone_correction_matrix_inv @ matrix + if settings.bone_correction_matrix: + matrix = matrix @ settings.bone_correction_matrix + leaf_bones.append((node_name, parent_uuid, node_uuid, attr_uuid, matrix, hide, size)) + + return leaf_bones + + +def fbx_animations_do(scene_data, ref_id, f_start, f_end, start_zero, objects=None, force_keep=False): + """ + Generate animation data (a single AnimStack) from objects, for a given frame range. + """ + bake_step = scene_data.settings.bake_anim_step + simplify_fac = scene_data.settings.bake_anim_simplify_factor + scene = scene_data.scene + depsgraph = scene_data.depsgraph + force_keying = scene_data.settings.bake_anim_use_all_bones + force_sek = scene_data.settings.bake_anim_force_startend_keying + gscale = scene_data.settings.global_scale + + if objects is not None: + # Add bones and duplis! + for ob_obj in tuple(objects): + if not ob_obj.is_object: + continue + if ob_obj.type == 'ARMATURE': + objects |= {bo_obj for bo_obj in ob_obj.bones if bo_obj in scene_data.objects} + for dp_obj in ob_obj.dupli_list_gen(depsgraph): + if dp_obj in scene_data.objects: + objects.add(dp_obj) + else: + objects = scene_data.objects + + back_currframe = scene.frame_current + animdata_ob = {} + p_rots = {} + + for ob_obj in objects: + if ob_obj.parented_to_armature: + continue + ACNW = AnimationCurveNodeWrapper + loc, rot, scale, _m, _mr = ob_obj.fbx_object_tx(scene_data) + rot_deg = tuple(convert_rad_to_deg_iter(rot)) + force_key = (simplify_fac == 0.0) or (ob_obj.is_bone and force_keying) + animdata_ob[ob_obj] = (ACNW(ob_obj.key, 'LCL_TRANSLATION', force_key, force_sek, loc), + ACNW(ob_obj.key, 'LCL_ROTATION', force_key, force_sek, rot_deg), + ACNW(ob_obj.key, 'LCL_SCALING', force_key, force_sek, scale)) + p_rots[ob_obj] = rot + + force_key = (simplify_fac == 0.0) + animdata_shapes = {} + + for me, (me_key, _shapes_key, shapes) in scene_data.data_deformers_shape.items(): + # Ignore absolute shape keys for now! + if not me.shape_keys.use_relative: + continue + for shape, (channel_key, geom_key, _shape_verts_co, _shape_verts_nors, _shape_verts_idx) in shapes.items(): + acnode = AnimationCurveNodeWrapper(channel_key, 'SHAPE_KEY', force_key, force_sek, (0.0,)) + # Sooooo happy to have to twist again like a mad snake... Yes, we need to write those curves twice. :/ + acnode.add_group(me_key, shape.name, shape.name, (shape.name,)) + animdata_shapes[channel_key] = (acnode, me, shape) + + animdata_cameras = {} + for cam_obj, cam_key in scene_data.data_cameras.items(): + cam = cam_obj.bdata.data + acnode_lens = AnimationCurveNodeWrapper(cam_key, 'CAMERA_FOCAL', force_key, force_sek, (cam.lens,)) + acnode_focus_distance = AnimationCurveNodeWrapper(cam_key, 'CAMERA_FOCUS_DISTANCE', force_key, + force_sek, (cam.dof.focus_distance,)) + animdata_cameras[cam_key] = (acnode_lens, acnode_focus_distance, cam) + + # Get all parent bdata of animated dupli instances, so that we can quickly identify which instances in + # `depsgraph.object_instances` are animated and need their ObjectWrappers' matrices updated each frame. + dupli_parent_bdata = {dup.get_parent().bdata for dup in animdata_ob if dup.is_dupli} + has_animated_duplis = bool(dupli_parent_bdata) + + # Initialize keyframe times array. Each AnimationCurveNodeWrapper will share the same instance. + # `np.arange` excludes the `stop` argument like when using `range`, so we use np.nextafter to get the next + # representable value after f_end and use that as the `stop` argument instead. + currframes = np.arange(f_start, np.nextafter(f_end, np.inf), step=bake_step) + + # Convert from Blender time to FBX time. + fps = scene.render.fps / scene.render.fps_base + real_currframes = currframes - f_start if start_zero else currframes + real_currframes = (real_currframes / fps * FBX_KTIME).astype(np.int64) + + # Generator that yields the animated values of each frame in order. + def frame_values_gen(): + # Precalculate integer frames and subframes. + int_currframes = currframes.astype(int) + subframes = currframes - int_currframes + + # Create simpler iterables that return only the values we care about. + animdata_shapes_only = [shape for _anim_shape, _me, shape in animdata_shapes.values()] + animdata_cameras_only = [camera for _anim_camera_lens, _anim_camera_focus_distance, camera + in animdata_cameras.values()] + # Previous frame's rotation for each object in animdata_ob, this will be updated each frame. + animdata_ob_p_rots = p_rots.values() + + # Iterate through each frame and yield the values for that frame. + # Iterating .data, the memoryview of an array, is faster than iterating the array directly. + for int_currframe, subframe in zip(int_currframes.data, subframes.data): + scene.frame_set(int_currframe, subframe=subframe) + + if has_animated_duplis: + # Changing the scene's frame invalidates existing dupli instances. To get the updated matrices of duplis + # for this frame, we must get the duplis from the depsgraph again. + for dup in depsgraph.object_instances: + if (parent := dup.parent) and parent.original in dupli_parent_bdata: + # ObjectWrapper caches its instances. Attempting to create a new instance updates the existing + # ObjectWrapper instance with the current frame's matrix and then returns the existing instance. + ObjectWrapper(dup) + next_p_rots = [] + for ob_obj, p_rot in zip(animdata_ob, animdata_ob_p_rots): + # We compute baked loc/rot/scale for all objects (rot being euler-compat with previous value!). + loc, rot, scale, _m, _mr = ob_obj.fbx_object_tx(scene_data, rot_euler_compat=p_rot) + next_p_rots.append(rot) + yield from loc + yield from rot + yield from scale + animdata_ob_p_rots = next_p_rots + for shape in animdata_shapes_only: + yield shape.value + for camera in animdata_cameras_only: + yield camera.lens + yield camera.dof.focus_distance + + # Providing `count` to np.fromiter pre-allocates the array, avoiding extra memory allocations while iterating. + num_ob_values = len(animdata_ob) * 9 # Location, rotation and scale, each of which have x, y, and z components + num_shape_values = len(animdata_shapes) # Only 1 value per shape key + num_camera_values = len(animdata_cameras) * 2 # Focal length (`.lens`) and focus distance + num_values_per_frame = num_ob_values + num_shape_values + num_camera_values + num_frames = len(real_currframes) + all_values_flat = np.fromiter(frame_values_gen(), dtype=float, count=num_frames * num_values_per_frame) + + # Restore the scene's current frame. + scene.frame_set(back_currframe, subframe=0.0) + + # View such that each column is all values for a single frame and each row is all values for a single curve. + all_values = all_values_flat.reshape(num_frames, num_values_per_frame).T + # Split into views of the arrays for each curve type. + split_at = [num_ob_values, num_shape_values, num_camera_values] + # For unequal sized splits, np.split takes indices to split at, which can be acquired through a cumulative sum + # across the list. + # The last value isn't needed, because the last split is assumed to go to the end of the array. + split_at = np.cumsum(split_at[:-1]) + all_ob_values, all_shape_key_values, all_camera_values = np.split(all_values, split_at) + + all_anims = [] + + # Set location/rotation/scale curves. + # Split into equal sized views of the arrays for each object. + split_into = len(animdata_ob) + per_ob_values = np.split(all_ob_values, split_into) if split_into > 0 else () + for anims, ob_values in zip(animdata_ob.values(), per_ob_values): + # Split again into equal sized views of the location, rotation and scaling arrays. + loc_xyz, rot_xyz, sca_xyz = np.split(ob_values, 3) + # In-place convert from Blender rotation to FBX rotation. + np.rad2deg(rot_xyz, out=rot_xyz) + + anim_loc, anim_rot, anim_scale = anims + anim_loc.set_keyframes(real_currframes, loc_xyz) + anim_rot.set_keyframes(real_currframes, rot_xyz) + anim_scale.set_keyframes(real_currframes, sca_xyz) + all_anims.extend(anims) + + # Set shape key curves. + # There's only one array per shape key, so there's no need to split `all_shape_key_values`. + for (anim_shape, _me, _shape), shape_key_values in zip(animdata_shapes.values(), all_shape_key_values): + # In-place convert from Blender Shape Key Value to FBX Deform Percent. + shape_key_values *= 100.0 + anim_shape.set_keyframes(real_currframes, shape_key_values) + all_anims.append(anim_shape) + + # Set camera curves. + # Split into equal sized views of the arrays for each camera. + split_into = len(animdata_cameras) + per_camera_values = np.split(all_camera_values, split_into) if split_into > 0 else () + zipped = zip(animdata_cameras.values(), per_camera_values) + for (anim_camera_lens, anim_camera_focus_distance, _camera), (lens_values, focus_distance_values) in zipped: + # In-place convert from Blender focus distance to FBX. + focus_distance_values *= (1000 * gscale) + anim_camera_lens.set_keyframes(real_currframes, lens_values) + anim_camera_focus_distance.set_keyframes(real_currframes, focus_distance_values) + all_anims.append(anim_camera_lens) + all_anims.append(anim_camera_focus_distance) + + animations = {} + + # And now, produce final data (usable by FBX export code) + for anim in all_anims: + anim.simplify(simplify_fac, bake_step, force_keep) + if not anim: + continue + for obj_key, group_key, group, fbx_group, fbx_gname in anim.get_final_data(scene, ref_id, force_keep): + anim_data = animations.setdefault(obj_key, ("dummy_unused_key", {})) + anim_data[1][fbx_group] = (group_key, group, fbx_gname) + + astack_key = get_blender_anim_stack_key(scene, ref_id) + alayer_key = get_blender_anim_layer_key(scene, ref_id) + name = (get_blenderID_name(ref_id) if ref_id else scene.name).encode() + + if start_zero: + f_end -= f_start + f_start = 0.0 + + return (astack_key, animations, alayer_key, name, f_start, f_end) if animations else None + + +def fbx_animations(scene_data): + """ + Generate global animation data from objects. + """ + scene = scene_data.scene + animations = [] + animated = set() + frame_start = 1e100 + frame_end = -1e100 + + def add_anim(animations, animated, anim): + nonlocal frame_start, frame_end + if anim is not None: + animations.append(anim) + f_start, f_end = anim[4:6] + if f_start < frame_start: + frame_start = f_start + if f_end > frame_end: + frame_end = f_end + + _astack_key, astack, _alayer_key, _name, _fstart, _fend = anim + for elem_key, (alayer_key, acurvenodes) in astack.items(): + for fbx_prop, (acurvenode_key, acurves, acurvenode_name) in acurvenodes.items(): + animated.add((elem_key, fbx_prop)) + + # Per-NLA strip animstacks. + if scene_data.settings.bake_anim_use_nla_strips: + strips = [] + ob_actions = [] + for ob_obj in scene_data.objects: + # NLA tracks only for objects, not bones! + if not ob_obj.is_object: + continue + ob = ob_obj.bdata # Back to real Blender Object. + if not ob.animation_data: + continue + + # Some actions are read-only, one cause is being in NLA tweak-mode. + restore_use_tweak_mode = ob.animation_data.use_tweak_mode + if ob.animation_data.is_property_readonly('action'): + ob.animation_data.use_tweak_mode = False + + # We have to remove active action from objects, it overwrites strips actions otherwise... + ob_actions.append((ob, ob.animation_data.action, restore_use_tweak_mode)) + ob.animation_data.action = None + for track in ob.animation_data.nla_tracks: + if track.mute: + continue + for strip in track.strips: + if strip.mute: + continue + strips.append(strip) + strip.mute = True + + for strip in strips: + strip.mute = False + add_anim(animations, animated, + fbx_animations_do(scene_data, strip, strip.frame_start, strip.frame_end, True, force_keep=True)) + strip.mute = True + scene.frame_set(scene.frame_current, subframe=0.0) + + for strip in strips: + strip.mute = False + + for ob, ob_act, restore_use_tweak_mode in ob_actions: + ob.animation_data.action = ob_act + ob.animation_data.use_tweak_mode = restore_use_tweak_mode + + # All actions. + if scene_data.settings.bake_anim_use_all_actions: + def find_validate_action_slot(act, path_resolve) -> bpy.types.ActionSlot | None: + for layer in act.layers: + for strip in layer.strips: + for channelbag in strip.channelbags: + if not channelbag.fcurves: + # Do not export empty Channelbags. + continue + for fc in channelbag.fcurves: + data_path = fc.data_path + if fc.array_index: + data_path = data_path + "[%d]" % fc.array_index + try: + path_resolve(data_path) + except ValueError: + break # Invalid, go to next strip. + else: + # Did not 'break', so all F-Curves are valid. + return channelbag.slot + return None # Found nothing to return. + + def restore_object(ob_to, ob_from): + # Restore org state of object (ugh :/ ). + props = ( + 'location', 'rotation_quaternion', 'rotation_axis_angle', 'rotation_euler', 'rotation_mode', 'scale', + 'delta_location', 'delta_rotation_euler', 'delta_rotation_quaternion', 'delta_scale', + 'lock_location', 'lock_rotation', 'lock_rotation_w', 'lock_rotations_4d', 'lock_scale', + 'tag', 'track_axis', 'up_axis', 'active_material', 'active_material_index', + 'matrix_parent_inverse', 'empty_display_type', 'empty_display_size', 'empty_image_offset', 'pass_index', + 'color', 'hide_viewport', 'hide_select', 'hide_render', 'instance_type', + 'use_instance_vertices_rotation', 'use_instance_faces_scale', 'instance_faces_scale', + 'display_type', 'show_bounds', 'display_bounds_type', 'show_name', 'show_axis', 'show_texture_space', + 'show_wire', 'show_all_edges', 'show_transparent', 'show_in_front', + 'show_only_shape_key', 'use_shape_key_edit_mode', 'active_shape_key_index', + ) + for p in props: + if not ob_to.is_property_readonly(p): + setattr(ob_to, p, getattr(ob_from, p)) + + for ob_obj in scene_data.objects: + # Actions only for objects, not bones! + if not ob_obj.is_object: + continue + + ob = ob_obj.bdata # Back to real Blender Object. + + if not ob.animation_data: + continue # Do not export animations for objects that are absolutely not animated, see T44386. + + if ob.animation_data.is_property_readonly('action'): + continue # Cannot re-assign 'active action' to this object (usually related to NLA usage, see T48089). + + # We can't play with animdata and actions and get back to org state easily. + # So we have to add a temp copy of the object to the scene, animate it, and remove it... :/ + ob_copy = ob.copy() + # Great, have to handle bones as well if needed... + pbones_matrices = [pbo.matrix_basis.copy() for pbo in ob.pose.bones] if ob.type == 'ARMATURE' else ... + + org_act = ob.animation_data.action + org_act_slot = ob.animation_data.action_slot + path_resolve = ob.path_resolve + + for act in bpy.data.actions: + # For now, *all* paths in the action must be valid for the object, to validate the action. + # Unless that action was already assigned to the object! + if act == org_act: + act_slot = org_act_slot + else: + act_slot = find_validate_action_slot(act, path_resolve) + if not act_slot: + continue + ob.animation_data.action = act + ob.animation_data.action_slot = act_slot + frame_start, frame_end = act.frame_range # sic! + add_anim(animations, animated, + fbx_animations_do(scene_data, (ob, act), frame_start, frame_end, True, + objects={ob_obj}, force_keep=True)) + # Ugly! :/ + if pbones_matrices is not ...: + for pbo, mat in zip(ob.pose.bones, pbones_matrices): + pbo.matrix_basis = mat.copy() + ob.animation_data.action = org_act + if org_act: + ob.animation_data.action_slot = org_act_slot + restore_object(ob, ob_copy) + scene.frame_set(scene.frame_current, subframe=0.0) + + if pbones_matrices is not ...: + for pbo, mat in zip(ob.pose.bones, pbones_matrices): + pbo.matrix_basis = mat.copy() + ob.animation_data.action = org_act + if org_act: + ob.animation_data.action_slot = org_act_slot + + bpy.data.objects.remove(ob_copy) + scene.frame_set(scene.frame_current, subframe=0.0) + + # Global (containing everything) animstack, only if not exporting NLA strips and/or all actions. + if not scene_data.settings.bake_anim_use_nla_strips and not scene_data.settings.bake_anim_use_all_actions: + add_anim(animations, animated, fbx_animations_do(scene_data, None, scene.frame_start, scene.frame_end, False)) + + # Be sure to update all matrices back to org state! + scene.frame_set(scene.frame_current, subframe=0.0) + + return animations, animated, frame_start, frame_end + + +def fbx_data_from_scene(scene, depsgraph, settings): + """ + Do some pre-processing over scene's data... + """ + objtypes = settings.object_types + dp_objtypes = objtypes - {'ARMATURE'} # Armatures are not supported as dupli instances currently... + perfmon = PerfMon() + perfmon.level_up() + + # ##### Gathering data... + + perfmon.step("FBX export prepare: Wrapping Objects...") + + # This is rather simple for now, maybe we could end generating templates with most-used values + # instead of default ones? + objects = {} # Because we do not have any ordered set... + for ob in settings.context_objects: + if ob.type not in objtypes: + continue + ob_obj = ObjectWrapper(ob) + objects[ob_obj] = None + # Duplis... + for dp_obj in ob_obj.dupli_list_gen(depsgraph): + if dp_obj.type not in dp_objtypes: + continue + objects[dp_obj] = None + + perfmon.step("FBX export prepare: Wrapping Data (lamps, cameras, empties)...") + + data_lights = {ob_obj.bdata.data: get_blenderID_key(ob_obj.bdata.data) + for ob_obj in objects if ob_obj.type == 'LIGHT'} + # Unfortunately, FBX camera data contains object-level data (like position, orientation, etc.)... + data_cameras = {ob_obj: get_blenderID_key(ob_obj.bdata.data) + for ob_obj in objects if ob_obj.type == 'CAMERA'} + # Yep! Contains nothing, but needed! + data_empties = {ob_obj: get_blender_empty_key(ob_obj.bdata) + for ob_obj in objects if ob_obj.type == 'EMPTY'} + + perfmon.step("FBX export prepare: Wrapping Meshes...") + + data_meshes = {} + for ob_obj in objects: + if ob_obj.type not in BLENDER_OBJECT_TYPES_MESHLIKE: + continue + ob = ob_obj.bdata + org_ob_obj = None + + # Do not want to systematically recreate a new mesh for dupli-object instances, kind of break purpose of those. + if ob_obj.is_dupli: + org_ob_obj = ObjectWrapper(ob) # We get the "real" object wrapper from that dupli instance. + if org_ob_obj in data_meshes: + data_meshes[ob_obj] = data_meshes[org_ob_obj] + continue + + # There are 4 different cases for what we need to do with the original data of each Object: + # 1) The original data can be used without changes. + # 2) A copy of the original data needs to be made. + # - If an export option modifies the data, e.g. Triangulate Faces is enabled. + # - If the Object has Object-linked materials. This is because our current mapping of materials to FBX requires + # that multiple Objects sharing a single mesh must have the same materials. + # 3) The Object needs to be converted to a mesh. + # - All mesh-like Objects that are not meshes need to be converted to a mesh in order to be exported. + # 4) The Object needs to be evaluated and then converted to a mesh. + # - Whenever use_mesh_modifiers is enabled and either there are modifiers to apply or the Object needs to be + # converted to a mesh. + # If multiple cases apply to an Object, then only the last applicable case is relevant. + do_copy = any(ms.link == 'OBJECT' for ms in ob.material_slots) or settings.use_triangles + do_convert = ob.type in BLENDER_OTHER_OBJECT_TYPES + do_evaluate = do_convert and settings.use_mesh_modifiers + + # If the Object is a mesh, and we're applying modifiers, check if there are actually any modifiers to apply. + # If there are then the mesh will need to be evaluated, and we may need to make some temporary changes to the + # modifiers or scene before the mesh is evaluated. + backup_pose_positions = [] + tmp_mods = [] + if ob.type == 'MESH' and settings.use_mesh_modifiers: + # No need to create a new mesh in this case, if no modifier is active! + last_subsurf = None + for mod in ob.modifiers: + # For meshes, when armature export is enabled, disable Armature modifiers here! + # XXX Temp hacks here since currently we only have access to a viewport depsgraph... + # + # NOTE: We put armature to the rest pose instead of disabling it so we still + # have vertex groups in the evaluated mesh. + if mod.type == 'ARMATURE' and 'ARMATURE' in settings.object_types: + object = mod.object + if object and object.type == 'ARMATURE': + armature = object.data + # If armature is already in REST position, there's nothing to back-up + # This cuts down on export time dramatically, if all armatures are already in REST position + # by not triggering dependency graph update + if armature.pose_position != 'REST': + backup_pose_positions.append((armature, armature.pose_position)) + armature.pose_position = 'REST' + elif mod.show_render or mod.show_viewport: + # If exporting with subsurf collect the last Catmull-Clark subsurf modifier + # and disable it. We can use the original data as long as this is the first + # found applicable subsurf modifier. + if settings.use_subsurf and mod.type == 'SUBSURF' and mod.subdivision_type == 'CATMULL_CLARK': + if last_subsurf: + do_evaluate = True + last_subsurf = mod + else: + do_evaluate = True + if settings.use_subsurf and last_subsurf: + # XXX: When exporting with subsurf information temporarily disable + # the last subsurf modifier. + tmp_mods.append((last_subsurf, last_subsurf.show_render, last_subsurf.show_viewport)) + last_subsurf.show_render = False + last_subsurf.show_viewport = False + + if do_evaluate: + # If modifiers has been altered need to update dependency graph. + if backup_pose_positions or tmp_mods: + depsgraph.update() + ob_to_convert = ob.evaluated_get(depsgraph) + # NOTE: The dependency graph might be re-evaluating multiple times, which could + # potentially free the mesh created early on. So we put those meshes to bmain and + # free them afterwards. Not ideal but ensures correct ownership. + # This also converts non-mesh Objects to Mesh data. + tmp_me = bpy.data.meshes.new_from_object( + ob_to_convert, preserve_all_data_layers=True, depsgraph=depsgraph) + + # Usually the materials of the evaluated Object converted to a Mesh will be the same as the original + # Object, but modifiers, such as Geometry Nodes, can change the materials. + orig_mats = [slot.material for slot in ob.material_slots] + eval_mats = list(tmp_me.materials) + if orig_mats != eval_mats: + # An object-linked material slot replaces the material on the data at the slot's index. If applying + # modifiers changes the materials on the data, the object-linked material slot will replace the new + # material at the same index as before. + for i, slot in zip(range(len(eval_mats)), ob.material_slots): + if slot.link == 'OBJECT': + eval_mats[i] = slot.material + # Override the default behavior of getting materials from `ob_obj.bdata.material_slots`. + ob_obj.override_materials = tuple(eval_mats) + elif do_convert: + tmp_me = bpy.data.meshes.new_from_object(ob, preserve_all_data_layers=True, depsgraph=depsgraph) + elif do_copy: + # bpy.data.meshes.new_from_object removes shape keys (see #104714), so create a copy of the mesh instead. + tmp_me = ob.data.copy() + else: + tmp_me = None + + if tmp_me is None: + # Use the original data of this Object. + data_meshes[ob_obj] = (get_blenderID_key(ob.data), ob.data, False) + else: + # Triangulate the mesh if requested + if settings.use_triangles: + import bmesh + bm = bmesh.new() + bm.from_mesh(tmp_me) + bmesh.ops.triangulate(bm, faces=bm.faces) + bm.to_mesh(tmp_me) + bm.free() + # A temporary mesh was created for this Object, which should be deleted once the export is complete. + data_meshes[ob_obj] = (get_blenderID_key(tmp_me), tmp_me, True) + + # Change armatures back. + for armature, pose_position in backup_pose_positions: + print((armature, pose_position)) + armature.pose_position = pose_position + # Update now, so we don't leave modified state after last object was exported. + # Re-enable temporary disabled modifiers. + for mod, show_render, show_viewport in tmp_mods: + mod.show_render = show_render + mod.show_viewport = show_viewport + if backup_pose_positions or tmp_mods: + depsgraph.update() + + # In case "real" source object of that dupli did not yet still existed in data_meshes, create it now! + if org_ob_obj is not None: + data_meshes[org_ob_obj] = data_meshes[ob_obj] + + perfmon.step("FBX export prepare: Wrapping ShapeKeys...") + + # ShapeKeys. + data_deformers_shape = {} + geom_mat_co = settings.global_matrix if settings.bake_space_transform else None + co_bl_dtype = np.single + co_fbx_dtype = np.float64 + idx_fbx_dtype = np.int32 + normal_bl_dtype = np.single + normal_fbx_dtype = np.float64 + geom_mat_no = Matrix(settings.global_matrix_inv_transposed) if settings.bake_space_transform else None + if geom_mat_no is not None: + # Remove translation & scaling! + geom_mat_no.translation = Vector() + geom_mat_no.normalize() + + def empty_verts_fallbacks(): + """Create fallback arrays for when there are no verts""" + # FBX does not like empty shapes (makes Unity crash e.g.). + # To prevent this, we add a vertex that does nothing, but it keeps the shape key intact + single_vert_co = np.zeros((1, 3), dtype=co_fbx_dtype) + single_vert_nor = np.zeros((1, 3), dtype=co_fbx_dtype) + single_vert_idx = np.zeros(1, dtype=idx_fbx_dtype) + return single_vert_co, single_vert_nor, single_vert_idx + + for me_key, me, _free in data_meshes.values(): + # We do not want basis-only relative shape-keys. + if not (me.shape_keys and len(me.shape_keys.key_blocks) > 1): + continue + if me in data_deformers_shape: + continue + + shapes_key = get_blender_mesh_shape_key(me) + + sk_base = me.shape_keys.key_blocks[0] + + # Get and cache only the cos that we need + @cache + def sk_cos_nors(shape_key): + if shape_key == sk_base: + _cos = MESH_ATTRIBUTE_POSITION.to_ndarray(me.attributes) + else: + _cos = np.empty(len(me.vertices) * 3, dtype=co_bl_dtype) + shape_key.points.foreach_get("co", _cos) + _nors = np.array(shape_key.normals_vertex_get(), dtype=normal_bl_dtype) + return ( + vcos_transformed(_cos, geom_mat_co, co_fbx_dtype), + nors_transformed(_nors, geom_mat_no, normal_fbx_dtype) + ) + + for shape in me.shape_keys.key_blocks[1:]: + # Only write vertices really different from base coordinates! + relative_key = shape.relative_key + if shape == relative_key: + # Shape is its own relative key, so it does nothing + shape_verts_co, shape_verts_nors, shape_verts_idx = empty_verts_fallbacks() + else: + sv_cos_nors = sk_cos_nors(shape) + ref_cos_nors = sk_cos_nors(shape.relative_key) + + # Exclude cos similar to ref_cos and get the indices of the cos that remain + shape_verts_co, shape_verts_nors, shape_verts_idx = shape_difference_exclude_similar( + sv_cos_nors, ref_cos_nors) + + if not shape_verts_co.size: + shape_verts_co, shape_verts_nors, shape_verts_idx = empty_verts_fallbacks() + else: + # Ensure the indices are of the correct type + shape_verts_idx = astype_view_signedness(shape_verts_idx, idx_fbx_dtype) + + channel_key, geom_key = get_blender_mesh_shape_channel_key(me, shape) + data = (channel_key, geom_key, shape_verts_co, shape_verts_nors, shape_verts_idx) + data_deformers_shape.setdefault(me, (me_key, shapes_key, {}))[2][shape] = data + + del sk_cos_nors + + perfmon.step("FBX export prepare: Wrapping Armatures...") + + # Armatures! + data_deformers_skin = {} + data_bones = {} + arm_parents = set() + for ob_obj in tuple(objects): + if not (ob_obj.is_object and ob_obj.type in {'ARMATURE'}): + continue + fbx_skeleton_from_armature(scene, settings, ob_obj, objects, data_meshes, + data_bones, data_deformers_skin, data_empties, arm_parents) + + # Generate leaf bones + data_leaf_bones = [] + if settings.add_leaf_bones: + data_leaf_bones = fbx_generate_leaf_bones(settings, data_bones) + + perfmon.step("FBX export prepare: Wrapping World...") + + # Some world settings are embedded in FBX materials... + if scene.world: + data_world = {scene.world: get_blenderID_key(scene.world)} + else: + data_world = {} + + perfmon.step("FBX export prepare: Wrapping Materials...") + + # TODO: Check all the material stuff works even when they are linked to Objects + # (we can then have the same mesh used with different materials...). + # *Should* work, as FBX always links its materials to Models (i.e. objects). + # XXX However, material indices would probably break... + data_materials = {} + for ob_obj in objects: + # If obj is not a valid object for materials, wrapper will just return an empty tuple... + for ma in ob_obj.materials: + if ma is None: + continue # Empty slots! + # Note theoretically, FBX supports any kind of materials, even GLSL shaders etc. + # However, I doubt anything else than Lambert/Phong is really portable! + # Note we want to keep a 'dummy' empty material even when we can't really support it, see T41396. + ma_data = data_materials.setdefault(ma, (get_blenderID_key(ma), [])) + ma_data[1].append(ob_obj) + + perfmon.step("FBX export prepare: Wrapping Textures...") + + # Note FBX textures also hold their mapping info. + # TODO: Support layers? + data_textures = {} + # FbxVideo also used to store static images... + data_videos = {} + # For now, do not use world textures, don't think they can be linked to anything FBX wise... + for ma in data_materials.keys(): + # Note: with nodal shaders, we'll could be generating much more textures, but that's kind of unavoidable, + # given that textures actually do not exist anymore in material context in Blender... + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=True) + for sock_name, fbx_name in PRINCIPLED_TEXTURE_SOCKETS_TO_FBX: + tex = getattr(ma_wrap, sock_name) + if tex is None or tex.image is None: + continue + blender_tex_key = (ma, sock_name) + data_textures[blender_tex_key] = (get_blender_nodetexture_key(*blender_tex_key), fbx_name) + + img = tex.image + vid_data = data_videos.setdefault(img, (get_blenderID_key(img), [])) + vid_data[1].append(blender_tex_key) + + perfmon.step("FBX export prepare: Wrapping Animations...") + + # Animation... + animations = () + animated = set() + frame_start = scene.frame_start + frame_end = scene.frame_end + if settings.bake_anim: + # From objects & bones only for a start. + # Kind of hack, we need a temp scene_data for object's space handling to bake animations... + tmp_scdata = FBXExportData( + None, None, None, + settings, scene, depsgraph, objects, None, None, 0.0, 0.0, + data_empties, data_lights, data_cameras, data_meshes, None, + data_bones, data_leaf_bones, data_deformers_skin, data_deformers_shape, + data_world, data_materials, data_textures, data_videos, + ) + animations, animated, frame_start, frame_end = fbx_animations(tmp_scdata) + + # ##### Creation of templates... + + perfmon.step("FBX export prepare: Generating templates...") + + templates = {} + templates[b"GlobalSettings"] = fbx_template_def_globalsettings(scene, settings, nbr_users=1) + + if data_empties: + templates[b"Null"] = fbx_template_def_null(scene, settings, nbr_users=len(data_empties)) + + if data_lights: + templates[b"Light"] = fbx_template_def_light(scene, settings, nbr_users=len(data_lights)) + + if data_cameras: + templates[b"Camera"] = fbx_template_def_camera(scene, settings, nbr_users=len(data_cameras)) + + if data_bones: + templates[b"Bone"] = fbx_template_def_bone(scene, settings, nbr_users=len(data_bones)) + + if data_meshes: + nbr = len({me_key for me_key, _me, _free in data_meshes.values()}) + if data_deformers_shape: + nbr += sum(len(shapes[2]) for shapes in data_deformers_shape.values()) + templates[b"Geometry"] = fbx_template_def_geometry(scene, settings, nbr_users=nbr) + + if objects: + templates[b"Model"] = fbx_template_def_model(scene, settings, nbr_users=len(objects)) + + if arm_parents: + # Number of Pose|BindPose elements should be the same as number of meshes-parented-to-armatures + templates[b"BindPose"] = fbx_template_def_pose(scene, settings, nbr_users=len(arm_parents)) + + if data_deformers_skin or data_deformers_shape: + nbr = 0 + if data_deformers_skin: + nbr += len(data_deformers_skin) + nbr += sum(len(clusters) for def_me in data_deformers_skin.values() for a, b, clusters in def_me.values()) + if data_deformers_shape: + nbr += len(data_deformers_shape) + nbr += sum(len(shapes[2]) for shapes in data_deformers_shape.values()) + assert nbr != 0 + templates[b"Deformers"] = fbx_template_def_deformer(scene, settings, nbr_users=nbr) + + # No world support in FBX... + """ + if data_world: + templates[b"World"] = fbx_template_def_world(scene, settings, nbr_users=len(data_world)) + """ + + if data_materials: + templates[b"Material"] = fbx_template_def_material(scene, settings, nbr_users=len(data_materials)) + + if data_textures: + templates[b"TextureFile"] = fbx_template_def_texture_file(scene, settings, nbr_users=len(data_textures)) + + if data_videos: + templates[b"Video"] = fbx_template_def_video(scene, settings, nbr_users=len(data_videos)) + + if animations: + nbr_astacks = len(animations) + nbr_acnodes = 0 + nbr_acurves = 0 + for _astack_key, astack, _al, _n, _fs, _fe in animations: + for _alayer_key, alayer in astack.values(): + for _acnode_key, acnode, _acnode_name in alayer.values(): + nbr_acnodes += 1 + for _acurve_key, _dval, (keys, _values), acurve_valid in acnode.values(): + if len(keys): + nbr_acurves += 1 + + templates[b"AnimationStack"] = fbx_template_def_animstack(scene, settings, nbr_users=nbr_astacks) + # Would be nice to have one layer per animated object, but this seems tricky and not that well supported. + # So for now, only one layer per anim stack. + templates[b"AnimationLayer"] = fbx_template_def_animlayer(scene, settings, nbr_users=nbr_astacks) + templates[b"AnimationCurveNode"] = fbx_template_def_animcurvenode(scene, settings, nbr_users=nbr_acnodes) + templates[b"AnimationCurve"] = fbx_template_def_animcurve(scene, settings, nbr_users=nbr_acurves) + + templates_users = sum(tmpl.nbr_users for tmpl in templates.values()) + + # ##### Creation of connections... + + perfmon.step("FBX export prepare: Generating Connections...") + + connections = [] + + # Objects (with classical parenting). + for ob_obj in objects: + # Bones are handled later. + if not ob_obj.is_bone: + par_obj = ob_obj.parent + # Meshes parented to armature are handled separately, yet we want the 'no parent' connection (0). + if par_obj and ob_obj.has_valid_parent(objects) and (par_obj, ob_obj) not in arm_parents: + connections.append((b"OO", ob_obj.fbx_uuid, par_obj.fbx_uuid, None)) + else: + connections.append((b"OO", ob_obj.fbx_uuid, 0, None)) + + # Armature & Bone chains. + for bo_obj in data_bones.keys(): + par_obj = bo_obj.parent + if par_obj not in objects: + continue + connections.append((b"OO", bo_obj.fbx_uuid, par_obj.fbx_uuid, None)) + + # Object data. + for ob_obj in objects: + if ob_obj.is_bone: + bo_data_key = data_bones[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(bo_data_key), ob_obj.fbx_uuid, None)) + else: + if ob_obj.type == 'LIGHT': + light_key = data_lights[ob_obj.bdata.data] + connections.append((b"OO", get_fbx_uuid_from_key(light_key), ob_obj.fbx_uuid, None)) + elif ob_obj.type == 'CAMERA': + cam_key = data_cameras[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(cam_key), ob_obj.fbx_uuid, None)) + elif ob_obj.type == 'EMPTY' or ob_obj.type == 'ARMATURE': + empty_key = data_empties[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(empty_key), ob_obj.fbx_uuid, None)) + elif ob_obj.type in BLENDER_OBJECT_TYPES_MESHLIKE: + mesh_key, _me, _free = data_meshes[ob_obj] + connections.append((b"OO", get_fbx_uuid_from_key(mesh_key), ob_obj.fbx_uuid, None)) + + # Leaf Bones + for (_node_name, par_uuid, node_uuid, attr_uuid, _matrix, _hide, _size) in data_leaf_bones: + connections.append((b"OO", node_uuid, par_uuid, None)) + connections.append((b"OO", attr_uuid, node_uuid, None)) + + # 'Shape' deformers (shape keys, only for meshes currently)... + for me_key, shapes_key, shapes in data_deformers_shape.values(): + # shape -> geometry + connections.append((b"OO", get_fbx_uuid_from_key(shapes_key), get_fbx_uuid_from_key(me_key), None)) + for channel_key, geom_key, _shape_verts_co, _shape_verts_nors, _shape_verts_idx in shapes.values(): + # shape channel -> shape + connections.append((b"OO", get_fbx_uuid_from_key(channel_key), get_fbx_uuid_from_key(shapes_key), None)) + # geometry (keys) -> shape channel + connections.append((b"OO", get_fbx_uuid_from_key(geom_key), get_fbx_uuid_from_key(channel_key), None)) + + # 'Skin' deformers (armature-to-geometry, only for meshes currently)... + for arm, deformed_meshes in data_deformers_skin.items(): + for me, (skin_key, ob_obj, clusters) in deformed_meshes.items(): + # skin -> geometry + mesh_key, _me, _free = data_meshes[ob_obj] + assert me == _me + connections.append((b"OO", get_fbx_uuid_from_key(skin_key), get_fbx_uuid_from_key(mesh_key), None)) + for bo_obj, clstr_key in clusters.items(): + # cluster -> skin + connections.append((b"OO", get_fbx_uuid_from_key(clstr_key), get_fbx_uuid_from_key(skin_key), None)) + # bone -> cluster + connections.append((b"OO", bo_obj.fbx_uuid, get_fbx_uuid_from_key(clstr_key), None)) + + # Materials + mesh_material_indices = {} + for ob_obj in objects: + ob_mat_idx = 0 + me = None + if ob_obj.type in BLENDER_OBJECT_TYPES_MESHLIKE: + _mesh_key, me, _free = data_meshes[ob_obj] + # NOTE: If a mesh has multiple material slots with the same material, they are combined into one + # single connection (slot). + # Even if duplicate materials were exported without combining them into one slot, keeping duplicate + # materials separated does not appear to be common behavior of external software when importing FBX. + # Also, None (empty slots, no material) are always skipped/ignored. + done_materials_for_object = {None} + for ma in ob_obj.materials: + if ma in done_materials_for_object: + continue + done_materials_for_object.add(ma) + ma_key, _ob_objs = data_materials[ma] + connections.append((b"OO", get_fbx_uuid_from_key(ma_key), ob_obj.fbx_uuid, None)) + # Get index of this material for this object (or dupli-object). + # Material indices for mesh faces are determined by their order in 'ma to ob' connections. + # Only materials for meshes currently... + # Note in case of dupli-objects a same me/ma idx will be generated several times... + # Should not be an issue in practice, and it's needed in case we export duplis but not the original! + if ob_obj.type not in BLENDER_OBJECT_TYPES_MESHLIKE: + continue + if ma not in mesh_material_indices.setdefault(me, {}): + mesh_material_indices[me][ma] = ob_mat_idx + else: + print("WARNING: Cannot register a valid material index for '{}' from '{}' mesh, '{}' object. " + "Most likely, different objects using the same mesh, but different material slots layouts." + "".format(ma.name, me.name, ob_obj.name)) + ob_mat_idx += 1 + + # Textures + for (ma, sock_name), (tex_key, fbx_prop) in data_textures.items(): + ma_key, _ob_objs = data_materials[ma] + # texture -> material properties + connections.append((b"OP", get_fbx_uuid_from_key(tex_key), get_fbx_uuid_from_key(ma_key), fbx_prop)) + + # Images + for vid, (vid_key, blender_tex_keys) in data_videos.items(): + for blender_tex_key in blender_tex_keys: + tex_key, _fbx_prop = data_textures[blender_tex_key] + connections.append((b"OO", get_fbx_uuid_from_key(vid_key), get_fbx_uuid_from_key(tex_key), None)) + + # Animations + for astack_key, astack, alayer_key, _name, _fstart, _fend in animations: + # Anim-stack itself is linked nowhere! + astack_id = get_fbx_uuid_from_key(astack_key) + # For now, only one layer! + alayer_id = get_fbx_uuid_from_key(alayer_key) + connections.append((b"OO", alayer_id, astack_id, None)) + for elem_key, (alayer_key, acurvenodes) in astack.items(): + elem_id = get_fbx_uuid_from_key(elem_key) + # Anim-layer -> animstack. + # alayer_id = get_fbx_uuid_from_key(alayer_key) + # connections.append((b"OO", alayer_id, astack_id, None)) + for fbx_prop, (acurvenode_key, acurves, acurvenode_name) in acurvenodes.items(): + # Animcurvenode -> animalayer. + acurvenode_id = get_fbx_uuid_from_key(acurvenode_key) + connections.append((b"OO", acurvenode_id, alayer_id, None)) + # Animcurvenode -> object property. + connections.append((b"OP", acurvenode_id, elem_id, fbx_prop.encode())) + for fbx_item, (acurve_key, default_value, (keys, values), acurve_valid) in acurves.items(): + if len(keys): + # Animcurve -> Animcurvenode. + connections.append((b"OP", get_fbx_uuid_from_key(acurve_key), acurvenode_id, fbx_item.encode())) + + perfmon.level_down() + + # ##### And pack all this! + + return FBXExportData( + templates, templates_users, connections, + settings, scene, depsgraph, objects, animations, animated, frame_start, frame_end, + data_empties, data_lights, data_cameras, data_meshes, mesh_material_indices, + data_bones, data_leaf_bones, data_deformers_skin, data_deformers_shape, + data_world, data_materials, data_textures, data_videos, + ) + + +def fbx_scene_data_cleanup(scene_data): + """ + Some final cleanup... + """ + # Delete temp meshes. + done_meshes = set() + for me_key, me, free in scene_data.data_meshes.values(): + if free and me_key not in done_meshes: + bpy.data.meshes.remove(me) + done_meshes.add(me_key) + + +# ##### Top-level FBX elements generators. ##### + +def fbx_header_elements(root, scene_data, time=None): + """ + Write boiling code of FBX root. + time is expected to be a datetime.datetime object, or None (using now() in this case). + """ + app_vendor = "Blender Foundation" + app_name = "Blender (stable FBX IO)" + app_ver = bpy.app.version_string + + from . import bl_info + addon_ver = bl_info["version"] + del bl_info + + # ##### Start of FBXHeaderExtension element. + header_ext = elem_empty(root, b"FBXHeaderExtension") + + elem_data_single_int32(header_ext, b"FBXHeaderVersion", FBX_HEADER_VERSION) + + elem_data_single_int32(header_ext, b"FBXVersion", FBX_VERSION) + + # No encryption! + elem_data_single_int32(header_ext, b"EncryptionType", 0) + + if time is None: + time = datetime.datetime.now() + elem = elem_empty(header_ext, b"CreationTimeStamp") + elem_data_single_int32(elem, b"Version", 1000) + elem_data_single_int32(elem, b"Year", time.year) + elem_data_single_int32(elem, b"Month", time.month) + elem_data_single_int32(elem, b"Day", time.day) + elem_data_single_int32(elem, b"Hour", time.hour) + elem_data_single_int32(elem, b"Minute", time.minute) + elem_data_single_int32(elem, b"Second", time.second) + elem_data_single_int32(elem, b"Millisecond", time.microsecond // 1000) + + elem_data_single_string_unicode(header_ext, b"Creator", "%s - %s - %d.%d.%d" + % (app_name, app_ver, addon_ver[0], addon_ver[1], addon_ver[2])) + + # 'SceneInfo' seems mandatory to get a valid FBX file... + # TODO use real values! + # XXX Should we use scene.name.encode() here? + scene_info = elem_data_single_string(header_ext, b"SceneInfo", fbx_name_class(b"GlobalInfo", b"SceneInfo")) + scene_info.add_string(b"UserData") + elem_data_single_string(scene_info, b"Type", b"UserData") + elem_data_single_int32(scene_info, b"Version", FBX_SCENEINFO_VERSION) + meta_data = elem_empty(scene_info, b"MetaData") + elem_data_single_int32(meta_data, b"Version", FBX_SCENEINFO_VERSION) + elem_data_single_string(meta_data, b"Title", b"") + elem_data_single_string(meta_data, b"Subject", b"") + elem_data_single_string(meta_data, b"Author", b"") + elem_data_single_string(meta_data, b"Keywords", b"") + elem_data_single_string(meta_data, b"Revision", b"") + elem_data_single_string(meta_data, b"Comment", b"") + + props = elem_properties(scene_info) + elem_props_set(props, "p_string_url", b"DocumentUrl", "/foobar.fbx") + elem_props_set(props, "p_string_url", b"SrcDocumentUrl", "/foobar.fbx") + original = elem_props_compound(props, b"Original") + original("p_string", b"ApplicationVendor", app_vendor) + original("p_string", b"ApplicationName", app_name) + original("p_string", b"ApplicationVersion", app_ver) + original("p_datetime", b"DateTime_GMT", "01/01/1970 00:00:00.000") + original("p_string", b"FileName", "/foobar.fbx") + lastsaved = elem_props_compound(props, b"LastSaved") + lastsaved("p_string", b"ApplicationVendor", app_vendor) + lastsaved("p_string", b"ApplicationName", app_name) + lastsaved("p_string", b"ApplicationVersion", app_ver) + lastsaved("p_datetime", b"DateTime_GMT", "01/01/1970 00:00:00.000") + original("p_string", b"ApplicationNativeFile", bpy.data.filepath) + + # ##### End of FBXHeaderExtension element. + + # FileID is replaced by dummy value currently... + elem_data_single_bytes(root, b"FileId", b"FooBar") + + # CreationTime is replaced by dummy value currently, but anyway... + elem_data_single_string_unicode(root, b"CreationTime", + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}:{:03}" + "".format(time.year, time.month, time.day, time.hour, time.minute, time.second, + time.microsecond * 1000)) + + elem_data_single_string_unicode(root, b"Creator", "%s - %s - %d.%d.%d" + % (app_name, app_ver, addon_ver[0], addon_ver[1], addon_ver[2])) + + # ##### Start of GlobalSettings element. + global_settings = elem_empty(root, b"GlobalSettings") + scene = scene_data.scene + + elem_data_single_int32(global_settings, b"Version", 1000) + + props = elem_properties(global_settings) + up_axis, front_axis, coord_axis = RIGHT_HAND_AXES[scene_data.settings.to_axes] + # ~ # DO NOT take into account global scale here! That setting is applied to object transformations during export + # ~ # (in other words, this is pure blender-exporter feature, and has nothing to do with FBX data). + # ~ if scene_data.settings.apply_unit_scale: + # ~ # Unit scaling is applied to objects' scale, so our unit is effectively FBX one (centimeter). + # ~ scale_factor_org = 1.0 + # ~ scale_factor = 1.0 / units_blender_to_fbx_factor(scene) + # ~ else: + # ~ scale_factor_org = units_blender_to_fbx_factor(scene) + # ~ scale_factor = scale_factor_org + scale_factor = scale_factor_org = scene_data.settings.unit_scale + elem_props_set(props, "p_integer", b"UpAxis", up_axis[0]) + elem_props_set(props, "p_integer", b"UpAxisSign", up_axis[1]) + elem_props_set(props, "p_integer", b"FrontAxis", front_axis[0]) + elem_props_set(props, "p_integer", b"FrontAxisSign", front_axis[1]) + elem_props_set(props, "p_integer", b"CoordAxis", coord_axis[0]) + elem_props_set(props, "p_integer", b"CoordAxisSign", coord_axis[1]) + elem_props_set(props, "p_integer", b"OriginalUpAxis", -1) + elem_props_set(props, "p_integer", b"OriginalUpAxisSign", 1) + elem_props_set(props, "p_double", b"UnitScaleFactor", scale_factor) + elem_props_set(props, "p_double", b"OriginalUnitScaleFactor", scale_factor_org) + elem_props_set(props, "p_color_rgb", b"AmbientColor", (0.0, 0.0, 0.0)) + elem_props_set(props, "p_string", b"DefaultCamera", "Producer Perspective") + + # Global timing data. + r = scene.render + _, fbx_fps_mode = FBX_FRAMERATES[0] # Custom frame-rate. + fbx_fps = fps = r.fps / r.fps_base + for ref_fps, fps_mode in FBX_FRAMERATES: + if similar_values(fps, ref_fps): + fbx_fps = ref_fps + fbx_fps_mode = fps_mode + break + elem_props_set(props, "p_enum", b"TimeMode", fbx_fps_mode) + elem_props_set(props, "p_timestamp", b"TimeSpanStart", 0) + elem_props_set(props, "p_timestamp", b"TimeSpanStop", FBX_KTIME) + elem_props_set(props, "p_double", b"CustomFrameRate", fbx_fps) + + # ##### End of GlobalSettings element. + + +def fbx_documents_elements(root, scene_data): + """ + Write 'Document' part of FBX root. + Seems like FBX support multiple documents, but until I find examples of such, we'll stick to single doc! + time is expected to be a datetime.datetime object, or None (using now() in this case). + """ + name = scene_data.scene.name + + # ##### Start of Documents element. + docs = elem_empty(root, b"Documents") + + elem_data_single_int32(docs, b"Count", 1) + + doc_uid = get_fbx_uuid_from_key("__FBX_Document__" + name) + doc = elem_data_single_int64(docs, b"Document", doc_uid) + doc.add_string_unicode(name) + doc.add_string_unicode(name) + + props = elem_properties(doc) + elem_props_set(props, "p_object", b"SourceObject") + elem_props_set(props, "p_string", b"ActiveAnimStackName", "") + + # XXX Some kind of ID? Offset? + # Anyway, as long as we have only one doc, probably not an issue. + elem_data_single_int64(doc, b"RootNode", 0) + + +def fbx_references_elements(root, scene_data): + """ + Have no idea what references are in FBX currently... Just writing empty element. + """ + docs = elem_empty(root, b"References") + + +def fbx_definitions_elements(root, scene_data): + """ + Templates definitions. Only used by Objects data AFAIK (apart from dummy GlobalSettings one). + """ + definitions = elem_empty(root, b"Definitions") + + elem_data_single_int32(definitions, b"Version", FBX_TEMPLATES_VERSION) + elem_data_single_int32(definitions, b"Count", scene_data.templates_users) + + fbx_templates_generate(definitions, scene_data.templates) + + +def fbx_objects_elements(root, scene_data): + """ + Data (objects, geometry, material, textures, armatures, etc.). + """ + perfmon = PerfMon() + perfmon.level_up() + objects = elem_empty(root, b"Objects") + + perfmon.step("FBX export fetch empties (%d)..." % len(scene_data.data_empties)) + + for empty in scene_data.data_empties: + fbx_data_empty_elements(objects, empty, scene_data) + + perfmon.step("FBX export fetch lamps (%d)..." % len(scene_data.data_lights)) + + for lamp in scene_data.data_lights: + fbx_data_light_elements(objects, lamp, scene_data) + + perfmon.step("FBX export fetch cameras (%d)..." % len(scene_data.data_cameras)) + + for cam in scene_data.data_cameras: + fbx_data_camera_elements(objects, cam, scene_data) + + perfmon.step("FBX export fetch meshes (%d)..." + % len({me_key for me_key, _me, _free in scene_data.data_meshes.values()})) + + done_meshes = set() + for me_obj in scene_data.data_meshes: + fbx_data_mesh_elements(objects, me_obj, scene_data, done_meshes) + del done_meshes + + perfmon.step("FBX export fetch objects (%d)..." % len(scene_data.objects)) + + for ob_obj in scene_data.objects: + if ob_obj.is_dupli: + continue + fbx_data_object_elements(objects, ob_obj, scene_data) + for dp_obj in ob_obj.dupli_list_gen(scene_data.depsgraph): + if dp_obj not in scene_data.objects: + continue + fbx_data_object_elements(objects, dp_obj, scene_data) + + perfmon.step("FBX export fetch remaining...") + + for ob_obj in scene_data.objects: + if not (ob_obj.is_object and ob_obj.type == 'ARMATURE'): + continue + fbx_data_armature_elements(objects, ob_obj, scene_data) + + if scene_data.data_leaf_bones: + fbx_data_leaf_bone_elements(objects, scene_data) + + for ma in scene_data.data_materials: + fbx_data_material_elements(objects, ma, scene_data) + + for blender_tex_key in scene_data.data_textures: + fbx_data_texture_file_elements(objects, blender_tex_key, scene_data) + + for vid in scene_data.data_videos: + fbx_data_video_elements(objects, vid, scene_data) + + perfmon.step("FBX export fetch animations...") + start_time = time.process_time() + + fbx_data_animation_elements(objects, scene_data) + + perfmon.level_down() + + +def fbx_connections_elements(root, scene_data): + """ + Relations between Objects (which material uses which texture, and so on). + """ + connections = elem_empty(root, b"Connections") + + for c in scene_data.connections: + elem_connection(connections, *c) + + +def fbx_takes_elements(root, scene_data): + """ + Animations. + """ + # XXX Pretty sure takes are no more needed... + takes = elem_empty(root, b"Takes") + elem_data_single_string(takes, b"Current", b"") + + animations = scene_data.animations + for astack_key, animations, alayer_key, name, f_start, f_end in animations: + scene = scene_data.scene + fps = scene.render.fps / scene.render.fps_base + start_ktime = int(convert_sec_to_ktime(f_start / fps)) + end_ktime = int(convert_sec_to_ktime(f_end / fps)) + + take = elem_data_single_string(takes, b"Take", name) + elem_data_single_string(take, b"FileName", name + b".tak") + take_loc_time = elem_data_single_int64(take, b"LocalTime", start_ktime) + take_loc_time.add_int64(end_ktime) + take_ref_time = elem_data_single_int64(take, b"ReferenceTime", start_ktime) + take_ref_time.add_int64(end_ktime) + + +# ##### "Main" functions. ##### + +# This func can be called with just the filepath +def save_single(operator, scene, depsgraph, filepath="", + global_matrix=Matrix(), + apply_unit_scale=False, + global_scale=1.0, + apply_scale_options='FBX_SCALE_NONE', + axis_up="Z", + axis_forward="Y", + context_objects=None, + object_types=None, + use_mesh_modifiers=True, + use_mesh_modifiers_render=True, + mesh_smooth_type='FACE', + use_subsurf=False, + use_armature_deform_only=False, + bake_anim=True, + bake_anim_use_all_bones=True, + bake_anim_use_nla_strips=True, + bake_anim_use_all_actions=True, + bake_anim_step=1.0, + bake_anim_simplify_factor=1.0, + bake_anim_force_startend_keying=True, + add_leaf_bones=False, + primary_bone_axis='Y', + secondary_bone_axis='X', + use_metadata=True, + path_mode='AUTO', + use_mesh_edges=True, + use_tspace=True, + use_triangles=False, + embed_textures=False, + use_custom_props=False, + bake_space_transform=False, + armature_nodetype='NULL', + colors_type='SRGB', + prioritize_active_color=False, + **kwargs + ): + + # Clear cached ObjectWrappers (just in case...). + ObjectWrapper.cache_clear() + + if object_types is None: + object_types = {'EMPTY', 'CAMERA', 'LIGHT', 'ARMATURE', 'MESH', 'OTHER'} + + if 'OTHER' in object_types: + object_types |= BLENDER_OTHER_OBJECT_TYPES + + # Default Blender unit is equivalent to meter, while FBX one is centimeter... + unit_scale = units_blender_to_fbx_factor(scene) if apply_unit_scale else 100.0 + if apply_scale_options == 'FBX_SCALE_NONE': + global_matrix = Matrix.Scale(unit_scale * global_scale, 4) @ global_matrix + unit_scale = 1.0 + elif apply_scale_options == 'FBX_SCALE_UNITS': + global_matrix = Matrix.Scale(global_scale, 4) @ global_matrix + elif apply_scale_options == 'FBX_SCALE_CUSTOM': + global_matrix = Matrix.Scale(unit_scale, 4) @ global_matrix + unit_scale = global_scale + else: # if apply_scale_options == 'FBX_SCALE_ALL': + unit_scale = global_scale * unit_scale + + global_scale = global_matrix.median_scale + global_matrix_inv = global_matrix.inverted() + # For transforming mesh normals. + global_matrix_inv_transposed = global_matrix_inv.transposed() + + # Only embed textures in COPY mode! + if embed_textures and path_mode != 'COPY': + embed_textures = False + + # Calculate bone correction matrix + bone_correction_matrix = None # Default is None = no change + bone_correction_matrix_inv = None + if (primary_bone_axis, secondary_bone_axis) != ('Y', 'X'): + from bpy_extras.io_utils import axis_conversion + bone_correction_matrix = axis_conversion(from_forward=secondary_bone_axis, + from_up=primary_bone_axis, + to_forward='X', + to_up='Y', + ).to_4x4() + bone_correction_matrix_inv = bone_correction_matrix.inverted() + + media_settings = FBXExportSettingsMedia( + path_mode, + os.path.dirname(bpy.data.filepath), # base_src + os.path.dirname(filepath), # base_dst + # Local dir where to put images (media), using FBX conventions. + os.path.splitext(os.path.basename(filepath))[0] + ".fbm", # subdir + embed_textures, + set(), # copy_set + set(), # embedded_set + ) + + settings = FBXExportSettings( + operator.report, (axis_up, axis_forward), global_matrix, global_scale, apply_unit_scale, unit_scale, + bake_space_transform, global_matrix_inv, global_matrix_inv_transposed, + context_objects, object_types, use_mesh_modifiers, use_mesh_modifiers_render, + mesh_smooth_type, use_subsurf, use_mesh_edges, use_tspace, use_triangles, + armature_nodetype, use_armature_deform_only, + add_leaf_bones, bone_correction_matrix, bone_correction_matrix_inv, + bake_anim, bake_anim_use_all_bones, bake_anim_use_nla_strips, bake_anim_use_all_actions, + bake_anim_step, bake_anim_simplify_factor, bake_anim_force_startend_keying, + False, media_settings, use_custom_props, colors_type, prioritize_active_color + ) + + import bpy_extras.io_utils + + print('\nFBX export starting... %r' % filepath) + start_time = time.time() + + # Generate some data about exported scene... + scene_data = fbx_data_from_scene(scene, depsgraph, settings) + + # Enable multithreaded array compression in FBXElem and wait until all threads are done before exiting the context + # manager. + with encode_bin.FBXElem.enable_multithreading_cm(): + # Writing elements into an FBX hierarchy can now begin. + root = elem_empty(None, b"") # Root element has no id, as it is not saved per se! + + # Mostly FBXHeaderExtension and GlobalSettings. + fbx_header_elements(root, scene_data) + + # Documents and References are pretty much void currently. + fbx_documents_elements(root, scene_data) + fbx_references_elements(root, scene_data) + + # Templates definitions. + fbx_definitions_elements(root, scene_data) + + # Actual data. + fbx_objects_elements(root, scene_data) + + # How data are inter-connected. + fbx_connections_elements(root, scene_data) + + # Animation. + fbx_takes_elements(root, scene_data) + + # Cleanup! + fbx_scene_data_cleanup(scene_data) + + # And we are done, all multithreaded tasks are complete, and we can write the whole thing to file! + encode_bin.write(filepath, root, FBX_VERSION) + + # Clear cached ObjectWrappers! + ObjectWrapper.cache_clear() + + # copy all collected files, if we did not embed them. + if not media_settings.embed_textures: + bpy_extras.io_utils.path_reference_copy(media_settings.copy_set) + + print('export finished in %.4f sec.' % (time.time() - start_time)) + return {'FINISHED'} + + +# defaults for applications, currently only unity but could add others. +def defaults_unity3d(): + return { + # These options seem to produce the same result as the old Ascii exporter in Unity3D: + "axis_up": 'Y', + "axis_forward": '-Z', + "global_matrix": Matrix.Rotation(-math.pi / 2.0, 4, 'X'), + # Should really be True, but it can cause problems if a model is already in a scene or prefab + # with the old transforms. + "bake_space_transform": False, + + "use_selection": False, + + "object_types": {'ARMATURE', 'EMPTY', 'MESH', 'OTHER'}, + "use_mesh_modifiers": True, + "use_mesh_modifiers_render": True, + "use_mesh_edges": False, + "mesh_smooth_type": 'FACE', + "colors_type": 'SRGB', + "use_subsurf": False, + "use_tspace": False, # XXX Why? Unity is expected to support tspace import... + "use_triangles": False, + + "use_armature_deform_only": True, + + "use_custom_props": True, + + "bake_anim": True, + "bake_anim_simplify_factor": 1.0, + "bake_anim_step": 1.0, + "bake_anim_use_nla_strips": True, + "bake_anim_use_all_actions": True, + "add_leaf_bones": False, # Avoid memory/performance cost for something only useful for modeling. + "primary_bone_axis": 'Y', # Doesn't really matter for Unity, so leave unchanged + "secondary_bone_axis": 'X', + + "path_mode": 'AUTO', + "embed_textures": False, + "batch_mode": 'OFF', + } + + +def save(operator, context, + filepath="", + use_selection=False, + use_visible=False, + use_active_collection=False, + collection="", + batch_mode='OFF', + use_batch_own_dir=False, + **kwargs + ): + """ + This is a wrapper around save_single, which handles multi-scenes (or collections) cases, when batch-exporting + a whole .blend file. + """ + + ret = {'FINISHED'} + + active_object = context.view_layer.objects.active + + org_mode = None + if active_object and active_object.mode != 'OBJECT' and bpy.ops.object.mode_set.poll(): + org_mode = active_object.mode + bpy.ops.object.mode_set(mode='OBJECT') + + if batch_mode == 'OFF': + kwargs_mod = kwargs.copy() + + source_collection = None + if use_active_collection: + source_collection = context.view_layer.active_layer_collection.collection + elif collection: + local_collection = bpy.data.collections.get((collection, None)) + if local_collection: + source_collection = local_collection + else: + operator.report({'ERROR'}, "Collection '%s' was not found" % collection) + return {'CANCELLED'} + + if source_collection: + if use_selection: + ctx_objects = tuple(obj for obj in source_collection.all_objects if obj.select_get()) + else: + ctx_objects = source_collection.all_objects + else: + if use_selection: + ctx_objects = context.selected_objects + else: + ctx_objects = context.view_layer.objects + if use_visible: + ctx_objects = tuple(obj for obj in ctx_objects if obj.visible_get()) + + # Sort exported objects by their names. + ctx_objects = sorted(ctx_objects, key=lambda ob: ob.name) + + # Ensure no Objects are in Edit mode. + # Copy to a tuple for safety, to avoid the risk of modifying ctx_objects while iterating. + for obj in ctx_objects: + if not ensure_object_not_in_edit_mode(context, obj): + operator.report({'ERROR'}, "%s could not be set out of Edit Mode, so cannot be exported" % obj.name) + return {'CANCELLED'} + + kwargs_mod["context_objects"] = ctx_objects + + depsgraph = context.evaluated_depsgraph_get() + ret = save_single(operator, context.scene, depsgraph, filepath, **kwargs_mod) + else: + # XXX We need a way to generate a depsgraph for inactive view_layers first... + # XXX Also, what to do in case of batch-exporting scenes, when there is more than one view layer? + # Scenes have no concept of 'active' view layer, that's on window level... + fbxpath = filepath + + prefix = os.path.basename(fbxpath) + if prefix: + fbxpath = os.path.dirname(fbxpath) + + if batch_mode == 'COLLECTION': + data_seq = tuple((coll, coll.name, 'objects') for coll in bpy.data.collections if coll.objects) + elif batch_mode in {'SCENE_COLLECTION', 'ACTIVE_SCENE_COLLECTION'}: + scenes = [context.scene] if batch_mode == 'ACTIVE_SCENE_COLLECTION' else bpy.data.scenes + data_seq = [] + for scene in scenes: + if not scene.objects: + continue + # Needed to avoid having tens of 'Scene Collection' entries. + todo_collections = [(scene.collection, "_".join((scene.name, scene.collection.name)))] + while todo_collections: + coll, coll_name = todo_collections.pop() + todo_collections.extend(((c, c.name) for c in coll.children if c.all_objects)) + data_seq.append((coll, coll_name, 'all_objects')) + else: + data_seq = tuple((scene, scene.name, 'objects') for scene in bpy.data.scenes if scene.objects) + + # Ensure no Objects are in Edit mode. + for data, data_name, data_obj_propname in data_seq: + # Copy to a tuple for safety, to avoid the risk of modifying the data prop while iterating it. + for obj in tuple(getattr(data, data_obj_propname)): + if not ensure_object_not_in_edit_mode(context, obj): + operator.report({'ERROR'}, + "%s in %s could not be set out of Edit Mode, so cannot be exported" + % (obj.name, data_name)) + return {'CANCELLED'} + + # call this function within a loop with BATCH_ENABLE == False + + new_fbxpath = fbxpath # own dir option modifies, we need to keep an original + for data, data_name, data_obj_propname in data_seq: # scene or collection + newname = "_".join((prefix, bpy.path.clean_name(data_name))) if prefix else bpy.path.clean_name(data_name) + + if use_batch_own_dir: + new_fbxpath = os.path.join(fbxpath, newname) + # path may already exist... and be a file. + while os.path.isfile(new_fbxpath): + new_fbxpath = "_".join((new_fbxpath, "dir")) + if not os.path.exists(new_fbxpath): + os.makedirs(new_fbxpath) + + filepath = os.path.join(new_fbxpath, newname + '.fbx') + + print('\nBatch exporting %s as...\n\t%r' % (data, filepath)) + + if batch_mode in {'COLLECTION', 'SCENE_COLLECTION', 'ACTIVE_SCENE_COLLECTION'}: + # Collection, so that objects update properly, add a dummy scene. + scene = bpy.data.scenes.new(name="FBX_Temp") + src_scenes = {} # Count how much each 'source' scenes are used. + for obj in getattr(data, data_obj_propname): + for src_sce in obj.users_scene: + src_scenes[src_sce] = src_scenes.setdefault(src_sce, 0) + 1 + scene.collection.objects.link(obj) + + # Find the 'most used' source scene, and use its unit settings. This is somewhat weak, but should work + # fine in most cases, and avoids stupid issues like T41931. + best_src_scene = None + best_src_scene_users = -1 + for sce, nbr_users in src_scenes.items(): + if (nbr_users) > best_src_scene_users: + best_src_scene_users = nbr_users + best_src_scene = sce + scene.unit_settings.system = best_src_scene.unit_settings.system + scene.unit_settings.system_rotation = best_src_scene.unit_settings.system_rotation + scene.unit_settings.scale_length = best_src_scene.unit_settings.scale_length + + # new scene [only one viewlayer to update] + scene.view_layers[0].update() + # TODO - BUMMER! Armatures not in the group wont animate the mesh + else: + scene = data + + kwargs_batch = kwargs.copy() + kwargs_batch["context_objects"] = getattr(data, data_obj_propname) + + save_single(operator, scene, scene.view_layers[0].depsgraph, filepath, **kwargs_batch) + + if batch_mode in {'COLLECTION', 'SCENE_COLLECTION', 'ACTIVE_SCENE_COLLECTION'}: + # Remove temp collection scene. + bpy.data.scenes.remove(scene) + + if active_object and org_mode: + context.view_layer.objects.active = active_object + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode=org_mode) + + return ret diff --git a/5.1/io_scene_fbx/fbx2json.py b/5.1/io_scene_fbx/fbx2json.py new file mode 100644 index 0000000..b87172c --- /dev/null +++ b/5.1/io_scene_fbx/fbx2json.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2006-2012 assimp team +# SPDX-FileCopyrightText: 2013 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +""" +Usage +===== + + fbx2json [FILES]... + +This script will write a JSON file for each FBX argument given. + + +Output +====== + +The JSON data is formatted into a list of nested lists of 4 items: + + ``[id, [data, ...], "data_types", [subtree, ...]]`` + +Where each list may be empty, and the items in +the subtree are formatted the same way. + +data_types is a string, aligned with data that specifies a type +for each property. + +The types are as follows: + +* 'Z': - INT8 +* 'Y': - INT16 +* 'B': - BOOL +* 'C': - CHAR +* 'I': - INT32 +* 'F': - FLOAT32 +* 'D': - FLOAT64 +* 'L': - INT64 +* 'R': - BYTES +* 'S': - STRING +* 'f': - FLOAT32_ARRAY +* 'i': - INT32_ARRAY +* 'd': - FLOAT64_ARRAY +* 'l': - INT64_ARRAY +* 'b': - BOOL ARRAY +* 'c': - BYTE ARRAY + +Note that key:value pairs aren't used since the id's are not +ensured to be unique. +""" + + +# ---------------------------------------------------------------------------- +# FBX Binary Parser + +from struct import unpack +import array +import zlib + +# at the end of each nested block, there is a NUL record to indicate +# that the sub-scope exists (i.e. to distinguish between P: and P : {}) +_BLOCK_SENTINEL_LENGTH = ... +_BLOCK_SENTINEL_DATA = ... +read_fbx_elem_uint = ... +_IS_BIG_ENDIAN = (__import__("sys").byteorder != 'little') +_HEAD_MAGIC = b'Kaydara FBX Binary\x20\x20\x00\x1a\x00' +from collections import namedtuple +FBXElem = namedtuple("FBXElem", ("id", "props", "props_type", "elems")) +del namedtuple + + +def read_uint(read): + return unpack(b'"TCDefinition" to control the FBX_KTIME opt-in in FBX version 7700. +FBX_HEADER_VERSION = 1003 +FBX_SCENEINFO_VERSION = 100 +FBX_TEMPLATES_VERSION = 100 + +FBX_MODELS_VERSION = 232 + +FBX_GEOMETRY_VERSION = 124 +# Revert back normals to 101 (simple 3D values) for now, 102 (4D + weights) seems not well supported by most apps +# currently, apart from some AD products. +FBX_GEOMETRY_NORMAL_VERSION = 101 +FBX_GEOMETRY_BINORMAL_VERSION = 101 +FBX_GEOMETRY_TANGENT_VERSION = 101 +FBX_GEOMETRY_SMOOTHING_VERSION = 102 +FBX_GEOMETRY_CREASE_VERSION = 101 +FBX_GEOMETRY_VCOLOR_VERSION = 101 +FBX_GEOMETRY_UV_VERSION = 101 +FBX_GEOMETRY_MATERIAL_VERSION = 101 +FBX_GEOMETRY_LAYER_VERSION = 100 +FBX_GEOMETRY_SHAPE_VERSION = 100 +FBX_DEFORMER_SHAPE_VERSION = 100 +FBX_DEFORMER_SHAPECHANNEL_VERSION = 100 +FBX_POSE_BIND_VERSION = 100 +FBX_DEFORMER_SKIN_VERSION = 101 +FBX_DEFORMER_CLUSTER_VERSION = 100 +FBX_MATERIAL_VERSION = 102 +FBX_TEXTURE_VERSION = 202 +FBX_ANIM_KEY_VERSION = 4008 + +FBX_NAME_CLASS_SEP = b"\x00\x01" +FBX_ANIM_PROPSGROUP_NAME = "d" + +FBX_KTIME_V7 = 46186158000 # This is the number of "ktimes" in one second (yep, precision over the nanosecond...) +# FBX 2019.5 (FBX version 7700) changed the number of "ktimes" per second, however, the new value is opt-in until FBX +# version 8000 where it will probably become opt-out. +FBX_KTIME_V8 = 141120000 +# To explicitly use the V7 value in FBX versions 7700-7XXX: fbx_root->"FBXHeaderExtension"->"OtherFlags"->"TCDefinition" +# is set to 127. +# To opt in to the V8 value in FBX version 7700-7XXX: "TCDefinition" is set to 0. +FBX_TIMECODE_DEFINITION_TO_KTIME_PER_SECOND = { + 0: FBX_KTIME_V8, + 127: FBX_KTIME_V7, +} +# The "ktimes" per second for Blender exported FBX is constant because the exported `FBX_VERSION` is constant. +FBX_KTIME = FBX_KTIME_V8 if FBX_VERSION >= 8000 else FBX_KTIME_V7 + + +MAT_CONVERT_LIGHT = Matrix.Rotation(math.pi / 2.0, 4, 'X') # Blender is -Z, FBX is -Y. +MAT_CONVERT_CAMERA = Matrix.Rotation(math.pi / 2.0, 4, 'Y') # Blender is -Z, FBX is +X. +# XXX I can't get this working :( +# MAT_CONVERT_BONE = Matrix.Rotation(math.pi / 2.0, 4, 'Z') # Blender is +Y, FBX is -X. +MAT_CONVERT_BONE = Matrix() + + +BLENDER_OTHER_OBJECT_TYPES = {'CURVE', 'SURFACE', 'FONT', 'META'} +BLENDER_OBJECT_TYPES_MESHLIKE = {'MESH'} | BLENDER_OTHER_OBJECT_TYPES + +SHAPE_KEY_SLIDER_HARD_MIN = bpy.types.ShapeKey.bl_rna.properties["slider_min"].hard_min +SHAPE_KEY_SLIDER_HARD_MAX = bpy.types.ShapeKey.bl_rna.properties["slider_max"].hard_max + + +# Lamps. +FBX_LIGHT_TYPES = { + 'POINT': 0, # Point. + 'SUN': 1, # Directional. + 'SPOT': 2, # Spot. + 'HEMI': 1, # Directional. + 'AREA': 3, # Area. +} +FBX_LIGHT_DECAY_TYPES = { + 'CONSTANT': 0, # None. + 'INVERSE_LINEAR': 1, # Linear. + 'INVERSE_SQUARE': 2, # Quadratic. + 'INVERSE_COEFFICIENTS': 2, # Quadratic... + 'CUSTOM_CURVE': 2, # Quadratic. + 'LINEAR_QUADRATIC_WEIGHTED': 2, # Quadratic. +} + + +RIGHT_HAND_AXES = { + # Up, Forward -> FBX values (tuples of (axis, sign), Up, Front, Coord). + ('X', '-Y'): ((0, 1), (1, 1), (2, 1)), + ('X', 'Y'): ((0, 1), (1, -1), (2, -1)), + ('X', '-Z'): ((0, 1), (2, 1), (1, -1)), + ('X', 'Z'): ((0, 1), (2, -1), (1, 1)), + ('-X', '-Y'): ((0, -1), (1, 1), (2, -1)), + ('-X', 'Y'): ((0, -1), (1, -1), (2, 1)), + ('-X', '-Z'): ((0, -1), (2, 1), (1, 1)), + ('-X', 'Z'): ((0, -1), (2, -1), (1, -1)), + ('Y', '-X'): ((1, 1), (0, 1), (2, -1)), + ('Y', 'X'): ((1, 1), (0, -1), (2, 1)), + ('Y', '-Z'): ((1, 1), (2, 1), (0, 1)), + ('Y', 'Z'): ((1, 1), (2, -1), (0, -1)), + ('-Y', '-X'): ((1, -1), (0, 1), (2, 1)), + ('-Y', 'X'): ((1, -1), (0, -1), (2, -1)), + ('-Y', '-Z'): ((1, -1), (2, 1), (0, -1)), + ('-Y', 'Z'): ((1, -1), (2, -1), (0, 1)), + ('Z', '-X'): ((2, 1), (0, 1), (1, 1)), + ('Z', 'X'): ((2, 1), (0, -1), (1, -1)), + ('Z', '-Y'): ((2, 1), (1, 1), (0, -1)), + ('Z', 'Y'): ((2, 1), (1, -1), (0, 1)), # Blender system! + ('-Z', '-X'): ((2, -1), (0, 1), (1, -1)), + ('-Z', 'X'): ((2, -1), (0, -1), (1, 1)), + ('-Z', '-Y'): ((2, -1), (1, 1), (0, 1)), + ('-Z', 'Y'): ((2, -1), (1, -1), (0, -1)), +} + + +# NOTE: Not fully in enum value order, since when exporting the first entry matching the frame-rate value is used +# (e.g. better have NTSC full-frame than NTSC drop frame for 29.97 frame-rate). +FBX_FRAMERATES = ( + # (-1.0, 0), # Default frame-rate. + (-1.0, 14), # Custom frame-rate. + (120.0, 1), + (100.0, 2), + (60.0, 3), + (50.0, 4), + (48.0, 5), + (30.0, 6), # BW NTSC, full frame. + (30.0, 7), # Drop frame. + (30.0 / 1.001, 9), # Color NTSC, full frame. + (30.0 / 1.001, 8), # Color NTSC, drop frame. + (25.0, 10), + (24.0, 11), + # (1.0, 12), # 1000 milli/s (use for date time?). + (24.0 / 1.001, 13), + (96.0, 15), + (72.0, 16), + (60.0 / 1.001, 17), + (120.0 / 1.001, 18), +) + + +# ##### Misc utilities ##### + +# Enable performance reports (measuring time used to perform various steps of importing or exporting). +DO_PERFMON = False + +if DO_PERFMON: + class PerfMon(): + def __init__(self): + self.level = -1 + self.ref_time = [] + + def level_up(self, message=""): + self.level += 1 + self.ref_time.append(None) + if message: + print("\t" * self.level, message, sep="") + + def level_down(self, message=""): + if not self.ref_time: + if message: + print(message) + return + ref_time = self.ref_time[self.level] + print("\t" * self.level, + "\tDone (%f sec)\n" % ((time.process_time() - ref_time) if ref_time is not None else 0.0), + sep="") + if message: + print("\t" * self.level, message, sep="") + del self.ref_time[self.level] + self.level -= 1 + + def step(self, message=""): + ref_time = self.ref_time[self.level] + curr_time = time.process_time() + if ref_time is not None: + print("\t" * self.level, "\tDone (%f sec)\n" % (curr_time - ref_time), sep="") + self.ref_time[self.level] = curr_time + print("\t" * self.level, message, sep="") +else: + class PerfMon(): + def __init__(self): + pass + + def level_up(self, message=""): + pass + + def level_down(self, message=""): + pass + + def step(self, message=""): + pass + + +# Scale/unit mess. FBX can store the 'reference' unit of a file in its UnitScaleFactor property +# (1.0 meaning centimeter, AFAIK). We use that to reflect user's default unit as set in Blender with scale_length. +# However, we always get values in BU (i.e. meters), so we have to reverse-apply that scale in global matrix... +# Note that when no default unit is available, we assume 'meters' (and hence scale by 100). +def units_blender_to_fbx_factor(scene): + return 100.0 if (scene.unit_settings.system == 'NONE') else (100.0 * scene.unit_settings.scale_length) + + +# Note: this could be in a utility (math.units e.g.)... + +UNITS = { + "meter": 1.0, # Ref unit! + "kilometer": 0.001, + "millimeter": 1000.0, + "foot": 1.0 / 0.3048, + "inch": 1.0 / 0.0254, + "turn": 1.0, # Ref unit! + "degree": 360.0, + "radian": math.pi * 2.0, + "second": 1.0, # Ref unit! + "ktime": FBX_KTIME, # For export use only because the imported "ktimes" per second may vary. +} + + +def units_convertor(u_from, u_to): + """Return a convertor between specified units.""" + conv = UNITS[u_to] / UNITS[u_from] + return lambda v: v * conv + + +def units_convertor_iter(u_from, u_to): + """Return an iterable convertor between specified units.""" + conv = units_convertor(u_from, u_to) + + def convertor(it): + for v in it: + yield conv(v) + + return convertor + + +def matrix4_to_array(mat): + """Concatenate matrix's columns into a single, flat tuple""" + # blender matrix is row major, fbx is col major so transpose on write + return tuple(f for v in mat.transposed() for f in v) + + +def array_to_matrix4(arr): + """Convert a single 16-len tuple into a valid 4D Blender matrix""" + # Blender matrix is row major, fbx is col major so transpose on read + return Matrix(tuple(zip(*[iter(arr)] * 4))).transposed() + + +def parray_as_ndarray(arr): + """Convert an array.array into an np.ndarray that shares the same memory""" + return np.frombuffer(arr, dtype=arr.typecode) + + +def similar_values(v1, v2, e=1e-6): + """Return True if v1 and v2 are nearly the same.""" + if v1 == v2: + return True + return ((abs(v1 - v2) / max(abs(v1), abs(v2))) <= e) + + +def similar_values_iter(v1, v2, e=1e-6): + """Return True if iterables v1 and v2 are nearly the same.""" + if v1 == v2: + return True + for v1, v2 in zip(v1, v2): + if (v1 != v2) and ((abs(v1 - v2) / max(abs(v1), abs(v2))) > e): + return False + return True + + +def shape_difference_exclude_similar(sv_cos_nors, ref_cos_nors, e=1e-6): + """Return a tuple of: + the difference between the vertex cos in sv_cos and ref_cos, excluding any that are nearly the same, + the corresponding vertex normal differences, + and the indices of the vertices that are not nearly the same""" + sv_cos, sv_nors = sv_cos_nors + ref_cos, ref_nors = ref_cos_nors + assert sv_cos.size == ref_cos.size == sv_nors.size == ref_nors.size + + # Create views of 1 co per row of the arrays, only making copies if needed. + sv_cos = sv_cos.reshape(-1, 3) + sv_nors = sv_nors.reshape(-1, 3) + ref_cos = ref_cos.reshape(-1, 3) + ref_nors = ref_nors.reshape(-1, 3) + + # Quick check for equality + if np.array_equal(sv_cos, ref_cos): + # There's no difference between the two arrays. + empty_cos = np.empty((0, 3), dtype=sv_cos.dtype) + empty_nors = np.empty((0, 3), dtype=sv_nors.dtype) + empty_indices = np.empty(0, dtype=np.int32) + return empty_cos, empty_nors, empty_indices + + # Note that unlike math.isclose(a,b), np.isclose(a,b) is not symmetrical and the second argument 'b', is + # considered to be the reference value. + # Note that atol=0 will mean that if only one co component being compared is zero, they won't be considered close. + similar_mask_cos = np.isclose(sv_cos, ref_cos, atol=0, rtol=e) + + # Normal tolerance is higher because it's only meant to add a few extra vertices compared to position check, + # and deltas below 1e-4 would hardly be visually noticeable anyway. + similar_mask_nors = np.isclose(sv_nors, ref_nors, atol=1e-4, rtol=e) + + # A vertex is only similar if every component in both its position and normal are similar. + similar_mask = np.all(similar_mask_cos & similar_mask_nors, axis=1) + + # Get the indices of cos that are not similar. + not_similar_verts_idx = np.flatnonzero(~similar_mask) + + # Subtracting first over the entire arrays and then indexing seems faster than indexing both arrays first and then + # subtracting, until less than about 3% of the cos are being indexed. + difference_cos = (sv_cos - ref_cos)[not_similar_verts_idx] + difference_nors = (sv_nors - ref_nors)[not_similar_verts_idx] + return difference_cos, difference_nors, not_similar_verts_idx + + +def _mat4_vec3_array_multiply(mat4, vec3_array, dtype=None, return_4d=False): + """Multiply a 4d matrix by each 3d vector in an array and return as an array of either 3d or 4d vectors. + + A view of the input array is returned if return_4d=False, the dtype matches the input array and either the matrix is + None or, ignoring the last row, is a 3x3 identity matrix with no translation: + ┌1, 0, 0, 0┐ + │0, 1, 0, 0│ + └0, 0, 1, 0┘ + + When dtype=None, it defaults to the dtype of the input array.""" + return_dtype = dtype if dtype is not None else vec3_array.dtype + vec3_array = vec3_array.reshape(-1, 3) + + # Multiplying a 4d mathutils.Matrix by a 3d mathutils.Vector implicitly extends the Vector to 4d during the + # calculation by appending 1.0 to the Vector and then the 4d result is truncated back to 3d. + # NumPy does not do an implicit extension to 4d, so it would have to be done explicitly by extending the entire + # vec3_array to 4d. + # However, since the w component of the vectors is always 1.0, the last column can be excluded from the + # multiplication and then added to every multiplied vector afterwards, which avoids having to make a 4d copy of + # vec3_array beforehand. + # For a single column vector: + # ┌a, b, c, d┐ ┌x┐ ┌ax+by+cz+d┐ + # │e, f, g, h│ @ │y│ = │ex+fy+gz+h│ + # │i, j, k, l│ │z│ │ix+jy+kz+l│ + # └m, n, o, p┘ └1┘ └mx+ny+oz+p┘ + # ┌a, b, c┐ ┌x┐ ┌d┐ ┌ax+by+cz┐ ┌d┐ ┌ax+by+cz+d┐ + # │e, f, g│ @ │y│ + │h│ = │ex+fy+gz│ + │h│ = │ex+fy+gz+h│ + # │i, j, k│ └z┘ │l│ │ix+jy+kz│ │l│ │ix+jy+kz+l│ + # └m, n, o┘ └p┘ └mx+ny+oz┘ └p┘ └mx+ny+oz+p┘ + + # column_vector_multiplication in mathutils_Vector.c uses double precision math for Matrix @ Vector by casting the + # matrix's values to double precision and then casts back to single precision when returning the result, so at least + # double precision math is always be used to match standard Blender behavior. + math_precision = np.result_type(np.double, vec3_array) + + to_multiply = None + to_add = None + w_to_set = 1.0 + if mat4 is not None: + mat_np = np.array(mat4, dtype=math_precision) + # Identity matrix is compared against to check if any matrix multiplication is required. + identity = np.identity(4, dtype=math_precision) + if not return_4d: + # If returning 3d, the entire last row of the matrix can be ignored because it only affects the w component. + mat_np = mat_np[:3] + identity = identity[:3] + + # Split mat_np into the columns to multiply and the column to add afterwards. + # First 3 columns + multiply_columns = mat_np[:, :3] + multiply_identity = identity[:, :3] + # Last column only + add_column = mat_np.T[3] + + # Analyze the split parts of the matrix to figure out if there is anything to multiply and anything to add. + if not np.array_equal(multiply_columns, multiply_identity): + to_multiply = multiply_columns + + if return_4d and to_multiply is None: + # When there's nothing to multiply, the w component of add_column can be set directly into the array because + # mx+ny+oz+p becomes 0x+0y+0z+p where p is add_column[3]. + w_to_set = add_column[3] + # Replace add_column with a view of only the translation. + add_column = add_column[:3] + + if add_column.any(): + to_add = add_column + + if to_multiply is None: + # If there's anything to add, ensure it's added using the precision being used for math. + array_dtype = math_precision if to_add is not None else return_dtype + if return_4d: + multiplied_vectors = np.empty((len(vec3_array), 4), dtype=array_dtype) + multiplied_vectors[:, :3] = vec3_array + multiplied_vectors[:, 3] = w_to_set + else: + # If there's anything to add, ensure a copy is made so that the input vec3_array isn't modified. + multiplied_vectors = vec3_array.astype(array_dtype, copy=to_add is not None) + else: + # Matrix multiplication has the signature (n,k) @ (k,m) -> (n,m). + # Where v is the number of vectors in vec3_array and d is the number of vector dimensions to return: + # to_multiply has shape (d,3), vec3_array has shape (v,3) and the result should have shape (v,d). + # Either vec3_array or to_multiply must be transposed: + # Can transpose vec3_array and then transpose the result: + # (v,3).T -> (3,v); (d,3) @ (3,v) -> (d,v); (d,v).T -> (v,d) + # Or transpose to_multiply and swap the order of multiplication: + # (d,3).T -> (3,d); (v,3) @ (3,d) -> (v,d) + # There's no, or negligible, performance difference between the two options, however, the result of the latter + # will be C contiguous in memory, making it faster to convert to flattened bytes with .tobytes(). + multiplied_vectors = vec3_array @ to_multiply.T + + if to_add is not None: + for axis, to_add_to_axis in zip(multiplied_vectors.T, to_add): + if to_add_to_axis != 0: + axis += to_add_to_axis + + # Cast to the desired return type before returning. + return multiplied_vectors.astype(return_dtype, copy=False) + + +def vcos_transformed(raw_cos, m=None, dtype=None): + return _mat4_vec3_array_multiply(m, raw_cos, dtype) + + +def nors_transformed(raw_nors, m=None, dtype=None): + # Great, now normals are also expected 4D! + # XXX Back to 3D normals for now! + # return _mat4_vec3_array_multiply(m, raw_nors, dtype, return_4d=True) + return _mat4_vec3_array_multiply(m, raw_nors, dtype) + + +def astype_view_signedness(arr, new_dtype): + """Unsafely views arr as new_dtype if the itemsize and byteorder of arr matches but the signedness does not. + + Safely views arr as new_dtype if both arr and new_dtype have the same itemsize, byteorder and signedness, but could + have a different character code, e.g. 'i' and 'l'. np.ndarray.astype with copy=False does not normally create this + view, but Blender can be picky about the character code used, so this function will create the view. + + Otherwise, calls np.ndarray.astype with copy=False. + + The benefit of copy=False is that if the array can be safely viewed as the new type, then a view is made, instead of + a copy with the new type. + + Unsigned types can't be viewed safely as signed or vice-versa, meaning that a copy would always be made by + .astype(..., copy=False). + + This is intended for viewing uintc data (a common Blender C type with variable itemsize, though usually 4 bytes, so + uint32) as int32 (a common FBX type), when the itemsizes match.""" + arr_dtype = arr.dtype + + if not isinstance(new_dtype, np.dtype): + # new_dtype could be a type instance or a string, but it needs to be a dtype to compare its itemsize, byteorder + # and kind. + new_dtype = np.dtype(new_dtype) + + # For simplicity, only dtypes of the same itemsize and byteorder, but opposite signedness, are handled. Everything + # else is left to .astype. + arr_kind = arr_dtype.kind + new_kind = new_dtype.kind + # Signed and unsigned int are opposite in terms of signedness. Other types don't have signedness. + integer_kinds = {'i', 'u'} + if ( + arr_kind in integer_kinds and new_kind in integer_kinds + and arr_dtype.itemsize == new_dtype.itemsize + and arr_dtype.byteorder == new_dtype.byteorder + ): + # arr and new_dtype have signedness and matching itemsize and byteorder, so return a view of the new type. + return arr.view(new_dtype) + else: + return arr.astype(new_dtype, copy=False) + + +def fast_first_axis_flat(ar): + """Get a flat view (or a copy if a view is not possible) of the input array whereby each element is a single element + of a dtype that is fast to sort, sorts according to individual bytes and contains the data for an entire row (and + any further dimensions) of the input array. + + Since the dtype of the view could sort in a different order to the dtype of the input array, this isn't typically + useful for actual sorting, but it is useful for sorting-based uniqueness, such as np.unique.""" + # If there are no rows, each element will be viewed as the new dtype. + elements_per_row = math.prod(ar.shape[1:]) + row_itemsize = ar.itemsize * elements_per_row + + # Get a dtype with itemsize that equals row_itemsize. + # Integer types sort the fastest, but are only available for specific itemsizes. + uint_dtypes_by_itemsize = {1: np.uint8, 2: np.uint16, 4: np.uint32, 8: np.uint64} + # Signed/unsigned makes no noticeable speed difference, but using unsigned will result in ordering according to + # individual bytes like the other, non-integer types. + if row_itemsize in uint_dtypes_by_itemsize: + entire_row_dtype = uint_dtypes_by_itemsize[row_itemsize] + else: + # When using kind='stable' sorting, numpy only uses radix sort with integer types, but it's still + # significantly faster to sort by a single item per row instead of multiple row elements or multiple structured + # type fields. + # Construct a flexible size dtype with matching itemsize. + # Should always be 4 because each character in a unicode string is UCS4. + str_itemsize = np.dtype((np.str_, 1)).itemsize + if row_itemsize % str_itemsize == 0: + # Unicode strings seem to be slightly faster to sort than bytes. + entire_row_dtype = np.dtype((np.str_, row_itemsize // str_itemsize)) + else: + # Bytes seem to be slightly faster to sort than raw bytes (np.void). + entire_row_dtype = np.dtype((np.bytes_, row_itemsize)) + + # View each element along the first axis as a single element. + # View (or copy if a view is not possible) as flat + ar = ar.reshape(-1) + # To view as a dtype of different size, the last axis (entire array in NumPy 1.22 and earlier) must be C-contiguous. + if row_itemsize != ar.itemsize and not ar.flags.c_contiguous: + ar = np.ascontiguousarray(ar) + return ar.view(entire_row_dtype) + + +def fast_first_axis_unique(ar, return_unique=True, return_index=False, return_inverse=False, return_counts=False): + """np.unique with axis=0 but optimized for when the input array has multiple elements per row, and the returned + unique array doesn't need to be sorted. + + Arrays with more than one element per row are more costly to sort in np.unique due to being compared one + row-element at a time, like comparing tuples. + + By viewing each entire row as a single non-structured element, much faster sorting can be achieved. Since the values + are viewed as a different type to their original, this means that the returned array of unique values may not be + sorted according to their original type. + + The array of unique values can be excluded from the returned tuple by specifying return_unique=False. + + Float type caveats: + All elements of -0.0 in the input array will be replaced with 0.0 to ensure that both values are collapsed into one. + NaN values can have lots of different byte representations (e.g. signaling/quiet and custom payloads). Only the + duplicates of each unique byte representation will be collapsed into one.""" + # At least something should always be returned. + assert return_unique or return_index or return_inverse or return_counts + # Only signed integer, unsigned integer and floating-point kinds of data are allowed. Other kinds of data have not + # been tested. + assert ar.dtype.kind in "iuf" + + # Floating-point types have different byte representations for -0.0 and 0.0. Collapse them together by replacing all + # -0.0 in the input array with 0.0. + if ar.dtype.kind == 'f': + ar[ar == -0.0] = 0.0 + + # It's a bit annoying that the unique array is always calculated even when it might not be needed, but it is + # generally insignificant compared to the cost of sorting. + result = np.unique(fast_first_axis_flat(ar), return_index=return_index, + return_inverse=return_inverse, return_counts=return_counts) + + if return_unique: + unique = result[0] if isinstance(result, tuple) else result + # View in the original dtype. + unique = unique.view(ar.dtype) + # Return the same number of elements per row and any extra dimensions per row as the input array. + unique.shape = (-1, *ar.shape[1:]) + if isinstance(result, tuple): + return (unique,) + result[1:] + else: + return unique + else: + # Remove the first element, the unique array. + result = result[1:] + if len(result) == 1: + # Unpack single element tuples. + return result[0] + else: + return result + + +def ensure_object_not_in_edit_mode(context, obj): + """Objects in Edit mode usually cannot be exported because much of the API used when exporting is not available for + Objects in Edit mode. + + Exiting the currently active Object (and any other Objects opened in multi-editing) from Edit mode is simple and + should be done with `bpy.ops.mesh.mode_set(mode='OBJECT')` instead of using this function. + + This function is for the rare case where an Object is in Edit mode, but the current context mode is not Edit mode. + This can occur from a state where the current context mode is Edit mode, but then the active Object of the current + View Layer is changed to a different Object that is not in Edit mode. This changes the current context mode, but + leaves the other Object(s) in Edit mode. + """ + if obj.mode != 'EDIT': + return True + + # Get the active View Layer. + view_layer = context.view_layer + + # A View Layer belongs to a scene. + scene = view_layer.id_data + + # Get the current active Object of this View Layer, so we can restore it once done. + orig_active = view_layer.objects.active + + # Check if obj is in the View Layer. If obj is not in the View Layer, it cannot be set as the active Object. + # We don't use `obj.name in view_layer.objects` because an Object from a Library could have the same name. + is_in_view_layer = any(o == obj for o in view_layer.objects) + + do_unlink_from_scene_collection = False + try: + if not is_in_view_layer: + # There might not be any enabled collections in the View Layer, so link obj into the Scene Collection + # instead, which is always available to all View Layers of that Scene. + scene.collection.objects.link(obj) + do_unlink_from_scene_collection = True + view_layer.objects.active = obj + + # Now we're finally ready to attempt to change obj's mode. + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT') + if obj.mode == 'EDIT': + # The Object could not be set out of EDIT mode and therefore cannot be exported. + return False + finally: + # Always restore the original active Object and unlink obj from the Scene Collection if it had to be linked. + view_layer.objects.active = orig_active + if do_unlink_from_scene_collection: + scene.collection.objects.unlink(obj) + + return True + + +def expand_shape_key_range(shape_key, value_to_fit): + """Attempt to expand the slider_min/slider_max of a shape key to fit `value_to_fit` within the slider range, + expanding slightly beyond `value_to_fit` if possible, so that the new slider_min/slider_max is not the same as + `value_to_fit`. Blender has a hard minimum and maximum for slider values, so it may not be possible to fit the value + within the slider range. + + If `value_to_fit` is already within the slider range, no changes are made. + + First tries setting slider_min/slider_max to double `value_to_fit`, otherwise, expands the range in the direction of + `value_to_fit` by double the distance to `value_to_fit`. + + The new slider_min/slider_max is rounded down/up to the nearest whole number for a more visually pleasing result. + + Returns whether it was possible to expand the slider range to fit `value_to_fit`.""" + if value_to_fit < (slider_min := shape_key.slider_min): + if value_to_fit < 0.0: + # For the most common case, set slider_min to double value_to_fit. + target_slider_min = value_to_fit * 2.0 + else: + # Doubling value_to_fit would make it larger, so instead decrease slider_min by double the distance between + # slider_min and value_to_fit. + target_slider_min = slider_min - (slider_min - value_to_fit) * 2.0 + # Set slider_min to the first whole number less than or equal to target_slider_min. + shape_key.slider_min = math.floor(target_slider_min) + + return value_to_fit >= SHAPE_KEY_SLIDER_HARD_MIN + elif value_to_fit > (slider_max := shape_key.slider_max): + if value_to_fit > 0.0: + # For the most common case, set slider_max to double value_to_fit. + target_slider_max = value_to_fit * 2.0 + else: + # Doubling value_to_fit would make it smaller, so instead increase slider_max by double the distance between + # slider_max and value_to_fit. + target_slider_max = slider_max + (value_to_fit - slider_max) * 2.0 + # Set slider_max to the first whole number greater than or equal to target_slider_max. + shape_key.slider_max = math.ceil(target_slider_max) + + return value_to_fit <= SHAPE_KEY_SLIDER_HARD_MAX + else: + # Value is already within the range. + return True + + +# ##### Attribute utils. ##### +AttributeDataTypeInfo = namedtuple("AttributeDataTypeInfo", ["dtype", "foreach_attribute", "item_size"]) +_attribute_data_type_info_lookup = { + 'FLOAT': AttributeDataTypeInfo(np.single, "value", 1), + 'INT': AttributeDataTypeInfo(np.intc, "value", 1), + 'FLOAT_VECTOR': AttributeDataTypeInfo(np.single, "vector", 3), + 'FLOAT_COLOR': AttributeDataTypeInfo(np.single, "color", 4), # color_srgb is an alternative + 'BYTE_COLOR': AttributeDataTypeInfo(np.single, "color", 4), # color_srgb is an alternative + 'STRING': AttributeDataTypeInfo(None, "value", 1), # Not usable with foreach_get/set + 'BOOLEAN': AttributeDataTypeInfo(bool, "value", 1), + 'FLOAT2': AttributeDataTypeInfo(np.single, "vector", 2), + 'INT8': AttributeDataTypeInfo(np.intc, "value", 1), + 'INT32_2D': AttributeDataTypeInfo(np.intc, "value", 2), +} + + +def attribute_get(attributes, name, data_type, domain): + """Get an attribute by its name, data_type and domain. + + Returns None if no attribute with this name, data_type and domain exists.""" + attr = attributes.get(name) + if not attr: + return None + if attr.data_type == data_type and attr.domain == domain: + return attr + # It shouldn't normally happen, but it's possible there are multiple attributes with the same name, but different + # data_types or domains. + for attr in attributes: + if attr.name == name and attr.data_type == data_type and attr.domain == domain: + return attr + return None + + +def attribute_foreach_set(attribute, array_or_list, foreach_attribute=None): + """Set every value of an attribute with foreach_set.""" + if foreach_attribute is None: + foreach_attribute = _attribute_data_type_info_lookup[attribute.data_type].foreach_attribute + attribute.data.foreach_set(foreach_attribute, array_or_list) + + +def attribute_to_ndarray(attribute, foreach_attribute=None): + """Create a NumPy ndarray from an attribute.""" + data = attribute.data + data_type_info = _attribute_data_type_info_lookup[attribute.data_type] + ndarray = np.empty(len(data) * data_type_info.item_size, dtype=data_type_info.dtype) + if foreach_attribute is None: + foreach_attribute = data_type_info.foreach_attribute + data.foreach_get(foreach_attribute, ndarray) + return ndarray + + +@dataclass +class AttributeDescription: + """Helper class to reduce duplicate code for handling built-in Blender attributes.""" + name: str + # Valid identifiers can be found in bpy.types.Attribute.bl_rna.properties["data_type"].enum_items + data_type: str + # Valid identifiers can be found in bpy.types.Attribute.bl_rna.properties["domain"].enum_items + domain: str + # Some attributes are required to exist if certain conditions are met. If a required attribute does not exist when + # attempting to get it, an AssertionError is raised. + is_required_check: Callable[[bpy.types.AttributeGroupMesh], bool] = None + # NumPy dtype that matches the internal C data of this attribute. + dtype: np.dtype = field(init=False) + # The default attribute name to use with foreach_get and foreach_set. + foreach_attribute: str = field(init=False) + # The number of elements per value of the attribute when flattened into a 1-dimensional list/array. + item_size: int = field(init=False) + + def __post_init__(self): + data_type_info = _attribute_data_type_info_lookup[self.data_type] + self.dtype = data_type_info.dtype + self.foreach_attribute = data_type_info.foreach_attribute + self.item_size = data_type_info.item_size + + def is_required(self, attributes): + """Check if the attribute is required to exist in the provided attributes.""" + is_required_check = self.is_required_check + return is_required_check and is_required_check(attributes) + + def get(self, attributes): + """Get the attribute. + + If the attribute is required, but does not exist, an AssertionError is raised, otherwise None is returned.""" + attr = attribute_get(attributes, self.name, self.data_type, self.domain) + if not attr and self.is_required(attributes): + raise AssertionError("Required attribute '%s' with type '%s' and domain '%s' not found in %r" + % (self.name, self.data_type, self.domain, attributes)) + return attr + + def ensure(self, attributes): + """Get the attribute, creating it if it does not exist. + + Raises a RuntimeError if the attribute could not be created, which should only happen when attempting to create + an attribute with a reserved name, but with the wrong data_type or domain. See usage of + BuiltinCustomDataLayerProvider in Blender source for most reserved names. + + There is no guarantee that the returned attribute has the desired name because the name could already be in use + by another attribute with a different data_type and/or domain.""" + attr = self.get(attributes) + if attr: + return attr + + attr = attributes.new(self.name, self.data_type, self.domain) + if not attr: + raise RuntimeError("Could not create attribute '%s' with type '%s' and domain '%s' in %r" + % (self.name, self.data_type, self.domain, attributes)) + return attr + + def foreach_set(self, attributes, array_or_list, foreach_attribute=None): + """Get the attribute, creating it if it does not exist, and then set every value in the attribute.""" + attribute_foreach_set(self.ensure(attributes), array_or_list, foreach_attribute) + + def get_ndarray(self, attributes, foreach_attribute=None): + """Get the attribute and if it exists, return a NumPy ndarray containing its data, otherwise return None.""" + attr = self.get(attributes) + return attribute_to_ndarray(attr, foreach_attribute) if attr else None + + def to_ndarray(self, attributes, foreach_attribute=None): + """Get the attribute and if it exists, return a NumPy ndarray containing its data, otherwise return a + zero-length ndarray.""" + ndarray = self.get_ndarray(attributes, foreach_attribute) + return ndarray if ndarray is not None else np.empty(0, dtype=self.dtype) + + +# Built-in Blender attributes +# Only attributes used by the importer/exporter are included here. +# See usage of BuiltinCustomDataLayerProvider in Blender source to find most built-in attributes. +MESH_ATTRIBUTE_MATERIAL_INDEX = AttributeDescription("material_index", 'INT', 'FACE') +MESH_ATTRIBUTE_POSITION = AttributeDescription("position", 'FLOAT_VECTOR', 'POINT', + is_required_check=lambda attributes: bool(attributes.id_data.vertices)) +MESH_ATTRIBUTE_SHARP_EDGE = AttributeDescription("sharp_edge", 'BOOLEAN', 'EDGE') +MESH_ATTRIBUTE_EDGE_VERTS = AttributeDescription(".edge_verts", 'INT32_2D', 'EDGE', + is_required_check=lambda attributes: bool(attributes.id_data.edges)) +MESH_ATTRIBUTE_CORNER_VERT = AttributeDescription(".corner_vert", 'INT', 'CORNER', + is_required_check=lambda attributes: bool(attributes.id_data.loops)) +MESH_ATTRIBUTE_CORNER_EDGE = AttributeDescription(".corner_edge", 'INT', 'CORNER', + is_required_check=lambda attributes: bool(attributes.id_data.loops)) +MESH_ATTRIBUTE_SHARP_FACE = AttributeDescription("sharp_face", 'BOOLEAN', 'FACE') + + +# ##### UIDs code. ##### + +# ID class (mere int). +class UUID(int): + pass + + +# UIDs storage. +_keys_to_uuids = {} +_uuids_to_keys = {} + + +def _key_to_uuid(uuids, key): + # TODO: Check this is robust enough for our needs! + # Note: We assume we have already checked the related key wasn't yet in _keys_to_uids! + # As int64 is signed in FBX, we keep uids below 2**63... + if isinstance(key, int) and 0 <= key < 2**63: + # We can use value directly as id! + uuid = key + else: + uuid = hash(key) + if uuid < 0: + uuid = -uuid + if uuid >= 2**63: + uuid //= 2 + # Try to make our uid shorter! + if uuid > int(1e9): + t_uuid = uuid % int(1e9) + if t_uuid not in uuids: + uuid = t_uuid + # Make sure our uuid *is* unique. + if uuid in uuids: + inc = 1 if uuid < 2**62 else -1 + while uuid in uuids: + uuid += inc + if 0 > uuid >= 2**63: + # Note that this is more that unlikely, but does not harm anyway... + raise ValueError("Unable to generate an UUID for key {!r}".format(key)) + return UUID(uuid) + + +def get_fbx_uuid_from_key(key): + """ + Return an UUID for given key, which is assumed to be hashable. + """ + uuid = _keys_to_uuids.get(key, None) + if uuid is None: + uuid = _key_to_uuid(_uuids_to_keys, key) + _keys_to_uuids[key] = uuid + _uuids_to_keys[uuid] = key + return uuid + + +# XXX Not sure we'll actually need this one? +def get_key_from_fbx_uuid(uuid): + """ + Return the key which generated this uid. + """ + assert uuid.__class__ == UUID + return _uuids_to_keys.get(uuid, None) + + +# Blender-specific key generators +def get_bid_name(bid): + library = getattr(bid, "library", None) + if library is not None: + return "%s_L_%s" % (bid.name, library.name) + else: + return bid.name + + +def get_blenderID_key(bid): + if isinstance(bid, Iterable): + return "|".join("B" + e.rna_type.name + "#" + get_bid_name(e) for e in bid) + else: + return "B" + bid.rna_type.name + "#" + get_bid_name(bid) + + +def get_blenderID_name(bid): + if isinstance(bid, Iterable): + return "|".join(get_bid_name(e) for e in bid) + else: + return get_bid_name(bid) + + +def get_blender_empty_key(obj): + """Return bone's keys (Model and NodeAttribute).""" + return "|".join((get_blenderID_key(obj), "Empty")) + + +def get_blender_mesh_shape_key(me): + """Return main shape deformer's key.""" + return "|".join((get_blenderID_key(me), "Shape")) + + +def get_blender_mesh_shape_channel_key(me, shape): + """Return shape channel and geometry shape keys.""" + return ("|".join((get_blenderID_key(me), "Shape", get_blenderID_key(shape))), + "|".join((get_blenderID_key(me), "Geometry", get_blenderID_key(shape)))) + + +def get_blender_bone_key(armature, bone): + """Return bone's keys (Model and NodeAttribute).""" + return "|".join((get_blenderID_key((armature, bone)), "Data")) + + +def get_blender_bindpose_key(obj, mesh): + """Return object's bindpose key.""" + return "|".join((get_blenderID_key(obj), get_blenderID_key(mesh), "BindPose")) + + +def get_blender_armature_skin_key(armature, mesh): + """Return armature's skin key.""" + return "|".join((get_blenderID_key(armature), get_blenderID_key(mesh), "DeformerSkin")) + + +def get_blender_bone_cluster_key(armature, mesh, bone): + """Return bone's cluster key.""" + return "|".join((get_blenderID_key(armature), get_blenderID_key(mesh), + get_blenderID_key(bone), "SubDeformerCluster")) + + +def get_blender_anim_id_base(scene, ref_id): + if ref_id is not None: + return get_blenderID_key(scene) + "|" + get_blenderID_key(ref_id) + else: + return get_blenderID_key(scene) + + +def get_blender_anim_stack_key(scene, ref_id): + """Return single anim stack key.""" + return get_blender_anim_id_base(scene, ref_id) + "|AnimStack" + + +def get_blender_anim_layer_key(scene, ref_id): + """Return ID's anim layer key.""" + return get_blender_anim_id_base(scene, ref_id) + "|AnimLayer" + + +def get_blender_anim_curve_node_key(scene, ref_id, obj_key, fbx_prop_name): + """Return (stack/layer, ID, fbxprop) curve node key.""" + return "|".join((get_blender_anim_id_base(scene, ref_id), obj_key, fbx_prop_name, "AnimCurveNode")) + + +def get_blender_anim_curve_key(scene, ref_id, obj_key, fbx_prop_name, fbx_prop_item_name): + """Return (stack/layer, ID, fbxprop, item) curve key.""" + return "|".join((get_blender_anim_id_base(scene, ref_id), obj_key, fbx_prop_name, + fbx_prop_item_name, "AnimCurve")) + + +def get_blender_nodetexture_key(ma, socket_names): + return "|".join((get_blenderID_key(ma), *socket_names)) + + +# ##### Element generators. ##### + +# Note: elem may be None, in this case the element is not added to any parent. +def elem_empty(elem, name): + sub_elem = encode_bin.FBXElem(name) + if elem is not None: + elem.elems.append(sub_elem) + return sub_elem + + +def _elem_data_single(elem, name, value, func_name): + sub_elem = elem_empty(elem, name) + getattr(sub_elem, func_name)(value) + return sub_elem + + +def _elem_data_vec(elem, name, value, func_name): + sub_elem = elem_empty(elem, name) + func = getattr(sub_elem, func_name) + for v in value: + func(v) + return sub_elem + + +def elem_data_single_bool(elem, name, value): + return _elem_data_single(elem, name, value, "add_bool") + + +def elem_data_single_char(elem, name, value): + return _elem_data_single(elem, name, value, "add_char") + + +def elem_data_single_int8(elem, name, value): + return _elem_data_single(elem, name, value, "add_int8") + + +def elem_data_single_int16(elem, name, value): + return _elem_data_single(elem, name, value, "add_int16") + + +def elem_data_single_int32(elem, name, value): + return _elem_data_single(elem, name, value, "add_int32") + + +def elem_data_single_int64(elem, name, value): + return _elem_data_single(elem, name, value, "add_int64") + + +def elem_data_single_float32(elem, name, value): + return _elem_data_single(elem, name, value, "add_float32") + + +def elem_data_single_float64(elem, name, value): + return _elem_data_single(elem, name, value, "add_float64") + + +def elem_data_single_bytes(elem, name, value): + return _elem_data_single(elem, name, value, "add_bytes") + + +def elem_data_single_string(elem, name, value): + return _elem_data_single(elem, name, value, "add_string") + + +def elem_data_single_string_unicode(elem, name, value): + return _elem_data_single(elem, name, value, "add_string_unicode") + + +def elem_data_single_bool_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_bool_array") + + +def elem_data_single_int32_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_int32_array") + + +def elem_data_single_int64_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_int64_array") + + +def elem_data_single_float32_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_float32_array") + + +def elem_data_single_float64_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_float64_array") + + +def elem_data_single_byte_array(elem, name, value): + return _elem_data_single(elem, name, value, "add_byte_array") + + +def elem_data_vec_float64(elem, name, value): + return _elem_data_vec(elem, name, value, "add_float64") + + +# ##### Generators for standard FBXProperties70 properties. ##### + +def elem_properties(elem): + return elem_empty(elem, b"Properties70") + + +# Properties definitions, format: (b"type_1", b"label(???)", "name_set_value_1", "name_set_value_2", ...) +# XXX Looks like there can be various variations of formats here... Will have to be checked ultimately! +# Also, those "custom" types like 'FieldOfView' or 'Lcl Translation' are pure nonsense, +# these are just Vector3D ultimately... *sigh* (again). +FBX_PROPERTIES_DEFINITIONS = { + # Generic types. + "p_bool": (b"bool", b"", "add_int32"), # Yes, int32 for a bool (and they do have a core bool type)!!! + "p_integer": (b"int", b"Integer", "add_int32"), + "p_ulonglong": (b"ULongLong", b"", "add_int64"), + "p_double": (b"double", b"Number", "add_float64"), # Non-animatable? + "p_number": (b"Number", b"", "add_float64"), # Animatable-only? + "p_enum": (b"enum", b"", "add_int32"), + "p_vector_3d": (b"Vector3D", b"Vector", "add_float64", "add_float64", "add_float64"), # Non-animatable? + "p_vector": (b"Vector", b"", "add_float64", "add_float64", "add_float64"), # Animatable-only? + "p_color_rgb": (b"ColorRGB", b"Color", "add_float64", "add_float64", "add_float64"), # Non-animatable? + "p_color": (b"Color", b"", "add_float64", "add_float64", "add_float64"), # Animatable-only? + "p_string": (b"KString", b"", "add_string_unicode"), + "p_string_url": (b"KString", b"Url", "add_string_unicode"), + "p_timestamp": (b"KTime", b"Time", "add_int64"), + "p_datetime": (b"DateTime", b"", "add_string_unicode"), + # Special types. + "p_object": (b"object", b""), # XXX Check this! No value for this prop??? Would really like to know how it works! + "p_compound": (b"Compound", b""), + # Specific types (sic). + # ## Objects (Models). + "p_lcl_translation": (b"Lcl Translation", b"", "add_float64", "add_float64", "add_float64"), + "p_lcl_rotation": (b"Lcl Rotation", b"", "add_float64", "add_float64", "add_float64"), + "p_lcl_scaling": (b"Lcl Scaling", b"", "add_float64", "add_float64", "add_float64"), + "p_visibility": (b"Visibility", b"", "add_float64"), + "p_visibility_inheritance": (b"Visibility Inheritance", b"", "add_int32"), + # ## Cameras!!! + "p_roll": (b"Roll", b"", "add_float64"), + "p_opticalcenterx": (b"OpticalCenterX", b"", "add_float64"), + "p_opticalcentery": (b"OpticalCenterY", b"", "add_float64"), + "p_fov": (b"FieldOfView", b"", "add_float64"), + "p_fov_x": (b"FieldOfViewX", b"", "add_float64"), + "p_fov_y": (b"FieldOfViewY", b"", "add_float64"), +} + + +def _elem_props_set(elem, ptype, name, value, flags): + p = elem_data_single_string(elem, b"P", name) + for t in ptype[:2]: + p.add_string(t) + p.add_string(flags) + if len(ptype) == 3: + getattr(p, ptype[2])(value) + elif len(ptype) > 3: + # We assume value is iterable, else it's a bug! + for callback, val in zip(ptype[2:], value): + getattr(p, callback)(val) + + +def _elem_props_flags(animatable, animated, custom): + # XXX: There are way more flags, see + # http://help.autodesk.com/view/FBX/2015/ENU/?guid=__cpp_ref_class_fbx_property_flags_html + # Unfortunately, as usual, no doc at all about their 'translation' in actual FBX file format. + # Curse you-know-who. + if animatable: + if animated: + if custom: + return b"A+U" + return b"A+" + if custom: + # Seems that customprops always need those 'flags', see T69554. Go figure... + return b"A+U" + return b"A" + if custom: + # Seems that customprops always need those 'flags', see T69554. Go figure... + return b"A+U" + return b"" + + +def elem_props_set(elem, ptype, name, value=None, animatable=False, animated=False, custom=False): + ptype = FBX_PROPERTIES_DEFINITIONS[ptype] + _elem_props_set(elem, ptype, name, value, _elem_props_flags(animatable, animated, custom)) + + +def elem_props_compound(elem, cmpd_name, custom=False): + def _setter(ptype, name, value, animatable=False, animated=False, custom=False): + name = cmpd_name + b"|" + name + elem_props_set(elem, ptype, name, value, animatable=animatable, animated=animated, custom=custom) + + elem_props_set(elem, "p_compound", cmpd_name, custom=custom) + return _setter + + +def elem_props_template_init(templates, template_type): + """ + Init a writing template of given type, for *one* element's properties. + """ + ret = {} + tmpl = templates.get(template_type) + if tmpl is not None: + written = tmpl.written[0] + props = tmpl.properties + ret = {name: [val, ptype, anim, written] for name, (val, ptype, anim) in props.items()} + return ret + + +def elem_props_template_set(template, elem, ptype_name, name, value, animatable=False, animated=False): + """ + Only add a prop if the same value is not already defined in given template. + Note it is important to not give iterators as value, here! + """ + ptype = FBX_PROPERTIES_DEFINITIONS[ptype_name] + if len(ptype) > 3: + value = tuple(value) + tmpl_val, tmpl_ptype, tmpl_animatable, tmpl_written = template.get(name, (None, None, False, False)) + # Note animatable flag from template takes precedence over given one, if applicable. + # However, animated properties are always written, since they cannot match their template! + if tmpl_ptype is not None and not animated: + if (tmpl_written and + ((len(ptype) == 3 and (tmpl_val, tmpl_ptype) == (value, ptype_name)) or + (len(ptype) > 3 and (tuple(tmpl_val), tmpl_ptype) == (value, ptype_name)))): + return # Already in template and same value. + _elem_props_set(elem, ptype, name, value, _elem_props_flags(tmpl_animatable, animated, False)) + template[name][3] = True + else: + _elem_props_set(elem, ptype, name, value, _elem_props_flags(animatable, animated, False)) + + +def elem_props_template_finalize(template, elem): + """ + Finalize one element's template/props. + Issue is, some templates might be "needed" by different types (e.g. NodeAttribute is for lights, cameras, etc.), + but values for only *one* subtype can be written as template. So we have to be sure we write those for the other + subtypes in each and every elements, if they are not overridden by that element. + Yes, hairy, FBX that is to say. When they could easily support several subtypes per template... :( + """ + for name, (value, ptype_name, animatable, written) in template.items(): + if written: + continue + ptype = FBX_PROPERTIES_DEFINITIONS[ptype_name] + _elem_props_set(elem, ptype, name, value, _elem_props_flags(animatable, False, False)) + + +# ##### Templates ##### +# TODO: check all those "default" values, they should match Blender's default as much as possible, I guess? + +FBXTemplate = namedtuple("FBXTemplate", ("type_name", "prop_type_name", "properties", "nbr_users", "written")) + + +def fbx_templates_generate(root, fbx_templates): + # We may have to gather different templates in the same node (e.g. NodeAttribute template gathers properties + # for Lights, Cameras, LibNodes, etc.). + ref_templates = {(tmpl.type_name, tmpl.prop_type_name): tmpl for tmpl in fbx_templates.values()} + + templates = {} + for type_name, prop_type_name, properties, nbr_users, _written in fbx_templates.values(): + tmpl = templates.setdefault(type_name, [{}, 0]) + tmpl[0][prop_type_name] = (properties, nbr_users) + tmpl[1] += nbr_users + + for type_name, (subprops, nbr_users) in templates.items(): + template = elem_data_single_string(root, b"ObjectType", type_name) + elem_data_single_int32(template, b"Count", nbr_users) + + if len(subprops) == 1: + prop_type_name, (properties, _nbr_sub_type_users) = next(iter(subprops.items())) + subprops = (prop_type_name, properties) + ref_templates[(type_name, prop_type_name)].written[0] = True + else: + # Ack! Even though this could/should work, looks like it is not supported. So we have to chose one. :| + max_users = max_props = -1 + written_prop_type_name = None + for prop_type_name, (properties, nbr_sub_type_users) in subprops.items(): + if nbr_sub_type_users > max_users or (nbr_sub_type_users == max_users and len(properties) > max_props): + max_users = nbr_sub_type_users + max_props = len(properties) + written_prop_type_name = prop_type_name + subprops = (written_prop_type_name, properties) + ref_templates[(type_name, written_prop_type_name)].written[0] = True + + prop_type_name, properties = subprops + if prop_type_name and properties: + elem = elem_data_single_string(template, b"PropertyTemplate", prop_type_name) + props = elem_properties(elem) + for name, (value, ptype, animatable) in properties.items(): + try: + elem_props_set(props, ptype, name, value, animatable=animatable) + except Exception as e: + print("Failed to write template prop (%r)" % e) + print(props, ptype, name, value, animatable) + + +# ##### FBX animation helpers. ##### + + +class AnimationCurveNodeWrapper: + """ + This class provides a same common interface for all (FBX-wise) AnimationCurveNode and AnimationCurve elements, + and easy API to handle those. + """ + __slots__ = ( + 'elem_keys', 'default_values', 'fbx_group', 'fbx_gname', 'fbx_props', + 'force_keying', 'force_startend_keying', + '_frame_times_array', '_frame_values_array', '_frame_write_mask_array', + ) + + kinds = { + 'LCL_TRANSLATION': ("Lcl Translation", "T", ("X", "Y", "Z")), + 'LCL_ROTATION': ("Lcl Rotation", "R", ("X", "Y", "Z")), + 'LCL_SCALING': ("Lcl Scaling", "S", ("X", "Y", "Z")), + 'SHAPE_KEY': ("DeformPercent", "DeformPercent", ("DeformPercent",)), + 'CAMERA_FOCAL': ("FocalLength", "FocalLength", ("FocalLength",)), + 'CAMERA_FOCUS_DISTANCE': ("FocusDistance", "FocusDistance", ("FocusDistance",)), + } + + def __init__(self, elem_key, kind, force_keying, force_startend_keying, default_values=...): + self.elem_keys = [elem_key] + assert kind in self.kinds + self.fbx_group = [self.kinds[kind][0]] + self.fbx_gname = [self.kinds[kind][1]] + self.fbx_props = [self.kinds[kind][2]] + self.force_keying = force_keying + self.force_startend_keying = force_startend_keying + self._frame_times_array = None + self._frame_values_array = None + self._frame_write_mask_array = None + if default_values is not ...: + assert len(default_values) == len(self.fbx_props[0]) + self.default_values = default_values + else: + self.default_values = (0.0) * len(self.fbx_props[0]) + + def __bool__(self): + # We are 'True' if we do have some validated keyframes... + return self._frame_write_mask_array is not None and bool(np.any(self._frame_write_mask_array)) + + def add_group(self, elem_key, fbx_group, fbx_gname, fbx_props): + """ + Add another whole group stuff (curve-node, animated item/prop + curve-node/curve identifiers). + E.g. Shapes animations is written twice, horror! + """ + assert len(fbx_props) == len(self.fbx_props[0]) + self.elem_keys.append(elem_key) + self.fbx_group.append(fbx_group) + self.fbx_gname.append(fbx_gname) + self.fbx_props.append(fbx_props) + + def set_keyframes(self, keyframe_times, keyframe_values): + """ + Set all keyframe times and values of the group. + Values can be a 2D array where each row is the values for a separate curve. + """ + # View 1D keyframe_values as 2D with a single row, so that the same code can be used for both 1D and + # 2D inputs. + if len(keyframe_values.shape) == 1: + keyframe_values = keyframe_values[np.newaxis] + # There must be a time for each column of values. + assert len(keyframe_times) == keyframe_values.shape[1] + # There must be as many rows of values as there are properties. + assert len(self.fbx_props[0]) == len(keyframe_values) + write_mask = np.full_like(keyframe_values, True, dtype=bool) # write everything by default + self._frame_times_array = keyframe_times + self._frame_values_array = keyframe_values + self._frame_write_mask_array = write_mask + + def simplify(self, fac, step, force_keep=False): + """ + Simplifies sampled curves by only enabling samples when: + * their values relatively differ from the previous sample ones. + """ + if self._frame_times_array is None: + # Keyframes have not been added yet. + return + + if fac == 0.0: + return + + # So that, with default factor and step values (1), we get: + min_reldiff_fac = fac * 1.0e-3 # min relative value evolution: 0.1% of current 'order of magnitude'. + min_absdiff_fac = 0.1 # A tenth of reldiff... + + # Initialize to no values enabled for writing. + self._frame_write_mask_array[:] = False + + # Values are enabled for writing if they differ enough from either of their adjacent values or if they differ + # enough from the closest previous value that is enabled due to either of these conditions. + for sampled_values, enabled_mask in zip(self._frame_values_array, self._frame_write_mask_array): + # Create overlapping views of the 'previous' (all but the last) and 'current' (all but the first) + # `sampled_values` and `enabled_mask`. + # Calculate absolute values from `sampled_values` so that the 'previous' and 'current' absolute arrays can + # be views into the same array instead of separately calculated arrays. + abs_sampled_values = np.abs(sampled_values) + # 'previous' views. + p_val_view = sampled_values[:-1] + p_abs_val_view = abs_sampled_values[:-1] + p_enabled_mask_view = enabled_mask[:-1] + # 'current' views. + c_val_view = sampled_values[1:] + c_abs_val_view = abs_sampled_values[1:] + c_enabled_mask_view = enabled_mask[1:] + + # If enough difference from previous sampled value, enable the current value *and* the previous one! + # The difference check is symmetrical, so this will compare each value to both of its adjacent values. + # Unless it is forcefully enabled later, this is the only way that the first value can be enabled. + # This is a contracted form of relative + absolute-near-zero difference: + # def is_different(a, b): + # abs_diff = abs(a - b) + # if abs_diff < min_reldiff_fac * min_absdiff_fac: + # return False + # return (abs_diff / ((abs(a) + abs(b)) / 2)) > min_reldiff_fac + # Note that we ignore the '/ 2' part here, since it's not much significant for us. + # Contracted form using only builtin Python functions: + # return abs(a - b) > (min_reldiff_fac * max(abs(a) + abs(b), min_absdiff_fac)) + abs_diff = np.abs(c_val_view - p_val_view) + different_if_greater_than = min_reldiff_fac * np.maximum(c_abs_val_view + p_abs_val_view, min_absdiff_fac) + enough_diff_p_val_mask = abs_diff > different_if_greater_than + # Enable both the current values *and* the previous values where `enough_diff_p_val_mask` is True. Some + # values may get set to True twice because the views overlap, but this is not a problem. + p_enabled_mask_view[enough_diff_p_val_mask] = True + c_enabled_mask_view[enough_diff_p_val_mask] = True + + # Else, if enough difference from previous enabled value, enable the current value only! + # For each 'current' value, get the index of the nearest previous enabled value in `sampled_values` (or + # itself if the value is enabled). + # Start with an array that is the index of the 'current' value in `sampled_values`. The 'current' values are + # all but the first value, so the indices will be from 1 to `len(sampled_values)` exclusive. + # Let len(sampled_values) == 9: + # [1, 2, 3, 4, 5, 6, 7, 8] + p_enabled_idx_in_sampled_values = np.arange(1, len(sampled_values)) + # Replace the indices of all disabled values with 0 in preparation of filling them in with the index of the + # nearest previous enabled value. We choose to replace with 0 so that if there is no nearest previous + # enabled value, we instead default to `sampled_values[0]`. + c_val_disabled_mask = ~c_enabled_mask_view + # Let `c_val_disabled_mask` be: + # [F, F, T, F, F, T, T, T] + # Set indices to 0 where `c_val_disabled_mask` is True: + # [1, 2, 3, 4, 5, 6, 7, 8] + # v v v v + # [1, 2, 0, 4, 5, 0, 0, 0] + p_enabled_idx_in_sampled_values[c_val_disabled_mask] = 0 + # Accumulative maximum travels across the array from left to right, filling in the zeroed indices with the + # maximum value so far, which will be the closest previous enabled index because the non-zero indices are + # strictly increasing. + # [1, 2, 0, 4, 5, 0, 0, 0] + # v v v v + # [1, 2, 2, 4, 5, 5, 5, 5] + p_enabled_idx_in_sampled_values = np.maximum.accumulate(p_enabled_idx_in_sampled_values) + # Only disabled values need to be checked against their nearest previous enabled values. + # We can additionally ignore all values which equal their immediately previous value because those values + # will never be enabled if they were not enabled by the earlier difference check against immediately + # previous values. + p_enabled_diff_to_check_mask = np.logical_and(c_val_disabled_mask, p_val_view != c_val_view) + # Convert from a mask to indices because we need the indices later and because the array of indices will + # usually be smaller than the mask array making it faster to index other arrays with. + p_enabled_diff_to_check_idx = np.flatnonzero(p_enabled_diff_to_check_mask) + # `p_enabled_idx_in_sampled_values` from earlier: + # [1, 2, 2, 4, 5, 5, 5, 5] + # `p_enabled_diff_to_check_mask` assuming no values equal their immediately previous value: + # [F, F, T, F, F, T, T, T] + # `p_enabled_diff_to_check_idx`: + # [ 2, 5, 6, 7] + # `p_enabled_idx_in_sampled_values_to_check`: + # [ 2, 5, 5, 5] + p_enabled_idx_in_sampled_values_to_check = p_enabled_idx_in_sampled_values[p_enabled_diff_to_check_idx] + # Get the 'current' disabled values that need to be checked. + c_val_to_check = c_val_view[p_enabled_diff_to_check_idx] + c_abs_val_to_check = c_abs_val_view[p_enabled_diff_to_check_idx] + # Get the nearest previous enabled value for each value to be checked. + nearest_p_enabled_val = sampled_values[p_enabled_idx_in_sampled_values_to_check] + abs_nearest_p_enabled_val = np.abs(nearest_p_enabled_val) + # Check the relative + absolute-near-zero difference again, but against the nearest previous enabled value + # this time. + abs_diff = np.abs(c_val_to_check - nearest_p_enabled_val) + different_if_greater_than = (min_reldiff_fac + * np.maximum(c_abs_val_to_check + abs_nearest_p_enabled_val, min_absdiff_fac)) + enough_diff_p_enabled_val_mask = abs_diff > different_if_greater_than + # If there are any that are different enough from the previous enabled value, then we have to check them all + # iteratively because enabling a new value can change the nearest previous enabled value of some elements, + # which changes their relative + absolute-near-zero difference: + # `p_enabled_diff_to_check_idx`: + # [2, 5, 6, 7] + # `p_enabled_idx_in_sampled_values_to_check`: + # [2, 5, 5, 5] + # Let `enough_diff_p_enabled_val_mask` be: + # [F, F, T, T] + # The first index that is newly enabled is 6: + # [2, 5,>6<,5] + # But 6 > 5, so the next value's nearest previous enabled index is also affected: + # [2, 5, 6,>6<] + # We had calculated a newly enabled index of 7 too, but that was calculated against the old nearest previous + # enabled index of 5, which has now been updated to 6, so whether 7 is enabled or not needs to be + # recalculated: + # [F, F, T, ?] + if np.any(enough_diff_p_enabled_val_mask): + # Accessing .data, the memoryview of the array, iteratively or by individual index is faster than doing + # the same with the array itself. + zipped = zip(p_enabled_diff_to_check_idx.data, + c_val_to_check.data, + c_abs_val_to_check.data, + p_enabled_idx_in_sampled_values_to_check.data, + enough_diff_p_enabled_val_mask.data) + # While iterating, we could set updated values into `enough_diff_p_enabled_val_mask` as we go and then + # update `enabled_mask` in bulk after the iteration, but if we're going to update an array while + # iterating, we may as well update `enabled_mask` directly instead and skip the bulk update. + # Additionally, the number of `True` writes to `enabled_mask` is usually much less than the number of + # updates that would be required to `enough_diff_p_enabled_val_mask`. + c_enabled_mask_view_mv = c_enabled_mask_view.data + + # While iterating, keep track of the most recent newly enabled index, so we can tell when we need to + # recalculate whether the current value needs to be enabled. + new_p_enabled_idx = -1 + # Keep track of its value too for performance. + new_p_enabled_val = -1 + new_abs_p_enabled_val = -1 + for cur_idx, c_val, c_abs_val, old_p_enabled_idx, enough_diff in zipped: + if new_p_enabled_idx > old_p_enabled_idx: + # The nearest previous enabled value is newly enabled and was not included when + # `enough_diff_p_enabled_val_mask` was calculated, so whether the current value is different + # enough needs to be recalculated using the newly enabled value. + # Check if the relative + absolute-near-zero difference is enough to enable this value. + enough_diff = (abs(c_val - new_p_enabled_val) + > (min_reldiff_fac * max(c_abs_val + new_abs_p_enabled_val, min_absdiff_fac))) + if enough_diff: + # The current value needs to be enabled. + c_enabled_mask_view_mv[cur_idx] = True + # Update the index and values for this newly enabled value. + new_p_enabled_idx = cur_idx + new_p_enabled_val = c_val + new_abs_p_enabled_val = c_abs_val + + # If we write nothing (action doing nothing) and are in 'force_keep' mode, we key everything! :P + # See T41766. + # Also, it seems some importers (e.g. UE4) do not handle correctly armatures where some bones + # are not animated, but are children of animated ones, so added an option to systematically force writing + # one key in this case. + # See T41719, T41605, T41254... + if self.force_keying or (force_keep and not self): + are_keyed = [True] * len(self._frame_write_mask_array) + else: + are_keyed = np.any(self._frame_write_mask_array, axis=1) + + # If we did key something, ensure first and last sampled values are keyed as well. + if self.force_startend_keying: + for is_keyed, frame_write_mask in zip(are_keyed, self._frame_write_mask_array): + if is_keyed: + frame_write_mask[:1] = True + frame_write_mask[-1:] = True + + def get_final_data(self, scene, ref_id, force_keep=False): + """ + Yield final anim data for this 'curvenode' (for all curvenodes defined). + force_keep is to force to keep a curve even if it only has one valid keyframe. + """ + curves = [ + (self._frame_times_array[write_mask], values[write_mask]) + for values, write_mask in zip(self._frame_values_array, self._frame_write_mask_array) + ] + + force_keep = force_keep or self.force_keying + for elem_key, fbx_group, fbx_gname, fbx_props in \ + zip(self.elem_keys, self.fbx_group, self.fbx_gname, self.fbx_props): + group_key = get_blender_anim_curve_node_key(scene, ref_id, elem_key, fbx_group) + group = {} + for c, def_val, fbx_item in zip(curves, self.default_values, fbx_props): + fbx_item = FBX_ANIM_PROPSGROUP_NAME + "|" + fbx_item + curve_key = get_blender_anim_curve_key(scene, ref_id, elem_key, fbx_group, fbx_item) + # (curve key, default value, keyframes, write flag). + times = c[0] + write_flag = len(times) > (0 if force_keep else 1) + group[fbx_item] = (curve_key, def_val, c, write_flag) + yield elem_key, group_key, group, fbx_group, fbx_gname + + +# ##### FBX objects generators. ##### + +# FBX Model-like data (i.e. Blender objects, depsgraph instances and bones) are wrapped in ObjectWrapper. +# This allows us to have a (nearly) same code FBX-wise for all those types. +# The wrapper tries to stay as small as possible, by mostly using callbacks (property(get...)) +# to actual Blender data it contains. +# Note it caches its instances, so that you may call several times ObjectWrapper(your_object) +# with a minimal cost (just re-computing the key). + +class MetaObjectWrapper(type): + def __call__(cls, bdata, armature=None): + if bdata is None: + return None + dup_mat = None + if isinstance(bdata, Object): + key = get_blenderID_key(bdata) + elif isinstance(bdata, DepsgraphObjectInstance): + if bdata.is_instance: + key = "|".join((get_blenderID_key((bdata.parent.original, bdata.instance_object.original)), + cls._get_dup_num_id(bdata))) + dup_mat = bdata.matrix_world.copy() + else: + key = get_blenderID_key(bdata.object.original) + else: # isinstance(bdata, (Bone, PoseBone)): + if isinstance(bdata, PoseBone): + bdata = armature.data.bones[bdata.name] + key = get_blenderID_key((armature, bdata)) + + cache = getattr(cls, "_cache", None) + if cache is None: + cache = cls._cache = {} + instance = cache.get(key) + if instance is not None: + # Duplis hack: since dupli instances are not persistent in Blender (we have to re-create them to get updated + # info like matrix...), we *always* need to reset that matrix when calling ObjectWrapper() (all + # other data is supposed valid during whole cache live span, so we can skip resetting it). + instance._dupli_matrix = dup_mat + return instance + + instance = cls.__new__(cls, bdata, armature) + instance.__init__(bdata, armature) + instance.key = key + instance._dupli_matrix = dup_mat + cache[key] = instance + return instance + + +class ObjectWrapper(metaclass=MetaObjectWrapper): + """ + This class provides a same common interface for all (FBX-wise) object-like elements: + * Blender Object + * Blender Bone and PoseBone + * Blender DepsgraphObjectInstance (for duplis). + Note since a same Blender object might be 'mapped' to several FBX models (esp. with duplis), + we need to use a key to identify each. + """ + __slots__ = ( + 'name', 'key', 'bdata', 'parented_to_armature', 'override_materials', + '_tag', '_ref', '_dupli_matrix' + ) + + @classmethod + def cache_clear(cls): + if hasattr(cls, "_cache"): + del cls._cache + + @staticmethod + def _get_dup_num_id(bdata): + INVALID_IDS = {2147483647, 0} + pids = tuple(bdata.persistent_id) + idx_valid = 0 + prev_i = ... + for idx, i in enumerate(pids[::-1]): + if i not in INVALID_IDS or (idx == len(pids) and i == 0 and prev_i != 0): + idx_valid = len(pids) - idx + break + prev_i = i + return ".".join(str(i) for i in pids[:idx_valid]) + + def __init__(self, bdata, armature=None): + """ + bdata might be an Object (deprecated), DepsgraphObjectInstance, Bone or PoseBone. + If Bone or PoseBone, armature Object must be provided. + """ + # Note: DepsgraphObjectInstance are purely runtime data, + # they become invalid as soon as we step to the next item! + # Hence we have to immediately copy *all* needed data... + if isinstance(bdata, Object): # DEPRECATED + self._tag = 'OB' + self.name = get_blenderID_name(bdata) + self.bdata = bdata + self._ref = None + elif isinstance(bdata, DepsgraphObjectInstance): + if bdata.is_instance: + # Note that dupli instance matrix is set by meta-class initialization. + self._tag = 'DP' + self.name = "|".join((get_blenderID_name((bdata.parent.original, bdata.instance_object.original)), + "Dupli", self._get_dup_num_id(bdata))) + self.bdata = bdata.instance_object.original + self._ref = bdata.parent.original + else: + self._tag = 'OB' + self.name = get_blenderID_name(bdata) + self.bdata = bdata.object.original + self._ref = None + else: # isinstance(bdata, (Bone, PoseBone)): + if isinstance(bdata, PoseBone): + bdata = armature.data.bones[bdata.name] + self._tag = 'BO' + self.name = get_blenderID_name(bdata) + self.bdata = bdata + self._ref = armature + self.parented_to_armature = False + self.override_materials = None + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.key == other.key + + def __hash__(self): + return hash(self.key) + + def __repr__(self): + return self.key + + # #### Common to all _tag values. + def get_fbx_uuid(self): + return get_fbx_uuid_from_key(self.key) + fbx_uuid = property(get_fbx_uuid) + + # XXX Not sure how much that’s useful now... :/ + def get_hide(self): + return self.bdata.hide_viewport if self._tag in {'OB', 'DP'} else self.bdata.hide + hide = property(get_hide) + + def get_parent(self): + if self._tag == 'OB': + if (self.bdata.parent and self.bdata.parent.type == 'ARMATURE' and + self.bdata.parent_type == 'BONE' and self.bdata.parent_bone): + # Try to parent to a bone. + bo_par = self.bdata.parent.pose.bones.get(self.bdata.parent_bone, None) + if (bo_par): + return ObjectWrapper(bo_par, self.bdata.parent) + else: # Fallback to mere object parenting. + return ObjectWrapper(self.bdata.parent) + else: + # Mere object parenting. + return ObjectWrapper(self.bdata.parent) + elif self._tag == 'DP': + return ObjectWrapper(self._ref) + else: # self._tag == 'BO' + return ObjectWrapper(self.bdata.parent, self._ref) or ObjectWrapper(self._ref) + parent = property(get_parent) + + def get_bdata_pose_bone(self): + if self._tag == 'BO': + return self._ref.pose.bones[self.bdata.name] + return None + bdata_pose_bone = property(get_bdata_pose_bone) + + def get_matrix_local(self): + if self._tag == 'OB': + return self.bdata.matrix_local.copy() + elif self._tag == 'DP': + return self._ref.matrix_world.inverted_safe() @ self._dupli_matrix + else: # 'BO', current pose + # PoseBone.matrix is in armature space, bring in back in real local one! + par = self.bdata.parent + par_mat_inv = self._ref.pose.bones[par.name].matrix.inverted_safe() if par else Matrix() + return par_mat_inv @ self._ref.pose.bones[self.bdata.name].matrix + matrix_local = property(get_matrix_local) + + def get_matrix_global(self): + if self._tag == 'OB': + return self.bdata.matrix_world.copy() + elif self._tag == 'DP': + return self._dupli_matrix + else: # 'BO', current pose + return self._ref.matrix_world @ self._ref.pose.bones[self.bdata.name].matrix + matrix_global = property(get_matrix_global) + + def get_matrix_rest_local(self): + if self._tag == 'BO': + # Bone.matrix_local is in armature space, bring in back in real local one! + par = self.bdata.parent + par_mat_inv = par.matrix_local.inverted_safe() if par else Matrix() + return par_mat_inv @ self.bdata.matrix_local + else: + return self.matrix_local.copy() + matrix_rest_local = property(get_matrix_rest_local) + + def get_matrix_rest_global(self): + if self._tag == 'BO': + return self._ref.matrix_world @ self.bdata.matrix_local + else: + return self.matrix_global.copy() + matrix_rest_global = property(get_matrix_rest_global) + + # #### Transform and helpers + def has_valid_parent(self, objects): + par = self.parent + if par in objects: + if self._tag == 'OB': + par_type = self.bdata.parent_type + if par_type in {'OBJECT', 'BONE'}: + return True + else: + print("Sorry, \"{:s}\" parenting type is not supported".format(par_type)) + return False + return True + return False + + def use_bake_space_transform(self, scene_data): + # NOTE: Only applies to object types supporting this!!! Currently, only meshes and the like... + # TODO: Check whether this can work for bones too... + return (scene_data.settings.bake_space_transform and self._tag in {'OB', 'DP'} and + self.bdata.type in BLENDER_OBJECT_TYPES_MESHLIKE | {'EMPTY'}) + + def fbx_object_matrix(self, scene_data, rest=False, local_space=False, global_space=False): + """ + Generate object transform matrix (*always* in matching *FBX* space!). + If local_space is True, returned matrix is *always* in local space. + Else if global_space is True, returned matrix is always in world space. + If both local_space and global_space are False, returned matrix is in parent space if parent is valid, + else in world space. + Note local_space has precedence over global_space. + If rest is True and object is a Bone, returns matching rest pose transform instead of current pose one. + Applies specific rotation to bones, lamps and cameras (conversion Blender -> FBX). + """ + # Objects which are not bones and do not have any parent are *always* in global space + # (unless local_space is True!). + is_global = (not local_space and + (global_space or not (self._tag in {'DP', 'BO'} or self.has_valid_parent(scene_data.objects)))) + + # Objects (meshes!) parented to armature are not parented to anything in FBX, hence we need them + # in global space, which is their 'virtual' local space... + is_global = is_global or self.parented_to_armature + + # Since we have to apply corrections to some types of object, we always need local Blender space here... + matrix = self.matrix_rest_local if rest else self.matrix_local + parent = self.parent + + # Bones, lamps and cameras need to be rotated (in local space!). + if self._tag == 'BO': + # If we have a bone parent we need to undo the parent correction. + if not is_global and scene_data.settings.bone_correction_matrix_inv and parent and parent.is_bone: + matrix = scene_data.settings.bone_correction_matrix_inv @ matrix + # Apply the bone correction. + if scene_data.settings.bone_correction_matrix: + matrix = matrix @ scene_data.settings.bone_correction_matrix + elif self.bdata.type == 'LIGHT': + matrix = matrix @ MAT_CONVERT_LIGHT + elif self.bdata.type == 'CAMERA': + matrix = matrix @ MAT_CONVERT_CAMERA + + if self._tag in {'DP', 'OB'} and parent: + if parent._tag == 'BO': + # In bone parent case, we get transformation in **bone tip** space (sigh). + # Have to bring it back into bone root, which is FBX expected value. + matrix = Matrix.Translation((0, (parent.bdata.tail - parent.bdata.head).length, 0)) @ matrix + + # Our matrix is in local space, time to bring it in its final desired space. + if parent: + if is_global: + # Move matrix to global Blender space. + matrix = (parent.matrix_rest_global if rest else parent.matrix_global) @ matrix + elif parent.use_bake_space_transform(scene_data): + # Blender's and FBX's local space of parent may differ if we use bake_space_transform... + # Apply parent's *Blender* local space... + matrix = (parent.matrix_rest_local if rest else parent.matrix_local) @ matrix + # ...and move it back into parent's *FBX* local space. + par_mat = parent.fbx_object_matrix(scene_data, rest=rest, local_space=True) + matrix = par_mat.inverted_safe() @ matrix + + if self.use_bake_space_transform(scene_data): + # If we bake the transforms we need to post-multiply inverse global transform. + # This means that the global transform will not apply to children of this transform. + matrix = matrix @ scene_data.settings.global_matrix_inv + if is_global: + # In any case, pre-multiply the global matrix to get it in FBX global space! + matrix = scene_data.settings.global_matrix @ matrix + + return matrix + + def fbx_object_tx(self, scene_data, rest=False, rot_euler_compat=None): + """ + Generate object transform data (always in local space when possible). + """ + matrix = self.fbx_object_matrix(scene_data, rest=rest) + loc, rot, scale = matrix.decompose() + matrix_rot = rot.to_matrix() + # Quaternion -> euler, we always use 'XYZ' order, use ref rotation if given. + if rot_euler_compat is not None: + rot = rot.to_euler('XYZ', rot_euler_compat) + else: + rot = rot.to_euler('XYZ') + return loc, rot, scale, matrix, matrix_rot + + # #### _tag dependent... + def get_is_object(self): + return self._tag == 'OB' + is_object = property(get_is_object) + + def get_is_dupli(self): + return self._tag == 'DP' + is_dupli = property(get_is_dupli) + + def get_is_bone(self): + return self._tag == 'BO' + is_bone = property(get_is_bone) + + def get_type(self): + if self._tag in {'OB', 'DP'}: + return self.bdata.type + return ... + type = property(get_type) + + def get_armature(self): + if self._tag == 'BO': + return ObjectWrapper(self._ref) + return None + armature = property(get_armature) + + def get_bones(self): + if self._tag == 'OB' and self.bdata.type == 'ARMATURE': + return (ObjectWrapper(bo, self.bdata) for bo in self.bdata.data.bones) + return () + bones = property(get_bones) + + def get_materials(self): + override_materials = self.override_materials + if override_materials is not None: + return override_materials + if self._tag in {'OB', 'DP'}: + return tuple(slot.material for slot in self.bdata.material_slots) + return () + materials = property(get_materials) + + def is_deformed_by_armature(self, arm_obj): + if not (self.is_object and self.type == 'MESH'): + return False + if self.parent == arm_obj and self.bdata.parent_type == 'ARMATURE': + return True + for mod in self.bdata.modifiers: + if mod.type == 'ARMATURE' and mod.object == arm_obj.bdata: + return True + + # #### Duplis... + def dupli_list_gen(self, depsgraph): + if self._tag == 'OB' and self.bdata.is_instancer: + return (ObjectWrapper(dup) for dup in depsgraph.object_instances + if dup.parent and ObjectWrapper(dup.parent.original) == self) + return () + + +def fbx_name_class(name, cls): + return FBX_NAME_CLASS_SEP.join((name, cls)) + + +# ##### Top-level FBX data container. ##### + +# Helper sub-container gathering all exporter settings related to media (texture files). +FBXExportSettingsMedia = namedtuple("FBXExportSettingsMedia", ( + "path_mode", "base_src", "base_dst", "subdir", + "embed_textures", "copy_set", "embedded_set", +)) + +# Helper container gathering all exporter settings. +FBXExportSettings = namedtuple("FBXExportSettings", ( + "report", "to_axes", "global_matrix", "global_scale", "apply_unit_scale", "unit_scale", + "bake_space_transform", "global_matrix_inv", "global_matrix_inv_transposed", + "context_objects", "object_types", "use_mesh_modifiers", "use_mesh_modifiers_render", + "mesh_smooth_type", "use_subsurf", "use_mesh_edges", "use_tspace", "use_triangles", + "armature_nodetype", "use_armature_deform_only", "add_leaf_bones", + "bone_correction_matrix", "bone_correction_matrix_inv", + "bake_anim", "bake_anim_use_all_bones", "bake_anim_use_nla_strips", "bake_anim_use_all_actions", + "bake_anim_step", "bake_anim_simplify_factor", "bake_anim_force_startend_keying", + "use_metadata", "media_settings", "use_custom_props", "colors_type", "prioritize_active_color" +)) + +# Helper container gathering some data we need multiple times: +# * templates. +# * settings, scene. +# * objects. +# * object data. +# * skinning data (binding armature/mesh). +# * animations. +FBXExportData = namedtuple("FBXExportData", ( + "templates", "templates_users", "connections", + "settings", "scene", "depsgraph", "objects", "animations", "animated", "frame_start", "frame_end", + "data_empties", "data_lights", "data_cameras", "data_meshes", "mesh_material_indices", + "data_bones", "data_leaf_bones", "data_deformers_skin", "data_deformers_shape", + "data_world", "data_materials", "data_textures", "data_videos", +)) + +# Helper container gathering all importer settings. +FBXImportSettings = namedtuple("FBXImportSettings", ( + "report", "to_axes", "global_matrix", "global_scale", + "bake_space_transform", "global_matrix_inv", "global_matrix_inv_transposed", + "use_custom_normals", "use_image_search", + "use_alpha_decals", "decal_offset", + "use_anim", "anim_offset", + "use_subsurf", + "use_custom_props", "use_custom_props_enum_as_string", + "nodal_material_wrap_map", "image_cache", + "ignore_leaf_bones", "force_connect_children", "automatic_bone_orientation", "bone_correction_matrix", + "use_prepost_rot", "colors_type", "mtl_name_collision_mode", +)) diff --git a/5.1/io_scene_fbx/fbx_utils_threading.py b/5.1/io_scene_fbx/fbx_utils_threading.py new file mode 100644 index 0000000..e4bc1ed --- /dev/null +++ b/5.1/io_scene_fbx/fbx_utils_threading.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: 2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from contextlib import contextmanager, nullcontext +import os +from queue import SimpleQueue + +# Note: `bpy` cannot be imported here because this module is also used by the fbx2json.py and json2fbx.py scripts. + +# For debugging/profiling purposes, can be modified at runtime to force single-threaded execution. +_MULTITHREADING_ENABLED = True +# The concurrent.futures module may not work or may not be available +# on WebAssembly platforms WASM32-EMSCRIPTEN and WASM32-WASI. +try: + from concurrent.futures import ThreadPoolExecutor +except ModuleNotFoundError: + _MULTITHREADING_ENABLED = False + ThreadPoolExecutor = None +else: + try: + # The module may be available, but not be fully functional. An error may be raised when attempting to start a + # new thread. + with ThreadPoolExecutor() as tpe: + # Attempt to start a thread by submitting a callable. + tpe.submit(lambda: None) + except Exception: + # Assume that multithreading is not supported and fall back to single-threaded execution. + _MULTITHREADING_ENABLED = False + + +def get_cpu_count(): + """Get the number of CPUs assigned to the current process if that information is available on this system. + If not available, get the total number of CPUs. + If the CPU count is indeterminable, it is assumed that there is only 1 cpu available.""" + sched_getaffinity = getattr(os, "sched_getaffinity", None) + if sched_getaffinity is not None: + # Return the number of CPUs assigned to the current process. + return len(sched_getaffinity(0)) + count = os.cpu_count() + return count if count is not None else 1 + + +class MultiThreadedTaskConsumer: + """Helper class that encapsulates everything needed to run a function on separate threads, with a single-threaded + fallback if multi-threading is not available. + + Lower overhead than typical use of ThreadPoolExecutor because no Future objects are returned, which makes this class + more suitable to running many smaller tasks. + + As with any threaded parallelization, because of Python's Global Interpreter Lock, only one thread can execute + Python code at a time, so threaded parallelization is only useful when the functions used release the GIL, such as + many IO related functions.""" + # A special task value used to signal task consumer threads to shut down. + _SHUT_DOWN_THREADS = object() + + __slots__ = ("_consumer_function", "_shared_task_queue", "_task_consumer_futures", "_executor", + "_max_consumer_threads", "_shutting_down", "_max_queue_per_consumer") + + def __init__(self, consumer_function, max_consumer_threads, max_queue_per_consumer=5): + # It's recommended to use MultiThreadedTaskConsumer.new_cpu_bound_cm() instead of creating new instances + # directly. + # __init__ should only be called after checking _MULTITHREADING_ENABLED. + assert _MULTITHREADING_ENABLED + # The function that will be called on separate threads to consume tasks. + self._consumer_function = consumer_function + # All the threads share a single queue. This is a simplistic approach, but it is unlikely to be problematic + # unless the main thread is expected to wait a long time for the consumer threads to finish. + self._shared_task_queue = SimpleQueue() + # Reference to each thread is kept through the returned Future objects. This is used as part of determining when + # new threads should be started and is used to be able to receive and handle exceptions from the threads. + self._task_consumer_futures = [] + # Create the executor. + self._executor = ThreadPoolExecutor(max_workers=max_consumer_threads) + # Technically the max workers of the executor is accessible through its `._max_workers`, but since it's private, + # meaning it could be changed without warning, we'll store the max workers/consumers ourselves. + self._max_consumer_threads = max_consumer_threads + # The maximum task queue size (before another consumer thread is started) increases by this amount with every + # additional consumer thread. + self._max_queue_per_consumer = max_queue_per_consumer + # When shutting down the threads, this is set to True as an extra safeguard to prevent new tasks being + # scheduled. + self._shutting_down = False + + @classmethod + def new_cpu_bound_cm(cls, consumer_function, other_cpu_bound_threads_in_use=1, hard_max_threads=32): + """Return a context manager that, when entered, returns a wrapper around `consumer_function` that schedules + `consumer_function` to be run on a separate thread. + + If the system can't use multithreading, then the context manager's returned function will instead be the input + `consumer_function` argument, causing tasks to be run immediately on the calling thread. + + When exiting the context manager, it waits for all scheduled tasks to complete and prevents the creation of new + tasks, similar to calling ThreadPoolExecutor.shutdown(). For these reasons, the wrapped function should only be + called from the thread that entered the context manager, otherwise there is no guarantee that all tasks will get + scheduled before the context manager exits. + + Any task that fails with an exception will cause all task consumer threads to stop. + + The maximum number of threads used matches the number of CPUs available up to a maximum of `hard_max_threads`. + `hard_max_threads`'s default of 32 matches ThreadPoolExecutor's default behavior. + + The maximum number of threads used is decreased by `other_cpu_bound_threads_in_use`. Defaulting to `1`, assuming + that the calling thread will also be doing CPU-bound work. + + Most IO-bound tasks can probably use a ThreadPoolExecutor directly instead because there will typically be fewer + tasks and, on average, each individual task will take longer. + If needed, `cls.new_cpu_bound_cm(consumer_function, -4)` could be suitable for lots of small IO-bound tasks, + because it ensures a minimum of 5 threads, like the default ThreadPoolExecutor.""" + if _MULTITHREADING_ENABLED: + max_threads = get_cpu_count() - other_cpu_bound_threads_in_use + max_threads = min(max_threads, hard_max_threads) + if max_threads > 0: + return cls(consumer_function, max_threads)._wrap_executor_cm() + # Fall back to single-threaded. + return nullcontext(consumer_function) + + def _task_consumer_callable(self): + """Callable that is run by each task consumer thread. + Signals the other task consumer threads to stop when stopped intentionally or when an exception occurs.""" + try: + while True: + # Blocks until it can get a task. + task_args = self._shared_task_queue.get() + + if task_args is self._SHUT_DOWN_THREADS: + # This special value signals that it's time for all the threads to stop. + break + else: + # Call the task consumer function. + self._consumer_function(*task_args) + finally: + # Either the thread has been told to shut down because it received _SHUT_DOWN_THREADS or an exception has + # occurred. + # Add _SHUT_DOWN_THREADS to the queue so that the other consumer threads will also shut down. + self._shared_task_queue.put(self._SHUT_DOWN_THREADS) + + def _schedule_task(self, *args): + """Task consumer threads are only started as tasks are added. + + To mitigate starting lots of threads if many tasks are scheduled in quick succession, new threads are only + started if the number of queued tasks grows too large. + + This function is a slight misuse of ThreadPoolExecutor. Normally each task to be scheduled would be submitted + through ThreadPoolExecutor.submit, but doing so is noticeably slower for small tasks. We could start new Thread + instances manually without using ThreadPoolExecutor, but ThreadPoolExecutor gives us a higher level API for + waiting for threads to finish and handling exceptions without having to implement an API using Thread ourselves. + """ + if self._shutting_down: + # Shouldn't occur through normal usage. + raise RuntimeError("Cannot schedule new tasks after shutdown") + # Schedule the task by adding it to the task queue. + self._shared_task_queue.put(args) + # Check if more consumer threads need to be added to account for the rate at which tasks are being scheduled + # compared to the rate at which tasks are being consumed. + current_consumer_count = len(self._task_consumer_futures) + if current_consumer_count < self._max_consumer_threads: + # The max queue size increases as new threads are added, otherwise, by the time the next task is added, it's + # likely that the queue size will still be over the max, causing another new thread to be added immediately. + # Increasing the max queue size whenever a new thread is started gives some time for the new thread to start + # up and begin consuming tasks before it's determined that another thread is needed. + max_queue_size_for_current_consumers = self._max_queue_per_consumer * current_consumer_count + + if self._shared_task_queue.qsize() > max_queue_size_for_current_consumers: + # Add a new consumer thread because the queue has grown too large. + self._task_consumer_futures.append(self._executor.submit(self._task_consumer_callable)) + + @contextmanager + def _wrap_executor_cm(self): + """Wrap the executor's context manager to instead return self._schedule_task and such that the threads + automatically start shutting down before the executor itself starts shutting down.""" + # .__enter__() + # Exiting the context manager of the executor will wait for all threads to finish and prevent new + # threads from being created, as if its shutdown() method had been called. + with self._executor: + try: + yield self._schedule_task + finally: + # .__exit__() + self._shutting_down = True + # Signal all consumer threads to finish up and shut down so that the executor can shut down. + # When this is run on the same thread that schedules new tasks, this guarantees that no more tasks will + # be scheduled after the consumer threads start to shut down. + self._shared_task_queue.put(self._SHUT_DOWN_THREADS) + + # Because `self._executor` was entered with a context manager, it will wait for all the consumer threads + # to finish even if we propagate an exception from one of the threads here. + for future in self._task_consumer_futures: + # .exception() waits for the future to finish and returns its raised exception or None. + ex = future.exception() + if ex is not None: + # If one of the threads raised an exception, propagate it to the main thread. + # Only the first exception will be propagated if there were multiple. + raise ex diff --git a/5.1/io_scene_fbx/import_fbx.py b/5.1/io_scene_fbx/import_fbx.py new file mode 100644 index 0000000..4c1aa56 --- /dev/null +++ b/5.1/io_scene_fbx/import_fbx.py @@ -0,0 +1,4047 @@ +# SPDX-FileCopyrightText: 2013-2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +# FBX 7.1.0 -> 7.4.0 loader for Blender + +# Not totally pep8 compliant. +# pep8 import_fbx.py --ignore=E501,E123,E702,E125 + +if "bpy" in locals(): + import importlib + if "parse_fbx" in locals(): + importlib.reload(parse_fbx) + if "fbx_utils" in locals(): + importlib.reload(fbx_utils) + +import bpy +from bpy.app.translations import pgettext_rpt as rpt_ +from mathutils import Matrix, Euler, Vector, Quaternion +from bpy_extras import anim_utils + +# Also imported in .fbx_utils, so importing here is unlikely to further affect Blender startup time. +import numpy as np + +# ----- +# Utils +from . import parse_fbx, fbx_utils + +from .parse_fbx import ( + data_types, + FBXElem, +) +from .fbx_utils import ( + PerfMon, + units_blender_to_fbx_factor, + units_convertor_iter, + array_to_matrix4, + similar_values, + similar_values_iter, + FBXImportSettings, + vcos_transformed, + nors_transformed, + parray_as_ndarray, + astype_view_signedness, + MESH_ATTRIBUTE_MATERIAL_INDEX, + MESH_ATTRIBUTE_POSITION, + MESH_ATTRIBUTE_EDGE_VERTS, + MESH_ATTRIBUTE_CORNER_VERT, + MESH_ATTRIBUTE_SHARP_FACE, + MESH_ATTRIBUTE_SHARP_EDGE, + expand_shape_key_range, + FBX_KTIME_V7, + FBX_KTIME_V8, + FBX_TIMECODE_DEFINITION_TO_KTIME_PER_SECOND, +) + +LINEAR_INTERPOLATION_VALUE = bpy.types.Keyframe.bl_rna.properties['interpolation'].enum_items['LINEAR'].value + +# global singleton, assign on execution +fbx_elem_nil = None + +# Units converters... +convert_deg_to_rad_iter = units_convertor_iter("degree", "radian") + +MAT_CONVERT_BONE = fbx_utils.MAT_CONVERT_BONE.inverted() +MAT_CONVERT_LIGHT = fbx_utils.MAT_CONVERT_LIGHT.inverted() +MAT_CONVERT_CAMERA = fbx_utils.MAT_CONVERT_CAMERA.inverted() + + +def validate_blend_names(name): + assert type(name) == bytes + # Blender typically does not accept names over 63 bytes... + if len(name) > 63: + import hashlib + h = hashlib.sha1(name).hexdigest() + n = 55 + name_utf8 = name[:n].decode('utf-8', 'replace') + "_" + h[:7] + while len(name_utf8.encode()) > 63: + n -= 1 + name_utf8 = name[:n].decode('utf-8', 'replace') + "_" + h[:7] + return name_utf8 + else: + # We use 'replace' even though FBX 'specs' say it should always be utf8, see T53841. + return name.decode('utf-8', 'replace') + + +def elem_find_first(elem, id_search, default=None): + for fbx_item in elem.elems: + if fbx_item.id == id_search: + return fbx_item + return default + + +def elem_find_iter(elem, id_search): + for fbx_item in elem.elems: + if fbx_item.id == id_search: + yield fbx_item + + +def elem_find_first_string(elem, id_search): + fbx_item = elem_find_first(elem, id_search) + if fbx_item is not None and fbx_item.props: # Do not error on complete empty properties (see T45291). + assert len(fbx_item.props) == 1 + assert fbx_item.props_type[0] == data_types.STRING + return fbx_item.props[0].decode('utf-8', 'replace') + return None + + +def elem_find_first_string_as_bytes(elem, id_search): + fbx_item = elem_find_first(elem, id_search) + if fbx_item is not None and fbx_item.props: # Do not error on complete empty properties (see T45291). + assert len(fbx_item.props) == 1 + assert fbx_item.props_type[0] == data_types.STRING + return fbx_item.props[0] # Keep it as bytes as requested... + return None + + +def elem_find_first_bytes(elem, id_search, decode=True): + fbx_item = elem_find_first(elem, id_search) + if fbx_item is not None and fbx_item.props: # Do not error on complete empty properties (see T45291). + assert len(fbx_item.props) == 1 + assert fbx_item.props_type[0] == data_types.BYTES + return fbx_item.props[0] + return None + + +def elem_repr(elem): + return "%s: props[%d=%r], elems=(%r)" % ( + elem.id, + len(elem.props), + ", ".join([repr(p) for p in elem.props]), + # elem.props_type, + b", ".join([e.id for e in elem.elems]), + ) + + +def elem_split_name_class(elem): + assert elem.props_type[-2] == data_types.STRING + elem_name, elem_class = elem.props[-2].split(b'\x00\x01') + return elem_name, elem_class + + +def elem_name_ensure_class(elem, clss=...): + elem_name, elem_class = elem_split_name_class(elem) + if clss is not ...: + assert elem_class == clss + return validate_blend_names(elem_name) + + +def elem_name_ensure_classes(elem, clss=...): + elem_name, elem_class = elem_split_name_class(elem) + if clss is not ...: + assert elem_class in clss + return validate_blend_names(elem_name) + + +def elem_split_name_class_nodeattr(elem): + assert elem.props_type[-2] == data_types.STRING + elem_name, elem_class = elem.props[-2].split(b'\x00\x01') + assert elem_class == b'NodeAttribute' + assert elem.props_type[-1] == data_types.STRING + elem_class = elem.props[-1] + return elem_name, elem_class + + +def elem_uuid(elem): + assert elem.props_type[0] == data_types.INT64 + return elem.props[0] + + +def elem_prop_first(elem, default=None): + return elem.props[0] if (elem is not None) and elem.props else default + + +# ---- +# Support for +# Properties70: { ... P: +# Custom properties ("user properties" in FBX) are ignored here and get handled separately (see #104773). +def elem_props_find_first(elem, elem_prop_id): + if elem is None: + # When properties are not found... Should never happen, but happens - as usual. + return None + # support for templates (tuple of elems) + if type(elem) is not FBXElem: + assert type(elem) is tuple + for e in elem: + result = elem_props_find_first(e, elem_prop_id) + if result is not None: + return result + assert len(elem) > 0 + return None + + for subelem in elem.elems: + assert subelem.id == b'P' + # 'U' flag indicates that the property has been defined by the user. + if subelem.props[0] == elem_prop_id and b'U' not in subelem.props[3]: + return subelem + return None + + +def elem_props_get_color_rgb(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert elem_prop.props[0] == elem_prop_id + if elem_prop.props[1] == b'Color': + # FBX version 7300 + assert elem_prop.props[1] == b'Color' + assert elem_prop.props[2] == b'' + else: + assert elem_prop.props[1] == b'ColorRGB' + assert elem_prop.props[2] == b'Color' + assert elem_prop.props_type[4:7] == bytes((data_types.FLOAT64,)) * 3 + return elem_prop.props[4:7] + return default + + +def elem_props_get_vector_3d(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert elem_prop.props_type[4:7] == bytes((data_types.FLOAT64,)) * 3 + return elem_prop.props[4:7] + return default + + +def elem_props_get_number(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert elem_prop.props[0] == elem_prop_id + if elem_prop.props[1] == b'double': + assert elem_prop.props[1] == b'double' + assert elem_prop.props[2] == b'Number' + else: + assert elem_prop.props[1] == b'Number' + assert elem_prop.props[2] == b'' + + # we could allow other number types + assert elem_prop.props_type[4] == data_types.FLOAT64 + + return elem_prop.props[4] + return default + + +def elem_props_get_integer(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert elem_prop.props[0] == elem_prop_id + if elem_prop.props[1] == b'int': + assert elem_prop.props[1] == b'int' + assert elem_prop.props[2] == b'Integer' + elif elem_prop.props[1] == b'ULongLong': + assert elem_prop.props[1] == b'ULongLong' + assert elem_prop.props[2] == b'' + + # we could allow other number types + assert elem_prop.props_type[4] in {data_types.INT32, data_types.INT64} + + return elem_prop.props[4] + return default + + +def elem_props_get_bool(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert elem_prop.props[0] == elem_prop_id + # b'Bool' with a capital seems to be used for animated property... go figure... + assert elem_prop.props[1] in {b'bool', b'Bool'} + assert elem_prop.props[2] == b'' + + # we could allow other number types + assert elem_prop.props_type[4] == data_types.INT32 + assert elem_prop.props[4] in {0, 1} + + return bool(elem_prop.props[4]) + return default + + +def elem_props_get_enum(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert elem_prop.props[0] == elem_prop_id + assert elem_prop.props[1] == b'enum' + assert elem_prop.props[2] == b'' + assert elem_prop.props[3] == b'' + + # we could allow other number types + assert elem_prop.props_type[4] == data_types.INT32 + + return elem_prop.props[4] + return default + + +def elem_props_get_visibility(elem, elem_prop_id, default=None): + elem_prop = elem_props_find_first(elem, elem_prop_id) + if elem_prop is not None: + assert elem_prop.props[0] == elem_prop_id + assert elem_prop.props[1] == b'Visibility' + assert elem_prop.props[2] == b'' + + # we could allow other number types + assert elem_prop.props_type[4] == data_types.FLOAT64 + + return elem_prop.props[4] + return default + + +# ---------------------------------------------------------------------------- +# Blender + +# ------ +# Object +from collections import namedtuple + + +FBXTransformData = namedtuple("FBXTransformData", ( + "loc", "geom_loc", + "rot", "rot_ofs", "rot_piv", "pre_rot", "pst_rot", "rot_ord", "rot_alt_mat", "geom_rot", + "sca", "sca_ofs", "sca_piv", "geom_sca", +)) + + +def blen_read_custom_properties(fbx_obj, blen_obj, settings): + # There doesn't seem to be a way to put user properties into templates, so this only get the object properties: + fbx_obj_props = elem_find_first(fbx_obj, b'Properties70') + if fbx_obj_props: + for fbx_prop in fbx_obj_props.elems: + assert fbx_prop.id == b'P' + + if b'U' in fbx_prop.props[3]: + if fbx_prop.props[0] == b'UDP3DSMAX': + # Special case for 3DS Max user properties: + try: + assert fbx_prop.props[1] == b'KString' + except AssertionError as exc: + print(exc) + assert fbx_prop.props_type[4] == data_types.STRING + items = fbx_prop.props[4].decode('utf-8', 'replace') + for item in items.split('\r\n'): + if item: + split_item = item.split('=', 1) + if len(split_item) != 2: + split_item = item.split(':', 1) + if len(split_item) != 2: + print("cannot parse UDP3DSMAX custom property '%s', ignoring..." % item) + else: + prop_name, prop_value = split_item + prop_name = validate_blend_names(prop_name.strip().encode('utf-8')) + blen_obj[prop_name] = prop_value.strip() + else: + prop_name = validate_blend_names(fbx_prop.props[0]) + prop_type = fbx_prop.props[1] + if prop_type in {b'Vector', b'Vector3D', b'Color', b'ColorRGB'}: + assert fbx_prop.props_type[4:7] == bytes((data_types.FLOAT64,)) * 3 + blen_obj[prop_name] = fbx_prop.props[4:7] + elif prop_type in {b'Vector4', b'ColorRGBA'}: + assert fbx_prop.props_type[4:8] == bytes((data_types.FLOAT64,)) * 4 + blen_obj[prop_name] = fbx_prop.props[4:8] + elif prop_type == b'Vector2D': + assert fbx_prop.props_type[4:6] == bytes((data_types.FLOAT64,)) * 2 + blen_obj[prop_name] = fbx_prop.props[4:6] + elif prop_type in {b'Integer', b'int'}: + assert fbx_prop.props_type[4] == data_types.INT32 + blen_obj[prop_name] = fbx_prop.props[4] + elif prop_type == b'KString': + assert fbx_prop.props_type[4] == data_types.STRING + blen_obj[prop_name] = fbx_prop.props[4].decode('utf-8', 'replace') + elif prop_type in {b'Number', b'double', b'Double'}: + assert fbx_prop.props_type[4] == data_types.FLOAT64 + blen_obj[prop_name] = fbx_prop.props[4] + elif prop_type in {b'Float', b'float'}: + assert fbx_prop.props_type[4] == data_types.FLOAT32 + blen_obj[prop_name] = fbx_prop.props[4] + elif prop_type in {b'Bool', b'bool'}: + assert fbx_prop.props_type[4] == data_types.INT32 + blen_obj[prop_name] = fbx_prop.props[4] != 0 + elif prop_type in {b'Enum', b'enum'}: + assert fbx_prop.props_type[4:6] == bytes((data_types.INT32, data_types.STRING)) + val = fbx_prop.props[4] + if settings.use_custom_props_enum_as_string and fbx_prop.props[5]: + enum_items = fbx_prop.props[5].decode('utf-8', 'replace').split('~') + if val >= 0 and val < len(enum_items): + blen_obj[prop_name] = enum_items[val] + else: + print("WARNING: User property '%s' has wrong enum value, skipped" % prop_name) + else: + blen_obj[prop_name] = val + else: + print( + "WARNING: User property type '%s' is not supported" % + prop_type.decode( + 'utf-8', 'replace')) + + +def blen_read_object_transform_do(transform_data): + # This is a nightmare. FBX SDK uses Maya way to compute the transformation matrix of a node - utterly simple: + # + # WorldTransform = ParentWorldTransform @ T @ Roff @ Rp @ Rpre @ R @ Rpost-1 @ Rp-1 @ Soff @ Sp @ S @ Sp-1 + # + # Where all those terms are 4 x 4 matrices that contain: + # WorldTransform: Transformation matrix of the node in global space. + # ParentWorldTransform: Transformation matrix of the parent node in global space. + # T: Translation + # Roff: Rotation offset + # Rp: Rotation pivot + # Rpre: Pre-rotation + # R: Rotation + # Rpost-1: Inverse of the post-rotation (FBX 2011 documentation incorrectly specifies this without inversion) + # Rp-1: Inverse of the rotation pivot + # Soff: Scaling offset + # Sp: Scaling pivot + # S: Scaling + # Sp-1: Inverse of the scaling pivot + # + # But it was still too simple, and FBX notion of compatibility is... quite specific. So we also have to + # support 3DSMax way: + # + # WorldTransform = ParentWorldTransform @ T @ R @ S @ OT @ OR @ OS + # + # Where all those terms are 4 x 4 matrices that contain: + # WorldTransform: Transformation matrix of the node in global space + # ParentWorldTransform: Transformation matrix of the parent node in global space + # T: Translation + # R: Rotation + # S: Scaling + # OT: Geometric transform translation + # OR: Geometric transform rotation + # OS: Geometric transform scale + # + # Notes: + # Geometric transformations ***are not inherited***: ParentWorldTransform does not contain the OT, OR, OS + # of WorldTransform's parent node. + # The R matrix takes into account the rotation order. Other rotation matrices are always 'XYZ' order. + # + # Taken from https://help.autodesk.com/view/FBX/2020/ENU/ + # ?guid=FBX_Developer_Help_nodes_and_scene_graph_fbx_nodes_computing_transformation_matrix_html + + # translation + lcl_translation = Matrix.Translation(transform_data.loc) + geom_loc = Matrix.Translation(transform_data.geom_loc) + + # rotation + def to_rot(rot, rot_ord): return Euler(convert_deg_to_rad_iter(rot), rot_ord).to_matrix().to_4x4() + lcl_rot = to_rot(transform_data.rot, transform_data.rot_ord) @ transform_data.rot_alt_mat + pre_rot = to_rot(transform_data.pre_rot, 'XYZ') + pst_rot = to_rot(transform_data.pst_rot, 'XYZ') + geom_rot = to_rot(transform_data.geom_rot, 'XYZ') + + rot_ofs = Matrix.Translation(transform_data.rot_ofs) + rot_piv = Matrix.Translation(transform_data.rot_piv) + sca_ofs = Matrix.Translation(transform_data.sca_ofs) + sca_piv = Matrix.Translation(transform_data.sca_piv) + + # scale + lcl_scale = Matrix() + lcl_scale[0][0], lcl_scale[1][1], lcl_scale[2][2] = transform_data.sca + geom_scale = Matrix() + geom_scale[0][0], geom_scale[1][1], geom_scale[2][2] = transform_data.geom_sca + + base_mat = ( + lcl_translation @ + rot_ofs @ + rot_piv @ + pre_rot @ + lcl_rot @ + pst_rot.inverted_safe() @ + rot_piv.inverted_safe() @ + sca_ofs @ + sca_piv @ + lcl_scale @ + sca_piv.inverted_safe() + ) + geom_mat = geom_loc @ geom_rot @ geom_scale + # We return mat without 'geometric transforms' too, because it is to be used for children, sigh... + return (base_mat @ geom_mat, base_mat, geom_mat) + + +# XXX This might be weak, now that we can add vertex-groups from both bones and shapes, +# name collisions become more likely, will have to make this more robust!!! +def add_vgroup_to_objects(vg_indices, vg_weights, vg_name, objects): + assert len(vg_indices) == len(vg_weights) + if vg_indices: + for obj in objects: + # We replace/override here... + vg = obj.vertex_groups.get(vg_name) + if vg is None: + vg = obj.vertex_groups.new(name=vg_name) + vg_add = vg.add + for i, w in zip(vg_indices, vg_weights): + vg_add((i,), w, 'REPLACE') + + +def blen_read_object_transform_preprocess(fbx_props, fbx_obj, rot_alt_mat, use_prepost_rot): + # This is quite involved, 'fbxRNode.cpp' from openscenegraph used as a reference + const_vector_zero_3d = 0.0, 0.0, 0.0 + const_vector_one_3d = 1.0, 1.0, 1.0 + + loc = list(elem_props_get_vector_3d(fbx_props, b'Lcl Translation', const_vector_zero_3d)) + rot = list(elem_props_get_vector_3d(fbx_props, b'Lcl Rotation', const_vector_zero_3d)) + sca = list(elem_props_get_vector_3d(fbx_props, b'Lcl Scaling', const_vector_one_3d)) + + geom_loc = list(elem_props_get_vector_3d(fbx_props, b'GeometricTranslation', const_vector_zero_3d)) + geom_rot = list(elem_props_get_vector_3d(fbx_props, b'GeometricRotation', const_vector_zero_3d)) + geom_sca = list(elem_props_get_vector_3d(fbx_props, b'GeometricScaling', const_vector_one_3d)) + + rot_ofs = elem_props_get_vector_3d(fbx_props, b'RotationOffset', const_vector_zero_3d) + rot_piv = elem_props_get_vector_3d(fbx_props, b'RotationPivot', const_vector_zero_3d) + sca_ofs = elem_props_get_vector_3d(fbx_props, b'ScalingOffset', const_vector_zero_3d) + sca_piv = elem_props_get_vector_3d(fbx_props, b'ScalingPivot', const_vector_zero_3d) + + is_rot_act = elem_props_get_bool(fbx_props, b'RotationActive', False) + + if is_rot_act: + if use_prepost_rot: + pre_rot = elem_props_get_vector_3d(fbx_props, b'PreRotation', const_vector_zero_3d) + pst_rot = elem_props_get_vector_3d(fbx_props, b'PostRotation', const_vector_zero_3d) + else: + pre_rot = const_vector_zero_3d + pst_rot = const_vector_zero_3d + rot_ord = { + 0: 'XYZ', + 1: 'XZY', + 2: 'YZX', + 3: 'YXZ', + 4: 'ZXY', + 5: 'ZYX', + 6: 'XYZ', # XXX eSphericXYZ, not really supported... + }.get(elem_props_get_enum(fbx_props, b'RotationOrder', 0)) + else: + pre_rot = const_vector_zero_3d + pst_rot = const_vector_zero_3d + rot_ord = 'XYZ' + + return FBXTransformData(loc, geom_loc, + rot, rot_ofs, rot_piv, pre_rot, pst_rot, rot_ord, rot_alt_mat, geom_rot, + sca, sca_ofs, sca_piv, geom_sca) + + +# --------- +# Animation +def _blen_read_object_transform_do_anim(transform_data, lcl_translation_mat, lcl_rot_euler, lcl_scale_mat, + extra_pre_matrix, extra_post_matrix): + """Specialized version of blen_read_object_transform_do for animation that pre-calculates the non-animated matrices + and returns a function that calculates (base_mat @ geom_mat). See the comments in blen_read_object_transform_do for + a full description of what this function is doing. + + The lcl_translation_mat, lcl_rot_euler and lcl_scale_mat arguments should have their values updated each frame and + then calling the returned function will calculate the matrix for the current frame. + + extra_pre_matrix and extra_post_matrix are any extra matrices to multiply first/last.""" + # Translation + geom_loc = Matrix.Translation(transform_data.geom_loc) + + # Rotation + def to_rot_xyz(rot): + # All the rotations that can be pre-calculated have a fixed XYZ order. + return Euler(convert_deg_to_rad_iter(rot), 'XYZ').to_matrix().to_4x4() + pre_rot = to_rot_xyz(transform_data.pre_rot) + pst_rot_inv = to_rot_xyz(transform_data.pst_rot).inverted_safe() + geom_rot = to_rot_xyz(transform_data.geom_rot) + + # Offsets and pivots + rot_ofs = Matrix.Translation(transform_data.rot_ofs) + rot_piv = Matrix.Translation(transform_data.rot_piv) + rot_piv_inv = rot_piv.inverted_safe() + sca_ofs = Matrix.Translation(transform_data.sca_ofs) + sca_piv = Matrix.Translation(transform_data.sca_piv) + sca_piv_inv = sca_piv.inverted_safe() + + # Scale + geom_scale = Matrix() + geom_scale[0][0], geom_scale[1][1], geom_scale[2][2] = transform_data.geom_sca + + # Some matrices can be combined in advance, using the associative property of matrix multiplication, so that less + # matrix multiplication is required each frame. + geom_mat = geom_loc @ geom_rot @ geom_scale + post_lcl_translation = rot_ofs @ rot_piv @ pre_rot + post_lcl_rotation = transform_data.rot_alt_mat @ pst_rot_inv @ rot_piv_inv @ sca_ofs @ sca_piv + post_lcl_scaling = sca_piv_inv @ geom_mat @ extra_post_matrix + + # Get the bound to_matrix method to avoid re-binding it on each call. + lcl_rot_euler_to_matrix_3x3 = lcl_rot_euler.to_matrix + # Get the unbound Matrix.to_4x4 method to avoid having to look it up again on each call. + matrix_to_4x4 = Matrix.to_4x4 + + if extra_pre_matrix == Matrix(): + # There aren't any other matrices that must be multiplied before lcl_translation_mat that extra_pre_matrix can + # be combined with, so skip extra_pre_matrix when it's the identity matrix. + return lambda: (lcl_translation_mat @ + post_lcl_translation @ + matrix_to_4x4(lcl_rot_euler_to_matrix_3x3()) @ + post_lcl_rotation @ + lcl_scale_mat @ + post_lcl_scaling) + else: + return lambda: (extra_pre_matrix @ + lcl_translation_mat @ + post_lcl_translation @ + matrix_to_4x4(lcl_rot_euler_to_matrix_3x3()) @ + post_lcl_rotation @ + lcl_scale_mat @ + post_lcl_scaling) + + +def _transformation_curves_gen(item, values_arrays, channel_keys): + """Yields flattened location/rotation/scaling values for imported PoseBone/Object Lcl Translation/Rotation/Scaling + animation curve values. + + The value arrays must have the same lengths, where each index of each array corresponds to a single keyframe. + + Each value array must have a corresponding channel key tuple that identifies the fbx property + (b'Lcl Translation'/b'Lcl Rotation'/b'Lcl Scaling') and the channel (x/y/z as 0/1/2) of that property.""" + from operator import setitem + from functools import partial + + if item.is_bone: + bl_obj = item.bl_obj.pose.bones[item.bl_bone] + else: + bl_obj = item.bl_obj + + rot_mode = bl_obj.rotation_mode + transform_data = item.fbx_transform_data + rot_eul_prev = bl_obj.rotation_euler.copy() + rot_quat_prev = bl_obj.rotation_quaternion.copy() + + # Pre-compute combined pre-matrix + # Remove that rest pose matrix from current matrix (also in parent space) by computing the inverted local rest + # matrix of the bone, if relevant. + combined_pre_matrix = item.get_bind_matrix().inverted_safe() if item.is_bone else Matrix() + # item.pre_matrix will contain any correction for a parent's correction matrix or the global matrix + if item.pre_matrix: + combined_pre_matrix @= item.pre_matrix + + # Pre-compute combined post-matrix + # Compensate for changes in the local matrix during processing + combined_post_matrix = item.anim_compensation_matrix.copy() if item.anim_compensation_matrix else Matrix() + # item.post_matrix will contain any correction for lights, camera and bone orientation + if item.post_matrix: + combined_post_matrix @= item.post_matrix + + # Create matrices/euler from the initial transformation values of this item. + # These variables will be updated in-place as we iterate through each frame. + lcl_translation_mat = Matrix.Translation(transform_data.loc) + lcl_rotation_eul = Euler(convert_deg_to_rad_iter(transform_data.rot), transform_data.rot_ord) + lcl_scaling_mat = Matrix() + lcl_scaling_mat[0][0], lcl_scaling_mat[1][1], lcl_scaling_mat[2][2] = transform_data.sca + + # Create setters into lcl_translation_mat, lcl_rotation_eul and lcl_scaling_mat for each values_array and convert + # any rotation values into radians. + lcl_setters = [] + values_arrays_converted = [] + for values_array, (fbx_prop, channel) in zip(values_arrays, channel_keys): + if fbx_prop == b'Lcl Translation': + # lcl_translation_mat.translation[channel] = value + setter = partial(setitem, lcl_translation_mat.translation, channel) + elif fbx_prop == b'Lcl Rotation': + # FBX rotations are in degrees, but Blender uses radians, so convert all rotation values in advance. + values_array = np.deg2rad(values_array) + # lcl_rotation_eul[channel] = value + setter = partial(setitem, lcl_rotation_eul, channel) + else: + assert fbx_prop == b'Lcl Scaling' + # lcl_scaling_mat[channel][channel] = value + setter = partial(setitem, lcl_scaling_mat[channel], channel) + lcl_setters.append(setter) + values_arrays_converted.append(values_array) + + # Create an iterator that gets one value from each array. Each iterated tuple will be all the imported + # Lcl Translation/Lcl Rotation/Lcl Scaling values for a single frame, in that order. + # Note that an FBX animation does not have to animate all the channels, so only the animated channels of each + # property will be present. + # .data, the memoryview of an np.ndarray, is faster to iterate than the ndarray itself. + frame_values_it = zip(*(arr.data for arr in values_arrays_converted)) + + # Getting the unbound methods in advance avoids having to look them up again on each call within the loop. + mat_decompose = Matrix.decompose + quat_to_axis_angle = Quaternion.to_axis_angle + quat_to_euler = Quaternion.to_euler + quat_dot = Quaternion.dot + + calc_mat = _blen_read_object_transform_do_anim(transform_data, + lcl_translation_mat, lcl_rotation_eul, lcl_scaling_mat, + combined_pre_matrix, combined_post_matrix) + + # Iterate through the values for each frame. + for frame_values in frame_values_it: + # Set each value into its corresponding lcl matrix/euler. + for lcl_setter, value in zip(lcl_setters, frame_values): + lcl_setter(value) + + # Calculate the updated matrix for this frame. + mat = calc_mat() + + # Now we have a virtual matrix of transform from AnimCurves, we can yield keyframe values! + loc, rot, sca = mat_decompose(mat) + if rot_mode == 'QUATERNION': + if quat_dot(rot_quat_prev, rot) < 0.0: + rot = -rot + rot_quat_prev = rot + elif rot_mode == 'AXIS_ANGLE': + vec, ang = quat_to_axis_angle(rot) + rot = ang, vec.x, vec.y, vec.z + else: # Euler + rot = quat_to_euler(rot, rot_mode, rot_eul_prev) + rot_eul_prev = rot + + # Yield order matches the order that the location/rotation/scale FCurves are created in. + yield from loc + yield from rot + yield from sca + + +def _combine_curve_keyframe_times(times_and_values_tuples, initial_values): + """Combine multiple parsed animation curves, that affect different channels, such that every animation curve + contains the keyframes from every other curve, interpolating the values for the newly inserted keyframes in each + curve. + + Currently, linear interpolation is assumed, but FBX does store how keyframes should be interpolated, so correctly + interpolating the keyframe values is a TODO.""" + if len(times_and_values_tuples) == 1: + # Nothing to do when there is only a single curve. + times, values = times_and_values_tuples[0] + return times, [values] + + all_times = [t[0] for t in times_and_values_tuples] + + # Get the combined sorted unique times of all the curves. + sorted_all_times = np.unique(np.concatenate(all_times)) + + values_arrays = [] + for (times, values), initial_value in zip(times_and_values_tuples, initial_values): + if sorted_all_times.size == times.size: + # `sorted_all_times` will always contain all values in `times` and both `times` and `sorted_all_times` must + # be strictly increasing, so if both arrays have the same size, they must be identical. + extended_values = values + else: + # For now, linear interpolation is assumed. NumPy conveniently has a fast C-compiled function for this. + # Efficiently implementing other FBX supported interpolation will most likely be much more complicated. + extended_values = np.interp(sorted_all_times, times, values, left=initial_value) + values_arrays.append(extended_values) + return sorted_all_times, values_arrays + + +def blen_read_invalid_animation_curve(key_times, key_values): + """FBX will parse animation curves even when their keyframe times are invalid (not strictly increasing). It's + unclear exactly how FBX handles invalid curves, but this matches in some cases and is how the FBX IO addon has been + handling invalid keyframe times for a long time. + + Notably, this function will also correctly parse valid animation curves, though is much slower than the trivial, + regular way. + + The returned keyframe times are guaranteed to be strictly increasing.""" + sorted_unique_times = np.unique(key_times) + + # Unsure if this can be vectorized with numpy, so using iteration for now. + def index_gen(): + idx = 0 + key_times_data = key_times.data + key_times_len = len(key_times) + # Iterating .data, the memoryview of the array, is faster than iterating the array directly. + for curr_fbxktime in sorted_unique_times.data: + if key_times_data[idx] < curr_fbxktime: + if idx >= 0: + idx += 1 + if idx >= key_times_len: + # We have reached our last element for this curve, stay on it from now on... + idx = -1 + yield idx + + indices = np.fromiter(index_gen(), dtype=np.int64, count=len(sorted_unique_times)) + indexed_times = key_times[indices] + indexed_values = key_values[indices] + + # Linear interpolate the value for each time in sorted_unique_times according to the times and values at each index + # and the previous index. + interpolated_values = np.empty_like(indexed_values) + + # Where the index is 0, there's no previous value to interpolate from, so we set the value without interpolating. + # Because the indices are in increasing order, all zeroes must be at the start, so we can find the index of the last + # zero and use that to index with a slice instead of a boolean array for performance. + # Equivalent to, but as a slice: + # idx_zero_mask = indices == 0 + # idx_nonzero_mask = ~idx_zero_mask + first_nonzero_idx = np.searchsorted(indices, 0, side='right') + idx_zero_slice = slice(0, first_nonzero_idx) # [:first_nonzero_idx] + idx_nonzero_slice = slice(first_nonzero_idx, None) # [first_nonzero_idx:] + + interpolated_values[idx_zero_slice] = indexed_values[idx_zero_slice] + + indexed_times_nonzero_idx = indexed_times[idx_nonzero_slice] + indexed_values_nonzero_idx = indexed_values[idx_nonzero_slice] + indices_nonzero = indices[idx_nonzero_slice] + + prev_indices_nonzero = indices_nonzero - 1 + prev_indexed_times_nonzero_idx = key_times[prev_indices_nonzero] + prev_indexed_values_nonzero_idx = key_values[prev_indices_nonzero] + + ifac_a = sorted_unique_times[idx_nonzero_slice] - prev_indexed_times_nonzero_idx + ifac_b = indexed_times_nonzero_idx - prev_indexed_times_nonzero_idx + # If key_times contains two (or more) duplicate times in a row, then values in `ifac_b` can be zero which would + # result in division by zero. + # Use the `np.errstate` context manager to suppress printing the RuntimeWarning to the system console. + with np.errstate(divide='ignore'): + ifac = ifac_a / ifac_b + interpolated_values[idx_nonzero_slice] = ((indexed_values_nonzero_idx - prev_indexed_values_nonzero_idx) * ifac + + prev_indexed_values_nonzero_idx) + + # If the time to interpolate at is larger than the time in indexed_times, then the value has been extrapolated. + # Extrapolated values are excluded. + valid_mask = indexed_times >= sorted_unique_times + + key_times = sorted_unique_times[valid_mask] + key_values = interpolated_values[valid_mask] + + return key_times, key_values + + +def _convert_fbx_time_to_blender_time(key_times, blen_start_offset, fbx_start_offset, fps, fbx_ktime): + timefac = fps / fbx_ktime + + # Convert from FBX timing to Blender timing. + # Cannot subtract in-place because key_times could be read directly from FBX and could be used by multiple Actions. + key_times = key_times - fbx_start_offset + # FBX times are integers and timefac is a Python float, so the new array will be a np.float64 array. + key_times = key_times * timefac + + key_times += blen_start_offset + + return key_times + + +def blen_read_animation_curve(fbx_curve): + """Read an animation curve from FBX data. + + The parsed keyframe times are guaranteed to be strictly increasing.""" + key_times = parray_as_ndarray(elem_prop_first(elem_find_first(fbx_curve, b'KeyTime'))) + key_values = parray_as_ndarray(elem_prop_first(elem_find_first(fbx_curve, b'KeyValueFloat'))) + + assert len(key_values) == len(key_times) + + # The FBX SDK specifies that only one key per time is allowed and that the keys are sorted in time order. + # https://help.autodesk.com/view/FBX/2020/ENU/?guid=FBX_Developer_Help_cpp_ref_class_fbx_anim_curve_html + all_times_strictly_increasing = (key_times[1:] > key_times[:-1]).all() + + if all_times_strictly_increasing: + return key_times, key_values + else: + # FBX will still read animation curves even if they are invalid. + return blen_read_invalid_animation_curve(key_times, key_values) + + +def blen_store_keyframes(fbx_key_times, blen_fcurve, key_values, blen_start_offset, fps, fbx_ktime, fbx_start_offset=0): + """Set all keyframe times and values for a newly created FCurve. + Linear interpolation is currently assumed. + + This is a convenience function for calling blen_store_keyframes_multi with only a single fcurve and values array.""" + blen_store_keyframes_multi(fbx_key_times, [(blen_fcurve, key_values)], blen_start_offset, fps, fbx_ktime, + fbx_start_offset) + + +def blen_store_keyframes_multi(fbx_key_times, fcurve_and_key_values_pairs, blen_start_offset, fps, fbx_ktime, + fbx_start_offset=0): + """Set all keyframe times and values for multiple pairs of newly created FCurves and keyframe values arrays, where + each pair has the same keyframe times. + Linear interpolation is currently assumed.""" + bl_key_times = _convert_fbx_time_to_blender_time(fbx_key_times, blen_start_offset, fbx_start_offset, fps, fbx_ktime) + num_keys = len(bl_key_times) + + # Compatible with C float type + bl_keyframe_dtype = np.single + # Compatible with C char type + bl_enum_dtype = np.ubyte + + # The keyframe_points 'co' are accessed as flattened pairs of (time, value). + # The key times are the same for each (blen_fcurve, key_values) pair, so only the values need to be updated for each + # array of values. + keyframe_points_co = np.empty(len(bl_key_times) * 2, dtype=bl_keyframe_dtype) + # Even indices are times. + keyframe_points_co[0::2] = bl_key_times + + interpolation_array = np.full(num_keys, LINEAR_INTERPOLATION_VALUE, dtype=bl_enum_dtype) + + for blen_fcurve, key_values in fcurve_and_key_values_pairs: + # The fcurve must be newly created and thus have no keyframe_points. + assert len(blen_fcurve.keyframe_points) == 0 + + # Odd indices are values. + keyframe_points_co[1::2] = key_values + + # Add the keyframe points to the FCurve and then set the 'co' and 'interpolation' of each point. + blen_fcurve.keyframe_points.add(num_keys) + blen_fcurve.keyframe_points.foreach_set('co', keyframe_points_co) + blen_fcurve.keyframe_points.foreach_set('interpolation', interpolation_array) + + # Since we inserted our keyframes in 'ultra-fast' mode, we have to update the fcurves now. + blen_fcurve.update() + + +def blen_read_animations_action_item(channelbag, item, cnodes, fps, anim_offset, global_scale, shape_key_deforms, + fbx_ktime): + """ + 'Bake' loc/rot/scale into the channelbag, + taking any pre_ and post_ matrix into account to transform from fbx into blender space. + """ + from bpy.types import ShapeKey, Material, Camera + + fbx_curves: dict[bytes, dict[int, FBXElem]] = {} + for curves, fbxprop in cnodes.values(): + channels_dict = fbx_curves.setdefault(fbxprop, {}) + for (fbx_acdata, _blen_data), channel in curves.values(): + if channel in channels_dict: + # Ignore extra curves when one has already been found for this channel because FBX's default animation + # system implementation only uses the first curve assigned to a channel. + # Additional curves per channel are allowed by the FBX specification, but the handling of these curves + # is considered the responsibility of the application that created them. Note that each curve node is + # expected to have a unique set of channels, so these additional curves with the same channel would have + # to belong to separate curve nodes. See the FBX SDK documentation for FbxAnimCurveNode. + continue + channels_dict[channel] = fbx_acdata + + # Leave if no curves are attached (if a blender curve is attached to scale but without keys it defaults to 0). + if len(fbx_curves) == 0: + return + + if isinstance(item, Material): + grpname = item.name + props = [("diffuse_color", 3, grpname or "Diffuse Color")] + elif isinstance(item, ShapeKey): + props = [(item.path_from_id("value"), 1, "Key")] + elif isinstance(item, Camera): + props = [(item.path_from_id("lens"), 1, "Camera"), (item.dof.path_from_id("focus_distance"), 1, "Camera")] + else: # Object or PoseBone: + if item.is_bone: + bl_obj = item.bl_obj.pose.bones[item.bl_bone] + else: + bl_obj = item.bl_obj + + # We want to create actions for objects, but for bones we 'reuse' armatures' actions! + grpname = bl_obj.name + + # Since we might get other channels animated in the end, due to all FBX transform magic, + # we need to add curves for whole loc/rot/scale in any case. + props = [(bl_obj.path_from_id("location"), 3, grpname or "Location"), + None, + (bl_obj.path_from_id("scale"), 3, grpname or "Scale")] + rot_mode = bl_obj.rotation_mode + if rot_mode == 'QUATERNION': + props[1] = (bl_obj.path_from_id("rotation_quaternion"), 4, grpname or "Quaternion Rotation") + elif rot_mode == 'AXIS_ANGLE': + props[1] = (bl_obj.path_from_id("rotation_axis_angle"), 4, grpname or "Axis Angle Rotation") + else: # Euler + props[1] = (bl_obj.path_from_id("rotation_euler"), 3, grpname or "Euler Rotation") + + blen_curves = [channelbag.fcurves.new(prop, index=channel, group_name=grpname) + for prop, nbr_channels, grpname in props for channel in range(nbr_channels)] + + if isinstance(item, Material): + for fbxprop, channel_to_curve in fbx_curves.items(): + assert fbxprop == b'DiffuseColor' + for channel, curve in channel_to_curve.items(): + assert channel in {0, 1, 2} + blen_curve = blen_curves[channel] + fbx_key_times, values = blen_read_animation_curve(curve) + blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps, fbx_ktime) + + elif isinstance(item, ShapeKey): + for fbxprop, channel_to_curve in fbx_curves.items(): + assert fbxprop == b'DeformPercent' + for channel, curve in channel_to_curve.items(): + assert channel == 0 + blen_curve = blen_curves[channel] + + fbx_key_times, values = blen_read_animation_curve(curve) + # A fully activated shape key in FBX DeformPercent is 100.0 whereas it is 1.0 in Blender. + values = values / 100.0 + blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps, fbx_ktime) + + # Store the minimum and maximum shape key values, so that the shape key's slider range can be expanded + # if necessary after reading all animations. + if values.size: + deform_values = shape_key_deforms.setdefault(item, []) + deform_values.append(values.min()) + deform_values.append(values.max()) + + elif isinstance(item, Camera): + for fbxprop, channel_to_curve in fbx_curves.items(): + is_focus_distance = fbxprop == b'FocusDistance' + assert fbxprop == b'FocalLength' or is_focus_distance + for channel, curve in channel_to_curve.items(): + assert channel == 0 + # The indices are determined by the creation of the `props` list above. + blen_curve = blen_curves[1 if is_focus_distance else 0] + + fbx_key_times, values = blen_read_animation_curve(curve) + if is_focus_distance: + # Remap the imported values from FBX to Blender. + values = values / 1000.0 + values *= global_scale + blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps, fbx_ktime) + + else: # Object or PoseBone: + transform_data = item.fbx_transform_data + + # Each transformation curve needs to have keyframes at the times of every other transformation curve + # (interpolating missing values), so that we can construct a matrix at every keyframe. + transform_prop_to_attr = { + b'Lcl Translation': transform_data.loc, + b'Lcl Rotation': transform_data.rot, + b'Lcl Scaling': transform_data.sca, + } + + times_and_values_tuples = [] + initial_values = [] + channel_keys = [] + for fbxprop, channel_to_curve in fbx_curves.items(): + if fbxprop not in transform_prop_to_attr: + # Currently, we only care about transformation curves. + continue + for channel, curve in channel_to_curve.items(): + assert channel in {0, 1, 2} + fbx_key_times, values = blen_read_animation_curve(curve) + + channel_keys.append((fbxprop, channel)) + + initial_values.append(transform_prop_to_attr[fbxprop][channel]) + + times_and_values_tuples.append((fbx_key_times, values)) + if not times_and_values_tuples: + # If `times_and_values_tuples` is empty, all the imported animation curves are for properties other than + # transformation (e.g. animated custom properties), so there is nothing to do until support for those other + # properties is added. + return + + # Combine the keyframe times of all the transformation curves so that each curve has a value at every time. + combined_fbx_times, values_arrays = _combine_curve_keyframe_times(times_and_values_tuples, initial_values) + + # Convert from FBX Lcl Translation/Lcl Rotation/Lcl Scaling to the Blender location/rotation/scaling properties + # of this Object/PoseBone. + # The number of fcurves for the Blender properties varies depending on the rotation mode. + num_loc_channels = 3 + num_rot_channels = 4 if rot_mode in {'QUATERNION', 'AXIS_ANGLE'} else 3 # Variations of EULER are all 3 + num_sca_channels = 3 + num_channels = num_loc_channels + num_rot_channels + num_sca_channels + num_frames = len(combined_fbx_times) + full_length = num_channels * num_frames + + # Do the conversion. + flattened_channel_values_gen = _transformation_curves_gen(item, values_arrays, channel_keys) + flattened_channel_values = np.fromiter(flattened_channel_values_gen, dtype=np.single, count=full_length) + + # Reshape to one row per frame and then view the transpose so that each row corresponds to a single channel. + # e.g. + # loc_channels = channel_values[:num_loc_channels] + # rot_channels = channel_values[num_loc_channels:num_loc_channels + num_rot_channels] + # sca_channels = channel_values[num_loc_channels + num_rot_channels:] + channel_values = flattened_channel_values.reshape(num_frames, num_channels).T + + # Each channel has the same keyframe times, so the combined times can be passed once along with all the curves + # and values arrays. + blen_store_keyframes_multi(combined_fbx_times, zip(blen_curves, channel_values), anim_offset, fps, fbx_ktime) + + +def blen_read_animations(fbx_tmpl_astack, fbx_tmpl_alayer, stacks, scene, anim_offset, global_scale, fbx_ktime): + """ + Recreate an action per stack/layer/object combinations. + Only the first found action is linked to objects, more complex setups are not handled, + it's up to user to reproduce them! + """ + from bpy.types import ShapeKey, Material, Camera + + shape_key_values = {} + actions = {} + for as_uuid, ((fbx_asdata, _blen_data), alayers) in stacks.items(): + stack_name = elem_name_ensure_class(fbx_asdata, b'AnimStack') + for al_uuid, ((fbx_aldata, _blen_data), items) in alayers.items(): + layer_name = elem_name_ensure_class(fbx_aldata, b'AnimLayer') + for item, cnodes in items.items(): + if isinstance(item, Material): + id_data = item + elif isinstance(item, ShapeKey): + id_data = item.id_data + elif isinstance(item, Camera): + id_data = item + else: + id_data = item.bl_obj + # XXX Ignore rigged mesh animations - those are a nightmare to handle, see note about it in + # FbxImportHelperNode class definition. + if id_data and id_data.type == 'MESH' and id_data.parent and id_data.parent.type == 'ARMATURE': + continue + if id_data is None: + continue + + # Create new action if needed (should always be needed, except for key-blocks from shape-keys cases). + key = (as_uuid, al_uuid, id_data) + action = actions.get(key) + if action is None: + if stack_name == layer_name: + action_name = "|".join((id_data.name, stack_name)) + else: + action_name = "|".join((id_data.name, stack_name, layer_name)) + actions[key] = action = bpy.data.actions.new(action_name) + action.use_fake_user = True + + # Always use the same name for the slot. It should be simple + # to switch between imported Actions while keeping Slot + # auto-assignment, which means that all Actions should use + # the same slot name. As long as there's no separate + # indicator for the "intended object name" for this FBX + # animation, this is the best Blender can do. Maybe the + # 'stack name' would be a better choice? + action.slots.new(id_data.id_type, "Slot") + + # If none yet assigned, assign this action to id_data. + if not id_data.animation_data: + id_data.animation_data_create() + if not id_data.animation_data.action: + id_data.animation_data.action = action + id_data.animation_data.action_slot = action.slots[0] + + # And actually populate the action! + channelbag = anim_utils.action_ensure_channelbag_for_slot(action, action.slots[0]) + blen_read_animations_action_item(channelbag, item, cnodes, scene.render.fps, anim_offset, global_scale, + shape_key_values, fbx_ktime) + + # If the minimum/maximum animated value is outside the slider range of the shape key, attempt to expand the slider + # range until the animated range fits and has extra room to be decreased or increased further. + # Shape key slider_min and slider_max have hard min/max values, if an imported animation uses a value outside that + # range, a warning message will be printed to the console and the slider_min/slider_max values will end up clamped. + shape_key_values_in_range = True + for shape_key, deform_values in shape_key_values.items(): + min_animated_deform = min(deform_values) + max_animated_deform = max(deform_values) + shape_key_values_in_range &= expand_shape_key_range(shape_key, min_animated_deform) + shape_key_values_in_range &= expand_shape_key_range(shape_key, max_animated_deform) + if not shape_key_values_in_range: + print("WARNING: The imported animated Value of a Shape Key is beyond the minimum/maximum allowed and will be" + " clamped during playback.") + + +# ---- +# Mesh + +def blen_read_geom_layerinfo(fbx_layer): + return ( + validate_blend_names(elem_find_first_string_as_bytes(fbx_layer, b'Name')), + elem_find_first_string_as_bytes(fbx_layer, b'MappingInformationType'), + elem_find_first_string_as_bytes(fbx_layer, b'ReferenceInformationType'), + ) + + +def blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size): + """Validate blen_data when it's not a bpy_prop_collection. + Returns whether blen_data is a bpy_prop_collection""" + blen_data_is_collection = isinstance(blen_data, bpy.types.bpy_prop_collection) + if not blen_data_is_collection: + if item_size > 1: + assert len(blen_data.shape) == 2 + assert blen_data.shape[1] == item_size + assert blen_data.dtype == blen_dtype + return blen_data_is_collection + + +def blen_read_geom_parse_fbx_data(fbx_data, stride, item_size): + """Parse fbx_data as an array.array into a 2d np.ndarray that shares the same memory, where each row is a single + item""" + # Technically stride < item_size could be supported, but there's probably not a use case for it since it would + # result in a view of the data with self-overlapping memory. + assert stride >= item_size + # View the array.array as an np.ndarray. + fbx_data_np = parray_as_ndarray(fbx_data) + + if stride == item_size: + if item_size > 1: + # Need to make sure fbx_data_np has a whole number of items to be able to view item_size elements per row. + items_remainder = len(fbx_data_np) % item_size + if items_remainder: + print("ERROR: not a whole number of items in this FBX layer, skipping the partial item!") + fbx_data_np = fbx_data_np[:-items_remainder] + fbx_data_np = fbx_data_np.reshape(-1, item_size) + else: + # Create a view of fbx_data_np that is only the first item_size elements of each stride. Note that the view will + # not be C-contiguous. + stride_remainder = len(fbx_data_np) % stride + if stride_remainder: + if stride_remainder < item_size: + print("ERROR: not a whole number of items in this FBX layer, skipping the partial item!") + # Not enough in the remainder for a full item, so cut off the partial stride + fbx_data_np = fbx_data_np[:-stride_remainder] + # Reshape to one stride per row and then create a view that includes only the first item_size elements + # of each stride. + fbx_data_np = fbx_data_np.reshape(-1, stride)[:, :item_size] + else: + print("ERROR: not a whole number of strides in this FBX layer! There are a whole number of items, but" + " this could indicate an error!") + # There is not a whole number of strides, but there is a whole number of items. + # This is a pain to deal with because fbx_data_np.reshape(-1, stride) is not possible. + # A view of just the items can be created using stride_tricks.as_strided by specifying the shape and + # strides of the view manually. + # Extreme care must be taken when using stride_tricks.as_strided because improper usage can result in + # a view that gives access to memory outside the array. + from numpy.lib import stride_tricks + + # fbx_data_np should always start off as flat and C-contiguous. + assert fbx_data_np.strides == (fbx_data_np.itemsize,) + + num_whole_strides = len(fbx_data_np) // stride + # Plus the one partial stride that is enough elements for a complete item. + num_items = num_whole_strides + 1 + shape = (num_items, item_size) + + # strides are the number of bytes to step to get to the next element, for each axis. + step_per_item = fbx_data_np.itemsize * stride + step_per_item_element = fbx_data_np.itemsize + strides = (step_per_item, step_per_item_element) + + fbx_data_np = stride_tricks.as_strided(fbx_data_np, shape, strides) + else: + # There's a whole number of strides, so first reshape to one stride per row and then create a view that + # includes only the first item_size elements of each stride. + fbx_data_np = fbx_data_np.reshape(-1, stride)[:, :item_size] + + return fbx_data_np + + +def blen_read_geom_check_fbx_data_length(blen_data, fbx_data_np, is_indices=False): + """Check that there are the same number of items in blen_data and fbx_data_np. + + Returns a tuple of two elements: + 0: fbx_data_np or, if fbx_data_np contains more items than blen_data, a view of fbx_data_np with the excess + items removed + 1: Whether the returned fbx_data_np contains enough items to completely fill blen_data""" + bl_num_items = len(blen_data) + fbx_num_items = len(fbx_data_np) + enough_data = fbx_num_items >= bl_num_items + if not enough_data: + if is_indices: + print("ERROR: not enough indices in this FBX layer, missing data will be left as default!") + else: + print("ERROR: not enough data in this FBX layer, missing data will be left as default!") + elif fbx_num_items > bl_num_items: + if is_indices: + print("ERROR: too many indices in this FBX layer, skipping excess!") + else: + print("ERROR: too much data in this FBX layer, skipping excess!") + fbx_data_np = fbx_data_np[:bl_num_items] + + return fbx_data_np, enough_data + + +def blen_read_geom_xform(fbx_data_np, xform): + """xform is either None, or a function that takes fbx_data_np as its only positional argument and returns an + np.ndarray with the same total number of elements as fbx_data_np. + It is acceptable for xform to return an array with a different dtype to fbx_data_np. + + Returns xform(fbx_data_np) when xform is not None and ensures the result of xform(fbx_data_np) has the same shape as + fbx_data_np before returning it. + When xform is None, fbx_data_np is returned as is.""" + if xform is not None: + item_size = fbx_data_np.shape[1] + fbx_total_data = fbx_data_np.size + fbx_data_np = xform(fbx_data_np) + # The amount of data should not be changed by xform + assert fbx_data_np.size == fbx_total_data + # Ensure fbx_data_np is still item_size elements per row + if len(fbx_data_np.shape) != 2 or fbx_data_np.shape[1] != item_size: + fbx_data_np = fbx_data_np.reshape(-1, item_size) + return fbx_data_np + + +def blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_data, stride, item_size, descr, + xform): + """Generic fbx_layer to blen_data foreach setter for Direct layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array.""" + fbx_data_np = blen_read_geom_parse_fbx_data(fbx_data, stride, item_size) + fbx_data_np, enough_data = blen_read_geom_check_fbx_data_length(blen_data, fbx_data_np) + fbx_data_np = blen_read_geom_xform(fbx_data_np, xform) + + blen_data_is_collection = blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size) + + if blen_data_is_collection: + if not enough_data: + blen_total_data = len(blen_data) * item_size + buffer = np.empty(blen_total_data, dtype=blen_dtype) + # It's not clear what values should be used for the missing data, so read the current values into a buffer. + blen_data.foreach_get(blen_attr, buffer) + + # Change the buffer shape to one item per row + buffer.shape = (-1, item_size) + + # Copy the fbx data into the start of the buffer + buffer[:len(fbx_data_np)] = fbx_data_np + else: + # Convert the buffer to the Blender C type of blen_attr + buffer = astype_view_signedness(fbx_data_np, blen_dtype) + + # Set blen_attr of blen_data. The buffer must be flat and C-contiguous, which ravel() ensures + blen_data.foreach_set(blen_attr, buffer.ravel()) + else: + assert blen_data.size % item_size == 0 + blen_data = blen_data.view() + blen_data.shape = (-1, item_size) + blen_data[:len(fbx_data_np)] = fbx_data_np + + +def blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_data, fbx_layer_index, stride, + item_size, descr, xform): + """Generic fbx_layer to blen_data foreach setter for IndexToDirect layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array or a 1d np.ndarray.""" + fbx_data_np = blen_read_geom_parse_fbx_data(fbx_data, stride, item_size) + fbx_data_np = blen_read_geom_xform(fbx_data_np, xform) + + # fbx_layer_index is allowed to be a 1d np.ndarray for use with blen_read_geom_array_foreach_set_looptovert. + if not isinstance(fbx_layer_index, np.ndarray): + fbx_layer_index = parray_as_ndarray(fbx_layer_index) + + fbx_layer_index, enough_indices = blen_read_geom_check_fbx_data_length(blen_data, fbx_layer_index, is_indices=True) + + blen_data_is_collection = blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size) + + blen_data_items_len = len(blen_data) + blen_data_len = blen_data_items_len * item_size + fbx_num_items = len(fbx_data_np) + + # Find all indices that are out of bounds of fbx_data_np. + min_index_inclusive = -fbx_num_items + max_index_inclusive = fbx_num_items - 1 + valid_index_mask = np.equal(fbx_layer_index, fbx_layer_index.clip(min_index_inclusive, max_index_inclusive)) + indices_invalid = not valid_index_mask.all() + + fbx_data_items = fbx_data_np.reshape(-1, item_size) + + if indices_invalid or not enough_indices: + if blen_data_is_collection: + buffer = np.empty(blen_data_len, dtype=blen_dtype) + buffer_item_view = buffer.view() + buffer_item_view.shape = (-1, item_size) + # Since we don't know what the default values should be for the missing data, read the current values into a + # buffer. + blen_data.foreach_get(blen_attr, buffer) + else: + buffer_item_view = blen_data + + if not enough_indices: + # Reduce the length of the view to the same length as the number of indices. + buffer_item_view = buffer_item_view[:len(fbx_layer_index)] + + # Copy the result of indexing fbx_data_items by each element in fbx_layer_index into the buffer. + if indices_invalid: + print("ERROR: indices in this FBX layer out of bounds of the FBX data, skipping invalid indices!") + buffer_item_view[valid_index_mask] = fbx_data_items[fbx_layer_index[valid_index_mask]] + else: + buffer_item_view[:] = fbx_data_items[fbx_layer_index] + + if blen_data_is_collection: + blen_data.foreach_set(blen_attr, buffer.ravel()) + else: + if blen_data_is_collection: + # Cast the buffer to the Blender C type of blen_attr + fbx_data_items = astype_view_signedness(fbx_data_items, blen_dtype) + buffer_items = fbx_data_items[fbx_layer_index] + blen_data.foreach_set(blen_attr, buffer_items.ravel()) + else: + blen_data[:] = fbx_data_items[fbx_layer_index] + + +def blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_data, stride, item_size, descr, + xform): + """Generic fbx_layer to blen_data foreach setter for AllSame layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array.""" + fbx_data_np = blen_read_geom_parse_fbx_data(fbx_data, stride, item_size) + fbx_data_np = blen_read_geom_xform(fbx_data_np, xform) + blen_data_is_collection = blen_read_geom_validate_blen_data(blen_data, blen_dtype, item_size) + fbx_items_len = len(fbx_data_np) + blen_items_len = len(blen_data) + + if fbx_items_len < 1: + print("ERROR: not enough data in this FBX layer, skipping!") + return + + if blen_data_is_collection: + # Create an array filled with the value from fbx_data_np + buffer = np.full((blen_items_len, item_size), fbx_data_np[0], dtype=blen_dtype) + + blen_data.foreach_set(blen_attr, buffer.ravel()) + else: + blen_data[:] = fbx_data_np[0] + + +def blen_read_geom_array_foreach_set_looptovert(mesh, blen_data, blen_attr, blen_dtype, fbx_data, stride, item_size, + descr, xform): + """Generic fbx_layer to blen_data foreach setter for face corner ByVertice layers. + blen_data must be a bpy_prop_collection or 2d np.ndarray whose second axis length is item_size. + fbx_data must be an array.array""" + # The fbx_data is mapped to vertices. To expand fbx_data to face corners, get an array of the vertex index of each + # face corner that will then be used to index fbx_data. + corner_vertex_indices = MESH_ATTRIBUTE_CORNER_VERT.to_ndarray(mesh.attributes) + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_data, corner_vertex_indices, stride, + item_size, descr, xform) + + +# generic error printers. +def blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet=False): + if not quiet: + print("warning layer %r mapping type unsupported: %r" % (descr, fbx_layer_mapping)) + + +def blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet=False): + if not quiet: + print("warning layer %r ref type unsupported: %r" % (descr, fbx_layer_ref)) + + +def blen_read_geom_array_mapped_vert( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByVertice': + if fbx_layer_ref == b'IndexToDirect': + # XXX Looks like we often get no fbx_layer_index in this case, shall not happen but happens... + # We fallback to 'Direct' mapping in this case. + # ~ assert fbx_layer_index is not None + if fbx_layer_index is None: + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + else: + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_layer_data, + fbx_layer_index, stride, item_size, descr, xform) + return True + elif fbx_layer_ref == b'Direct': + assert fbx_layer_index is None + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert fbx_layer_index is None + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_array_mapped_edge( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByEdge': + if fbx_layer_ref == b'Direct': + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert fbx_layer_index is None + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_array_mapped_polygon( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByPolygon': + if fbx_layer_ref == b'IndexToDirect': + # XXX Looks like we often get no fbx_layer_index in this case, shall not happen but happens... + # We fallback to 'Direct' mapping in this case. + # ~ assert fbx_layer_index is not None + if fbx_layer_index is None: + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + else: + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_layer_data, + fbx_layer_index, stride, item_size, descr, xform) + return True + elif fbx_layer_ref == b'Direct': + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert fbx_layer_index is None + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_array_mapped_polyloop( + mesh, blen_data, blen_attr, blen_dtype, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + stride, item_size, descr, + xform=None, quiet=False, +): + if fbx_layer_mapping == b'ByPolygonVertex': + if fbx_layer_ref == b'IndexToDirect': + # XXX Looks like we often get no fbx_layer_index in this case, shall not happen but happens... + # We fallback to 'Direct' mapping in this case. + # ~ assert fbx_layer_index is not None + if fbx_layer_index is None: + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + else: + blen_read_geom_array_foreach_set_indexed(blen_data, blen_attr, blen_dtype, fbx_layer_data, + fbx_layer_index, stride, item_size, descr, xform) + return True + elif fbx_layer_ref == b'Direct': + blen_read_geom_array_foreach_set_direct(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, item_size, + descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'ByVertice': + if fbx_layer_ref == b'Direct': + assert fbx_layer_index is None + blen_read_geom_array_foreach_set_looptovert(mesh, blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + elif fbx_layer_mapping == b'AllSame': + if fbx_layer_ref == b'IndexToDirect': + assert fbx_layer_index is None + blen_read_geom_array_foreach_set_allsame(blen_data, blen_attr, blen_dtype, fbx_layer_data, stride, + item_size, descr, xform) + return True + blen_read_geom_array_error_ref(descr, fbx_layer_ref, quiet) + else: + blen_read_geom_array_error_mapping(descr, fbx_layer_mapping, quiet) + + return False + + +def blen_read_geom_layer_material(fbx_obj, mesh): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementMaterial') + + if fbx_layer is None: + return + + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + layer_id = b'Materials' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + + blen_data = MESH_ATTRIBUTE_MATERIAL_INDEX.ensure(mesh.attributes).data + fbx_item_size = 1 + assert fbx_item_size == MESH_ATTRIBUTE_MATERIAL_INDEX.item_size + blen_read_geom_array_mapped_polygon( + mesh, blen_data, MESH_ATTRIBUTE_MATERIAL_INDEX.foreach_attribute, MESH_ATTRIBUTE_MATERIAL_INDEX.dtype, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, fbx_item_size, layer_id, + ) + + +def blen_read_geom_layer_uv(fbx_obj, mesh): + for layer_id in (b'LayerElementUV',): + for fbx_layer in elem_find_iter(fbx_obj, layer_id): + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, b'UV')) + fbx_layer_index = elem_prop_first(elem_find_first(fbx_layer, b'UVIndex')) + + # Always init our new layers with (0, 0) UVs. + uv_lay = mesh.uv_layers.new(name=fbx_layer_name, do_init=False) + if uv_lay is None: + print("Failed to add {%r %r} UVLayer to %r (probably too many of them?)" + "" % (layer_id, fbx_layer_name, mesh.name)) + continue + + blen_data = uv_lay.uv + + # some valid files omit this data + if fbx_layer_data is None: + print("%r %r missing data" % (layer_id, fbx_layer_name)) + continue + + blen_read_geom_array_mapped_polyloop( + mesh, blen_data, "vector", np.single, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + 2, 2, layer_id, + ) + + +def blen_read_geom_layer_color(fbx_obj, mesh, colors_type): + if colors_type == 'NONE': + return + use_srgb = colors_type == 'SRGB' + layer_type = 'BYTE_COLOR' if use_srgb else 'FLOAT_COLOR' + color_prop_name = "color_srgb" if use_srgb else "color" + # almost same as UVs + for layer_id in (b'LayerElementColor',): + for fbx_layer in elem_find_iter(fbx_obj, layer_id): + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, b'Colors')) + fbx_layer_index = elem_prop_first(elem_find_first(fbx_layer, b'ColorIndex')) + + color_lay = mesh.color_attributes.new(name=fbx_layer_name, type=layer_type, domain='CORNER') + + if color_lay is None: + print("Failed to add {%r %r} vertex color layer to %r (probably too many of them?)" + "" % (layer_id, fbx_layer_name, mesh.name)) + continue + + blen_data = color_lay.data + + # some valid files omit this data + if fbx_layer_data is None: + print("%r %r missing data" % (layer_id, fbx_layer_name)) + continue + + blen_read_geom_array_mapped_polyloop( + mesh, blen_data, color_prop_name, np.single, + fbx_layer_data, fbx_layer_index, + fbx_layer_mapping, fbx_layer_ref, + 4, 4, layer_id, + ) + + +def blen_read_geom_layer_smooth(fbx_obj, mesh): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementSmoothing') + + if fbx_layer is None: + return + + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + layer_id = b'Smoothing' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + + # udk has 'Direct' mapped, with no Smoothing, not sure why, but ignore these + if fbx_layer_data is None: + return + + if fbx_layer_mapping == b'ByEdge': + # some models have bad edge data, we can't use this info... + if not mesh.edges: + print("warning skipping sharp edges data, no valid edges...") + return + + blen_data = MESH_ATTRIBUTE_SHARP_EDGE.ensure(mesh.attributes).data + fbx_item_size = 1 + assert fbx_item_size == MESH_ATTRIBUTE_SHARP_EDGE.item_size + blen_read_geom_array_mapped_edge( + mesh, blen_data, MESH_ATTRIBUTE_SHARP_EDGE.foreach_attribute, MESH_ATTRIBUTE_SHARP_EDGE.dtype, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, fbx_item_size, layer_id, + xform=np.logical_not, # in FBX, 0 (False) is sharp, but in Blender True is sharp. + ) + elif fbx_layer_mapping == b'ByPolygon': + sharp_face = MESH_ATTRIBUTE_SHARP_FACE.ensure(mesh.attributes) + blen_data = sharp_face.data + fbx_item_size = 1 + assert fbx_item_size == MESH_ATTRIBUTE_SHARP_FACE.item_size + sharp_face_set_successfully = blen_read_geom_array_mapped_polygon( + mesh, blen_data, MESH_ATTRIBUTE_SHARP_FACE.foreach_attribute, MESH_ATTRIBUTE_SHARP_FACE.dtype, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, fbx_item_size, layer_id, + xform=lambda s: (s == 0), # smooth-group bit-flags, treat as booleans for now. + ) + if not sharp_face_set_successfully: + mesh.attributes.remove(sharp_face) + else: + print("warning layer %r mapping type unsupported: %r" % (fbx_layer.id, fbx_layer_mapping)) + + +def blen_read_geom_layer_edge_crease(fbx_obj, mesh): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementEdgeCrease') + + if fbx_layer is None: + return False + + # all should be valid + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + if fbx_layer_mapping != b'ByEdge': + return False + + layer_id = b'EdgeCrease' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + + # some models have bad edge data, we can't use this info... + if not mesh.edges: + print("warning skipping edge crease data, no valid edges...") + return False + + if fbx_layer_mapping == b'ByEdge': + # some models have bad edge data, we can't use this info... + if not mesh.edges: + print("warning skipping edge crease data, no valid edges...") + return False + + blen_data = mesh.edge_creases_ensure().data + return blen_read_geom_array_mapped_edge( + mesh, blen_data, "value", np.single, + fbx_layer_data, None, + fbx_layer_mapping, fbx_layer_ref, + 1, 1, layer_id, + # Blender squares those values before sending them to OpenSubdiv, when other software don't, + # so we need to compensate that to get similar results through FBX... + xform=np.sqrt, + ) + else: + print("warning layer %r mapping type unsupported: %r" % (fbx_layer.id, fbx_layer_mapping)) + return False + + +def blen_read_geom_layer_normal(fbx_obj, mesh, xform=None): + fbx_layer = elem_find_first(fbx_obj, b'LayerElementNormal') + + if fbx_layer is None: + return False + + (fbx_layer_name, + fbx_layer_mapping, + fbx_layer_ref, + ) = blen_read_geom_layerinfo(fbx_layer) + + layer_id = b'Normals' + fbx_layer_data = elem_prop_first(elem_find_first(fbx_layer, layer_id)) + fbx_layer_index = elem_prop_first(elem_find_first(fbx_layer, b'NormalsIndex')) + + if fbx_layer_data is None: + print("warning %r %r missing data" % (layer_id, fbx_layer_name)) + return False + + # Normals are temporarily set here so that they can be retrieved again after a call to Mesh.validate(). + bl_norm_dtype = np.single + item_size = 3 + # try loops, then polygons, then vertices. + tries = ((mesh.attributes["temp_custom_normals"].data, "Loops", False, blen_read_geom_array_mapped_polyloop), + (mesh.polygons, "Polygons", True, blen_read_geom_array_mapped_polygon), + (mesh.vertices, "Vertices", True, blen_read_geom_array_mapped_vert)) + for blen_data, blen_data_type, is_fake, func in tries: + bdata = np.zeros((len(blen_data), item_size), dtype=bl_norm_dtype) if is_fake else blen_data + if func(mesh, bdata, "vector", bl_norm_dtype, + fbx_layer_data, fbx_layer_index, fbx_layer_mapping, fbx_layer_ref, 3, item_size, layer_id, xform, True): + if blen_data_type == "Polygons": + # To expand to per-loop normals, repeat each per-polygon normal by the number of loops of each polygon. + poly_loop_totals = np.empty(len(mesh.polygons), dtype=np.uintc) + mesh.polygons.foreach_get("loop_total", poly_loop_totals) + loop_normals = np.repeat(bdata, poly_loop_totals, axis=0) + mesh.attributes["temp_custom_normals"].data.foreach_set("vector", loop_normals.ravel()) + elif blen_data_type == "Vertices": + # We have to copy vnors to lnors! Far from elegant, but simple. + loop_vertex_indices = MESH_ATTRIBUTE_CORNER_VERT.to_ndarray(mesh.attributes) + mesh.attributes["temp_custom_normals"].data.foreach_set("vector", bdata[loop_vertex_indices].ravel()) + return True + + blen_read_geom_array_error_mapping("normal", fbx_layer_mapping) + blen_read_geom_array_error_ref("normal", fbx_layer_ref) + return False + + +def blen_read_geom(fbx_tmpl, fbx_obj, settings): + # Vertices are in object space, but we are post-multiplying all transforms with the inverse of the + # global matrix, so we need to apply the global matrix to the vertices to get the correct result. + geom_mat_co = settings.global_matrix if settings.bake_space_transform else None + # We need to apply the inverse transpose of the global matrix when transforming normals. + geom_mat_no = Matrix(settings.global_matrix_inv_transposed) if settings.bake_space_transform else None + if geom_mat_no is not None: + # Remove translation & scaling! + geom_mat_no.translation = Vector() + geom_mat_no.normalize() + + # TODO, use 'fbx_tmpl' + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'Geometry') + + fbx_verts = elem_prop_first(elem_find_first(fbx_obj, b'Vertices')) + fbx_polys = elem_prop_first(elem_find_first(fbx_obj, b'PolygonVertexIndex')) + fbx_edges = elem_prop_first(elem_find_first(fbx_obj, b'Edges')) + + # The dtypes when empty don't matter, but are set to what the fbx arrays are expected to be. + fbx_verts = parray_as_ndarray(fbx_verts) if fbx_verts else np.empty(0, dtype=data_types.ARRAY_FLOAT64) + fbx_polys = parray_as_ndarray(fbx_polys) if fbx_polys else np.empty(0, dtype=data_types.ARRAY_INT32) + fbx_edges = parray_as_ndarray(fbx_edges) if fbx_edges else np.empty(0, dtype=data_types.ARRAY_INT32) + + # Each vert is a 3d vector so is made of 3 components. + tot_verts = len(fbx_verts) // 3 + if tot_verts * 3 != len(fbx_verts): + print("ERROR: Not a whole number of vertices. Ignoring the partial vertex!") + # Remove any remainder. + fbx_verts = fbx_verts[:tot_verts * 3] + + tot_loops = len(fbx_polys) + tot_edges = len(fbx_edges) + + mesh = bpy.data.meshes.new(name=elem_name_utf8) + attributes = mesh.attributes + + if tot_verts: + if geom_mat_co is not None: + fbx_verts = vcos_transformed(fbx_verts, geom_mat_co, MESH_ATTRIBUTE_POSITION.dtype) + else: + fbx_verts = fbx_verts.astype(MESH_ATTRIBUTE_POSITION.dtype, copy=False) + + mesh.vertices.add(tot_verts) + MESH_ATTRIBUTE_POSITION.foreach_set(attributes, fbx_verts.ravel()) + + if tot_loops: + bl_loop_start_dtype = np.uintc + + mesh.loops.add(tot_loops) + # The end of each polygon is specified by an inverted index. + fbx_loop_end_idx = np.flatnonzero(fbx_polys < 0) + + tot_polys = len(fbx_loop_end_idx) + + # Un-invert the loop ends. + fbx_polys[fbx_loop_end_idx] ^= -1 + # Set loop vertex indices, casting to the Blender C type first for performance. + MESH_ATTRIBUTE_CORNER_VERT.foreach_set( + attributes, astype_view_signedness(fbx_polys, MESH_ATTRIBUTE_CORNER_VERT.dtype)) + + poly_loop_starts = np.empty(tot_polys, dtype=bl_loop_start_dtype) + # The first loop is always a loop start. + poly_loop_starts[0] = 0 + # Ignoring the last loop end, the indices after every loop end are the remaining loop starts. + poly_loop_starts[1:] = fbx_loop_end_idx[:-1] + 1 + + mesh.polygons.add(tot_polys) + mesh.polygons.foreach_set("loop_start", poly_loop_starts) + + blen_read_geom_layer_material(fbx_obj, mesh) + blen_read_geom_layer_uv(fbx_obj, mesh) + blen_read_geom_layer_color(fbx_obj, mesh, settings.colors_type) + + if tot_edges: + # edges in fact index the polygons (NOT the vertices) + + # The first vertex index of each edge is the vertex index of the corresponding loop in fbx_polys. + edges_a = fbx_polys[fbx_edges] + + # The second vertex index of each edge is the vertex index of the next loop in the same polygon. The + # complexity here is that if the first vertex index was the last loop of that polygon in fbx_polys, the next + # loop in the polygon is the first loop of that polygon, which is not the next loop in fbx_polys. + + # Copy fbx_polys, but rolled backwards by 1 so that indexing the result by [fbx_edges] will get the next + # loop of the same polygon unless the first vertex index was the last loop of the polygon. + fbx_polys_next = np.roll(fbx_polys, -1) + # Get the first loop of each polygon and set them into fbx_polys_next at the same indices as the last loop + # of each polygon in fbx_polys. + fbx_polys_next[fbx_loop_end_idx] = fbx_polys[poly_loop_starts] + + # Indexing fbx_polys_next by fbx_edges now gets the vertex index of the next loop in fbx_polys. + edges_b = fbx_polys_next[fbx_edges] + + # edges_a and edges_b need to be combined so that the first vertex index of each edge is immediately + # followed by the second vertex index of that same edge. + # Stack edges_a and edges_b as individual columns like np.column_stack((edges_a, edges_b)). + # np.concatenate is used because np.column_stack doesn't allow specifying the dtype of the returned array. + edges_conv = np.concatenate((edges_a.reshape(-1, 1), edges_b.reshape(-1, 1)), + axis=1, dtype=MESH_ATTRIBUTE_EDGE_VERTS.dtype, casting='unsafe') + + # Add the edges and set their vertex indices. + mesh.edges.add(len(edges_conv)) + # ravel() because edges_conv must be flat and C-contiguous when passed to foreach_set. + MESH_ATTRIBUTE_EDGE_VERTS.foreach_set(attributes, edges_conv.ravel()) + elif tot_edges: + print("ERROR: No polygons, but edges exist. Ignoring the edges!") + + # must be after edge, face loading. + blen_read_geom_layer_smooth(fbx_obj, mesh) + + blen_read_geom_layer_edge_crease(fbx_obj, mesh) + + ok_normals = False + if settings.use_custom_normals: + # Note: we store 'temp' normals in loops, since validate() may alter final mesh, + # we can only set custom lnors *after* calling it. + mesh.attributes.new("temp_custom_normals", 'FLOAT_VECTOR', 'CORNER') + if geom_mat_no is None: + ok_normals = blen_read_geom_layer_normal(fbx_obj, mesh) + else: + ok_normals = blen_read_geom_layer_normal(fbx_obj, mesh, + lambda v_array: nors_transformed(v_array, geom_mat_no)) + + mesh.validate(clean_customdata=False) # *Very* important to not remove lnors here! + + if ok_normals: + bl_nors_dtype = np.single + clnors = np.empty(len(mesh.loops) * 3, dtype=bl_nors_dtype) + mesh.attributes["temp_custom_normals"].data.foreach_get("vector", clnors) + + # Iterating clnors into a nested tuple first is faster than passing clnors.reshape(-1, 3) directly into + # normals_split_custom_set. We use clnors.data since it is a memoryview, which is faster to iterate than clnors. + mesh.normals_split_custom_set(tuple(zip(*(iter(clnors.data),) * 3))) + if settings.use_custom_normals: + mesh.attributes.remove(mesh.attributes["temp_custom_normals"]) + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, mesh, settings) + + return mesh + + +def blen_read_shapes(fbx_tmpl, fbx_data, objects, me, scene): + if not fbx_data: + # No shape key data. Nothing to do. + return + + me_vcos = MESH_ATTRIBUTE_POSITION.to_ndarray(me.attributes) + me_vcos_vector_view = me_vcos.reshape(-1, 3) + + objects = list({node.bl_obj for node in objects}) + assert objects + + # Blender has a hard minimum and maximum shape key Value. If an imported shape key has a value outside this range it + # will be clamped, and we'll print a warning message to the console. + shape_key_values_in_range = True + bc_uuid_to_keyblocks = {} + for bc_uuid, fbx_sdata, fbx_bcdata, shapes_assigned_to_channel in fbx_data: + num_shapes_assigned_to_channel = len(shapes_assigned_to_channel) + if num_shapes_assigned_to_channel > 1: + # Relevant design task: #104698 + raise RuntimeError("FBX in-between Shapes are not currently supported") # See bug report #84111 + elem_name_utf8 = elem_name_ensure_class(fbx_sdata, b'Geometry') + indices = elem_prop_first(elem_find_first(fbx_sdata, b'Indexes')) + dvcos = elem_prop_first(elem_find_first(fbx_sdata, b'Vertices')) + + indices = parray_as_ndarray(indices) if indices else np.empty(0, dtype=data_types.ARRAY_INT32) + dvcos = parray_as_ndarray(dvcos) if dvcos else np.empty(0, dtype=data_types.ARRAY_FLOAT64) + + # If there's not a whole number of vectors, trim off the remainder. + # 3 components per vector. + remainder = len(dvcos) % 3 + if remainder: + dvcos = dvcos[:-remainder] + dvcos = dvcos.reshape(-1, 3) + + # There must be the same number of indices as vertex coordinate differences. + assert len(indices) == len(dvcos) + + # We completely ignore normals here! + weight = elem_prop_first(elem_find_first(fbx_bcdata, b'DeformPercent'), default=100.0) / 100.0 + + # The FullWeights array stores the deformation percentages of the BlendShapeChannel that fully activate each + # Shape assigned to the BlendShapeChannel. Blender also uses this array to store Vertex Group weights, but this + # is not part of the FBX standard. + full_weights = elem_prop_first(elem_find_first(fbx_bcdata, b'FullWeights')) + full_weights = parray_as_ndarray(full_weights) if full_weights else np.empty(0, dtype=data_types.ARRAY_FLOAT64) + + # Special case for Blender exported Shape Keys with a Vertex Group assigned. The Vertex Group weights are stored + # in the FullWeights array. + # XXX - It's possible, though very rare, to get a false positive here and create a Vertex Group when we + # shouldn't. This should only be possible when there are extraneous FullWeights or when there is a single + # FullWeight and its value is not 100.0. + if ( + # Blender exported Shape Keys only ever export as 1 Shape per BlendShapeChannel. + num_shapes_assigned_to_channel == 1 + # There should be one vertex weight for each vertex moved by the Shape. + and len(full_weights) == len(indices) + # Skip creating a Vertex Group when all the weights are 100.0 because such a Vertex Group has no effect. + # This also avoids creating a Vertex Group for imported Shapes that only move a single vertex because + # their BlendShapeChannel's singular FullWeight is expected to always be 100.0. + and not np.all(full_weights == 100.0) + # Blender vertex weights are always within the [0.0, 1.0] range (scaled to [0.0, 100.0] when saving to + # FBX). This can eliminate imported BlendShapeChannels from Unreal that have extraneous FullWeights + # because the extraneous values are usually negative. + and np.all((full_weights >= 0.0) & (full_weights <= 100.0)) + ): + # Not doing the division in-place because it's technically possible for FBX BlendShapeChannels to be used by + # more than one FBX BlendShape, though this shouldn't be the case for Blender exported Shape Keys. + vgweights = full_weights / 100.0 + else: + vgweights = None + # There must be a FullWeight for each Shape. Any extra FullWeights are ignored. + assert len(full_weights) >= num_shapes_assigned_to_channel + + # To add shape keys to the mesh, an Object using the mesh is needed. + if me.shape_keys is None: + objects[0].shape_key_add(name="Basis", from_mix=False) + kb = objects[0].shape_key_add(name=elem_name_utf8, from_mix=False) + kb.value = 0.0 + me.shape_keys.use_relative = True # Should already be set as such. + + # Only need to set the shape key co if there are any non-zero dvcos. + if dvcos.any(): + shape_cos = me_vcos_vector_view.copy() + shape_cos[indices] += dvcos + kb.points.foreach_set("co", shape_cos.ravel()) + + shape_key_values_in_range &= expand_shape_key_range(kb, weight) + + kb.value = weight + + # Add vgroup if necessary. + if vgweights is not None: + # VertexGroup.add only allows sequences of int indices, but iterating the indices array directly would + # produce numpy scalars of types such as np.int32. The underlying memoryview of the indices array, however, + # does produce standard Python ints when iterated, so pass indices.data to add_vgroup_to_objects instead of + # indices. + # memoryviews tend to be faster to iterate than numpy arrays anyway, so vgweights.data is passed too. + add_vgroup_to_objects(indices.data, vgweights.data, kb.name, objects) + kb.vertex_group = kb.name + + bc_uuid_to_keyblocks.setdefault(bc_uuid, []).append(kb) + + if not shape_key_values_in_range: + print("WARNING: The imported Value of a Shape Key on the Mesh '%s' is beyond the minimum/maximum allowed and" + " has been clamped." % me.name) + + return bc_uuid_to_keyblocks + + +# -------- +# Material + +def blen_read_material(fbx_tmpl, fbx_obj, settings): + from bpy_extras import node_shader_utils + from math import sqrt + + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'Material') + + if settings.mtl_name_collision_mode == "REFERENCE_EXISTING": + if (ma := bpy.data.materials.get(elem_name_utf8)): + return ma + + nodal_material_wrap_map = settings.nodal_material_wrap_map + ma = bpy.data.materials.new(name=elem_name_utf8) + + const_color_white = 1.0, 1.0, 1.0 + const_color_black = 0.0, 0.0, 0.0 + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + fbx_props_no_template = (fbx_props[0], fbx_elem_nil) + + ma_wrap = node_shader_utils.PrincipledBSDFWrapper(ma, is_readonly=False) + ma_wrap.base_color = elem_props_get_color_rgb(fbx_props, b'DiffuseColor', const_color_white) + # No specular color in Principled BSDF shader, assumed to be either white or take some tint from diffuse one... + # TODO: add way to handle tint option (guesstimate from spec color + intensity...)? + ma_wrap.specular = elem_props_get_number(fbx_props, b'SpecularFactor', 0.25) * 2.0 + # XXX Totally empirical conversion, trying to adapt it (and protect against invalid negative values, see T96076): + # From [1.0 - 0.0] Principled BSDF range to [0.0 - 100.0] FBX shininess range)... + fbx_shininess = max(elem_props_get_number(fbx_props, b'Shininess', 20.0), 0.0) + ma_wrap.roughness = 1.0 - (sqrt(fbx_shininess) / 10.0) + # Sweetness... Looks like we are not the only ones to not know exactly how FBX is supposed to work (see T59850). + # According to one of its developers, Unity uses that formula to extract alpha value: + # + # alpha = 1 - TransparencyFactor + # if (alpha == 1 or alpha == 0): + # alpha = 1 - TransparentColor.r + # + # Until further info, let's assume this is correct way to do, hence the following code for TransparentColor. + # However, there are some cases (from 3DSMax, see T65065), where we do have TransparencyFactor only defined + # in the template to 0.0, and then materials defining TransparentColor to pure white (1.0, 1.0, 1.0), + # and setting alpha value in Opacity... try to cope with that too. :(((( + alpha = 1.0 - elem_props_get_number(fbx_props, b'TransparencyFactor', 0.0) + if (alpha == 1.0 or alpha == 0.0): + alpha = elem_props_get_number(fbx_props_no_template, b'Opacity', None) + if alpha is None: + alpha = 1.0 - elem_props_get_color_rgb(fbx_props, b'TransparentColor', const_color_black)[0] + ma_wrap.alpha = alpha + ma_wrap.metallic = elem_props_get_number(fbx_props, b'ReflectionFactor', 0.0) + # We have no metallic (a.k.a. reflection) color... + # elem_props_get_color_rgb(fbx_props, b'ReflectionColor', const_color_white) + ma_wrap.normalmap_strength = elem_props_get_number(fbx_props, b'BumpFactor', 1.0) + # Emission strength and color + ma_wrap.emission_strength = elem_props_get_number(fbx_props, b'EmissiveFactor', 1.0) + ma_wrap.emission_color = elem_props_get_color_rgb(fbx_props, b'EmissiveColor', const_color_black) + + nodal_material_wrap_map[ma] = ma_wrap + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, ma, settings) + + return ma + + +# ------- +# Image & Texture + +def blen_read_texture_image(fbx_tmpl, fbx_obj, basedir, settings): + import os + from bpy_extras import image_utils + + def pack_data_from_content(image, fbx_obj): + data = elem_find_first_bytes(fbx_obj, b'Content') + if (data): + data_len = len(data) + if (data_len): + image.pack(data=data, data_len=data_len) + + elem_name_utf8 = elem_name_ensure_classes(fbx_obj, {b'Texture', b'Video'}) + + image_cache = settings.image_cache + + # Yet another beautiful logic demonstration by Master FBX: + # * RelativeFilename in both Video and Texture nodes. + # * FileName in texture nodes. + # * Filename in video nodes. + # Aaaaaaaarrrrrrrrgggggggggggg!!!!!!!!!!!!!! + filepath = elem_find_first_string(fbx_obj, b'RelativeFilename') + if filepath: + # Make sure we do handle a relative path, and not an absolute one (see D5143). + filepath = filepath.lstrip(os.path.sep).lstrip(os.path.altsep) + filepath = os.path.join(basedir, filepath) + else: + filepath = elem_find_first_string(fbx_obj, b'FileName') + if not filepath: + filepath = elem_find_first_string(fbx_obj, b'Filename') + if not filepath: + print("Error, could not find any file path in ", fbx_obj) + print(" Falling back to: ", elem_name_utf8) + filepath = elem_name_utf8 + else: + filepath = filepath.replace('\\', '/') if (os.sep == '/') else filepath.replace('/', '\\') + + image = image_cache.get(filepath) + if image is not None: + # Data is only embedded once, we may have already created the image but still be missing its data! + if not image.has_data: + pack_data_from_content(image, fbx_obj) + return image + + image = image_utils.load_image( + filepath, + dirname=basedir, + place_holder=True, + recursive=settings.use_image_search, + ) + + # Try to use embedded data, if available! + pack_data_from_content(image, fbx_obj) + + image_cache[filepath] = image + # name can be ../a/b/c + image.name = os.path.basename(elem_name_utf8) + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, image, settings) + + return image + + +def blen_read_camera(fbx_tmpl, fbx_obj, settings): + # meters to inches + M2I = 0.0393700787 + + global_scale = settings.global_scale + + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'NodeAttribute') + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + camera = bpy.data.cameras.new(name=elem_name_utf8) + + camera.type = 'ORTHO' if elem_props_get_enum(fbx_props, b'CameraProjectionType', 0) == 1 else 'PERSP' + + camera.dof.focus_distance = elem_props_get_number(fbx_props, b'FocusDistance', 10) * global_scale + if (elem_props_get_bool(fbx_props, b'UseDepthOfField', False)): + camera.dof.use_dof = True + + camera.lens = elem_props_get_number(fbx_props, b'FocalLength', 35.0) + camera.sensor_width = elem_props_get_number(fbx_props, b'FilmWidth', 32.0 * M2I) / M2I + camera.sensor_height = elem_props_get_number(fbx_props, b'FilmHeight', 32.0 * M2I) / M2I + + camera.ortho_scale = elem_props_get_number(fbx_props, b'OrthoZoom', 1.0) + + filmaspect = camera.sensor_width / camera.sensor_height + # film offset + camera.shift_x = elem_props_get_number(fbx_props, b'FilmOffsetX', 0.0) / (M2I * camera.sensor_width) + camera.shift_y = elem_props_get_number(fbx_props, b'FilmOffsetY', 0.0) / (M2I * camera.sensor_height * filmaspect) + + camera.clip_start = elem_props_get_number(fbx_props, b'NearPlane', 0.01) * global_scale + camera.clip_end = elem_props_get_number(fbx_props, b'FarPlane', 100.0) * global_scale + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, camera, settings) + + return camera + + +def blen_read_light(fbx_tmpl, fbx_obj, settings): + import math + elem_name_utf8 = elem_name_ensure_class(fbx_obj, b'NodeAttribute') + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + light_type = { + 0: 'POINT', + 1: 'SUN', + 2: 'SPOT'}.get(elem_props_get_enum(fbx_props, b'LightType', 0), 'POINT') + + lamp = bpy.data.lights.new(name=elem_name_utf8, type=light_type) + + if light_type == 'SPOT': + spot_size = elem_props_get_number(fbx_props, b'OuterAngle', None) + if spot_size is None: + # Deprecated. + spot_size = elem_props_get_number(fbx_props, b'Cone angle', 45.0) + lamp.spot_size = math.radians(spot_size) + + spot_blend = elem_props_get_number(fbx_props, b'InnerAngle', None) + if spot_blend is None: + # Deprecated. + spot_blend = elem_props_get_number(fbx_props, b'HotSpot', 45.0) + lamp.spot_blend = 1.0 - (spot_blend / spot_size) + + # TODO, cycles nodes??? + lamp.color = elem_props_get_color_rgb(fbx_props, b'Color', (1.0, 1.0, 1.0)) + lamp.energy = elem_props_get_number(fbx_props, b'Intensity', 100.0) / 100.0 + lamp.exposure = elem_props_get_number(fbx_props, b'Exposure', 0.0) + lamp.use_shadow = elem_props_get_bool(fbx_props, b'CastShadow', True) + if hasattr(lamp, "cycles"): + lamp.cycles.cast_shadow = lamp.use_shadow + # Removed but could be restored if the value can be applied. + # `lamp.shadow_color = elem_props_get_color_rgb(fbx_props, b'ShadowColor', (0.0, 0.0, 0.0))` + + if settings.use_custom_props: + blen_read_custom_properties(fbx_obj, lamp, settings) + + return lamp + + +# ### Import Utility class +class FbxImportHelperNode: + """ + Temporary helper node to store a hierarchy of fbxNode objects before building Objects, Armatures and Bones. + It tries to keep the correction data in one place so it can be applied consistently to the imported data. + """ + + __slots__ = ( + '_parent', 'anim_compensation_matrix', 'is_global_animation', 'armature_setup', 'armature', 'bind_matrix', + 'bl_bone', 'bl_data', 'bl_obj', 'bone_child_matrix', 'children', 'clusters', + 'fbx_elem', 'fbx_data_elem', 'fbx_name', 'fbx_transform_data', 'fbx_type', + 'is_armature', 'has_bone_children', 'is_bone', 'is_root', 'is_leaf', + 'matrix', 'matrix_as_parent', 'matrix_geom', 'meshes', 'post_matrix', 'pre_matrix') + + def __init__(self, fbx_elem, bl_data, fbx_transform_data, is_bone): + self.fbx_name = elem_name_ensure_class(fbx_elem, b'Model') if fbx_elem else 'Unknown' + self.fbx_type = fbx_elem.props[2] if fbx_elem else None + self.fbx_elem = fbx_elem + # FBX elem of a connected NodeAttribute/Geometry for helpers whose bl_data + # does not exist or is yet to be created. + self.fbx_data_elem = None + self.bl_obj = None + self.bl_data = bl_data + # Name of bone if this is a bone (this may be different to fbx_name if there was a name conflict in Blender!) + self.bl_bone = None + self.fbx_transform_data = fbx_transform_data + self.is_root = False + self.is_bone = is_bone + self.is_armature = False + self.armature = None # For bones only, relevant armature node. + # True if the hierarchy below this node contains bones, important to support mixed hierarchies. + self.has_bone_children = False + # True for leaf-bones added to the end of some bone chains to set the lengths. + self.is_leaf = False + self.pre_matrix = None # correction matrix that needs to be applied before the FBX transform + self.bind_matrix = None # for bones this is the matrix used to bind to the skin + if fbx_transform_data: + self.matrix, self.matrix_as_parent, self.matrix_geom = blen_read_object_transform_do(fbx_transform_data) + else: + self.matrix, self.matrix_as_parent, self.matrix_geom = (None, None, None) + self.post_matrix = None # correction matrix that needs to be applied after the FBX transform + # Objects attached to a bone end not the beginning, this matrix corrects for that. + self.bone_child_matrix = None + + # XXX Those two are to handle the fact that rigged meshes are not linked to their armature in FBX, which implies + # that their animation is in global space (AFAIK...). + # This is actually not really solvable currently, since anim_compensation_matrix is not valid if armature + # itself is animated (we'd have to recompute global-to-local anim_compensation_matrix for each frame, + # and for each armature action... beyond being an insane work). + # Solution for now: do not read rigged meshes animations at all! sic... + # a mesh moved in the hierarchy may have a different local matrix. This compensates animations for this. + self.anim_compensation_matrix = None + self.is_global_animation = False + + self.meshes = None # List of meshes influenced by this bone. + self.clusters = [] # Deformer Cluster nodes + self.armature_setup = {} # mesh and armature matrix when the mesh was bound + + self._parent = None + self.children = [] + + @property + def parent(self): + return self._parent + + @parent.setter + def parent(self, value): + if self._parent is not None: + self._parent.children.remove(self) + self._parent = value + if self._parent is not None: + self._parent.children.append(self) + + @property + def ignore(self): + # Separating leaf status from ignore status itself. + # Currently they are equivalent, but this may change in future. + return self.is_leaf + + def __repr__(self): + if self.fbx_elem: + return self.fbx_elem.props[1].decode() + else: + return "None" + + def print_info(self, indent=0): + print(" " * indent + (self.fbx_name if self.fbx_name else "(Null)") + + ("[root]" if self.is_root else "") + + ("[leaf]" if self.is_leaf else "") + + ("[ignore]" if self.ignore else "") + + ("[armature]" if self.is_armature else "") + + ("[bone]" if self.is_bone else "") + + ("[HBC]" if self.has_bone_children else "") + ) + for c in self.children: + c.print_info(indent + 1) + + def mark_leaf_bones(self): + if self.is_bone and len(self.children) == 1: + child = self.children[0] + if child.is_bone and len(child.children) == 0: + child.is_leaf = True + for child in self.children: + child.mark_leaf_bones() + + def do_bake_transform(self, settings): + return (settings.bake_space_transform and self.fbx_type in (b'Mesh', b'Null') and + not self.is_armature and not self.is_bone) + + def find_correction_matrix(self, settings, parent_correction_inv=None): + from bpy_extras.io_utils import axis_conversion + + if self.parent and (self.parent.is_root or self.parent.do_bake_transform(settings)): + self.pre_matrix = settings.global_matrix + + if parent_correction_inv: + self.pre_matrix = parent_correction_inv @ (self.pre_matrix if self.pre_matrix else Matrix()) + + correction_matrix = None + + if self.is_bone: + if settings.automatic_bone_orientation: + # find best orientation to align bone with + bone_children = tuple(child for child in self.children if child.is_bone) + if len(bone_children) == 0: + # no children, inherit the correction from parent (if possible) + if self.parent and self.parent.is_bone: + correction_matrix = parent_correction_inv.inverted() if parent_correction_inv else None + else: + # else find how best to rotate the bone to align the Y axis with the children + best_axis = (1, 0, 0) + if len(bone_children) == 1: + vec = bone_children[0].get_bind_matrix().to_translation() + best_axis = Vector((0, 0, 1 if vec[2] >= 0 else -1)) + if abs(vec[0]) > abs(vec[1]): + if abs(vec[0]) > abs(vec[2]): + best_axis = Vector((1 if vec[0] >= 0 else -1, 0, 0)) + elif abs(vec[1]) > abs(vec[2]): + best_axis = Vector((0, 1 if vec[1] >= 0 else -1, 0)) + else: + # get the child directions once because they may be checked several times + child_locs = (child.get_bind_matrix().to_translation() for child in bone_children) + child_locs = tuple(loc.normalized() for loc in child_locs if loc.magnitude > 0.0) + + # I'm not sure which one I like better... + if False: + best_angle = -1.0 + for i in range(6): + a = i // 2 + s = -1 if i % 2 == 1 else 1 + test_axis = Vector((s if a == 0 else 0, s if a == 1 else 0, s if a == 2 else 0)) + + # find max angle to children + max_angle = 1.0 + for loc in child_locs: + max_angle = min(max_angle, test_axis.dot(loc)) + + # is it better than the last one? + if best_angle < max_angle: + best_angle = max_angle + best_axis = test_axis + else: + best_angle = -1.0 + for vec in child_locs: + test_axis = Vector((0, 0, 1 if vec[2] >= 0 else -1)) + if abs(vec[0]) > abs(vec[1]): + if abs(vec[0]) > abs(vec[2]): + test_axis = Vector((1 if vec[0] >= 0 else -1, 0, 0)) + elif abs(vec[1]) > abs(vec[2]): + test_axis = Vector((0, 1 if vec[1] >= 0 else -1, 0)) + + # find max angle to children + max_angle = 1.0 + for loc in child_locs: + max_angle = min(max_angle, test_axis.dot(loc)) + + # is it better than the last one? + if best_angle < max_angle: + best_angle = max_angle + best_axis = test_axis + + # convert best_axis to axis string + to_up = 'Z' if best_axis[2] >= 0 else '-Z' + if abs(best_axis[0]) > abs(best_axis[1]): + if abs(best_axis[0]) > abs(best_axis[2]): + to_up = 'X' if best_axis[0] >= 0 else '-X' + elif abs(best_axis[1]) > abs(best_axis[2]): + to_up = 'Y' if best_axis[1] >= 0 else '-Y' + to_forward = 'X' if to_up not in {'X', '-X'} else 'Y' + + # Build correction matrix + if (to_up, to_forward) != ('Y', 'X'): + correction_matrix = axis_conversion(from_forward='X', + from_up='Y', + to_forward=to_forward, + to_up=to_up, + ).to_4x4() + else: + correction_matrix = settings.bone_correction_matrix + else: + # camera and light can be hard wired + if self.fbx_type == b'Camera': + correction_matrix = MAT_CONVERT_CAMERA + elif self.fbx_type == b'Light': + correction_matrix = MAT_CONVERT_LIGHT + + self.post_matrix = correction_matrix + + if self.do_bake_transform(settings): + self.post_matrix = settings.global_matrix_inv @ (self.post_matrix if self.post_matrix else Matrix()) + + # process children + correction_matrix_inv = correction_matrix.inverted_safe() if correction_matrix else None + for child in self.children: + child.find_correction_matrix(settings, correction_matrix_inv) + + def find_armature_bones(self, armature): + for child in self.children: + if child.is_bone: + child.armature = armature + child.find_armature_bones(armature) + + def find_armatures(self): + needs_armature = False + for child in self.children: + if child.is_bone: + needs_armature = True + break + if needs_armature: + if self.fbx_type in {b'Null', b'Root'}: + # if empty then convert into armature + self.is_armature = True + armature = self + else: + # otherwise insert a new node + # XXX Maybe in case self is virtual FBX root node, we should instead add one armature per bone child? + armature = FbxImportHelperNode(None, None, None, False) + armature.fbx_name = "Armature" + armature.is_armature = True + + for child in tuple(self.children): + if child.is_bone: + child.parent = armature + + armature.parent = self + + armature.find_armature_bones(armature) + + for child in self.children: + if child.is_armature or child.is_bone: + continue + child.find_armatures() + + def find_bone_children(self): + has_bone_children = False + for child in self.children: + has_bone_children |= child.find_bone_children() + self.has_bone_children = has_bone_children + return self.is_bone or has_bone_children + + def find_fake_bones(self, in_armature=False): + if in_armature and not self.is_bone and self.has_bone_children: + self.is_bone = True + # if we are not a null node we need an intermediate node for the data + if self.fbx_type not in {b'Null', b'Root'}: + node = FbxImportHelperNode(self.fbx_elem, self.bl_data, None, False) + self.fbx_elem = None + self.bl_data = None + + # transfer children + for child in self.children: + if child.is_bone or child.has_bone_children: + continue + child.parent = node + + # attach to parent + node.parent = self + + if self.is_armature: + in_armature = True + for child in self.children: + child.find_fake_bones(in_armature) + + def get_world_matrix_as_parent(self): + matrix = self.parent.get_world_matrix_as_parent() if self.parent else Matrix() + if self.matrix_as_parent: + matrix = matrix @ self.matrix_as_parent + return matrix + + def get_world_matrix(self): + matrix = self.parent.get_world_matrix_as_parent() if self.parent else Matrix() + if self.matrix: + matrix = matrix @ self.matrix + return matrix + + def get_matrix(self): + matrix = self.matrix if self.matrix else Matrix() + if self.pre_matrix: + matrix = self.pre_matrix @ matrix + if self.post_matrix: + matrix = matrix @ self.post_matrix + return matrix + + def get_bind_matrix(self): + matrix = self.bind_matrix if self.bind_matrix else Matrix() + if self.pre_matrix: + matrix = self.pre_matrix @ matrix + if self.post_matrix: + matrix = matrix @ self.post_matrix + return matrix + + def make_bind_pose_local(self, parent_matrix=None): + if parent_matrix is None: + parent_matrix = Matrix() + + if self.bind_matrix: + bind_matrix = parent_matrix.inverted_safe() @ self.bind_matrix + else: + bind_matrix = self.matrix.copy() if self.matrix else None + + self.bind_matrix = bind_matrix + if bind_matrix: + parent_matrix = parent_matrix @ bind_matrix + + for child in self.children: + child.make_bind_pose_local(parent_matrix) + + def collect_skeleton_meshes(self, meshes): + for _, m in self.clusters: + meshes.update(m) + for child in self.children: + if not child.meshes: + child.collect_skeleton_meshes(meshes) + + def collect_armature_meshes(self): + if self.is_armature: + armature_matrix_inv = self.get_world_matrix().inverted_safe() + + meshes = set() + for child in self.children: + # Children meshes may be linked to children armatures, in which case we do not want to link them + # to a parent one. See T70244. + child.collect_armature_meshes() + if not child.meshes: + child.collect_skeleton_meshes(meshes) + for m in meshes: + old_matrix = m.matrix + m.matrix = armature_matrix_inv @ m.get_world_matrix() + m.anim_compensation_matrix = old_matrix.inverted_safe() @ m.matrix + m.is_global_animation = True + m.parent = self + self.meshes = meshes + else: + for child in self.children: + child.collect_armature_meshes() + + def build_skeleton(self, arm, parent_matrix, settings, parent_bone_size=1): + def child_connect(par_bone, child_bone, child_head, connect_ctx): + # child_bone or child_head may be None. + force_connect_children, connected = connect_ctx + if child_bone is not None: + child_bone.parent = par_bone + child_head = child_bone.head + + if similar_values_iter(par_bone.tail, child_head): + if child_bone is not None: + child_bone.use_connect = True + # Disallow any force-connection at this level from now on, since that child was 'really' + # connected, we do not want to move current bone's tail anymore! + connected = None + elif force_connect_children and connected is not None: + # We only store position where tail of par_bone should be in the end. + # Actual tail moving and force connection of compatible child bones will happen + # once all have been checked. + if connected is ...: + connected = ([child_head.copy(), 1], [child_bone] if child_bone is not None else []) + else: + connected[0][0] += child_head + connected[0][1] += 1 + if child_bone is not None: + connected[1].append(child_bone) + connect_ctx[1] = connected + + def child_connect_finalize(par_bone, connect_ctx): + force_connect_children, connected = connect_ctx + # Do nothing if force connection is not enabled! + if force_connect_children and connected is not None and connected is not ...: + # Here again we have to be wary about zero-length bones!!! + par_tail = connected[0][0] / connected[0][1] + if (par_tail - par_bone.head).magnitude < 1e-2: + par_bone_vec = (par_bone.tail - par_bone.head).normalized() + par_tail = par_bone.head + par_bone_vec * 0.01 + par_bone.tail = par_tail + for child_bone in connected[1]: + if similar_values_iter(par_tail, child_bone.head): + child_bone.use_connect = True + + # Create the (edit)bone. + bone = arm.bl_data.edit_bones.new(name=self.fbx_name) + bone.select = True + self.bl_obj = arm.bl_obj + self.bl_data = arm.bl_data + self.bl_bone = bone.name # Could be different from the FBX name! + # Read EditBone custom props the NodeAttribute + if settings.use_custom_props and self.fbx_data_elem: + blen_read_custom_properties(self.fbx_data_elem, bone, settings) + + # get average distance to children + bone_size = 0.0 + bone_count = 0 + for child in self.children: + if child.is_bone: + bone_size += child.get_bind_matrix().to_translation().magnitude + bone_count += 1 + if bone_count > 0: + bone_size /= bone_count + else: + bone_size = parent_bone_size + + # So that our bone gets its final length, but still Y-aligned in armature space. + # 0-length bones are automatically collapsed into their parent when you leave edit mode, + # so this enforces a minimum length. + bone_tail = Vector((0.0, 1.0, 0.0)) * max(0.01, bone_size) + bone.tail = bone_tail + + # And rotate/move it to its final "rest pose". + bone_matrix = parent_matrix @ self.get_bind_matrix().normalized() + + bone.matrix = bone_matrix + + force_connect_children = settings.force_connect_children + + connect_ctx = [force_connect_children, ...] + for child in self.children: + if child.is_leaf and force_connect_children: + # Arggggggggggggggggg! We do not want to create this bone, but we need its 'virtual head' location + # to orient current one!!! + child_head = (bone_matrix @ child.get_bind_matrix().normalized()).translation + child_connect(bone, None, child_head, connect_ctx) + elif child.is_bone and not child.ignore: + child_bone = child.build_skeleton(arm, bone_matrix, settings, bone_size) + # Connection to parent. + child_connect(bone, child_bone, None, connect_ctx) + + child_connect_finalize(bone, connect_ctx) + + # Correction for children attached to a bone. FBX expects to attach to the head of a bone, while Blender + # attaches to the tail. + if force_connect_children: + # When forcefully connecting, the bone's tail position may be changed, which can change both the bone's + # rotation and its length. + # Set the correction matrix such that it transforms the current tail transformation back to the original + # head transformation. + head_to_origin = bone.matrix.inverted_safe() + tail_to_head = Matrix.Translation(bone.head - bone.tail) + origin_to_original_head = bone_matrix + tail_to_original_head = head_to_origin @ tail_to_head @ origin_to_original_head + self.bone_child_matrix = tail_to_original_head + else: + self.bone_child_matrix = Matrix.Translation(-bone_tail) + + return bone + + def build_node_obj(self, fbx_tmpl, settings): + if self.bl_obj: + return self.bl_obj + + if self.is_bone or not self.fbx_elem: + return None + + # create when linking since we need object data + elem_name_utf8 = self.fbx_name + + # Object data must be created already + self.bl_obj = obj = bpy.data.objects.new(name=elem_name_utf8, object_data=self.bl_data) + + fbx_props = (elem_find_first(self.fbx_elem, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + # ---- + # Misc Attributes + + obj.color[0:3] = elem_props_get_color_rgb(fbx_props, b'Color', (0.8, 0.8, 0.8)) + obj.hide_viewport = not bool(elem_props_get_visibility(fbx_props, b'Visibility', 1.0)) + + obj.matrix_basis = self.get_matrix() + + if settings.use_custom_props: + blen_read_custom_properties(self.fbx_elem, obj, settings) + + return obj + + def build_skeleton_children(self, fbx_tmpl, settings, scene, view_layer): + if self.is_bone: + for child in self.children: + if child.ignore: + continue + child.build_skeleton_children(fbx_tmpl, settings, scene, view_layer) + return None + else: + # child is not a bone + obj = self.build_node_obj(fbx_tmpl, settings) + + if obj is None: + return None + + for child in self.children: + if child.ignore: + continue + child.build_skeleton_children(fbx_tmpl, settings, scene, view_layer) + + # instance in scene + view_layer.active_layer_collection.collection.objects.link(obj) + obj.select_set(True) + + return obj + + def link_skeleton_children(self, fbx_tmpl, settings, scene): + if self.is_bone: + for child in self.children: + if child.ignore: + continue + child_obj = child.bl_obj + if child_obj and child_obj != self.bl_obj: + child_obj.parent = self.bl_obj # get the armature the bone belongs to + child_obj.parent_bone = self.bl_bone + child_obj.parent_type = 'BONE' + child_obj.matrix_parent_inverse = Matrix() + + # Blender attaches to the end of a bone, while FBX attaches to the start. + # bone_child_matrix corrects for that. + if child.pre_matrix: + child.pre_matrix = self.bone_child_matrix @ child.pre_matrix + else: + child.pre_matrix = self.bone_child_matrix + + child_obj.matrix_basis = child.get_matrix() + child.link_skeleton_children(fbx_tmpl, settings, scene) + return None + else: + obj = self.bl_obj + + for child in self.children: + if child.ignore: + continue + child_obj = child.link_skeleton_children(fbx_tmpl, settings, scene) + if child_obj: + child_obj.parent = obj + + return obj + + def set_pose_matrix_and_custom_props(self, arm, settings): + pose_bone = arm.bl_obj.pose.bones[self.bl_bone] + pose_bone.matrix_basis = self.get_bind_matrix().inverted_safe() @ self.get_matrix() + + # `self.fbx_elem` can be `None` in cases where the imported hierarchy contains a mix of bone and non-bone FBX + # Nodes parented to one another, e.g. "bone1"->"mesh1"->"bone2". In Blender, an Armature can only consist of + # bones, so to maintain the imported hierarchy, a placeholder bone with the same name as "mesh1" is inserted + # into the Armature and then the imported "mesh1" Object is parented to the placeholder bone. The placeholder + # bone won't have a `self.fbx_elem` because it belongs to the "mesh1" Object instead. + # See FbxImportHelperNode.find_fake_bones(). + if settings.use_custom_props and self.fbx_elem: + blen_read_custom_properties(self.fbx_elem, pose_bone, settings) + + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.set_pose_matrix_and_custom_props(arm, settings) + + def merge_weights(self, combined_weights, fbx_cluster): + indices = elem_prop_first(elem_find_first(fbx_cluster, b'Indexes', default=None), default=()) + weights = elem_prop_first(elem_find_first(fbx_cluster, b'Weights', default=None), default=()) + + for index, weight in zip(indices, weights): + w = combined_weights.get(index) + if w is None: + combined_weights[index] = [weight] + else: + w.append(weight) + + def set_bone_weights(self): + ignored_children = tuple(child for child in self.children + if child.is_bone and child.ignore and len(child.clusters) > 0) + + if len(ignored_children) > 0: + # If we have an ignored child bone we need to merge their weights into the current bone weights. + # This can happen both intentionally and accidentally when skinning a model. Either way, they + # need to be moved into a parent bone or they cause animation glitches. + for fbx_cluster, meshes in self.clusters: + combined_weights = {} + self.merge_weights(combined_weights, fbx_cluster) + + for child in ignored_children: + for child_cluster, child_meshes in child.clusters: + if not meshes.isdisjoint(child_meshes): + self.merge_weights(combined_weights, child_cluster) + + # combine child weights + indices = [] + weights = [] + for i, w in combined_weights.items(): + indices.append(i) + if len(w) > 1: + # Add ignored child weights to the current bone's weight. + # XXX - Weights that sum to more than 1.0 get clamped to 1.0 when set in the vertex group. + weights.append(sum(w)) + else: + weights.append(w[0]) + + add_vgroup_to_objects(indices, weights, self.bl_bone, [node.bl_obj for node in meshes]) + + # clusters that drive meshes not included in a parent don't need to be merged + all_meshes = set().union(*[meshes for _, meshes in self.clusters]) + for child in ignored_children: + for child_cluster, child_meshes in child.clusters: + if all_meshes.isdisjoint(child_meshes): + indices = elem_prop_first(elem_find_first(child_cluster, b'Indexes', default=None), default=()) + weights = elem_prop_first(elem_find_first(child_cluster, b'Weights', default=None), default=()) + add_vgroup_to_objects(indices, weights, self.bl_bone, [node.bl_obj for node in child_meshes]) + else: + # set the vertex weights on meshes + for fbx_cluster, meshes in self.clusters: + indices = elem_prop_first(elem_find_first(fbx_cluster, b'Indexes', default=None), default=()) + weights = elem_prop_first(elem_find_first(fbx_cluster, b'Weights', default=None), default=()) + add_vgroup_to_objects(indices, weights, self.bl_bone, [node.bl_obj for node in meshes]) + + for child in self.children: + if child.is_bone and not child.ignore: + child.set_bone_weights() + + def build_hierarchy(self, fbx_tmpl, settings, scene, view_layer): + if self.is_armature: + # create when linking since we need object data + elem_name_utf8 = self.fbx_name + + self.bl_data = arm_data = bpy.data.armatures.new(name=elem_name_utf8) + + # Object data must be created already + self.bl_obj = arm = bpy.data.objects.new(name=elem_name_utf8, object_data=arm_data) + + arm.matrix_basis = self.get_matrix() + + if self.fbx_elem: + fbx_props = (elem_find_first(self.fbx_elem, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + if settings.use_custom_props: + # Read Armature Object custom props from the Node + blen_read_custom_properties(self.fbx_elem, arm, settings) + + if self.fbx_data_elem: + # Read Armature Data custom props from the NodeAttribute + blen_read_custom_properties(self.fbx_data_elem, arm_data, settings) + + # instance in scene + view_layer.active_layer_collection.collection.objects.link(arm) + arm.select_set(True) + + # Add bones: + + # Switch to Edit mode. + view_layer.objects.active = arm + is_hidden = arm.hide_viewport + arm.hide_viewport = False # Can't switch to Edit mode hidden objects... + bpy.ops.object.mode_set(mode='EDIT') + + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.build_skeleton(self, Matrix(), settings) + + bpy.ops.object.mode_set(mode='OBJECT') + + arm.hide_viewport = is_hidden + + # Set pose matrix and PoseBone custom properties + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.set_pose_matrix_and_custom_props(self, settings) + + # Add bone children: + for child in self.children: + if child.ignore: + continue + child_obj = child.build_skeleton_children(fbx_tmpl, settings, scene, view_layer) + + return arm + elif self.fbx_elem and not self.is_bone: + obj = self.build_node_obj(fbx_tmpl, settings) + + # walk through children + for child in self.children: + child.build_hierarchy(fbx_tmpl, settings, scene, view_layer) + + # instance in scene + view_layer.active_layer_collection.collection.objects.link(obj) + obj.select_set(True) + + return obj + else: + for child in self.children: + child.build_hierarchy(fbx_tmpl, settings, scene, view_layer) + + return None + + def link_hierarchy(self, fbx_tmpl, settings, scene): + if self.is_armature: + arm = self.bl_obj + + # Link bone children: + for child in self.children: + if child.ignore: + continue + child_obj = child.link_skeleton_children(fbx_tmpl, settings, scene) + if child_obj: + child_obj.parent = arm + + # Add armature modifiers to the meshes + if self.meshes: + for mesh in self.meshes: + (mmat, amat) = mesh.armature_setup[self] + me_obj = mesh.bl_obj + + # bring global armature & mesh matrices into *Blender* global space. + # Note: Usage of matrix_geom (local 'diff' transform) here is quite brittle. + # Among other things, why in hell isn't it taken into account by bindpose & co??? + # Probably because org app (max) handles it completely aside from any parenting stuff, + # which we obviously cannot do in Blender. :/ + if amat is None: + amat = self.bind_matrix + amat = settings.global_matrix @ (Matrix() if amat is None else amat) + if self.matrix_geom: + amat = amat @ self.matrix_geom + mmat = settings.global_matrix @ mmat + if mesh.matrix_geom: + mmat = mmat @ mesh.matrix_geom + + # Now that we have armature and mesh in there (global) bind 'state' (matrix), + # we can compute inverse parenting matrix of the mesh. + me_obj.matrix_parent_inverse = amat.inverted_safe() @ mmat @ me_obj.matrix_basis.inverted_safe() + + mod = mesh.bl_obj.modifiers.new(arm.name, 'ARMATURE') + mod.object = arm + + # Add bone weights to the deformers + for child in self.children: + if child.ignore: + continue + if child.is_bone: + child.set_bone_weights() + + return arm + elif self.bl_obj: + obj = self.bl_obj + + # walk through children + for child in self.children: + child_obj = child.link_hierarchy(fbx_tmpl, settings, scene) + if child_obj: + child_obj.parent = obj + + return obj + else: + for child in self.children: + child.link_hierarchy(fbx_tmpl, settings, scene) + + return None + + +def load(operator, context, filepath="", + use_manual_orientation=False, + axis_forward='-Z', + axis_up='Y', + global_scale=1.0, + bake_space_transform=False, + use_custom_normals=True, + use_image_search=False, + use_alpha_decals=False, + decal_offset=0.0, + use_anim=True, + anim_offset=1.0, + use_subsurf=False, + use_custom_props=True, + use_custom_props_enum_as_string=True, + ignore_leaf_bones=False, + force_connect_children=False, + automatic_bone_orientation=False, + primary_bone_axis='Y', + secondary_bone_axis='X', + use_prepost_rot=True, + colors_type='SRGB', + mtl_name_collision_mode="MAKE_UNIQUE"): + + global fbx_elem_nil + fbx_elem_nil = FBXElem('', (), (), ()) + + import os + import time + from bpy_extras.io_utils import axis_conversion + + from . import parse_fbx + from .fbx_utils import RIGHT_HAND_AXES, FBX_FRAMERATES + + start_time_proc = time.process_time() + start_time_sys = time.time() + + perfmon = PerfMon() + perfmon.level_up() + perfmon.step("FBX Import: start importing %s" % filepath) + perfmon.level_up() + + # Detect ASCII files. + + # Typically it's bad practice to fail silently on any error, + # however the file may fail to read for many reasons, + # and this situation is handled later in the code, + # right now we only want to know if the file successfully reads as ascii. + try: + with open(filepath, 'r', encoding="utf-8") as fh: + fh.read(24) + is_ascii = True + except Exception: + is_ascii = False + + if is_ascii: + operator.report({'ERROR'}, rpt_("ASCII FBX files are not supported %r") % filepath) + return {'CANCELLED'} + del is_ascii + # End ascii detection. + + try: + elem_root, version = parse_fbx.parse(filepath) + except Exception as e: + import traceback + traceback.print_exc() + + operator.report({'ERROR'}, rpt_("Couldn't open file %r (%s)") % (filepath, e)) + return {'CANCELLED'} + + if version < 7100: + operator.report({'ERROR'}, rpt_("Version %r unsupported, must be %r or later") % (version, 7100)) + return {'CANCELLED'} + + print("FBX version: %r" % version) + + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT', toggle=False) + + # deselect all + if bpy.ops.object.select_all.poll(): + bpy.ops.object.select_all(action='DESELECT') + + basedir = os.path.dirname(filepath) + + nodal_material_wrap_map = {} + image_cache = {} + + # Tables: (FBX_byte_id -> [FBX_data, None or Blender_datablock]) + fbx_table_nodes = {} + + if use_alpha_decals: + material_decals = set() + else: + material_decals = None + + scene = context.scene + view_layer = context.view_layer + + # #### Get some info from GlobalSettings. + + perfmon.step("FBX import: Prepare...") + + fbx_settings = elem_find_first(elem_root, b'GlobalSettings') + fbx_settings_props = elem_find_first(fbx_settings, b'Properties70') + if fbx_settings is None or fbx_settings_props is None: + operator.report({'ERROR'}, rpt_("No 'GlobalSettings' found in file %r") % filepath) + return {'CANCELLED'} + + # FBX default base unit seems to be the centimeter, while raw Blender Unit is equivalent to the meter... + unit_scale = elem_props_get_number(fbx_settings_props, b'UnitScaleFactor', 1.0) + unit_scale_org = elem_props_get_number(fbx_settings_props, b'OriginalUnitScaleFactor', 1.0) + global_scale *= (unit_scale / units_blender_to_fbx_factor(context.scene)) + # Compute global matrix and scale. + if not use_manual_orientation: + axis_forward = (elem_props_get_integer(fbx_settings_props, b'FrontAxis', 1), + elem_props_get_integer(fbx_settings_props, b'FrontAxisSign', 1)) + axis_up = (elem_props_get_integer(fbx_settings_props, b'UpAxis', 2), + elem_props_get_integer(fbx_settings_props, b'UpAxisSign', 1)) + axis_coord = (elem_props_get_integer(fbx_settings_props, b'CoordAxis', 0), + elem_props_get_integer(fbx_settings_props, b'CoordAxisSign', 1)) + axis_key = (axis_up, axis_forward, axis_coord) + axis_up, axis_forward = {v: k for k, v in RIGHT_HAND_AXES.items()}.get(axis_key, ('Z', 'Y')) + global_matrix = (Matrix.Scale(global_scale, 4) @ + axis_conversion(from_forward=axis_forward, from_up=axis_up).to_4x4()) + + # To cancel out unwanted rotation/scale on nodes. + global_matrix_inv = global_matrix.inverted() + # For transforming mesh normals. + global_matrix_inv_transposed = global_matrix_inv.transposed() + + # Compute bone correction matrix + bone_correction_matrix = None # None means no correction/identity + if not automatic_bone_orientation: + if (primary_bone_axis, secondary_bone_axis) != ('Y', 'X'): + bone_correction_matrix = axis_conversion(from_forward='X', + from_up='Y', + to_forward=secondary_bone_axis, + to_up=primary_bone_axis, + ).to_4x4() + + # Compute frame-rate settings. + custom_fps = elem_props_get_number(fbx_settings_props, b'CustomFrameRate', 25.0) + time_mode = elem_props_get_enum(fbx_settings_props, b'TimeMode') + real_fps = {eid: val for val, eid in FBX_FRAMERATES[1:]}.get(time_mode, custom_fps) + if real_fps <= 0.0: + real_fps = 25.0 + scene.render.fps = round(real_fps) + scene.render.fps_base = scene.render.fps / real_fps + + # store global settings that need to be accessed during conversion + settings = FBXImportSettings( + operator.report, (axis_up, axis_forward), global_matrix, global_scale, + bake_space_transform, global_matrix_inv, global_matrix_inv_transposed, + use_custom_normals, use_image_search, + use_alpha_decals, decal_offset, + use_anim, anim_offset, + use_subsurf, + use_custom_props, use_custom_props_enum_as_string, + nodal_material_wrap_map, image_cache, + ignore_leaf_bones, force_connect_children, automatic_bone_orientation, bone_correction_matrix, + use_prepost_rot, colors_type, mtl_name_collision_mode, + ) + + # #### And now, the "real" data. + + perfmon.step("FBX import: Templates...") + + fbx_defs = elem_find_first(elem_root, b'Definitions') # can be None + fbx_nodes = elem_find_first(elem_root, b'Objects') + fbx_connections = elem_find_first(elem_root, b'Connections') + + if fbx_nodes is None: + operator.report({'ERROR'}, rpt_("No 'Objects' found in file %r") % filepath) + return {'CANCELLED'} + if fbx_connections is None: + operator.report({'ERROR'}, rpt_("No 'Connections' found in file %r") % filepath) + return {'CANCELLED'} + + # ---- + # First load property templates + # Load 'PropertyTemplate' values. + # Key is a tuple, (ObjectType, FBXNodeType) + # eg, (b'Texture', b'KFbxFileTexture') + # (b'Geometry', b'KFbxMesh') + fbx_templates = {} + + def _(): + if fbx_defs is not None: + for fbx_def in fbx_defs.elems: + if fbx_def.id == b'ObjectType': + for fbx_subdef in fbx_def.elems: + if fbx_subdef.id == b'PropertyTemplate': + assert fbx_def.props_type == b'S' + assert fbx_subdef.props_type == b'S' + # (b'Texture', b'KFbxFileTexture') - eg. + key = fbx_def.props[0], fbx_subdef.props[0] + fbx_templates[key] = fbx_subdef + _() + del _ + + def fbx_template_get(key): + ret = fbx_templates.get(key, fbx_elem_nil) + if ret is fbx_elem_nil: + # Newest FBX (7.4 and above) use no more 'K' in their type names... + key = (key[0], key[1][1:]) + return fbx_templates.get(key, fbx_elem_nil) + return ret + + perfmon.step("FBX import: Nodes...") + + # ---- + # Build FBX node-table + def _(): + for fbx_obj in fbx_nodes.elems: + # TODO, investigate what other items after first 3 may be + assert fbx_obj.props_type[:3] == b'LSS' + fbx_uuid = elem_uuid(fbx_obj) + fbx_table_nodes[fbx_uuid] = [fbx_obj, None] + _() + del _ + + # ---- + # Load in the data + # http://download.autodesk.com/us/fbx/20112/FBX_SDK_HELP/index.html?url= + # WS73099cc142f487551fea285e1221e4f9ff8-7fda.htm,topicNumber=d0e6388 + + perfmon.step("FBX import: Connections...") + + fbx_connection_map = {} + fbx_connection_map_reverse = {} + + def _(): + for fbx_link in fbx_connections.elems: + c_type = fbx_link.props[0] + if fbx_link.props_type[1:3] == b'LL': + c_src, c_dst = fbx_link.props[1:3] + fbx_connection_map.setdefault(c_src, []).append((c_dst, fbx_link)) + fbx_connection_map_reverse.setdefault(c_dst, []).append((c_src, fbx_link)) + _() + del _ + + perfmon.step("FBX import: Meshes...") + + # ---- + # Load mesh data + def _(): + fbx_tmpl = fbx_template_get((b'Geometry', b'KFbxMesh')) + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Geometry': + continue + if fbx_obj.props[-1] == b'Mesh': + assert blen_data is None + fbx_item[1] = blen_read_geom(fbx_tmpl, fbx_obj, settings) + _() + del _ + + perfmon.step("FBX import: Materials & Textures...") + + # ---- + # Load material data + def _(): + fbx_tmpl = fbx_template_get((b'Material', b'KFbxSurfacePhong')) + # b'KFbxSurfaceLambert' + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Material': + continue + assert blen_data is None + fbx_item[1] = blen_read_material(fbx_tmpl, fbx_obj, settings) + _() + del _ + + # ---- + # Load image & textures data + def _(): + fbx_tmpl_tex = fbx_template_get((b'Texture', b'KFbxFileTexture')) + fbx_tmpl_img = fbx_template_get((b'Video', b'KFbxVideo')) + + # Important to run all 'Video' ones first, embedded images are stored in those nodes. + # XXX Note we simplify things here, assuming both matching Video and Texture will use same file path, + # this may be a bit weak, if issue arise we'll fallback to plain connection stuff... + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Video': + continue + fbx_item[1] = blen_read_texture_image(fbx_tmpl_img, fbx_obj, basedir, settings) + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Texture': + continue + fbx_item[1] = blen_read_texture_image(fbx_tmpl_tex, fbx_obj, basedir, settings) + _() + del _ + + perfmon.step("FBX import: Cameras & Lamps...") + + # ---- + # Load camera data + def _(): + fbx_tmpl = fbx_template_get((b'NodeAttribute', b'KFbxCamera')) + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'NodeAttribute': + continue + if fbx_obj.props[-1] == b'Camera': + assert blen_data is None + fbx_item[1] = blen_read_camera(fbx_tmpl, fbx_obj, settings) + _() + del _ + + # ---- + # Load lamp data + def _(): + fbx_tmpl = fbx_template_get((b'NodeAttribute', b'KFbxLight')) + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'NodeAttribute': + continue + if fbx_obj.props[-1] == b'Light': + assert blen_data is None + fbx_item[1] = blen_read_light(fbx_tmpl, fbx_obj, settings) + _() + del _ + + # ---- + # Connections + def connection_filter_ex(fbx_uuid, fbx_id, dct): + return [(c_found[0], c_found[1], c_type) + for (c_uuid, c_type) in dct.get(fbx_uuid, ()) + # 0 is used for the root node, which isn't in fbx_table_nodes + for c_found in (() if c_uuid == 0 else (fbx_table_nodes.get(c_uuid, (None, None)),)) + if (fbx_id is None) or (c_found[0] and c_found[0].id == fbx_id)] + + def connection_filter_forward(fbx_uuid, fbx_id): + return connection_filter_ex(fbx_uuid, fbx_id, fbx_connection_map) + + def connection_filter_reverse(fbx_uuid, fbx_id): + return connection_filter_ex(fbx_uuid, fbx_id, fbx_connection_map_reverse) + + perfmon.step("FBX import: Objects & Armatures...") + + # -- temporary helper hierarchy to build armatures and objects from + # lookup from uuid to helper node. Used to build parent-child relations and later to look up animated nodes. + fbx_helper_nodes = {} + + def _(): + # We build an intermediate hierarchy used to: + # - Calculate and store bone orientation correction matrices. The same matrices will be reused for animation. + # - Find/insert armature nodes. + # - Filter leaf bones. + + # create scene root + fbx_helper_nodes[0] = root_helper = FbxImportHelperNode(None, None, None, False) + root_helper.is_root = True + + # add fbx nodes + fbx_tmpl = fbx_template_get((b'Model', b'KFbxNode')) + for a_uuid, a_item in fbx_table_nodes.items(): + fbx_obj, bl_data = a_item + if fbx_obj is None or fbx_obj.id != b'Model': + continue + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + + transform_data = blen_read_object_transform_preprocess(fbx_props, fbx_obj, Matrix(), use_prepost_rot) + # Note: 'Root' "bones" are handled as (armature) objects. + # Note: See T46912 for first FBX file I ever saw with 'Limb' bones - thought those were totally deprecated. + is_bone = fbx_obj.props[2] in {b'LimbNode', b'Limb'} + fbx_helper_nodes[a_uuid] = FbxImportHelperNode(fbx_obj, bl_data, transform_data, is_bone) + + # add parent-child relations and add blender data to the node + for fbx_link in fbx_connections.elems: + if fbx_link.props[0] != b'OO': + continue + if fbx_link.props_type[1:3] == b'LL': + c_src, c_dst = fbx_link.props[1:3] + parent = fbx_helper_nodes.get(c_dst) + if parent is None: + continue + + child = fbx_helper_nodes.get(c_src) + if child is None: + # add blender data (meshes, lights, cameras, etc.) to a helper node + fbx_sdata, bl_data = p_item = fbx_table_nodes.get(c_src, (None, None)) + if fbx_sdata is None: + continue + if fbx_sdata.id not in {b'Geometry', b'NodeAttribute'}: + continue + parent.bl_data = bl_data + if bl_data is None: + # If there's no bl_data, add the fbx_sdata so that it can be read when creating the bl_data/bone + parent.fbx_data_elem = fbx_sdata + else: + # set parent + child.parent = parent + + # find armatures (either an empty below a bone or a new node inserted at the bone + root_helper.find_armatures() + + # mark nodes that have bone children + root_helper.find_bone_children() + + # mark nodes that need a bone to attach child-bones to + root_helper.find_fake_bones() + + # mark leaf nodes that are only required to mark the end of their parent bone + if settings.ignore_leaf_bones: + root_helper.mark_leaf_bones() + + # What a mess! Some bones have several BindPoses, some have none, clusters contain a bind pose as well, + # and you can have several clusters per bone! + # Maybe some conversion can be applied to put them all into the same frame of reference? + + # get the bind pose from pose elements + for a_uuid, a_item in fbx_table_nodes.items(): + fbx_obj, bl_data = a_item + if fbx_obj is None: + continue + if fbx_obj.id != b'Pose': + continue + if fbx_obj.props[2] != b'BindPose': + continue + for fbx_pose_node in fbx_obj.elems: + if fbx_pose_node.id != b'PoseNode': + continue + node_elem = elem_find_first(fbx_pose_node, b'Node') + node = elem_uuid(node_elem) + matrix_elem = elem_find_first(fbx_pose_node, b'Matrix') + matrix = array_to_matrix4(matrix_elem.props[0]) if matrix_elem else None + bone = fbx_helper_nodes.get(node) + if bone and matrix: + # Store the matrix in the helper node. + # There may be several bind pose matrices for the same node, but in tests they seem to be identical. + bone.bind_matrix = matrix # global space + + # get clusters and bind pose + for helper_uuid, helper_node in fbx_helper_nodes.items(): + if not helper_node.is_bone: + continue + for cluster_uuid, cluster_link in fbx_connection_map.get(helper_uuid, ()): + if cluster_link.props[0] != b'OO': + continue + fbx_cluster, _ = fbx_table_nodes.get(cluster_uuid, (None, None)) + if fbx_cluster is None or fbx_cluster.id != b'Deformer' or fbx_cluster.props[2] != b'Cluster': + continue + + # Get the bind pose from the cluster: + tx_mesh_elem = elem_find_first(fbx_cluster, b'Transform', default=None) + tx_mesh = array_to_matrix4(tx_mesh_elem.props[0]) if tx_mesh_elem else Matrix() + + tx_bone_elem = elem_find_first(fbx_cluster, b'TransformLink', default=None) + tx_bone = array_to_matrix4(tx_bone_elem.props[0]) if tx_bone_elem else None + + tx_arm_elem = elem_find_first(fbx_cluster, b'TransformAssociateModel', default=None) + tx_arm = array_to_matrix4(tx_arm_elem.props[0]) if tx_arm_elem else None + + mesh_matrix = tx_mesh + armature_matrix = tx_arm + + if tx_bone: + mesh_matrix = tx_bone @ mesh_matrix + helper_node.bind_matrix = tx_bone # overwrite the bind matrix + + # Get the meshes driven by this cluster: (Shouldn't that be only one?) + meshes = set() + for skin_uuid, skin_link in fbx_connection_map.get(cluster_uuid): + if skin_link.props[0] != b'OO': + continue + fbx_skin, _ = fbx_table_nodes.get(skin_uuid, (None, None)) + if fbx_skin is None or fbx_skin.id != b'Deformer' or fbx_skin.props[2] != b'Skin': + continue + skin_connection = fbx_connection_map.get(skin_uuid) + if skin_connection is None: + continue + for mesh_uuid, mesh_link in skin_connection: + if mesh_link.props[0] != b'OO': + continue + fbx_mesh, _ = fbx_table_nodes.get(mesh_uuid, (None, None)) + if fbx_mesh is None or fbx_mesh.id != b'Geometry' or fbx_mesh.props[2] != b'Mesh': + continue + for object_uuid, object_link in fbx_connection_map.get(mesh_uuid): + if object_link.props[0] != b'OO': + continue + mesh_node = fbx_helper_nodes[object_uuid] + if mesh_node: + # ---- + # If we get a valid mesh matrix (in bone space), store armature and + # mesh global matrices, we need them to compute mesh's matrix_parent_inverse + # when actually binding them via the modifier. + # Note we assume all bones were bound with the same mesh/armature (global) matrix, + # we do not support otherwise in Blender anyway! + mesh_node.armature_setup[helper_node.armature] = (mesh_matrix, armature_matrix) + meshes.add(mesh_node) + + helper_node.clusters.append((fbx_cluster, meshes)) + + # convert bind poses from global space into local space + root_helper.make_bind_pose_local() + + # collect armature meshes + root_helper.collect_armature_meshes() + + # find the correction matrices to align FBX objects with their Blender equivalent + root_helper.find_correction_matrix(settings) + + # build the Object/Armature/Bone hierarchy + root_helper.build_hierarchy(fbx_tmpl, settings, scene, view_layer) + + # Link the Object/Armature/Bone hierarchy + root_helper.link_hierarchy(fbx_tmpl, settings, scene) + + # root_helper.print_info(0) + _() + del _ + + perfmon.step("FBX import: ShapeKeys...") + + # We can handle shapes. + blend_shape_channels = {} # We do not need Shapes themselves, but keyblocks, for anim. + + def _(): + fbx_tmpl = fbx_template_get((b'Geometry', b'KFbxShape')) + + # - FBX | - Blender equivalent + # Mesh | `Mesh` + # BlendShape | `Key` + # BlendShapeChannel | `ShapeKey`, but without its `.data`. + # Shape | `ShapeKey.data`, but also includes normals and the values are relative to the base Mesh + # | instead of being absolute. The data is sparse, so each Shape has an "Indexes" array too. + # | FBX 2020 introduced 'Modern Style' Shapes that also support tangents, binormals, vertex + # | colors and UVs, and can be absolute values instead of relative, but 'Modern Style' Shapes + # | are not currently supported. + # + # The FBX connections between Shapes and Meshes form multiple many-many relationships: + # Mesh >-< BlendShape >-< BlendShapeChannel >-< Shape + # In practice, the relationships are almost never many-many and are more typically 1-many or 1-1: + # Mesh --- BlendShape: + # usually 1-1 and the FBX SDK might enforce that each BlendShape is connected to at most one Mesh. + # BlendShape --< BlendShapeChannel: + # usually 1-many. + # BlendShapeChannel --- or uncommonly --< Shape: + # usually 1-1, but 1-many is a documented feature. + + def connections_gen(c_src_uuid, fbx_id, fbx_type): + """Helper to reduce duplicate code""" + # Rarely, an imported FBX file will have duplicate connections. For Shape Key related connections, FBX + # appears to ignore the duplicates, or overwrite the existing duplicates such that the end result is the + # same as ignoring them, so keep a set of the seen connections and ignore any duplicates. + seen_connections = set() + for c_dst_uuid, ctype in fbx_connection_map.get(c_src_uuid, ()): + if ctype.props[0] != b'OO': + # 'Object-Object' connections only. + continue + fbx_data, bl_data = fbx_table_nodes.get(c_dst_uuid, (None, None)) + if fbx_data is None or fbx_data.id != fbx_id or fbx_data.props[2] != fbx_type: + # Either `c_dst_uuid` doesn't exist, or it has a different id or type. + continue + connection_key = (c_src_uuid, c_dst_uuid) + if connection_key in seen_connections: + # The connection is a duplicate, skip it. + continue + seen_connections.add(connection_key) + yield c_dst_uuid, fbx_data, bl_data + + # XXX - Multiple Shapes can be assigned to a single BlendShapeChannel to create a progressive blend between the + # base mesh and the assigned Shapes, with the percentage at which each Shape is fully blended being stored + # in the BlendShapeChannel's FullWeights array. This is also known as 'in-between shapes'. + # We don't have any support for in-between shapes currently. + blend_shape_channel_to_shapes = {} + mesh_to_shapes = {} + for s_uuid, (fbx_sdata, _bl_sdata) in fbx_table_nodes.items(): + if fbx_sdata is None or fbx_sdata.id != b'Geometry' or fbx_sdata.props[2] != b'Shape': + continue + + # shape -> blend-shape-channel -> blend-shape -> mesh. + for bc_uuid, fbx_bcdata, _bl_bcdata in connections_gen(s_uuid, b'Deformer', b'BlendShapeChannel'): + # Track the Shapes connected to each BlendShapeChannel. + shapes_assigned_to_channel = blend_shape_channel_to_shapes.setdefault(bc_uuid, []) + shapes_assigned_to_channel.append(s_uuid) + for bs_uuid, _fbx_bsdata, _bl_bsdata in connections_gen(bc_uuid, b'Deformer', b'BlendShape'): + for m_uuid, _fbx_mdata, bl_mdata in connections_gen(bs_uuid, b'Geometry', b'Mesh'): + # Blenmeshes are assumed already created at that time! + assert isinstance(bl_mdata, bpy.types.Mesh) + # Group shapes by mesh so that each mesh only needs to be processed once for all of its shape + # keys. + if bl_mdata not in mesh_to_shapes: + # And we have to find all objects using this mesh! + objects = [] + for o_uuid, o_ctype in fbx_connection_map.get(m_uuid, ()): + if o_ctype.props[0] != b'OO': + continue + node = fbx_helper_nodes[o_uuid] + if node: + objects.append(node) + shapes_list = [] + mesh_to_shapes[bl_mdata] = (objects, shapes_list) + else: + shapes_list = mesh_to_shapes[bl_mdata][1] + # Only the number of shapes assigned to each BlendShapeChannel needs to be passed through to + # `blen_read_shapes`, but that number isn't known until all the connections have been + # iterated, so pass the `shapes_assigned_to_channel` list instead. + shapes_list.append((bc_uuid, fbx_sdata, fbx_bcdata, shapes_assigned_to_channel)) + # BlendShape deformers are only here to connect BlendShapeChannels to meshes, nothing else to do. + + # Iterate through each mesh and create its shape keys + for bl_mdata, (objects, shapes) in mesh_to_shapes.items(): + for bc_uuid, keyblocks in blen_read_shapes(fbx_tmpl, shapes, objects, bl_mdata, scene).items(): + # keyblocks is a list of tuples (mesh, key-block) + # matching that shape/blend-shape-channel, for animation. + blend_shape_channels.setdefault(bc_uuid, []).extend(keyblocks) + _() + del _ + + if settings.use_subsurf: + perfmon.step("FBX import: Subdivision surfaces") + + # Look through connections for subsurf in meshes and add it to the parent object + def _(): + for fbx_link in fbx_connections.elems: + if fbx_link.props[0] != b'OO': + continue + if fbx_link.props_type[1:3] == b'LL': + c_src, c_dst = fbx_link.props[1:3] + parent = fbx_helper_nodes.get(c_dst) + if parent is None: + continue + + child = fbx_helper_nodes.get(c_src) + if child is None: + fbx_sdata, bl_data = fbx_table_nodes.get(c_src, (None, None)) + if fbx_sdata.id != b'Geometry': + continue + + preview_levels = elem_prop_first(elem_find_first(fbx_sdata, b'PreviewDivisionLevels')) + render_levels = elem_prop_first(elem_find_first(fbx_sdata, b'RenderDivisionLevels')) + if isinstance(preview_levels, int) and isinstance(render_levels, int): + mod = parent.bl_obj.modifiers.new('subsurf', 'SUBSURF') + mod.levels = preview_levels + mod.render_levels = render_levels + boundary_rule = elem_prop_first(elem_find_first(fbx_sdata, b'BoundaryRule'), default=1) + if boundary_rule == 1: + mod.boundary_smooth = "PRESERVE_CORNERS" + else: + mod.boundary_smooth = "ALL" + + _() + del _ + + if use_anim: + perfmon.step("FBX import: Animations...") + + # Animation! + def _(): + # Find the number of "ktimes" per second for this file. + # Start with the default for this FBX version. + fbx_ktime = FBX_KTIME_V8 if version >= 8000 else FBX_KTIME_V7 + # Try to find the value of the nested elem_root->'FBXHeaderExtension'->'OtherFlags'->'TCDefinition' element + # and look up the "ktimes" per second for its value. + if header := elem_find_first(elem_root, b'FBXHeaderExtension'): + # The header version that added TCDefinition support is 1004. + if elem_prop_first(elem_find_first(header, b'FBXHeaderVersion'), default=0) >= 1004: + if other_flags := elem_find_first(header, b'OtherFlags'): + if timecode_definition := elem_find_first(other_flags, b'TCDefinition'): + timecode_definition_value = elem_prop_first(timecode_definition) + # If its value is unknown or missing, default to FBX_KTIME_V8. + fbx_ktime = FBX_TIMECODE_DEFINITION_TO_KTIME_PER_SECOND.get(timecode_definition_value, + FBX_KTIME_V8) + + fbx_tmpl_astack = fbx_template_get((b'AnimationStack', b'FbxAnimStack')) + fbx_tmpl_alayer = fbx_template_get((b'AnimationLayer', b'FbxAnimLayer')) + stacks = {} + + # AnimationStacks. + for as_uuid, fbx_asitem in fbx_table_nodes.items(): + fbx_asdata, _blen_data = fbx_asitem + if fbx_asdata.id != b'AnimationStack' or fbx_asdata.props[2] != b'': + continue + stacks[as_uuid] = (fbx_asitem, {}) + + # AnimationLayers + # (mixing is completely ignored for now, each layer results in an independent set of actions). + def get_astacks_from_alayer(al_uuid): + for as_uuid, as_ctype in fbx_connection_map.get(al_uuid, ()): + if as_ctype.props[0] != b'OO': + continue + fbx_asdata, _bl_asdata = fbx_table_nodes.get(as_uuid, (None, None)) + if (fbx_asdata is None or fbx_asdata.id != b'AnimationStack' or + fbx_asdata.props[2] != b'' or as_uuid not in stacks): + continue + yield as_uuid + for al_uuid, fbx_alitem in fbx_table_nodes.items(): + fbx_aldata, _blen_data = fbx_alitem + if fbx_aldata.id != b'AnimationLayer' or fbx_aldata.props[2] != b'': + continue + for as_uuid in get_astacks_from_alayer(al_uuid): + _fbx_asitem, alayers = stacks[as_uuid] + alayers[al_uuid] = (fbx_alitem, {}) + + # AnimationCurveNodes (also the ones linked to actual animated data!). + curvenodes = {} + for acn_uuid, fbx_acnitem in fbx_table_nodes.items(): + fbx_acndata, _blen_data = fbx_acnitem + if fbx_acndata.id != b'AnimationCurveNode' or fbx_acndata.props[2] != b'': + continue + cnode = curvenodes[acn_uuid] = {} + items = [] + for n_uuid, n_ctype in fbx_connection_map.get(acn_uuid, ()): + if n_ctype.props[0] != b'OP': + continue + lnk_prop = n_ctype.props[3] + if lnk_prop in {b'Lcl Translation', b'Lcl Rotation', b'Lcl Scaling'}: + # n_uuid can (????) be linked to root '0' node, instead of a mere object node... See T41712. + ob = fbx_helper_nodes.get(n_uuid, None) + if ob is None or ob.is_root: + continue + items.append((ob, lnk_prop)) + elif lnk_prop == b'DeformPercent': # Shape keys. + keyblocks = blend_shape_channels.get(n_uuid, None) + if keyblocks is None: + continue + items += [(kb, lnk_prop) for kb in keyblocks] + elif lnk_prop == b'FocalLength': # Camera lens. + from bpy.types import Camera + fbx_item = fbx_table_nodes.get(n_uuid, None) + if fbx_item is None or not isinstance(fbx_item[1], Camera): + continue + cam = fbx_item[1] + items.append((cam, lnk_prop)) + elif lnk_prop == b'FocusDistance': # Camera focus. + from bpy.types import Camera + fbx_item = fbx_table_nodes.get(n_uuid, None) + if fbx_item is None or not isinstance(fbx_item[1], Camera): + continue + cam = fbx_item[1] + items.append((cam, lnk_prop)) + elif lnk_prop == b'DiffuseColor': + from bpy.types import Material + fbx_item = fbx_table_nodes.get(n_uuid, None) + if fbx_item is None or not isinstance(fbx_item[1], Material): + continue + mat = fbx_item[1] + items.append((mat, lnk_prop)) + print("WARNING! Importing material's animation is not supported for Nodal materials...") + for al_uuid, al_ctype in fbx_connection_map.get(acn_uuid, ()): + if al_ctype.props[0] != b'OO': + continue + fbx_aldata, _blen_aldata = fbx_alitem = fbx_table_nodes.get(al_uuid, (None, None)) + if fbx_aldata is None or fbx_aldata.id != b'AnimationLayer' or fbx_aldata.props[2] != b'': + continue + for as_uuid in get_astacks_from_alayer(al_uuid): + _fbx_alitem, anim_items = stacks[as_uuid][1][al_uuid] + assert _fbx_alitem == fbx_alitem + for item, item_prop in items: + # No need to keep curve-node FBX data here, contains nothing useful for us. + anim_items.setdefault(item, {})[acn_uuid] = (cnode, item_prop) + + # AnimationCurves (real animation data). + for ac_uuid, fbx_acitem in fbx_table_nodes.items(): + fbx_acdata, _blen_data = fbx_acitem + if fbx_acdata.id != b'AnimationCurve' or fbx_acdata.props[2] != b'': + continue + for acn_uuid, acn_ctype in fbx_connection_map.get(ac_uuid, ()): + if acn_ctype.props[0] != b'OP': + continue + fbx_acndata, _bl_acndata = fbx_table_nodes.get(acn_uuid, (None, None)) + if (fbx_acndata is None or fbx_acndata.id != b'AnimationCurveNode' or + fbx_acndata.props[2] != b'' or acn_uuid not in curvenodes): + continue + # Note this is an infamous simplification of the compound props stuff, + # seems to be standard naming but we'll probably have to be smarter to handle more exotic files? + channel = { + b'd|X': 0, b'd|Y': 1, b'd|Z': 2, + b'd|DeformPercent': 0, + b'd|FocalLength': 0, + b'd|FocusDistance': 0 + }.get(acn_ctype.props[3], None) + if channel is None: + continue + curvenodes[acn_uuid][ac_uuid] = (fbx_acitem, channel) + + # And now that we have sorted all this, apply animations! + blen_read_animations(fbx_tmpl_astack, fbx_tmpl_alayer, stacks, scene, settings.anim_offset, global_scale, + fbx_ktime) + + _() + del _ + + perfmon.step("FBX import: Assign materials...") + + def _(): + # link Material's to Geometry (via Model's) + processed_meshes = set() + for helper_uuid, helper_node in fbx_helper_nodes.items(): + obj = helper_node.bl_obj + if not obj or obj.type != 'MESH': + continue + + # Get the Mesh corresponding to the Geometry used by this Model. + mesh = obj.data + processed_meshes.add(mesh) + + # Get the Materials from the Model's connections. + material_connections = connection_filter_reverse(helper_uuid, b'Material') + if not material_connections: + continue + + mesh_mats = mesh.materials + num_mesh_mats = len(mesh_mats) + + if num_mesh_mats == 0: + # This is the first (or only) model to use this Geometry. This is the most common case when importing. + # All the Materials can trivially be appended to the Mesh's Materials. + mats_to_append = material_connections + mats_to_compare = () + elif num_mesh_mats == len(material_connections): + # Another Model uses the same Geometry and has already appended its Materials to the Mesh. This is the + # second most common case when importing. + # It's also possible that a Model could share the same Geometry and have the same number of Materials, + # but have different Materials, though this is less common. + # The Model Materials will need to be compared with the Mesh Materials at the same indices to check if + # they are different. + mats_to_append = () + mats_to_compare = material_connections + else: + # Under the assumption that only used Materials are connected to the Model, the number of Materials of + # each Model using a specific Geometry should be the same, otherwise the Material Indices of the + # Geometry will be out-of-bounds of the Materials of at least one of the Models using that Geometry. + # We wouldn't expect this case to happen, but there's nothing to say it can't. + # We'll handle a differing number of Materials by appending any extra Materials and comparing the rest. + mats_to_append = material_connections[num_mesh_mats:] + mats_to_compare = material_connections[:num_mesh_mats] + + for _fbx_lnk_material, material, _fbx_lnk_material_type in mats_to_append: + mesh_mats.append(material) + + mats_to_compare_and_slots = zip(mats_to_compare, obj.material_slots) + for (_fbx_lnk_material, material, _fbx_lnk_material_type), mat_slot in mats_to_compare_and_slots: + if material != mat_slot.material: + # Material Slots default to being linked to the Mesh, so a previously processed Object is also using + # this Mesh, but the Mesh uses a different Material for this Material Slot. + # To have a different Material for this Material Slot on this Object only, the Material Slot must be + # linked to the Object rather than the Mesh. + # TODO: add an option to link all materials to objects in Blender instead? + mat_slot.link = 'OBJECT' + mat_slot.material = material + + # We have to validate mesh polygons' ma_idx, see #41015! + # Some FBX seem to have an extra 'default' material which is not defined in FBX file. + for mesh in processed_meshes: + if mesh.validate_material_indices(): + print("WARNING: mesh '%s' had invalid material indices, those were reset to first material" % mesh.name) + _() + del _ + + perfmon.step("FBX import: Assign textures...") + + def _(): + material_images = {} + + fbx_tmpl = fbx_template_get((b'Material', b'KFbxSurfacePhong')) + # b'KFbxSurfaceLambert' + + def texture_mapping_set(fbx_obj, node_texture): + assert fbx_obj.id == b'Texture' + + fbx_props = (elem_find_first(fbx_obj, b'Properties70'), + elem_find_first(fbx_tmpl, b'Properties70', fbx_elem_nil)) + loc = elem_props_get_vector_3d(fbx_props, b'Translation', (0.0, 0.0, 0.0)) + rot = tuple(-r for r in elem_props_get_vector_3d(fbx_props, b'Rotation', (0.0, 0.0, 0.0))) + scale = tuple(((1.0 / s) if s != 0.0 else 1.0) + for s in elem_props_get_vector_3d(fbx_props, b'Scaling', (1.0, 1.0, 1.0))) + clamp = (bool(elem_props_get_enum(fbx_props, b'WrapModeU', 0)) or + bool(elem_props_get_enum(fbx_props, b'WrapModeV', 0))) + + if (loc == (0.0, 0.0, 0.0) and + rot == (0.0, 0.0, 0.0) and + scale == (1.0, 1.0, 1.0) and + clamp == False): + return + + node_texture.translation = loc + node_texture.rotation = rot + node_texture.scale = scale + if clamp: + node_texture.extension = 'EXTEND' + + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Material': + continue + + material = fbx_table_nodes.get(fbx_uuid, (None, None))[1] + for (fbx_lnk, + image, + fbx_lnk_type) in connection_filter_reverse(fbx_uuid, b'Texture'): + + if fbx_lnk_type.props[0] == b'OP': + lnk_type = fbx_lnk_type.props[3] + + ma_wrap = nodal_material_wrap_map[material] + + if lnk_type in {b'DiffuseColor', b'3dsMax|maps|texmap_diffuse'}: + ma_wrap.base_color_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.base_color_texture) + elif lnk_type in {b'SpecularColor', b'SpecularFactor'}: + # Intensity actually, not color... + ma_wrap.specular_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.specular_texture) + elif lnk_type in {b'ReflectionColor', b'ReflectionFactor', b'3dsMax|maps|texmap_reflection'}: + # Intensity actually, not color... + ma_wrap.metallic_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.metallic_texture) + elif lnk_type in {b'TransparentColor', b'TransparencyFactor'}: + ma_wrap.alpha_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.alpha_texture) + if use_alpha_decals: + material_decals.add(material) + elif lnk_type == b'ShininessExponent': + # That is probably reversed compared to expected results? TODO... + ma_wrap.roughness_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.roughness_texture) + # XXX, applications abuse bump! + elif lnk_type in {b'NormalMap', b'Bump', b'3dsMax|maps|texmap_bump'}: + ma_wrap.normalmap_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.normalmap_texture) + """ + elif lnk_type == b'Bump': + # TODO displacement... + """ + elif lnk_type in {b'EmissiveColor'}: + ma_wrap.emission_color_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.emission_color_texture) + elif lnk_type in {b'EmissiveFactor'}: + ma_wrap.emission_strength_texture.image = image + texture_mapping_set(fbx_lnk, ma_wrap.emission_strength_texture) + else: + print("WARNING: material link %r ignored" % lnk_type) + + material_images.setdefault(material, {})[lnk_type] = image + + # Check if the diffuse image has an alpha channel, + # if so, use the alpha channel. + + # Note: this could be made optional since images may have alpha but be entirely opaque + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Material': + continue + material = fbx_table_nodes.get(fbx_uuid, (None, None))[1] + image = material_images.get(material, {}).get(b'DiffuseColor', None) + # do we have alpha? + if image and image.depth == 32: + if use_alpha_decals: + material_decals.add(material) + + ma_wrap = nodal_material_wrap_map[material] + ma_wrap.alpha_texture.use_alpha = True + ma_wrap.alpha_texture.copy_from(ma_wrap.base_color_texture) + + # Propagate mapping from diffuse to all other channels which have none defined. + # XXX Commenting for now, I do not really understand the logic here, why should diffuse mapping + # be applied to all others if not defined for them??? + # ~ ma_wrap = nodal_material_wrap_map[material] + # ~ ma_wrap.mapping_set_from_diffuse() + + _() + del _ + + perfmon.step("FBX import: Cycles z-offset workaround...") + + def _(): + # Annoying workaround for cycles having no z-offset + if material_decals and use_alpha_decals: + for fbx_uuid, fbx_item in fbx_table_nodes.items(): + fbx_obj, blen_data = fbx_item + if fbx_obj.id != b'Geometry': + continue + if fbx_obj.props[-1] == b'Mesh': + mesh = fbx_item[1] + + num_verts = len(mesh.vertices) + if decal_offset != 0.0 and num_verts > 0: + for material in mesh.materials: + if material in material_decals: + blen_norm_dtype = np.single + vcos = MESH_ATTRIBUTE_POSITION.to_ndarray(mesh.attributes) + vnorm = np.empty(num_verts * 3, dtype=blen_norm_dtype) + mesh.vertex_normals.foreach_get("vector", vnorm) + + vcos += vnorm * decal_offset + + MESH_ATTRIBUTE_POSITION.foreach_set(mesh.attributes, vcos) + break + + for obj in (obj for obj in bpy.data.objects if obj.data == mesh): + obj.visible_shadow = False + _() + del _ + + perfmon.level_down() + + perfmon.level_down("Import finished.") + return {'FINISHED'} diff --git a/5.1/io_scene_fbx/json2fbx.py b/5.1/io_scene_fbx/json2fbx.py new file mode 100644 index 0000000..6f45ce1 --- /dev/null +++ b/5.1/io_scene_fbx/json2fbx.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2014-2023 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +""" +Usage +===== + + json2fbx [FILES]... + +This script will write a binary FBX file for each JSON argument given. + + +Input +====== + +The JSON data is formatted into a list of nested lists of 4 items: + + ``[id, [data, ...], "data_types", [subtree, ...]]`` + +Where each list may be empty, and the items in +the subtree are formatted the same way. + +data_types is a string, aligned with data that specifies a type +for each property. + +The types are as follows: + +* 'Z': - INT8 +* 'Y': - INT16 +* 'B': - BOOL +* 'C': - CHAR +* 'I': - INT32 +* 'F': - FLOAT32 +* 'D': - FLOAT64 +* 'L': - INT64 +* 'R': - BYTES +* 'S': - STRING +* 'f': - FLOAT32_ARRAY +* 'i': - INT32_ARRAY +* 'd': - FLOAT64_ARRAY +* 'l': - INT64_ARRAY +* 'b': - BOOL ARRAY +* 'c': - BYTE ARRAY + +Note that key:value pairs aren't used since the id's are not +ensured to be unique. +""" + + +def elem_empty(elem, name): + import encode_bin + sub_elem = encode_bin.FBXElem(name) + if elem is not None: + elem.elems.append(sub_elem) + return sub_elem + + +def parse_json_rec(fbx_root, json_node): + name, data, data_types, children = json_node + ver = 0 + + assert len(data_types) == len(data) + + e = elem_empty(fbx_root, name.encode()) + for d, dt in zip(data, data_types): + if dt == "B": + e.add_bool(d) + elif dt == "C": + d = eval('b"""' + d + '"""') + e.add_char(d) + elif dt == "Z": + e.add_int8(d) + elif dt == "Y": + e.add_int16(d) + elif dt == "I": + e.add_int32(d) + elif dt == "L": + e.add_int64(d) + elif dt == "F": + e.add_float32(d) + elif dt == "D": + e.add_float64(d) + elif dt == "R": + d = eval('b"""' + d + '"""') + e.add_bytes(d) + elif dt == "S": + d = d.encode().replace(b"::", b"\x00\x01") + e.add_string(d) + elif dt == "i": + e.add_int32_array(d) + elif dt == "l": + e.add_int64_array(d) + elif dt == "f": + e.add_float32_array(d) + elif dt == "d": + e.add_float64_array(d) + elif dt == "b": + e.add_bool_array(d) + elif dt == "c": + e.add_byte_array(d) + + if name == "FBXVersion": + assert data_types == "I" + ver = int(data[0]) + + for child in children: + _ver = parse_json_rec(e, child) + if _ver: + ver = _ver + + return ver + + +def parse_json(json_root): + root = elem_empty(None, b"") + ver = 0 + + for n in json_root: + _ver = parse_json_rec(root, n) + if _ver: + ver = _ver + + return root, ver + + +def json2fbx(fn): + import os + import json + + import encode_bin + + fn_fbx = "%s.fbx" % os.path.splitext(fn)[0] + print("Writing: %r " % fn_fbx, end="") + with open(fn) as f_json: + json_root = json.load(f_json) + with encode_bin.FBXElem.enable_multithreading_cm(): + fbx_root, fbx_version = parse_json(json_root) + print("(Version %d) ..." % fbx_version) + encode_bin.write(fn_fbx, fbx_root, fbx_version) + + +# ---------------------------------------------------------------------------- +# Command Line + +def main(): + import sys + + if "--help" in sys.argv: + print(__doc__) + return + + for arg in sys.argv[1:]: + try: + json2fbx(arg) + except: + print("Failed to convert %r, error:" % arg) + + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/5.1/io_scene_fbx/parse_fbx.py b/5.1/io_scene_fbx/parse_fbx.py new file mode 100644 index 0000000..e428eb1 --- /dev/null +++ b/5.1/io_scene_fbx/parse_fbx.py @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: 2006-2012 assimp team +# SPDX-FileCopyrightText: 2013 Blender Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +__all__ = ( + "parse", + "data_types", + "parse_version", + "FBXElem", +) + +from struct import unpack +import array +import zlib +from io import BytesIO + +from . import data_types +from .fbx_utils_threading import MultiThreadedTaskConsumer + +# at the end of each nested block, there is a NUL record to indicate +# that the sub-scope exists (i.e. to distinguish between P: and P : {}) +_BLOCK_SENTINEL_LENGTH = ... +_BLOCK_SENTINEL_DATA = ... +read_fbx_elem_start = ... +_IS_BIG_ENDIAN = (__import__("sys").byteorder != 'little') +_HEAD_MAGIC = b'Kaydara FBX Binary\x20\x20\x00\x1a\x00' +from collections import namedtuple +FBXElem = namedtuple("FBXElem", ("id", "props", "props_type", "elems")) +del namedtuple + + +def read_uint(read): + return unpack(b'