├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── debug.py ├── export_smf.py ├── import_smf.py └── pydq.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Markdown to PDF and HTML 4 | # You may pin to the exact commit or the version. 5 | # uses: BaileyJM02/markdown-to-pdf@1be26775add5f94fb55d4a2ce36ff7cad23b8dd0 6 | uses: BaileyJM02/markdown-to-pdf@v1 7 | with: 8 | # (Path) or (File) The location of the folder containing your .md or .markdown files, or a path to a single .md or .markdown file that you would like to convert. 9 | input_path: # optional 10 | # (Path) The location of the folder containing your .md or .markdown files 11 | input_dir: # optional 12 | # (Path) The location of the folder containing your images, this should be the route of all images 13 | images_dir: # optional 14 | # (String) The path you use to import your images that can be replaced with the server URL 15 | image_import: # optional 16 | # (Path) The location of the folder you want to place the built files 17 | output_dir: # optional 18 | # (Boolean) Whether to also create a .html file 19 | build_html: # optional 20 | # (Boolean) Whether to create a .pdf file (the intended behaviour) 21 | build_pdf: # optional 22 | # (File) The location of the CSS file you want to use as the theme 23 | theme: # optional 24 | # (Boolean) Whether to extend your custom CSS file with the default theme 25 | extend_default_theme: # optional 26 | # (File) The location of the CSS file you want to use as the code snipped highlight theme 27 | highlight_theme: # optional 28 | # (File) The location of the HTML/Mustache file you want to use as the HTML template 29 | template: # optional 30 | # (Boolean) Whether a table of contents should be generated 31 | table_of_contents: # optional 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore backup files 2 | *.blend1 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 bartteunis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blender to SMF 2 | Export of Blender model to [SMF model format](https://forum.yoyogames.com/index.php?threads/smf-3d-skeletal-animation-now-with-a-custom-blender-exporter.19806/) 3 | 4 | ## How To Use 5 | See the wiki's [How To Use](https://github.com/blender-to-gmstudio/blender-to-smf/wiki/How-To-Use) page for detailed documentation. 6 | 7 | ## Format 8 | The current version of the format is SMF v12. 9 | It is described on the wiki's [SMF Format](https://github.com/blender-to-gmstudio/blender-to-smf/wiki/SMF-Format) page. 10 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | bl_info = { 2 | "name": "Export SMF", 3 | "description": "Export to SMF 12 (SnidrsModelFormat)", 4 | "author": "Bart Teunis", 5 | "version": (0, 9, 5), 6 | "blender": (2, 80, 0), 7 | "location": "File > Export", 8 | "warning": "", # used for warning icon and text in addons panel 9 | "doc_url": "https://github.com/blender-to-gmstudio/blender-to-smf/wiki", 10 | "tracker_url": "https://github.com/blender-to-gmstudio/blender-to-smf/issues", 11 | "support": 'COMMUNITY', 12 | "category": "Import-Export"} 13 | 14 | # Make sure to reload changes to code that we maintain ourselves 15 | # when reloading scripts in Blender 16 | if "bpy" in locals(): 17 | import importlib 18 | if "export_smf" in locals(): 19 | importlib.reload(export_smf) 20 | if "import_smf" in locals(): 21 | importlib.reload(import_smf) 22 | 23 | import bpy 24 | from bpy.types import Operator 25 | from bpy.props import ( 26 | StringProperty, 27 | BoolProperty, 28 | EnumProperty, 29 | FloatProperty, 30 | IntProperty, 31 | ) 32 | from bpy_extras.io_utils import ( 33 | ExportHelper, 34 | ImportHelper, 35 | ) 36 | 37 | from . import export_smf 38 | from . import import_smf 39 | 40 | from .export_smf import export_smf_file 41 | from .import_smf import import_smf_file 42 | 43 | 44 | class ImportSMF(Operator, ImportHelper): 45 | """Import an SMF 3D model""" 46 | bl_idname = "import_scene.smf" 47 | bl_label = "SMF (*.smf)" 48 | bl_options = {'REGISTER'} 49 | 50 | filename_ext = ".smf" 51 | 52 | filter_glob: StringProperty( 53 | default="*.smf", 54 | options={'HIDDEN'}, 55 | maxlen=255, # Max internal buffer length, longer would be clamped. 56 | ) 57 | 58 | create_new_materials_images: BoolProperty( 59 | name="Create New Materials/Images", 60 | description= ("Force the creation of new materials and images, " 61 | "even if materials/images with the same name already exist"), 62 | default=True, 63 | ) 64 | 65 | def execute(self, context): 66 | keywords = self.as_keywords(ignore=("check_existing", "filter_glob", "ui_tab")) 67 | return import_smf_file(self, context, **keywords) 68 | 69 | 70 | class ExportSMF(Operator, ExportHelper): 71 | """Export a selection of the current scene to SMF (SnidrsModelFormat)""" 72 | bl_idname = "export_scene.smf" 73 | bl_label = "SMF (*.smf)" 74 | bl_options = {'REGISTER', 'PRESET'} 75 | 76 | # ExportHelper mixin class uses this 77 | filename_ext = ".smf" 78 | 79 | filter_glob: StringProperty( 80 | default="*.smf", 81 | options={'HIDDEN'}, 82 | maxlen=255, # Max internal buffer length, longer would be clamped. 83 | ) 84 | 85 | export_textures: BoolProperty( 86 | name="Export Textures", 87 | description="Whether textures should be exported with the model", 88 | default=True, 89 | ) 90 | 91 | # "Advanced" export settings 92 | anim_export_mode: EnumProperty( 93 | name="What to export", 94 | description="How to export animations", 95 | items=[ 96 | ("CUR", "Current Action", "Export the Armature's current action as a single animation", 0), 97 | ("LNK", "Linked NLA Actions", "Export all (unique) actions that are linked indirectly through NLA tracks", 1), 98 | #("TRA", "NLA Tracks", "Export every NLA track as a separate animation", 2), 99 | #("SCN", "Scene", "Export a single animation directly from the scene. This allows for the most advanced animations", 3), 100 | ], 101 | default="CUR", 102 | ) 103 | anim_length_mode: EnumProperty( 104 | name="Animation Length", 105 | description="What value to use for the exported animation lengths", 106 | items=[ 107 | ("SCN", "Scene", "Animation length equals scene length", 0), 108 | ("ACT", "Action", "Animation length equals action length", 1), 109 | ], 110 | default="SCN", 111 | ) 112 | 113 | export_type: EnumProperty( 114 | name="Export Type", 115 | description="What to export", 116 | items=[ 117 | ("KFR", "Keyframes", "Export the actual keyframes as defined in the animation", 0), 118 | ("SPL", "Samples", "Sample the animation at a given rate", 1), 119 | ], 120 | default="KFR", 121 | ) 122 | 123 | multiplier: IntProperty( 124 | name="Sample Frame Multiplier", 125 | description=("Sample Frame Multiplier - Determines number of precomputed samples " 126 | "using (number of keyframes) * (sample frame multiplier)"), 127 | default=4, 128 | soft_min=4, 129 | soft_max=20, 130 | ) 131 | 132 | """ 133 | normal_source: EnumProperty( 134 | name="Normal", 135 | description="The type of normal to export (vertex, loop, face)", 136 | items=[ 137 | ("VERT", "Vertex", "Vertex normal", 0), 138 | ("LOOP", "Loop", "Loop normal", 1), 139 | ("FACE", "Face", "Face normal", 2), 140 | ], 141 | default="VERT", 142 | ) 143 | """ 144 | 145 | subdivisions: IntProperty( 146 | name="Subdivisions", 147 | description=("Number of times to subdivide an animation when exporting samples. " 148 | "This subdivision is made for each animation individually."), 149 | default=10, 150 | soft_min=2, 151 | ) 152 | 153 | scale: FloatProperty( 154 | name="Scale", 155 | description="Scale factor to be applied to geometry and rig", 156 | default=1, 157 | soft_min=.1, 158 | ) 159 | 160 | interpolation: EnumProperty( 161 | name="Interpolation", 162 | description="The interpolation to use when playing the animations in SMF", 163 | items=[ 164 | ("KFR", "Keyframe", "Use keyframe interpolation", 0), 165 | ("LIN", "Linear", "Sample the animation at a given rate", 1), 166 | ("QAD", "Quadratic", "Use quadratic interpolation", 2), 167 | ], 168 | default="LIN", 169 | ) 170 | 171 | bone_influences: IntProperty( 172 | name="Bone Influences", 173 | description=("The number of bone influences to use"), 174 | default=4, 175 | min=1, 176 | max=4, 177 | ) 178 | 179 | invert_uv_v: BoolProperty( 180 | name="Invert UV", 181 | description="Invert the v coordinate of uvs, i.e. export (u, 1 - v)", 182 | default=False, 183 | ) 184 | 185 | def execute(self, context): 186 | keywords = self.as_keywords(ignore=("check_existing", "filter_glob", "ui_tab")) 187 | return export_smf_file(self, context, **keywords) 188 | 189 | def draw(self, context): 190 | # Everything gets displayed through the panels that are defined below 191 | pass 192 | 193 | 194 | class SMF_PT_export_general(bpy.types.Panel): 195 | bl_space_type = 'FILE_BROWSER' 196 | bl_region_type = 'TOOL_PROPS' 197 | bl_label = "General" 198 | bl_parent_id = "FILE_PT_operator" 199 | 200 | @classmethod 201 | def poll(cls, context): 202 | sfile = context.space_data 203 | operator = sfile.active_operator 204 | 205 | return operator.bl_idname == "EXPORT_SCENE_OT_smf" 206 | 207 | def draw(self, context): 208 | layout = self.layout 209 | layout.use_property_split = True 210 | layout.use_property_decorate = False # No animation. 211 | 212 | sfile = context.space_data 213 | operator = sfile.active_operator 214 | 215 | layout.prop(operator, 'export_textures') 216 | layout.prop(operator, "invert_uv_v") 217 | 218 | 219 | class SMF_PT_export_advanced(bpy.types.Panel): 220 | bl_space_type = 'FILE_BROWSER' 221 | bl_region_type = 'TOOL_PROPS' 222 | bl_label = "Advanced" 223 | bl_parent_id = "FILE_PT_operator" 224 | bl_options = {'DEFAULT_CLOSED'} 225 | 226 | @classmethod 227 | def poll(cls, context): 228 | sfile = context.space_data 229 | operator = sfile.active_operator 230 | 231 | return operator.bl_idname == "EXPORT_SCENE_OT_smf" 232 | 233 | def draw(self, context): 234 | layout = self.layout 235 | layout.use_property_split = True 236 | layout.use_property_decorate = False # No animation. 237 | 238 | sfile = context.space_data 239 | operator = sfile.active_operator 240 | 241 | layout.label(text="General") 242 | layout.prop(operator, 'anim_export_mode') 243 | #layout.prop(operator, 'anim_length_mode') 244 | 245 | layout.label(text="Sampling") 246 | layout.prop(operator, 'export_type') 247 | if operator.export_type == 'SPL': 248 | layout.prop(operator, 'subdivisions') 249 | 250 | layout.label(text="Skinning") 251 | layout.prop(operator, 'bone_influences') 252 | 253 | layout.label(text="Other") 254 | #layout.prop(operator, "normal_source") 255 | layout.prop(operator, 'interpolation') 256 | layout.prop(operator, 'multiplier') 257 | #layout.prop(operator, "scale") 258 | 259 | 260 | def menu_func_export(self, context): 261 | self.layout.operator(ExportSMF.bl_idname, text="SMF (*.smf)") 262 | 263 | 264 | def menu_func_import(self, context): 265 | self.layout.operator(ImportSMF.bl_idname, text="SMF (*.smf)") 266 | 267 | 268 | classes = ( 269 | SMF_PT_export_general, 270 | SMF_PT_export_advanced, 271 | ExportSMF, 272 | ImportSMF, # Uncomment this to enable the WIP importer 273 | ) 274 | 275 | 276 | def register(): 277 | for cls in classes: 278 | bpy.utils.register_class(cls) 279 | bpy.types.TOPBAR_MT_file_export.append(menu_func_export) 280 | bpy.types.TOPBAR_MT_file_import.append(menu_func_import) 281 | 282 | 283 | def unregister(): 284 | for cls in classes: 285 | bpy.utils.unregister_class(cls) 286 | bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) 287 | bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) 288 | 289 | 290 | if __name__ == "__main__": 291 | register() 292 | -------------------------------------------------------------------------------- /debug.py: -------------------------------------------------------------------------------- 1 | # A couple of functions to make debugging easier 2 | # 3 | 4 | def format_iterable(iterable): 5 | formatted = ["{:4.4f}".format(item) for item in iterable] 6 | return "[" + ", ".join(formatted) + "]" 7 | 8 | def print_dq_list(list, index_header, value_header): 9 | print(index_header, " - ", value_header) 10 | for i, dq in enumerate(list): 11 | print("{:2d}".format(i), " - ", format_iterable(dq)) 12 | -------------------------------------------------------------------------------- /export_smf.py: -------------------------------------------------------------------------------- 1 | # SMF export scripts for Blender 2 | # 3 | 4 | from struct import Struct, pack, calcsize 5 | from mathutils import * 6 | from math import * 7 | 8 | import numpy as np 9 | 10 | # Make sure to reload changes to code that we maintain ourselves 11 | # when reloading scripts in Blender 12 | if "bpy" in locals(): 13 | import importlib 14 | if "pydq" in locals(): 15 | importlib.reload(pydq) 16 | 17 | import bpy 18 | 19 | 20 | # from .debug import format_iterable, print_dq_list 21 | 22 | from . import pydq 23 | 24 | from .pydq import ( 25 | dq_create_matrix, 26 | dq_get_product, 27 | dq_get_conjugate, 28 | dq_negate, 29 | dq_to_tuple_xyzw, 30 | ) 31 | 32 | SMF_version = 12 # SMF 'export' version 33 | SMF_format_struct = Struct("ffffffffBBBBBBBBBBBB") # 44 bytes 34 | SMF_format_size = SMF_format_struct.size 35 | 36 | # Check Blender version 37 | version = bpy.app.version 38 | b41_up = version[0] == 4 and version[1] >= 1 # Blender 4.1 and up 39 | 40 | ### EXPORT ### 41 | 42 | # Mesh-like objects (the ones that can be converted to mesh) 43 | meshlike_types = {'MESH', 'CURVE', 'SURFACE', 'FONT', 'META'} 44 | 45 | def prep_mesh(obj, mesh): 46 | """Prepare the given mesh for export to SMF""" 47 | 48 | # Transform to world coordinates 49 | mat = Matrix.Scale(-1, 4, Vector([0, 1, 0])) 50 | mesh.transform(obj.matrix_world) 51 | 52 | # Invert Y 53 | mesh.transform(mat) 54 | mesh.flip_normals() 55 | 56 | # Make sure the mesh has a UV map 57 | if not mesh.uv_layers: 58 | mesh.uv_layers.new() 59 | 60 | # Calculate all needed data 61 | mesh.calc_loop_triangles() 62 | mesh.calc_tangents() 63 | if not b41_up: 64 | mesh.calc_normals_split() 65 | 66 | def smf_node_list(armature_object): 67 | """Construct the SMF node list from the given Armature object""" 68 | # TODO Insert root node (optional?) 69 | armature = armature_object.data 70 | bones = [bone for bone in armature.bones] 71 | bones_orig = bones.copy() 72 | for bone in bones_orig: 73 | if bone.parent and not bone.use_connect: 74 | pos = bones.index(bone) 75 | bones.insert(pos, None) 76 | return bones 77 | 78 | def smf_bindmap(bones): 79 | """Construct the SMF bindmap from the given list of Blender bones""" 80 | # Create the bindmap (i.e. which bones get sent to the shader in SMF) 81 | # See smf_rig.update_bindmap (we only need the bindmap part here!) 82 | # Only consider Blender bones that map to SMF bones 83 | # Every SMF node that has a parent and is attached to it, represents a bone 84 | # (the detached nodes represent the heads of bones) 85 | smf_bones = [b for b in bones if b and b.parent] 86 | bindmap = {} 87 | sample_bone_ind = 0 88 | for node in smf_bones: 89 | bindmap[node.name] = sample_bone_ind 90 | sample_bone_ind = sample_bone_ind + 1 91 | return bindmap 92 | 93 | def smf_skin_indices_weights(vertices, index_map, num_influences=4): 94 | """Get skinning info from all vertices""" 95 | # This requires the list of vertices and the direct mapping 96 | # of Blender vertex group index to SMF bone index 97 | iter = range(len(vertices)) 98 | indices = [[0, 0, 0, 0] for i in iter] # Use list comprehension 99 | weights = [[1, 0, 0, 0] for i in iter] # for fast initialization 100 | for v in vertices: 101 | # Only keep the vertex groups used for skinning 102 | mod_groups = [group for group in v.groups 103 | if group.group in index_map.keys()] 104 | # Filter all vertex group assignments with a weight of 0 105 | # See bpy.ops.object.vertex_group_clean 106 | groups = filter(lambda group: (group.weight > 0.0), mod_groups) 107 | # Keep the highest "num_influences" weights (SMF supports a maximum of 4) 108 | # See bpy.ops.object.vertex_group_limit_total 109 | groups = sorted(groups, key=lambda group: group.weight)[-num_influences:] 110 | s = sum([g.weight for g in groups]) 111 | # Write the highest weights (the others remain at 0, so don't contribute) 112 | for index, group in enumerate(groups): 113 | w = group.weight/s*255 114 | indices[v.index][index] = index_map[group.group] 115 | weights[v.index][index] = int(w if w <= 255 else 255) # ubyte range! 116 | 117 | return (indices, weights) 118 | 119 | def export_smf_file(operator, context, 120 | filepath, 121 | export_textures, 122 | export_type, 123 | anim_export_mode, 124 | anim_length_mode, 125 | multiplier, 126 | subdivisions, 127 | interpolation, 128 | invert_uv_v, 129 | bone_influences, 130 | **kwargs, 131 | ): 132 | """ 133 | Main entry point for SMF export 134 | """ 135 | 136 | # Figure out what we're going to export 137 | object_list = context.selected_objects 138 | #model_list = [o for o in object_list if o.type=='MESH'] 139 | model_list = [o for o in object_list if o.type in meshlike_types] 140 | armature_list = [o for o in object_list if o.type=='ARMATURE'] 141 | 142 | rig_object = None 143 | rig = None 144 | anim = None 145 | animations = dict() # Use a dictionary to preserve order of insertion! 146 | if armature_list: 147 | rig_object = armature_list[0] 148 | rig = rig_object.data 149 | anim_data = rig_object.animation_data 150 | if anim_data: 151 | if anim_export_mode == 'LNK': 152 | # Unique actions linked to NLA tracks 153 | tracks = anim_data.nla_tracks 154 | if tracks: 155 | for track in tracks: 156 | for strip in track.strips: 157 | animations[strip.action] = strip.action 158 | elif anim_export_mode == 'CUR': 159 | # Currently assigned action 160 | if anim_data.action: 161 | animations[anim_data.action] = anim_data.action 162 | elif anim_export_mode == 'TRA': 163 | # Every track separately 164 | pass 165 | elif anim_export_mode == 'SCN': 166 | # Final result of NLA tracks on scene 167 | pass 168 | else: 169 | pass 170 | 171 | # Make sure we don't try to export actions that look like they're linked 172 | # but don't exist anymore 173 | if None in animations: 174 | del animations[None] 175 | 176 | # Initalize variables that we need across chunks 177 | bindmap = {} 178 | bone_names = [] 179 | 180 | # Write rig 181 | rig_bytes = bytearray() 182 | 183 | if not rig: 184 | # No (valid) armature for export 185 | rig_bytes.extend(pack('I', 0)) # nodeNum 186 | else: 187 | # Construct node list for SMF 188 | # (heads of disconnected bones need to become nodes, too) 189 | bones = smf_node_list(rig_object) 190 | 191 | # Get the bindmap and relevant bone names 192 | bindmap = smf_bindmap(bones) 193 | bone_names = bindmap.keys() 194 | 195 | rig_bytes.extend(pack('I', len(bones))) # nodeNum 196 | 197 | if not rig.bones: 198 | #self.report({'WARNING'},"Armature has no bones. Exporting empty rig.") 199 | pass 200 | 201 | print("RIG") 202 | print("---") 203 | debug_rig = [] 204 | debug_vals = [] 205 | # Make sure to have a root bone! 206 | for n, bone in enumerate(bones): 207 | b = bone if bone else bones[n+1] 208 | 209 | parent_bone_index = 0 if not b.parent else bones.index(b.parent) 210 | connected = b.use_connect 211 | 212 | if bone and b.parent and not b.use_connect: 213 | # This is a node for which an added node has been written 214 | parent_bone_index = n-1 215 | connected = True 216 | bones[parent_bone_index] = False # This makes sure the "if bone" check keeps returning False! 217 | 218 | # Construct node matrix 219 | position_attr = 'tail_local' if bone else 'head_local' 220 | matrix = b.matrix_local.copy() 221 | matrix.translation = getattr(b, position_attr)[:] 222 | 223 | # Add the world transform to the nodes, ignore scale 224 | mat_w = apply_world_matrix(matrix, rig_object.matrix_world) 225 | 226 | dq = dq_negate(dq_create_matrix(mat_w)) # negate != invert (!!) 227 | vals = dq_to_tuple_xyzw(dq) 228 | 229 | rig_bytes.extend(pack('f'*len(vals), *vals)) 230 | rig_bytes.extend(pack('I', parent_bone_index)) # node[@ eAnimNode.Parent] 231 | rig_bytes.extend(pack('B', connected)) # node[@ eAnimNode.IsBone] 232 | rig_bytes.extend(pack('fff', *(0, 0, 0))) # Primary IK axis (default all zeroes) 233 | 234 | t = mat_w.translation 235 | name = b.name if position_attr == 'tail_local' else "Inserted for " + b.name 236 | debug_rig.append((n, name, t[0], t[1], t[2], parent_bone_index, connected)) 237 | debug_vals.append(str(["{0:.3f}".format(elem) for elem in vals])) 238 | 239 | # Print some extended, readable debug info 240 | print("SMF Node List") 241 | print("-------------") 242 | for i, d in enumerate(debug_rig): 243 | s = "{0:>4d} ({5:<3d}, {6:d}) - {1:<40} {2:<.3f} {3:<.3f} {4:<.3f}".format(*d) 244 | print(s) 245 | print(debug_vals[i]) 246 | 247 | # Write models, list the unique Blender materials in use while we're at it 248 | dg = bpy.context.evaluated_depsgraph_get() # We'll need this thing soon 249 | unique_materials = set() 250 | unique_images = set() 251 | model_bytes = bytearray() 252 | model_number = 0 253 | model_bytes.extend(pack('B', model_number)) # Reserve a byte for model count 254 | for obj in model_list: 255 | # Create a triangulated copy of the mesh 256 | # that has everything applied (modifiers, transforms, etc.) 257 | 258 | # First, see if this mesh object has an Armature modifier set 259 | # Set to rest pose if that's the case 260 | # TODO What else needs to be done here for this to work flawlessly? 261 | mods = [mod for mod in obj.modifiers if mod.type == "ARMATURE"] 262 | arma = None 263 | if mods and mods[0].object: 264 | arma = mods[0].object.data 265 | arma_prev_position = arma.pose_position 266 | arma.pose_position = 'REST' 267 | 268 | # Update the depsgraph! 269 | # This is important since it actually applies 270 | # the change to 'REST' position to the data 271 | dg.update() 272 | 273 | # The new way of doing things using the context depsgraph 274 | # Get an evaluated version of the current object 275 | # This includes modifiers, etc. 276 | # The world transform is not applied to the mesh 277 | obj_eval = obj.evaluated_get(dg) 278 | mesh = bpy.data.meshes.new_from_object(obj_eval, preserve_all_data_layers=True, depsgraph=dg) 279 | prep_mesh(obj, mesh) 280 | 281 | # Reset pose_position setting 282 | if arma: 283 | arma.pose_position = arma_prev_position 284 | 285 | # Get a direct mapping between vertex group index and SMF index 286 | valid_indices = [i for (i, vg) in enumerate(obj.vertex_groups) 287 | if vg.name in bone_names] 288 | vgid_to_smf_map = {i: bindmap[obj.vertex_groups[i].name] 289 | for i in valid_indices} 290 | 291 | # Precalculate skinning info for this mesh's vertices 292 | skin_indices, skin_weights = smf_skin_indices_weights( 293 | mesh.vertices, 294 | vgid_to_smf_map, 295 | bone_influences 296 | ) 297 | 298 | # Write vertex buffer contents 299 | # First create bytearrays for every material slot 300 | # (Some may stay empty in case not a single face uses the slot!) 301 | ba_count = len(obj.material_slots) if obj.material_slots else 1 302 | data = [bytearray() for i in range(ba_count)] 303 | 304 | uv_data = mesh.uv_layers.active.data if mesh.uv_layers else None 305 | 306 | # Loop through all polygons and write data to appropriate bytearray 307 | 308 | # This way we only need to loop through the data once and don't 309 | # have to do any grouping of the data by material. 310 | # This happens implicitly through the indexing by face.material_index 311 | # In the end we are left with a bytearray per material slot. 312 | # A bytearray that's empty at the end indicates no faces use the slot 313 | # and thus can be skipped. 314 | for face in mesh.loop_triangles: 315 | for loop in [mesh.loops[i] for i in face.loops]: 316 | vertex_data = [] 317 | 318 | vert = mesh.vertices[loop.vertex_index] 319 | 320 | uv = uv_data[loop.index].uv if uv_data else [0, 0] 321 | if invert_uv_v: 322 | uv[1] = 1 - uv[1] 323 | tan_int = [*(int((c+1)*127) for c in loop.tangent), 0] 324 | 325 | vertex_data.extend(vert.co) 326 | vertex_data.extend(loop.normal) 327 | vertex_data.extend(uv) 328 | vertex_data.extend(tan_int) 329 | vertex_data.extend(skin_indices[vert.index]) 330 | vertex_data.extend(skin_weights[vert.index]) 331 | 332 | vertex_bytedata = SMF_format_struct.pack(*vertex_data) 333 | data[face.material_index].extend(vertex_bytedata) 334 | 335 | # Now write an SMF model per bytearray that isn't empty ("if ba") 336 | # Retrieve unique materials and textures/images too! 337 | for index, ba in [(index, ba) for (index, ba) in enumerate(data) if ba]: 338 | model_number += 1 339 | model_bytes.extend(pack('I', len(ba))) 340 | model_bytes.extend(ba) 341 | mat = None 342 | if obj.material_slots: 343 | mat = obj.material_slots[index].material 344 | img = None 345 | if mat: 346 | unique_materials.add(mat) 347 | img = texture_image_from_node_tree(mat) 348 | if img: 349 | unique_images.add(img) 350 | mat_name = mat.name if mat else "" 351 | img_name = img.name if img else "" 352 | model_bytes.extend(bytearray(mat_name + '\0', 'utf-8')) 353 | model_bytes.extend(bytearray(img_name + '\0', 'utf-8')) 354 | model_bytes.extend(pack('B',int(not obj.hide_viewport))) 355 | 356 | # Delete triangulated copy of the mesh 357 | bpy.data.meshes.remove(mesh) 358 | 359 | # Now write the correct model count 360 | model_bytes[0] = model_number 361 | 362 | # Write textures and their image data (same thing as seen from SMF) 363 | 364 | texture_bytes = bytearray() 365 | if export_textures: 366 | texture_bytes.extend(pack('B', len(unique_images))) # Number of unique images 367 | for img in unique_images: 368 | texture_bytes.extend(bytearray(img.name + "\0",'utf-8')) # Texture name 369 | texture_bytes.extend(pack('HH', *img.size)) # Texture size (w,h) 370 | 371 | # TODO Proper solution to this, ideally extend texture dimensions 372 | for cpo in img.size: 373 | if floor(log2(cpo)) != log2(cpo): 374 | operator.report({'WARNING'}, img.name + " - dimension is not a power of two: " + str(cpo)) 375 | 376 | # Faster way using NumPy and Image.pixels.foreach_set (fast!) 377 | image_data = np.empty(len(img.pixels), dtype = np.single) 378 | img.pixels.foreach_get(image_data) 379 | image_data = (np.multiply(image_data, 255, out=image_data)).round(out=image_data).astype(np.ubyte) 380 | bytedata = image_data.tobytes() 381 | 382 | texture_bytes.extend(pack('B' * len(img.pixels), *bytedata)) 383 | else: 384 | texture_bytes.extend(pack('B', 0)) 385 | 386 | # Write animations 387 | animation_bytes = bytearray() 388 | 389 | def write_animation_data(name, scene, byte_data, rig_object, keyframe_times, frame_max, fps, interpolation): 390 | """Writes all animation data to bytearray byte_data. Used to keep the code a bit tidy.""" 391 | frame_number = len(keyframe_times) 392 | animation_bytes.extend(bytearray(name + "\0", 'utf-8')) # animName 393 | animation_bytes.extend(pack('B', True)) # loop 394 | animation_bytes.extend(pack('f', frame_max/fps*1000)) # playTime (ms) 395 | animation_bytes.extend(pack('B', interpolation)) # interpolation (0, 1, 2) 396 | animation_bytes.extend(pack('B', multiplier)) # sampleFrameMultiplier 397 | animation_bytes.extend(pack('H', 0)) # write 0 locked bones 398 | animation_bytes.extend(pack('I', frame_number)) # animFrameNumber 399 | 400 | # PRE Skeleton must be in Pose Position (see Armature.pose_position) 401 | frame_prev = scene.frame_current 402 | for kf_time in keyframe_times: 403 | subframe, frame = modf(kf_time) 404 | scene.frame_set(int(frame), subframe=subframe) 405 | 406 | smf_kf_time = kf_time/frame_max 407 | 408 | byte_data.extend(pack('f', smf_kf_time)) 409 | 410 | print("Blender frame ", kf_time, " at SMF time ", smf_kf_time) 411 | 412 | # Loop through the armature's PoseBones using the bone/node order we got earlier 413 | # This guarantees a correct mapping of PoseBones to Bones 414 | #for rbone in rig_object.data.bones: 415 | # for i, rbone in enumerate(bones): 416 | for rbone in bones: 417 | if rbone: 418 | # Get the bone (The name is identical (!)) 419 | bone = rig_object.pose.bones[rbone.name] 420 | 421 | # Use bone matrix 422 | mat = bone.matrix.copy() 423 | else: 424 | # Use an identity matrix (i.e. no change) 425 | mat = Matrix() 426 | 427 | mat.translation = bone.tail[:] 428 | mat_final = apply_world_matrix(mat, rig_object.matrix_world) 429 | mat_final.normalize() 430 | dq = dq_negate(dq_create_matrix(mat_final)) # negate != invert (!!) 431 | # print(format_iterable(dq_to_tuple_xyzw(dq))) 432 | 433 | # TODO fix_keyframe_dq should go here... 434 | 435 | # m = mat_final.to_3x3() # Verify orthogonality of upper 3x3 436 | # print(m) 437 | # print(m.is_orthogonal) 438 | # print(m.is_orthogonal_axis_vectors) 439 | # vals = [j for i in mat_final.col for j in i] 440 | vals = dq_to_tuple_xyzw(dq) 441 | byte_data.extend(pack('f'*len(vals), *vals)) 442 | 443 | # Restore frame position 444 | scene.frame_set(int(frame_prev)) 445 | 446 | # Export each NLA track linked to the armature object as an animation 447 | # (use the first action's name as the animation name for now) 448 | print("ANIMATION") 449 | print("---------") 450 | 451 | # Common variables 452 | render = context.scene.render 453 | fps = render.fps/render.fps_base 454 | 455 | # Write all animations (i.e. actions) 456 | # TODO 457 | if anim_export_mode == 'CUR': 458 | # Current action 459 | # Loop through current action's values 460 | # Use action length to determine number of frames 461 | pass 462 | elif anim_export_mode == 'LNK': 463 | # Linked actions 464 | # Find all unique actions and export them same as in "current" setting 465 | pass 466 | elif anim_export_mode == 'TRA': 467 | # NLA tracks 468 | # 469 | pass 470 | elif anim_export_mode == 'SCN': 471 | # Full NLA animation 472 | # Use current scene, leave NLA track mutes/solos as is 473 | pass 474 | else: 475 | # Invalid option 476 | pass 477 | 478 | animation_bytes.extend(pack('B', len(animations))) 479 | for anim in animations: 480 | print(anim) 481 | 482 | # Remember state 483 | anim_data = rig_object.animation_data 484 | action_prev = anim_data.action 485 | mute_prev = [False] * len(anim_data.nla_tracks) 486 | for i, track in enumerate(anim_data.nla_tracks): 487 | mute_prev[i] = track.mute 488 | track.mute = True 489 | 490 | # Set animation (i.e. action) 491 | anim_data.action = anim 492 | 493 | # Determine keyframe times 494 | if export_type == 'KFR': 495 | kf_times = sorted({p.co[0] for fcurve in anim_data.action.fcurves for p in fcurve.keyframe_points}) 496 | kf_end = kf_times[len(kf_times)-1] 497 | elif export_type == 'SPL': 498 | kf_times = [] 499 | for i in range(0, subdivisions+1): 500 | kf_times.append(anim.frame_range[0] + (anim.frame_range[1]-anim.frame_range[0])*i/subdivisions) 501 | kf_end = kf_times[subdivisions] 502 | else: 503 | # We shouldn't end up here 504 | pass 505 | 506 | #print(kf_times) 507 | 508 | # Play and write animation data 509 | if interpolation == "KFR": 510 | interpolation = 0 511 | if interpolation == "LIN": 512 | interpolation = 1 513 | if interpolation == "QAD": 514 | interpolation = 2 515 | 516 | write_animation_data(anim.name, context.scene, animation_bytes, rig_object, kf_times, kf_end, fps, interpolation) 517 | 518 | # Restore to previous state 519 | rig_object.animation_data.action = action_prev 520 | for i, track in enumerate(rig_object.animation_data.nla_tracks): 521 | track.mute = mute_prev[i] 522 | 523 | # Now build header 524 | version_string = "SMF_v{}_by_Snidr_and_Bart\0".format(SMF_version) 525 | header_bytes = bytearray(version_string, 'utf-8') 526 | 527 | tex_pos = len(header_bytes) + calcsize('IIIII') 528 | mod_pos = tex_pos + len(texture_bytes) 529 | rig_pos = mod_pos + len(model_bytes) 530 | ani_pos = rig_pos + len(rig_bytes) 531 | offsets = (tex_pos, mod_pos, rig_pos, ani_pos) 532 | header_bytes.extend(pack('I' * len(offsets), *offsets)) 533 | 534 | placeholder_byte = 0 535 | header_bytes.extend(pack('I', placeholder_byte)) 536 | 537 | # Write everything to file 538 | with open(filepath, "wb") as file: 539 | file.write(header_bytes) 540 | file.write(texture_bytes) 541 | file.write(model_bytes) 542 | file.write(rig_bytes) 543 | file.write(animation_bytes) 544 | 545 | return {'FINISHED'} 546 | 547 | def fix_keyframe_dq(dq, frame_index, node_index): 548 | """Fix the keyframe DQ to make sure the animation doesn't look choppy""" 549 | if node_index > 0: 550 | pose_local_dq = dq_multiply(dq_get_conjugate(dq), dq) 551 | dq = dq_multiply(local_dq_conj, pose_local_dq) 552 | 553 | if frame_index == 0: 554 | if dq.real.w < 0: 555 | dq_negate(dq) 556 | else: 557 | if prevframe.real.dot(dq.real) < 0: 558 | dq_negate(dq) 559 | else: 560 | world_dq_conjugate = dq_get_conjugate(rig_dq) 561 | dq = dq_get_product(world_dq_conjugate, dq) 562 | 563 | return dq 564 | 565 | def apply_world_matrix(matrix, matrix_world): 566 | """Applies the world matrix to the given bone matrix and makes sure scaling effects are ignored.""" 567 | mat_w = matrix_world.copy() 568 | mat_w @= matrix 569 | 570 | # Decompose to get rid of scale 571 | deco = mat_w.decompose() 572 | mat_rot = deco[1].to_matrix() 573 | 574 | # Swap columns for use with SMF 575 | # Also add the translation 576 | temp = mat_rot.col[0][:] 577 | mat_rot.col[0] = mat_rot.col[1][:] 578 | mat_rot.col[1] = temp 579 | mat_w = mat_rot.to_4x4() 580 | mat_w.translation = deco[0][:] 581 | 582 | # Invert y values of the vectors 583 | # (i.e. mirror along Y to convert to SMF's left handed system) 584 | mat_w.row[1] *= -1 585 | 586 | return mat_w 587 | 588 | def transform_matrix_to_smf(matrix): 589 | """""" 590 | # TODO Split up the two transforms properly (see apply_world_matrix) 591 | pass 592 | 593 | def texture_image_from_node_tree(material): 594 | "Try to get a texture Image from the given material's node tree" 595 | if not material.use_nodes: 596 | return None 597 | 598 | output_node_list = [node for node in material.node_tree.nodes if node.type == 'OUTPUT_MATERIAL'] 599 | if not output_node_list: 600 | return None 601 | 602 | node = output_node_list[0] 603 | if not node.inputs['Surface'].is_linked: 604 | return None 605 | 606 | node = node.inputs['Surface'].links[0].from_node 607 | if node.type == 'TEX_IMAGE' and node.image: 608 | # Directly connected texture image node with image set 609 | if node.image.has_data: 610 | return node.image 611 | else: 612 | """ 613 | operator.report({'WARNING'}, ( 614 | "Image " + node.image.name + " " 615 | "has no data loaded. Using default texture instead." 616 | )) 617 | return None 618 | """ 619 | else: 620 | # Look a bit further 621 | # Try to generalize a bit by assuming texture input is at index 0 622 | # (color/texture inputs seem to connect to input 0 for all shaders) 623 | if not node.inputs[0].is_linked: 624 | return None 625 | 626 | node = node.inputs[0].links[0].from_node 627 | if node.type == 'TEX_IMAGE' and node.image: 628 | #if not(0 in node.image.size): 629 | if node.image.has_data: 630 | return node.image 631 | else: 632 | """ 633 | operator.report({'WARNING'}, ( 634 | "Image " + node.image.name + " " 635 | "has no data loaded. Using default texture instead." 636 | )) 637 | return None 638 | """ 639 | -------------------------------------------------------------------------------- /import_smf.py: -------------------------------------------------------------------------------- 1 | # SMF import scripts for Blender 2 | # 3 | # 4 | # 5 | # 6 | 7 | import time 8 | from math import * 9 | from mathutils import * 10 | from pathlib import Path 11 | from struct import ( 12 | Struct, 13 | calcsize, 14 | unpack, 15 | unpack_from, 16 | ) 17 | 18 | # Used for much faster image loading 19 | import numpy as np 20 | 21 | # Make sure to reload changes to code that we maintain ourselves 22 | # when reloading scripts in Blender 23 | if "bpy" in locals(): 24 | import importlib 25 | if "pydq" in locals(): 26 | importlib.reload(pydq) 27 | 28 | import bpy 29 | 30 | from . import pydq 31 | from .pydq import dq_create_iterable 32 | 33 | # SMF definitions 34 | SMF_version = 7 35 | SMF_format_struct = Struct("ffffffffBBBBBBBBBBBB") # 44 bytes 36 | SMF_format_size = SMF_format_struct.size 37 | 38 | def unpack_string_from(data, offset=0): 39 | """Unpacks a zero terminated string from the given offset in the data""" 40 | result = "" 41 | while data[offset] != 0: 42 | result += chr(data[offset]) 43 | offset += 1 44 | return result 45 | 46 | def import_smf_file(operator, context, 47 | filepath, 48 | create_new_materials_images, 49 | ): 50 | """ 51 | Main entry point for SMF import 52 | """ 53 | 54 | import bmesh 55 | modName = Path(filepath).stem 56 | print("Model file: " + str(modName)) 57 | 58 | data = bytearray() 59 | with open(filepath, 'rb') as file: 60 | data = file.read() 61 | 62 | header_bytes = unpack_from("17s", data)[0] 63 | header_text = "".join([chr(b) for b in header_bytes]) 64 | #print(header_text) 65 | 66 | # SMF file? 67 | if header_text != "SnidrsModelFormat": 68 | print("File does not contain a valid SMF file. Exiting...") 69 | return {'FINISHED'} 70 | 71 | # Valid SMF v7 file? 72 | versionNum = int(unpack_from("f", data, offset=18)[0]) 73 | print("SMF version:", versionNum) 74 | 75 | if versionNum != 7: 76 | print("Invalid SMF version. The importer currently only supports SMF v7.") 77 | return {'FINISHED'} 78 | 79 | texPos, matPos, modPos, nodPos, colPos, rigPos, aniPos, selPos, subPos, placeholder = unpack_from( 80 | "I"*10, 81 | data, 82 | offset=18+4, 83 | ) 84 | 85 | # Read number of models 86 | modelNum = unpack_from("B", data, offset = 62)[0] 87 | 88 | print("Number of models:", modelNum) 89 | 90 | img = None 91 | 92 | n = unpack_from("B", data, offset=texPos)[0] 93 | print("Textures: ", n) 94 | 95 | # Read texture images 96 | # 97 | # In current SMF, textures and materials are the same 98 | # So for every texture we need to add a material as well 99 | material_map = {} 100 | offset = texPos+1 101 | for i in range(n): 102 | name = unpack_string_from(data, offset) 103 | offset = offset + len(name) + 1 104 | dimensions = ( 105 | unpack_from("H", data, offset=offset)[0], 106 | unpack_from("H", data, offset=offset+2)[0], 107 | ) 108 | offset = offset+4 109 | print("Name: ", name) 110 | print("Dimensions: ", dimensions) 111 | 112 | num_pixels = dimensions[0] * dimensions[1] 113 | print("Num pixels: ", num_pixels) 114 | 115 | if create_new_materials_images: 116 | # Create new material and image no matter what the .blend file contains 117 | img = image_from_data(name, dimensions, data, offset) 118 | mat = material_with_texture(name, img) 119 | else: 120 | if name in bpy.data.materials: 121 | # A material with the image name already exists => use it! 122 | # 123 | # Note how this may link to a material that has a wrong texture 124 | # assigned to it or even no texture at all (!) 125 | mat = bpy.data.materials[name] 126 | else: 127 | # Create new material and image (since it doesn't exist yet) 128 | img = image_from_data(name, dimensions, data, offset) 129 | mat = material_with_texture(name, img) 130 | 131 | offset += 4 * num_pixels 132 | 133 | material_map[name] = mat 134 | 135 | # Read model data 136 | # Create a new Blender 'MESH' type object for every SMF model 137 | print("Read model data...") 138 | print("Meshes:", modelNum) 139 | mesh_object_list = [] 140 | dataPos = modPos 141 | for model_index in range(modelNum): 142 | size = unpack_from("I", data, offset=dataPos)[0] 143 | pos = dataPos + 4 144 | print(modName, model_index) 145 | print(size) 146 | no_faces = int(size/3 / SMF_format_size) 147 | print(no_faces) 148 | 149 | bm = bmesh.new() 150 | uv_layer = bm.loops.layers.uv.verify() 151 | for i in range(no_faces): 152 | v = [] 153 | uvs = [] 154 | for j in range(3): 155 | v_data = SMF_format_struct.unpack_from(data, pos) 156 | pos = pos + SMF_format_struct.size 157 | co = v_data[0:3] 158 | nml = v_data[3:6] 159 | uv = v_data[6:8] 160 | tan = v_data[8:11] 161 | indices = v_data[11:15] 162 | weights = v_data[15:19] 163 | #print(pos, co, nml, uv, tan, indices, weights) 164 | v.append(bm.verts.new(co)) 165 | uvs.append(uv) 166 | face = bm.faces.new(v) 167 | 168 | for i in range(len(face.loops)): 169 | face.loops[i][uv_layer].uv = uvs[i] 170 | 171 | # Add mesh 172 | mesh = bpy.data.meshes.new(modName) 173 | bm.to_mesh(mesh) 174 | 175 | # Read mesh's material and texture image name 176 | # The texture image acts as the key! 177 | matName = unpack_string_from(data, offset=pos) 178 | pos = pos + len(matName) + 1 179 | texName = unpack_string_from(data, offset=pos) # Image name 180 | pos = pos + len(texName) + 1 181 | print("Material:", matName) 182 | print("Image:", texName) 183 | 184 | visible = unpack_from("B", data, offset=pos)[0] 185 | pos += 1 186 | skinning_info = unpack_from("II", data, offset=pos)[0] 187 | # if != (0, 0) ?? 188 | pos += 2*4 189 | 190 | bpy.ops.object.add(type="MESH") 191 | new_obj = bpy.context.active_object 192 | new_obj.name = mesh.name # Let Blender handle the number suffix 193 | new_obj.data = mesh 194 | 195 | mesh_object_list.append(new_obj) 196 | 197 | # Add a material slot and assign the material to it 198 | # The assigned material depends on whether new materials are created 199 | bpy.ops.object.material_slot_add() 200 | 201 | new_obj.material_slots[0].material = material_map[texName] 202 | 203 | # Advance to next model 204 | dataPos = pos 205 | 206 | # Read rig info and construct armature 207 | node_num = unpack_from("B", data, offset = rigPos)[0] 208 | armature_object = None 209 | if node_num > 0: 210 | 211 | # Create armature 212 | bpy.ops.object.armature_add(enter_editmode=True) 213 | armature_object = bpy.context.object 214 | armature_object.name = modName + "_Armature" 215 | armature = armature_object.data 216 | armature.name = modName 217 | bpy.ops.armature.select_all(action='SELECT') 218 | bpy.ops.armature.delete() # Delete default bone 219 | 220 | # Add the bones 221 | bone_list = [] 222 | item_bytesize = calcsize("ffffffffBB") 223 | print("Number of nodes", node_num) 224 | for node_index in range(node_num): 225 | data_tuple = unpack_from("ffffffffBB", data, 226 | offset = rigPos+1 + node_index*item_bytesize) 227 | dq = dq_create_iterable(data_tuple[0:8], w_last = True) # SMF stores w last 228 | parent_bone_index = data_tuple[8] 229 | is_bone = data_tuple[9] 230 | bpy.ops.armature.bone_primitive_add() 231 | new_bone = bpy.context.object.data.edit_bones[-1:][0] 232 | bone_list.append(new_bone) 233 | 234 | # Old attempt 235 | # """ 236 | new_tail = Vector((2 * (dq.dual @ dq.real.conjugated()))[1:4]) 237 | if bone_list and parent_bone_index >= 0: 238 | new_bone.parent = bone_list[parent_bone_index] 239 | new_bone.use_connect = is_bone 240 | new_bone.tail = new_tail 241 | # print(new_bone.matrix, new_bone.tail[:]) 242 | # """ 243 | 244 | # New attempt: do the exporter's conversion backwards 245 | # TODO! 246 | 247 | 248 | bpy.ops.armature.select_all(action='DESELECT') 249 | for bone in bpy.context.object.data.edit_bones: 250 | if not bone.use_connect: 251 | bone.select = True 252 | bpy.ops.armature.delete() # What about the root node/bone? 253 | 254 | bpy.context.active_object.select_set(False) 255 | 256 | # Parent the mesh(es) to the armature 257 | for mesh_obj in mesh_object_list: 258 | mesh_obj.select_set(True) 259 | 260 | if armature_object: 261 | armature_object.select_set(True) 262 | bpy.ops.object.parent_set(type='ARMATURE_NAME') 263 | 264 | # Read animations and add actions to the armature 265 | # todo 266 | """ 267 | anim_num = unpack_from("B", data, offset = aniPos) 268 | print(anim_num) 269 | for anim_index in range(anim_num): 270 | anim_name = unpack_string_from(data, offset=aniPos+1) 271 | print(anim_name) 272 | #anim = bpy.data.actions.new(anim_name)""" 273 | 274 | bpy.ops.object.mode_set(mode='OBJECT') 275 | 276 | return {'FINISHED'} 277 | 278 | 279 | def image_from_data(name, dimensions, data, offset): 280 | """Create a new image (bpy.types.Image) from the data""" 281 | 282 | img = bpy.data.images.new( 283 | name=name, 284 | width=dimensions[0], 285 | height=dimensions[1], 286 | ) 287 | 288 | # Read the image data 289 | print("Read Image Data") 290 | 291 | start = time.perf_counter_ns() 292 | 293 | # Process image data using NumPy, then use Image.pixels.foreach_set 294 | num_pixels = dimensions[0] * dimensions[1] 295 | image_data = np.frombuffer(data, dtype = np.ubyte, count = 4*num_pixels, offset = offset) 296 | image_data = (image_data / 255).astype(np.float) 297 | img.pixels.foreach_set(tuple(image_data)) 298 | 299 | end = time.perf_counter_ns() 300 | 301 | print(str((end-start)/1000) + "us") 302 | 303 | return img 304 | 305 | def material_with_texture(name, image): 306 | """Create a new material using nodes with the image as the base color input""" 307 | 308 | mat = bpy.data.materials.new(name) 309 | mat.use_nodes = True 310 | 311 | mat.node_tree.nodes.new(type="ShaderNodeTexImage") # This is the bl_rna identifier, NOT the type! 312 | image_node = mat.node_tree.nodes['Image Texture'] # Default name 313 | image_node.image = image 314 | shader_node = mat.node_tree.nodes["Principled BSDF"] 315 | mat.node_tree.links.new(image_node.outputs['Color'], shader_node.inputs['Base Color']) 316 | 317 | return mat 318 | -------------------------------------------------------------------------------- /pydq.py: -------------------------------------------------------------------------------- 1 | # SMF script equivalents for Blender 2 | # 3 | # DQs are stored as a named tuple of 2 Quaternions 4 | # and can be converted to a tuple for export to SMF using dq_to_tuple_xyzw 5 | # 6 | from collections import namedtuple 7 | from mathutils import Quaternion 8 | 9 | # TODO Derive from typing.NamedTuple? Use dataclasses.dataclass? 10 | DQ = namedtuple( 11 | 'DQ', 12 | "real dual", 13 | defaults=[Quaternion(), Quaternion((0, 0, 0, 0))] 14 | ) 15 | 16 | def dq_create_iterable(iterable, w_last = False): 17 | """Create a DQ from the given iterable 18 | 19 | By default the order of components is as used by mathutils.Quaternion: 20 | 21 | [wr, xr, yr, zr, wd, xd, yd, zd] 22 | 23 | Set argument w_last to True if the iterable stores the DQ with w last 24 | """ 25 | if w_last: 26 | q_real = Quaternion([iterable[3], *iterable[ 0:3]]) 27 | q_dual = Quaternion([iterable[7], *iterable[-4:7]]) 28 | else: 29 | q_real = Quaternion(iterable[0:4]) 30 | q_dual = Quaternion(iterable[4:8]) 31 | 32 | return DQ(q_real, q_dual) 33 | 34 | def dq_create_identity(): 35 | """Return a new identity DQ""" 36 | return DQ(Quaternion(), Quaternion([0, 0, 0, 0])) 37 | 38 | def dq_create_rotation_axis_angle(axis, angle): 39 | """Return a new pure rotation DQ i.e. with no translation""" 40 | return DQ(Quaternion(axis, angle), Quaternion([0, 0, 0, 0])) 41 | 42 | def dq_create_rotation_quat(quat): 43 | """Return a new pure rotation DQ i.e. with no translation""" 44 | return DQ(quat.copy(), Quaternion([0, 0, 0, 0])) 45 | 46 | def dq_create_translation(tx, ty, tz): 47 | """Return a new pure translation DQ i.e. with no rotation""" 48 | return DQ(Quaternion(), Quaternion([0, tx / 2, ty / 2, tz /2])) 49 | 50 | def dq_create_matrix_vector(matrix, vector): 51 | """Return a new DQ from the given rotation matrix and translation vector""" 52 | real = matrix.to_quaternion() 53 | return DQ(real, .5 * Quaternion([0, *vector]) @ real) 54 | 55 | def dq_create_matrix(matrix): 56 | """Return a new DQ from the given 4x4 matrix, which includes a translation vector""" 57 | translation = matrix.col[3][0:3] 58 | real = matrix.to_quaternion() 59 | return DQ(real, .5 * Quaternion([0, *translation]) @ real) 60 | 61 | def dq_create_axis_angle_vector(axis, angle, vector): 62 | """Return a new DQ from the given axis/angle and translation vector""" 63 | real = Quaternion(axis, angle) 64 | return DQ(real, .5 * Quaternion([0, *vector]) @ real) 65 | 66 | def dq_get_sum(dq1, dq2): 67 | """Return the sum of dq1 and dq2 as a new DQ""" 68 | return DQ(dq1.real + dq2.real, dq1.dual + dq2.dual) 69 | 70 | def dq_get_product(dq1, dq2): 71 | """Return the product of dq1 and dq2 as a new DQ""" 72 | return DQ(dq1.real @ dq2.real, 73 | dq1.real @ dq2.dual + dq1.dual @ dq2.real) 74 | 75 | def dq_get_conjugate(dq): 76 | """Return a new DQ that is the conjugate of dq""" 77 | return DQ(dq.real.conjugated(), dq.dual.conjugated()) 78 | 79 | def dq_rotate(dq, quat): 80 | """Rotate the DQ dq around quaternion quat""" 81 | pass 82 | 83 | def dq_transform_point(dq, point): 84 | """Rotate the given point by the DQ""" 85 | # q' = q * p * q* 86 | # TODO 87 | #return DQ() 88 | 89 | def dq_normalize(dq): 90 | """Normalize a dual quaternion""" 91 | l = 1 / dq.real.magnitude 92 | dq.real.normalize() 93 | 94 | d = dq.real.dot(dq.dual) 95 | # d = dq.real[0] * dq.dual[0] + dq.real[1] * dq.dual[1] + dq.real[2] * dq.dual[2] + dq.real[3] * dq.dual[3] 96 | dq.dual[0] = (dq.dual[0] - dq.real[0] * d) * l 97 | dq.dual[1] = (dq.dual[1] - dq.real[1] * d) * l 98 | dq.dual[2] = (dq.dual[2] - dq.real[2] * d) * l 99 | dq.dual[3] = (dq.dual[3] - dq.real[3] * d) * l 100 | return dq 101 | 102 | def dq_negate(dq): 103 | """Negate a dual quaternion, i.e. negate both real and dual components""" 104 | dq.real.negate() 105 | dq.dual.negate() 106 | return dq 107 | 108 | def dq_negated(dq): 109 | """Return a new dual quaternion that is the negated dual quaternion""" 110 | return DQ(-dq.real, -dq.dual) 111 | 112 | def dq_invert(dq): 113 | """Invert a dual quaternion""" 114 | pass 115 | 116 | def quat_to_tuple_xyzw(quat): 117 | """Convert a mathutils.Quaternion to a tuple with components ordered xyzw""" 118 | return (quat.x, quat.y, quat.z, quat.w) 119 | 120 | def dq_to_tuple_xyzw(dq): 121 | """Return the tuple representation of the given DQ with w last (e.g. for use with SMF)""" 122 | return (dq.real.x, dq.real.y, dq.real.z, dq.real.w, 123 | dq.dual.x, dq.dual.y, dq.dual.z, dq.dual.w,) 124 | 125 | def dq_to_tuple_wxyz(dq): 126 | """Return the tuple representation of the given DQ with w first""" 127 | return (dq.real.w, dq.real.x, dq.real.y, dq.real.z, 128 | dq.dual.w, dq.dual.x, dq.dual.y, dq.dual.z,) 129 | 130 | def dq_get_translation(dq): 131 | pass 132 | 133 | def dq_set_translation(dq, x, y, z): 134 | pass 135 | 136 | def dq_add_translation(dq, x, y, z): 137 | pass 138 | 139 | def dq_get_quotient(dq1, dq2): 140 | """Return the quotient of dq1 and dq2 as a new DQ""" 141 | pass 142 | --------------------------------------------------------------------------------