From 2e7282956f7bf3eb7d66b356b849e4ae0d641d4a Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Thu, 8 Apr 2021 23:23:55 +0200 Subject: [PATCH] WIP: Import bones Signed-off-by: Lucas Schwiderski --- addons/bitsquid/import_bsi.py | 314 ++++++++++++++++++++++++++++++---- 1 file changed, 277 insertions(+), 37 deletions(-) diff --git a/addons/bitsquid/import_bsi.py b/addons/bitsquid/import_bsi.py index 050e457..29d8ac5 100644 --- a/addons/bitsquid/import_bsi.py +++ b/addons/bitsquid/import_bsi.py @@ -178,12 +178,12 @@ def find(arr, f): return val, key -def create_object(self, context, name, node_data, geometries): +def create_mesh(self, context, name, node_data, geo_data): """ Create a Blender object from a BSI node definition and additional data from the file. """ - print("[create_object]", name, node_data) + # A list of vectors that represent vertex locations. vertices = [] # A list of vectors, where each vector contains three indices into `vertices`. @@ -192,10 +192,8 @@ def create_object(self, context, name, node_data, geometries): uv_name = "UVMap" uvs = [] - tri_count = int(geometries["indices"]["size"]) / 3 - - for i, index_stream in enumerate(geometries["indices"]["streams"]): - data_stream = geometries["streams"][i] + for i, index_stream in enumerate(geo_data["indices"]["streams"]): + data_stream = geo_data["streams"][i] stride = data_stream["stride"] / 4 for channel in data_stream["channels"]: @@ -230,7 +228,15 @@ def create_object(self, context, name, node_data, geometries): # Blender is able to create normals from the face definition # (i.e. the order in which the faces vertices were defined) # and that seems to be good enough - self.report({'INFO'}, "Ignoring custom normals") + # self.report({'INFO'}, "Ignoring custom normals") + continue + elif channel["name"] in {'ALPHA', 'COLOR'}: + # Not sure if this can be intended as a primitive material, + # but I'll assume it's not. And while Blender does feature + # the concept of viewport-only vertex colors, that's rather + # low priority to implement. + # self.report({'INFO'}, "Ignoring vertex color data") + continue elif channel["name"] == 'TEXCOORD': uv_name = "UVMap{}".format(channel["index"] + 1) uv_data = data_stream["data"] @@ -238,16 +244,16 @@ def create_object(self, context, name, node_data, geometries): uvs = [uv_defs[index_stream[j]] for j in range(0, len(index_stream))] else: # TODO: Implement other channel types - self.report( - {'WARNING'}, - "Unknown channel type: {}".format(channel["name"]) - ) + # self.report( + # {'WARNING'}, + # "Unknown channel type: {}".format(channel["name"]) + # ) + continue mesh = bpy.data.meshes.new(name) mesh.from_pydata(vertices, [], faces) if len(uvs) > 0: - print("{} has UVs".format(name)) uv_layer = mesh.uv_layers.new(name=uv_name) uv_layer.data.foreach_set("uv", unpack_list(uvs)) @@ -271,22 +277,193 @@ def matrix_from_list(list): return Matrix(rows) -def import_node(self, context, name, node_data, global_data): - """Import a BSI node. Recurses into child nodes.""" - print("[import_node]", name, node_data) - if "geometries" in node_data: - geometry_name = node_data["geometries"][0] - print("[import_node] Building geometry '{}' for object '{}'".format(geometry_name, name)) - geometries = global_data["geometries"][geometry_name] - mesh = create_object(self, context, name, node_data, geometries) - obj = bpy.data.objects.new(mesh.name, mesh) - else: - print("[import_node] Adding empty for '{}'".format(name)) - obj = bpy.data.objects.new(name, None) - # Decrease axis size to prevent overcrowding in the viewport - obj.empty_display_size = 0.1 +def import_joint( + self, + context, + name, + node_data, + armature, + parent_bone, + parent_rotation, + global_data + ): + """ + Imports a joint and all of its children. + In BSI (smilar to Maya) skeletons are defined as a series of joints, + with bones added virtually in between, when needed. + In Blender, skeletons are defined with bones, where each bone has a `head` + and `tail`. So we need to convert the list of joints to a series of bones + instead. + The challenge here is the fact that joints have rotation data, whereas + `head` and `tail` for bones don't. This changes how the position data has + to be treated, as it is relative to the respective previous joint. + Compared to the code that imports mesh objects, we can't just apply + the matrix to the bone and have it position itself relative to its parent. + Instead, we have to manually keep track of the parent's rotation. + """ + if "local" not in node_data: + raise RuntimeError("No position value for joint '{}'".format(name)) + + mat = matrix_from_list(node_data["local"]) + translation, rotation, _ = mat.decompose() + + if name.endswith("_scale"): + # print("Skipping joint '{}'".format(name)) + bone = parent_bone + else: + bone = armature.edit_bones.new(name) + if parent_bone: + # The values for `bone.head` and `bone.tail` are relative to their + # parent, so we need to apply that first. + bone.parent = parent_bone + bone.use_connect = True + # bone.head = parent_bone.tail + else: + bone.head = Vector((0, 0, 0)) + + if parent_rotation: + print("[import_joint] {} Parent @ Local:".format(name), parent_rotation, parent_rotation @ translation) + bone.tail = bone.head + (parent_rotation @ translation) + else: + bone.tail = bone.head + translation + + print("[import_joint] {} Local:".format(name), translation) + print("[import_joint] {} Bone:".format(name), bone.head, bone.tail) + + if "children" in node_data: + for child_name, child_data in node_data["children"].items(): + if child_data["parent"] != name: + raise RuntimeError( + "Assigned parent '{}' doesn't match actual parent node '{}'" + .format(child_data["parent"], name) + ) + + if child_name.startswith("j_"): + child_bone = import_joint( + self, + context, + child_name, + child_data, + armature, + bone, + rotation, + global_data + ) + child_bone.parent = bone + else: + # DEBUG: ignoring these for now + continue + # Not entirely sure, but I think these are considered + # "controller nodes" in Maya. Would make sense based on + # name. + if "children" in child_data: + raise RuntimeError( + "Controller node '{}' has children." + .format(child_name) + ) + + if "geometries" not in child_data: + raise RuntimeError( + "Controller node '{}' has no geometry." + .format(child_name) + ) + + child_obj = import_node( + self, + context, + child_name, + child_data, + global_data, + ) + # TODO: How to parent to a bone? + child_obj.parent = bone + + return bone + + +def import_armature(self, context, name, node_data, global_data): + armature = context.blend_data.armatures.new(name) + + # An armature itself cannot exist in the view layer. + # We need to create an object from it + obj = bpy.data.objects.new(name, armature) + context.collection.objects.link(obj) + + # Armature needs to be in EDIT mode to allow adding bones + context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT', toggle=False) + + if "local" not in node_data: + raise RuntimeError("No position value for joint '{}'".format(name)) + + mat = matrix_from_list(node_data["local"]) + obj.matrix_local = mat + # DEBUG + mat_loc, mat_rot, mat_scale = mat.decompose() + print("[import_joint] {}".format(name), mat_loc, mat_rot) + + if "children" in node_data: + for child_name, child_data in node_data["children"].items(): + if child_data["parent"] != name: + raise RuntimeError( + "Assigned parent '{}' doesn't match actual parent node '{}'" + .format(child_data["parent"], name) + ) + + if child_name.startswith("j_"): + import_joint( + self, + context, + child_name, + child_data, + armature, + None, + None, + global_data + ) + else: + # DEBUG: Ignoring these for now + continue + # Not entirely sure, but I think these are considered + # "controller nodes" in Maya. Would make sense based on + # name. + if "children" in child_data: + raise RuntimeError( + "Controller node '{}' has children." + .format(child_name) + ) + + child_obj = import_node( + self, + context, + child_name, + child_data, + global_data, + ) + child_obj.parent = obj + + # Disable EDIT mode + context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='OBJECT') + + return obj + + +def import_geometry(self, context, name, node_data, global_data): + if len(node_data["geometries"]) > 1: + self.report( + {'WARNING'}, + "More than one geometry for node '{}'.".format(name) + ) + + geometry_name = node_data["geometries"][0] + geo_data = global_data["geometries"][geometry_name] + + mesh = create_mesh(self, context, name, node_data, geo_data) + obj = bpy.data.objects.new(mesh.name, mesh) obj.matrix_world = Matrix() + if "local" in node_data: mat = matrix_from_list(node_data["local"]) obj.matrix_local = mat @@ -295,8 +472,8 @@ def import_node(self, context, name, node_data, global_data): for child_name, child_data in node_data["children"].items(): if child_data["parent"] != name: raise RuntimeError( - "Assigned parent '%s' doesn't match actual parent node '%s'" - % (child_data["parent"], name) + "Assigned parent '{}' doesn't match actual parent node '{}'" + .format(child_data["parent"], name) ) child_obj = import_node( @@ -306,6 +483,13 @@ def import_node(self, context, name, node_data, global_data): child_data, global_data, ) + + if not isinstance(child_obj, bpy.types.Object): + raise RuntimeError( + "Node of type '{}' cannot be child of a geometry node." + .format(type(child_obj)) + ) + child_obj.parent = obj # Make sure all objects are linked to the current collection. @@ -315,24 +499,80 @@ def import_node(self, context, name, node_data, global_data): return obj +def import_node(self, context, name, node_data, global_data): + """Import a BSI node. Recurses into child nodes.""" + has_geometry = "geometries" in node_data + is_joint = name in global_data["joint_index"] + + if is_joint: + if has_geometry: + raise RuntimeError("Found geometry data in joint '{}'".format(name)) + + if not name.startswith("j_"): + raise RuntimeError("Invalid name for joint: '{}".format(name)) + + self.report({'INFO'}, "Creating Armature '{}'".format(name)) + return import_armature(self, context, name, node_data, global_data) + + if has_geometry: + return import_geometry(self, context, name, node_data, global_data) + else: + if name != "root_point": + self.report({'WARNING'}, "Unknown kind of node: '{}'. Falling back to Empty.".format(name)) + + obj = bpy.data.objects.new(name, None) + # Decrease axis size to prevent overcrowding in the viewport + obj.empty_display_size = 0.1 + + if "children" in node_data: + for child_name, child_data in node_data["children"].items(): + if child_data["parent"] != name: + raise RuntimeError( + "Assigned parent '{}' doesn't match actual parent node '{}'" + .format(child_data["parent"], name) + ) + + child_obj = import_node( + self, + context, + child_name, + child_data, + global_data, + ) + child_obj.parent = obj + + # Make sure all objects are linked to the current collection. + # Otherwise they won't show up in the outliner. + collection = context.collection + collection.objects.link(obj) + + return obj + + def load(self, context, filepath, *, relpath=None): global_data = parse_sjson(filepath) - print(global_data) - # Nothing to do if there are no nodes if "nodes" not in global_data: self.report({'WARNING'}, "No nodes to import in {}".format(filepath)) return {'CANCELLED'} - view_layer = context.view_layer + # Build joint index + joint_index = [] + if "skins" in global_data: + for v in global_data["skins"].values(): + for joint in v["joints"]: + name = joint["name"] + if name in global_data["geometries"]: + self.report({'ERROR'}, "Found joint with mesh data.") + return {'CANCELLED'} + + if name not in joint_index: + joint_index.append(joint['name']) + + global_data["joint_index"] = joint_index for name, node_data in global_data["nodes"].items(): - obj = import_node(self, context, name, node_data, global_data) - - obj.select_set(True) - # One of the objects should be set as active. - # This is the easiest to implement and perfectly fine. - view_layer.objects.active = obj + import_node(self, context, name, node_data, global_data) return {'FINISHED'}