diff --git a/docs/user/features/geometry/geometry.rst b/docs/user/features/geometry/geometry.rst index ff1813f96..cb55b20dd 100644 --- a/docs/user/features/geometry/geometry.rst +++ b/docs/user/features/geometry/geometry.rst @@ -94,3 +94,15 @@ Vertex Color & Alpha * `This image should clarify per-face vertex colouring `_ * On export, the scripts will create extra vertices for different vertex colors per face. + + +.. _geometry-shapekeys: + +Shape Key Animations +-------------------- + +**Example:** + +#. :ref:`Create a mesh-object `. +#. Add relative shape keys to your mesh. +#. Keyframe each shape key's value so that the key influences the shape of the mesh at the desired time. \ No newline at end of file diff --git a/io_scene_niftools/modules/nif_export/animation/morph.py b/io_scene_niftools/modules/nif_export/animation/morph.py index a1e2680a4..6078bdb5f 100644 --- a/io_scene_niftools/modules/nif_export/animation/morph.py +++ b/io_scene_niftools/modules/nif_export/animation/morph.py @@ -55,10 +55,11 @@ def __init__(self): EGMData.data = None def export_morph(self, b_mesh, n_trishape, vertmap): - # shape b_key morphing + NifLog.debug(f"Checking {b_mesh.name} for shape keys") + # shape keys are only present on non-evaluated meshes! b_key = b_mesh.shape_keys if b_key and len(b_key.key_blocks) > 1: - + NifLog.debug(f"{b_mesh.name} has shape keys") # yes, there is a b_key object attached # export as egm, or as morph_data? if b_key.key_blocks[1].name.startswith("EGM"): diff --git a/io_scene_niftools/modules/nif_export/animation/transform.py b/io_scene_niftools/modules/nif_export/animation/transform.py index 964660061..3ae9ee05c 100644 --- a/io_scene_niftools/modules/nif_export/animation/transform.py +++ b/io_scene_niftools/modules/nif_export/animation/transform.py @@ -46,6 +46,7 @@ from io_scene_niftools.modules.nif_export.block_registry import block_store from io_scene_niftools.utils import math, consts from io_scene_niftools.utils.logging import NifError, NifLog +from io_scene_niftools.utils.consts import QUAT, EULER, LOC, SCALE class TransformAnimation(Animation): @@ -166,7 +167,7 @@ def export_transforms(self, parent_block, b_obj, b_action, bone=None): # matrix_local = matrix_parent_inverse * matrix_basis bind_matrix = b_obj.matrix_parent_inverse exp_fcurves = [fcu for fcu in b_action.fcurves if - fcu.data_path in ("rotation_quaternion", "rotation_euler", "location", "scale")] + fcu.data_path in (QUAT, EULER, LOC, SCALE)] else: # bone isn't keyframed in this action, nothing to do here diff --git a/io_scene_niftools/modules/nif_export/geometry/mesh/__init__.py b/io_scene_niftools/modules/nif_export/geometry/mesh/__init__.py index 1924f33be..0db8591b8 100644 --- a/io_scene_niftools/modules/nif_export/geometry/mesh/__init__.py +++ b/io_scene_niftools/modules/nif_export/geometry/mesh/__init__.py @@ -69,7 +69,7 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): n_parent, as NiTriShape and NiTriShapeData blocks, possibly along with some NiTexturingProperty, NiSourceTexture, NiMaterialProperty, and NiAlphaProperty blocks. We export one - trishape block per mesh material. We also export vertex weights. + n_geom block per mesh material. We also export vertex weights. The parameter trishape_name passes on the name for meshes that should be exported as a single mesh. @@ -78,41 +78,42 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): assert (b_obj.type == 'MESH') - # get mesh from b_obj - b_mesh = self.get_triangulated_mesh(b_obj) - b_mesh.calc_normals_split() + # get mesh from b_obj, and evaluate the mesh with modifiers applied, too + b_mesh = b_obj.data + eval_mesh = self.get_triangulated_mesh(b_obj) + eval_mesh.calc_normals_split() # getVertsFromGroup fails if the mesh has no vertices # (this happens when checking for fallout 3 body parts) # so quickly catch this (rare!) case - if not b_mesh.vertices: + if not eval_mesh.vertices: # do not export anything NifLog.warn(f"{b_obj} has no vertices, skipped.") return # get the mesh's materials, this updates the mesh material list if not isinstance(n_parent, NifFormat.RootCollisionNode): - mesh_materials = b_mesh.materials + mesh_materials = eval_mesh.materials else: # ignore materials on collision trishapes mesh_materials = [] - # if the mesh has no materials, all face material indices should be 0, so it's ok to fake one material in the material list + # if mesh has no materials, all face material indices should be 0, so fake one material in the material list if not mesh_materials: mesh_materials = [None] # vertex color check - mesh_hasvcol = b_mesh.vertex_colors + mesh_hasvcol = eval_mesh.vertex_colors # list of body part (name, index, vertices) in this mesh - polygon_parts = self.get_polygon_parts(b_obj, b_mesh) + polygon_parts = self.get_polygon_parts(b_obj, eval_mesh) game = bpy.context.scene.niftools_scene.game # Non-textured materials, vertex colors are used to color the mesh # Textured materials, they represent lighting details - # let's now export one trishape for every mesh material + # let's now export one n_geom for every mesh material # TODO [material] needs refactoring - move material, texture, etc. to separate function - for materialIndex, b_mat in enumerate(mesh_materials): + for b_mat_index, b_mat in enumerate(mesh_materials): mesh_hasnormals = False if b_mat is not None: @@ -120,53 +121,55 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): if (game == 'SKYRIM') and b_mat.niftools_shader.slsf_1_model_space_normals: mesh_hasnormals = False # for proper lighting - # create a trishape block + # create a n_geom block if not NifOp.props.stripify: - trishape = block_store.create_block("NiTriShape", b_obj) + n_geom = block_store.create_block("NiTriShape", b_obj) + n_geom.data = block_store.create_block("NiTriShapeData", b_obj) else: - trishape = block_store.create_block("NiTriStrips", b_obj) + n_geom = block_store.create_block("NiTriStrips", b_obj) + n_geom.data = block_store.create_block("NiTriStripsData", b_obj) # fill in the NiTriShape's non-trivial values if isinstance(n_parent, NifFormat.RootCollisionNode): - trishape.name = "" + n_geom.name = "" else: if not trishape_name: if n_parent.name: - trishape.name = "Tri " + n_parent.name.decode() + n_geom.name = "Tri " + n_parent.name.decode() else: - trishape.name = "Tri " + b_obj.name.decode() + n_geom.name = "Tri " + b_obj.name.decode() else: - trishape.name = trishape_name + n_geom.name = trishape_name # multimaterial meshes: add material index (Morrowind's child naming convention) if len(mesh_materials) > 1: - trishape.name = f"{trishape.name.decode()}: {materialIndex}" + n_geom.name = f"{n_geom.name.decode()}: {b_mat_index}" else: - trishape.name = block_store.get_full_name(trishape) + n_geom.name = block_store.get_full_name(n_geom) - self.set_mesh_flags(b_obj, trishape) + self.set_mesh_flags(b_obj, n_geom) # extra shader for Sid Meier's Railroads if game == 'SID_MEIER_S_RAILROADS': - trishape.has_shader = True - trishape.shader_name = "RRT_NormalMap_Spec_Env_CubeLight" - trishape.unknown_integer = -1 # default + n_geom.has_shader = True + n_geom.shader_name = "RRT_NormalMap_Spec_Env_CubeLight" + n_geom.unknown_integer = -1 # default # if we have an animation of a blender mesh # an intermediate NiNode has been created which holds this b_obj's transform - # the trishape itself then needs identity transform (default) + # the n_geom itself then needs identity transform (default) if trishape_name is not None: # only export the bind matrix on trishapes that were not animated - math.set_object_matrix(b_obj, trishape) + math.set_object_matrix(b_obj, n_geom) # check if there is a parent if n_parent: - # add texture effect block (must be added as parent of the trishape) + # add texture effect block (must be added as parent of the n_geom) n_parent = self.export_texture_effect(n_parent, b_mat) # refer to this mesh in the parent's children list - n_parent.add_child(trishape) + n_parent.add_child(n_geom) - self.object_property.export_properties(b_obj, b_mat, trishape) + self.object_property.export_properties(b_obj, b_mat, n_geom) # -> now comes the real export @@ -188,9 +191,9 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): # The following algorithm extracts all unique quads(vert, uv-vert, normal, vcol), # produce lists of vertices, uv-vertices, normals, vertex colors, and face indices. - mesh_uv_layers = b_mesh.uv_layers + b_uv_layers = eval_mesh.uv_layers vertquad_list = [] # (vertex, uv coordinate, normal, vertex color) list - vertex_map = [None for _ in range(len(b_mesh.vertices))] # blender vertex -> nif vertices + vertex_map = [None for _ in range(len(eval_mesh.vertices))] # blender vertex -> nif vertices vertex_positions = [] normals = [] vertex_colors = [] @@ -200,24 +203,28 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): bodypartfacemap = [] polygons_without_bodypart = [] - if b_mesh.polygons: - if mesh_uv_layers: + if eval_mesh.polygons: + if b_uv_layers: # if we have uv coordinates double check that we have uv data - if not b_mesh.uv_layer_stencil: - NifLog.warn(f"No UV map for texture associated with selected mesh '{b_mesh.name}'.") + if not eval_mesh.uv_layer_stencil: + NifLog.warn(f"No UV map for texture associated with selected mesh '{eval_mesh.name}'.") use_tangents = False - if mesh_uv_layers and mesh_hasnormals: + if b_uv_layers and mesh_hasnormals: if game in ('OBLIVION', 'FALLOUT_3', 'SKYRIM') or (game in self.texture_helper.USED_EXTRA_SHADER_TEXTURES): use_tangents = True - b_mesh.calc_tangents(uvmap=mesh_uv_layers[0].name) + eval_mesh.calc_tangents(uvmap=b_uv_layers[0].name) tangents = [] bitangent_signs = [] - for poly in b_mesh.polygons: + if game in ('FALLOUT_3', 'SKYRIM'): + if len(b_uv_layers) > 1: + raise NifError(f"{game} does not support multiple UV layers.") - # does the face belong to this trishape? - if b_mat is not None and poly.material_index != materialIndex: + for poly in eval_mesh.polygons: + + # does the face belong to this n_geom? + if b_mat is not None and poly.material_index != b_mat_index: # we have a material but this face has another material, so skip continue @@ -230,25 +237,25 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): f_index = [-1] * f_numverts for i, loop_index in enumerate(poly.loop_indices): - fv_index = b_mesh.loops[loop_index].vertex_index - vertex = b_mesh.vertices[fv_index] + fv_index = eval_mesh.loops[loop_index].vertex_index + vertex = eval_mesh.vertices[fv_index] vertex_index = vertex.index fv = vertex.co # smooth = vertex normal, non-smooth = face normal) if mesh_hasnormals: if poly.use_smooth: - fn = b_mesh.loops[loop_index].normal + fn = eval_mesh.loops[loop_index].normal else: fn = poly.normal else: fn = None - fuv = [uv_layer.data[loop_index].uv for uv_layer in b_mesh.uv_layers] + fuv = [uv_layer.data[loop_index].uv for uv_layer in eval_mesh.uv_layers] # TODO [geomotry][mesh] Need to map b_verts -> n_verts if mesh_hasvcol: - f_col = list(b_mesh.vertex_colors[0].data[loop_index].color) + f_col = list(eval_mesh.vertex_colors[0].data[loop_index].color) else: f_col = None @@ -282,11 +289,11 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): if mesh_hasnormals: normals.append(vertquad[2]) if use_tangents: - tangents.append(b_mesh.loops[loop_index].tangent) - bitangent_signs.append([b_mesh.loops[loop_index].bitangent_sign]) + tangents.append(eval_mesh.loops[loop_index].tangent) + bitangent_signs.append([eval_mesh.loops[loop_index].bitangent_sign]) if mesh_hasvcol: vertex_colors.append(vertquad[3]) - if mesh_uv_layers: + if b_uv_layers: uv_coords.append(vertquad[1]) # now add the (hopefully, convex) face, in triangles @@ -312,73 +319,32 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): # check that there are no missing body part polygons if polygons_without_bodypart: - self.select_unassigned_polygons(b_mesh, b_obj, polygons_without_bodypart) + self.select_unassigned_polygons(eval_mesh, b_obj, polygons_without_bodypart) if len(triangles) > 65535: raise NifError("Too many polygons. Decimate your mesh and try again.") if len(vertex_positions) == 0: continue # m_4444x: skip 'empty' material indices - # add NiTriShape's data - if isinstance(trishape, NifFormat.NiTriShape): - tridata = block_store.create_block("NiTriShapeData", b_obj) - else: - tridata = block_store.create_block("NiTriStripsData", b_obj) - trishape.data = tridata - - # data - tridata.num_vertices = len(vertex_positions) - tridata.has_vertices = True - tridata.vertices.update_size() - for i, v in enumerate(tridata.vertices): - v.x, v.y, v.z = vertex_positions[i] - tridata.update_center_radius() - - if mesh_hasnormals: - tridata.has_normals = True - tridata.normals.update_size() - for i, v in enumerate(tridata.normals): - v.x, v.y, v.z = normals[i] - - if mesh_hasvcol: - tridata.has_vertex_colors = True - tridata.vertex_colors.update_size() - for i, v in enumerate(tridata.vertex_colors): - v.r, v.g, v.b, v.a = vertex_colors[i] - - if mesh_uv_layers: - if game in ('FALLOUT_3', 'SKYRIM'): - if len(mesh_uv_layers) > 1: - raise NifError(f"{game} does not support multiple UV layers.") - tridata.num_uv_sets = len(mesh_uv_layers) - tridata.bs_num_uv_sets = len(mesh_uv_layers) - tridata.has_uv = True - tridata.uv_sets.update_size() - for j, uv_layer in enumerate(mesh_uv_layers): - for i, uv in enumerate(tridata.uv_sets[j]): - if len(uv_coords[i]) == 0: - continue # skip non-uv textures - uv.u = uv_coords[i][j][0] - # NIF flips the texture V-coordinate (OpenGL standard) - uv.v = 1.0 - uv_coords[i][j][1] # opengl standard + self.set_geom_data(n_geom, vertex_positions, normals, vertex_colors, uv_coords, b_uv_layers) # set triangles stitch strips for civ4 - tridata.set_triangles(triangles, stitchstrips=NifOp.props.stitch_strips) + n_geom.data.set_triangles(triangles, stitchstrips=NifOp.props.stitch_strips) - # update tangent space (as binary extra data only for Oblivion) + # update tangent space # for extra shader texture games, only export it if those textures are actually exported # (civ4 seems to be consistent with not using tangent space on non shadered nifs) if use_tangents: if game == 'SKYRIM': - tridata.bs_num_uv_sets = tridata.bs_num_uv_sets + 4096 + n_geom.data.bs_num_uv_sets = n_geom.data.bs_num_uv_sets + 4096 # calculate the bitangents using the normals, tangent list and bitangent sign bitangents = bitangent_signs * np.cross(normals, tangents) # B_tan: +d(B_u), B_bit: +d(B_v) and N_tan: +d(N_v), N_bit: +d(N_u) # moreover, N_v = 1 - B_v, so d(B_v) = - d(N_v), therefore N_tan = -B_bit and N_bit = B_tan - self.add_defined_tangents(trishape, + self.add_defined_tangents(n_geom, tangents=-bitangents, bitangents=tangents, - as_extra_data=(game == 'OBLIVION')) + as_extra_data=(game == 'OBLIVION')) # as binary extra data only for Oblivion # todo [mesh/object] use more sophisticated armature finding, also taking armature modifier into account # now export the vertex weights, if there are any @@ -391,7 +357,7 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): if boneinfluences: # yes we have skinning! # create new skinning instance block and link it skininst, skindata = self.create_skin_inst_data(b_obj, b_obj_armature, polygon_parts) - trishape.skin_instance = skininst + n_geom.skin_instance = skininst # Vertex weights, find weights and normalization factors vert_list = {} @@ -402,7 +368,7 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): b_list_weight = [] b_vert_group = b_obj.vertex_groups[bone_group] - for b_vert in b_mesh.vertices: + for b_vert in eval_mesh.vertices: if len(b_vert.groups) == 0: # check vert has weight_groups unweighted_vertices.append(b_vert.index) continue @@ -424,13 +390,8 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): self.select_unweighted_vertices(b_obj, unweighted_vertices) - # for each bone, first we get the bone block then we get the vertex weights and then we add it to the NiSkinData - # note: allocate memory for faster performance - vert_added = [False for _ in range(len(vertex_positions))] + # for each bone, get the vertex weights and add its n_node to the NiSkinData for b_bone_name in boneinfluences: - # find bone in exported blocks - bone_block = self.get_bone_block(b_obj_armature.data.bones[b_bone_name]) - # find vertex weights vert_weights = {} for v in vert_list[b_bone_name]: @@ -445,67 +406,105 @@ def export_tri_shapes(self, b_obj, n_parent, n_root, trishape_name=None): if vertex_map[v[0]] and vert_norm[v[0]]: for vert_index in vertex_map[v[0]]: vert_weights[vert_index] = v[1] / vert_norm[v[0]] - vert_added[vert_index] = True # add bone as influence, but only if there were actually any vertices influenced by the bone if vert_weights: - trishape.add_bone(bone_block, vert_weights) + # find bone in exported blocks + n_node = self.get_bone_block(b_obj_armature.data.bones[b_bone_name]) + n_geom.add_bone(n_node, vert_weights) + del vert_weights # update bind position skinning data - # trishape.update_bind_position() - # override pyffi trishape.update_bind_position with custom one that is relative to the nif root - self.update_bind_position(trishape, n_root, b_obj_armature) + # n_geom.update_bind_position() + # override pyffi n_geom.update_bind_position with custom one that is relative to the nif root + self.update_bind_position(n_geom, n_root, b_obj_armature) # calculate center and radius for each skin bone data block - trishape.update_skin_center_radius() - - if NifData.data.version >= 0x04020100 and NifOp.props.skin_partition: - NifLog.info("Creating skin partition") - - # warn on bad config settings - if game == 'OBLIVION': - if NifOp.props.pad_bones: - NifLog.warn("Using padbones on Oblivion export. Disable the pad bones option to get higher quality skin partitions.") - if game in ('OBLIVION', 'FALLOUT_3'): - if NifOp.props.max_bones_per_partition < 18: - NifLog.warn("Using less than 18 bones per partition on Oblivion/Fallout 3 export." - "Set it to 18 to get higher quality skin partitions.") - elif NifOp.props.max_bones_per_partition > 18: - NifLog.warn("Using more than 18 bones per partition on Oblivion/Fallout 3 export." - "This may cause issues in-game.") - if game == 'SKYRIM': - if NifOp.props.max_bones_per_partition < 24: - NifLog.warn("Using less than 24 bones per partition on Skyrim export." - "Set it to 24 to get higher quality skin partitions.") - # Skyrim Special Edition has a limit of 80 bones per partition, but export is not yet supported - - part_order = [getattr(NifFormat.BSDismemberBodyPartType, face_map.name, None) for face_map in b_obj.face_maps] - part_order = [body_part for body_part in part_order if body_part is not None] - # override pyffi trishape.update_skin_partition with custom one (that allows ordering) - trishape.update_skin_partition = update_skin_partition.__get__(trishape) - lostweight = trishape.update_skin_partition( - maxbonesperpartition=NifOp.props.max_bones_per_partition, - maxbonespervertex=NifOp.props.max_bones_per_vertex, - stripify=NifOp.props.stripify, - stitchstrips=NifOp.props.stitch_strips, - padbones=NifOp.props.pad_bones, - triangles=triangles, - trianglepartmap=bodypartfacemap, - maximize_bone_sharing=(game in ('FALLOUT_3', 'SKYRIM')), - part_sort_order=part_order) - - if lostweight > NifOp.props.epsilon: - NifLog.warn(f"Lost {lostweight:f} in vertex weights while creating a skin partition for Blender object '{b_obj.name}' (nif block '{trishape.name}')") - - # clean up - del vert_weights - del vert_added + n_geom.update_skin_center_radius() + + self.export_skin_partition(b_obj, bodypartfacemap, triangles, n_geom) # fix data consistency type - tridata.consistency_flags = b_obj.niftools.consistency_flags + n_geom.data.consistency_flags = b_obj.niftools.consistency_flags # export EGM or NiGeomMorpherController animation - self.morph_anim.export_morph(b_mesh, trishape, vertex_map) - return trishape + # shape keys are only present on the raw, unevaluated mesh + self.morph_anim.export_morph(b_mesh, n_geom, vertex_map) + return n_geom + + def set_geom_data(self, n_geom, vertex_positions, normals, vertex_colors, uv_coords, b_uv_layers): + """Sets flat lists of per-vertex data to n_geom""" + # coords + n_geom.data.num_vertices = len(vertex_positions) + n_geom.data.has_vertices = True + n_geom.data.vertices.update_size() + for n_v, b_v in zip(n_geom.data.vertices, vertex_positions): + n_v.x, n_v.y, n_v.z = b_v + n_geom.data.update_center_radius() + # normals + n_geom.data.has_normals = bool(normals) + n_geom.data.normals.update_size() + for n_v, b_v in zip(n_geom.data.normals, normals): + n_v.x, n_v.y, n_v.z = b_v + # vertex_colors + n_geom.data.has_vertex_colors = bool(vertex_colors) + n_geom.data.vertex_colors.update_size() + for n_v, b_v in zip(n_geom.data.vertex_colors, vertex_colors): + n_v.r, n_v.g, n_v.b, n_v.a = b_v + # uv_sets + n_geom.data.has_uv = bool(b_uv_layers) + n_geom.data.num_uv_sets = len(b_uv_layers) + n_geom.data.bs_num_uv_sets = len(b_uv_layers) + n_geom.data.uv_sets.update_size() + for j, n_uv_set in enumerate(n_geom.data.uv_sets): + for i, n_uv in enumerate(n_uv_set): + if len(uv_coords[i]) == 0: + continue # skip non-uv textures + n_uv.u = uv_coords[i][j][0] + # NIF flips the texture V-coordinate (OpenGL standard) + n_uv.v = 1.0 - uv_coords[i][j][1] # opengl standard + + def export_skin_partition(self, b_obj, bodypartfacemap, triangles, n_geom): + """Attaches a skin partition to n_geom if needed""" + game = bpy.context.scene.niftools_scene.game + if NifData.data.version >= 0x04020100 and NifOp.props.skin_partition: + NifLog.info("Creating skin partition") + + # warn on bad config settings + if game == 'OBLIVION': + if NifOp.props.pad_bones: + NifLog.warn( + "Using padbones on Oblivion export. Disable the pad bones option to get higher quality skin partitions.") + + # Skyrim Special Edition has a limit of 80 bones per partition, but export is not yet supported + bones_per_partition_lut = {"OBLIVION": 18, "FALLOUT_3": 18, "SKYRIM": 24} + rec_bones = bones_per_partition_lut.get(game, None) + if rec_bones is not None: + if NifOp.props.max_bones_per_partition < rec_bones: + NifLog.warn(f"Using less than {rec_bones} bones per partition on {game} export." + f"Set it to {rec_bones} to get higher quality skin partitions.") + elif NifOp.props.max_bones_per_partition > rec_bones: + NifLog.warn(f"Using more than {rec_bones} bones per partition on {game} export." + f"This may cause issues in-game.") + + part_order = [getattr(NifFormat.BSDismemberBodyPartType, face_map.name, None) for face_map in + b_obj.face_maps] + part_order = [body_part for body_part in part_order if body_part is not None] + # override pyffi n_geom.update_skin_partition with custom one (that allows ordering) + n_geom.update_skin_partition = update_skin_partition.__get__(n_geom) + lostweight = n_geom.update_skin_partition( + maxbonesperpartition=NifOp.props.max_bones_per_partition, + maxbonespervertex=NifOp.props.max_bones_per_vertex, + stripify=NifOp.props.stripify, + stitchstrips=NifOp.props.stitch_strips, + padbones=NifOp.props.pad_bones, + triangles=triangles, + trianglepartmap=bodypartfacemap, + maximize_bone_sharing=(game in ('FALLOUT_3', 'SKYRIM')), + part_sort_order=part_order) + + if lostweight > NifOp.props.epsilon: + NifLog.warn( + f"Lost {lostweight:f} in vertex weights while creating a skin partition for Blender object '{b_obj.name}' (nif block '{n_geom.name}')") def update_bind_position(self, n_geom, n_root, b_obj_armature): """Transfer the Blender bind position to the nif bind position. @@ -728,7 +727,7 @@ def export_texture_effect(self, n_block, b_mat): extra_node.rotation.set_identity() extra_node.scale = 1.0 extra_node.flags = 0x000C # morrowind - # create texture effect block and parent the texture effect and trishape to it + # create texture effect block and parent the texture effect and n_geom to it texeff = self.texture_helper.export_texture_effect(ref_mtex) extra_node.add_child(texeff) extra_node.add_effect(texeff) @@ -761,40 +760,34 @@ def ensure_tri_modifier(self, b_obj): else: b_obj.modifiers.new('Triangulate', 'TRIANGULATE') - def add_defined_tangents(self, trishape, tangents, bitangents, as_extra_data): + def add_defined_tangents(self, n_geom, tangents, bitangents, as_extra_data): # check if size of tangents and bitangents is equal to num_vertices - if not (len(tangents) == len(bitangents) == trishape.data.num_vertices): - raise NifError(f'Number of tangents or bitangents does not agree with number of vertices in {trishape.name}') + if not (len(tangents) == len(bitangents) == n_geom.data.num_vertices): + raise NifError(f'Number of tangents or bitangents does not agree with number of vertices in {n_geom.name}') if as_extra_data: # if tangent space extra data already exists, use it # find possible extra data block - for extra in trishape.get_extra_datas(): + extra_name = b'Tangent space (binormal & tangent vectors)' + for extra in n_geom.get_extra_datas(): if isinstance(extra, NifFormat.NiBinaryExtraData): - if extra.name == b'Tangent space (binormal & tangent vectors)': + if extra.name == extra_name: break else: - extra = None - if not extra: - # otherwise, create a new block and link it + # create a new block and link it extra = NifFormat.NiBinaryExtraData() - extra.name = b'Tangent space (binormal & tangent vectors)' - trishape.add_extra_data(extra) - + extra.name = extra_name + n_geom.add_extra_data(extra) # write the data extra.binary_data = np.concatenate((tangents, bitangents), axis=0).astype(' root collision node (can be mesh or empty) - # self.nif_export.collisionhelper.export_collision(b_obj, n_parent) + # self.export_collision(b_obj, n_parent) # return None # done; stop here self.n_root = None + node_type = bpy.context.scene.niftools_scene.rootnode # there is only one root object so that will be our final root if len(root_objects) == 1: b_obj = root_objects[0] - self.export_node(b_obj, None) + self.export_node(b_obj, None, n_node_type=node_type) # there is more than one root object so we create a meta root else: - NifLog.info("Created meta root because blender scene had multiple root objects") - self.n_root = types.create_ninode() + NifLog.info(f"Created meta root because blender scene had {len(root_objects)} root objects") + self.n_root = types.create_ninode(n_node_type=node_type) self.n_root.name = "Scene Root" for b_obj in root_objects: self.export_node(b_obj, self.n_root) - # TODO [object] How dow we know we are selecting the right node in the case of multi-root? - # making root block a fade node - root_type = b_obj.niftools.rootnode - if bpy.context.scene.niftools_scene.game in ('FALLOUT_3', 'SKYRIM') and root_type == 'BSFadeNode': - NifLog.info("Making root block a BSFadeNode") - fade_root_block = NifFormat.BSFadeNode().deepcopy(self.n_root) - fade_root_block.replace_global_node(self.n_root, fade_root_block) - self.n_root = fade_root_block - # various extra datas object_property = ObjectDataProperty() object_property.export_bsxflags_upb(self.n_root, root_objects) - object_property.export_inventory_marker(self.n_root, root_objects) + object_property.export_inventory_marker(self.n_root, b_obj) object_property.export_weapon_location(self.n_root, b_obj) types.export_furniture_marker(self.n_root, filebase) return self.n_root @@ -147,7 +139,7 @@ def set_node_flags(self, b_obj, n_node): else: n_node.flags = 0x000C # morrowind - def export_node(self, b_obj, n_parent): + def export_node(self, b_obj, n_parent, n_node_type=None): """Export a mesh/armature/empty object b_obj as child of n_parent. Export also all children of b_obj. @@ -190,7 +182,7 @@ def export_node(self, b_obj, n_parent): b_action = False # -> everything else (empty/armature) is a (more or less regular) node - node = types.create_ninode(b_obj) + node = types.create_ninode(b_obj, n_node_type=n_node_type) # set parenting here so that it can be accessed if not self.n_root: self.n_root = node @@ -207,32 +199,31 @@ def export_node(self, b_obj, n_parent): # export object animation self.transform_anim.export_transforms(node, b_obj, b_action) self.object_anim.export_visibility(node, b_action) - # if it is a mesh, export the mesh as trishape children of this ninode + # if it is a mesh, export the mesh as n_geom children of this ninode if b_obj.type == 'MESH': return self.mesh_helper.export_tri_shapes(b_obj, node, self.n_root) # if it is an armature, export the bones as ninode children of this ninode elif b_obj.type == 'ARMATURE': self.armaturehelper.export_bones(b_obj, node) - - # export all children of this b_obj as children of this NiNode - self.export_children(b_obj, node) + # special case: objects parented to armature bones + for b_child in b_obj.children: + # find and attach to the right node + if b_child.parent_bone: + b_obj_bone = b_obj.data.bones[b_child.parent_bone] + # find the correct n_node + # todo [object] this is essentially the same as Mesh.get_bone_block() + n_node = [k for k, v in block_store.block_to_obj.items() if v == b_obj_bone][0] + self.export_node(b_child, n_node) + # just child of the armature itself, so attach to armature root + else: + self.export_node(b_child, node) + else: + # export all children of this empty object as children of this node + for b_child in b_obj.children: + self.export_node(b_child, node) return node - def export_children(self, b_parent, n_parent): - """Export all children of blender object b_parent as children of n_parent.""" - # loop over all obj's children - for b_child in b_parent.children: - temp_parent = n_parent - # special case: objects parented to armature bones - find the nif parent bone - if b_parent.type == 'ARMATURE' and b_child.parent_bone != "": - parent_bone = b_parent.data.bones[b_child.parent_bone] - assert (parent_bone in block_store.block_to_obj.values()) - for temp_parent, obj in block_store.block_to_obj.items(): - if obj == parent_bone: - break - self.export_node(b_child, temp_parent) - def export_collision(self, b_obj, n_parent): """Main function for adding collision object b_obj to a node. Returns True if this object is exported as a collision""" @@ -259,7 +250,7 @@ def export_collision(self, b_obj, n_parent): else: # all nodes failed so add new one node = types.create_ninode(b_obj) - node.name = 'collisiondummy{:d}'.format(n_parent.num_children) + node.name = f'collisiondummy{n_parent.num_children}' if b_obj.niftools.flags != 0: node_flag_hex = hex(b_obj.niftools.flags) else: @@ -273,4 +264,4 @@ def export_collision(self, b_obj, n_parent): else: NifLog.warn(f"Collisions not supported for game '{bpy.context.scene.niftools_scene.game}', skipped collision object '{b_obj.name}'") - return True \ No newline at end of file + return True diff --git a/io_scene_niftools/modules/nif_export/property/object/__init__.py b/io_scene_niftools/modules/nif_export/property/object/__init__.py index 844908b3f..2644a0725 100644 --- a/io_scene_niftools/modules/nif_export/property/object/__init__.py +++ b/io_scene_niftools/modules/nif_export/property/object/__init__.py @@ -220,21 +220,19 @@ def has_collision(): # TODO [object][property] Move to object property @staticmethod - def export_inventory_marker(n_root, root_objects): - if bpy.context.scene.niftools_scene.game in ('SKYRIM',): - for root_object in root_objects: - if root_object.niftools_bs_invmarker: - for extra_item in n_root.extra_data_list: - if isinstance(extra_item, NifFormat.BSInvMarker): - raise NifError("Multiple Items have Inventory marker data only one item may contain this data") - else: - n_extra_list = NifFormat.BSInvMarker() - n_extra_list.name = root_object.niftools_bs_invmarker[0].name.encode() - n_extra_list.rotation_x = (-root_object.niftools_bs_invmarker[0].bs_inv_x % (2 * pi)) * 1000 - n_extra_list.rotation_y = (-root_object.niftools_bs_invmarker[0].bs_inv_y % (2 * pi)) * 1000 - n_extra_list.rotation_z = (-root_object.niftools_bs_invmarker[0].bs_inv_z % (2 * pi)) * 1000 - n_extra_list.zoom = root_object.niftools_bs_invmarker[0].bs_inv_zoom - n_root.add_extra_data(n_extra_list) + def export_inventory_marker(n_root, b_obj): + """Attaches a BSInvMarker to n_root if desired and fill in its values""" + niftools_scene = bpy.context.scene.niftools_scene + bs_inv_store = b_obj.niftools.bs_inv + if niftools_scene.game in ('SKYRIM',) and bs_inv_store: + bs_inv = bs_inv_store[0] + n_bs_inv_marker = NifFormat.BSInvMarker() + n_bs_inv_marker.name = bs_inv.name.encode() + n_bs_inv_marker.rotation_x = (-bs_inv.x % (2 * pi)) * 1000 + n_bs_inv_marker.rotation_y = (-bs_inv.y % (2 * pi)) * 1000 + n_bs_inv_marker.rotation_z = (-bs_inv.z % (2 * pi)) * 1000 + n_bs_inv_marker.zoom = bs_inv.zoom + n_root.add_extra_data(n_bs_inv_marker) # TODO [object][property] Move to new object type def export_weapon_location(self, n_root, root_obj): diff --git a/io_scene_niftools/modules/nif_export/types.py b/io_scene_niftools/modules/nif_export/types.py index 758b18a3a..c62a5a711 100644 --- a/io_scene_niftools/modules/nif_export/types.py +++ b/io_scene_niftools/modules/nif_export/types.py @@ -45,21 +45,22 @@ from io_scene_niftools.utils.singleton import NifOp -def create_ninode(b_obj=None): +def create_ninode(b_obj=None, n_node_type=None): """Essentially a wrapper around create_block() that creates nodes of the right type""" - # when no b_obj is passed, it means we create a root node + # when no b_obj is passed, use the passed n_node_type if not b_obj: - return block_store.create_block("NiNode") - + if n_node_type is None: + n_node_type = "NiNode" # get node type - some are stored as custom property of the b_obj - try: - n_node_type = b_obj["type"] - except KeyError: - n_node_type = "NiNode" - - # ...others by presence of constraints - if has_track(b_obj): - n_node_type = "NiBillboardNode" + else: + try: + n_node_type = b_obj["type"] + except KeyError: + n_node_type = "NiNode" + + # ...others by presence of constraints + if has_track(b_obj): + n_node_type = "NiBillboardNode" # now create the node n_node = block_store.create_block(n_node_type, b_obj) diff --git a/io_scene_niftools/modules/nif_import/animation/__init__.py b/io_scene_niftools/modules/nif_import/animation/__init__.py index 9f0af6d9a..e3fd22246 100644 --- a/io_scene_niftools/modules/nif_import/animation/__init__.py +++ b/io_scene_niftools/modules/nif_import/animation/__init__.py @@ -41,6 +41,7 @@ from pyffi.formats.nif import NifFormat from io_scene_niftools.utils.logging import NifLog +from io_scene_niftools.utils.consts import QUAT, EULER, LOC, SCALE class Animation: @@ -54,6 +55,23 @@ def __init__(self): # and still be able to access existing actions from this run self.actions = {} + @staticmethod + def get_controller_data(ctrl): + """Return data for ctrl, look in interpolator (for newer games) or directly on ctrl""" + if ctrl.interpolator: + data = ctrl.interpolator.data + else: + data = ctrl.data + # these have their data set as a KeyGroup on data + if isinstance(data, (NifFormat.NiBoolData, NifFormat.NiFloatData, NifFormat.NiPosData)): + return data.data + return data + + @staticmethod + def get_keys_values(items): + """Returns list of times and keys for an array 'items' with key elements having 'time' and 'value' attributes""" + return [key.time for key in items], [key.value for key in items] + @staticmethod def show_pose_markers(): """Helper function to ensure that pose markers are shown""" @@ -89,21 +107,21 @@ def create_action(self, b_obj, action_name): b_obj.animation_data.action = b_action return b_action - def create_fcurves(self, action, dtype, drange, flags=None, bonename=None, keyname=None): + def create_fcurves(self, action, dtype, drange, flags, bone_name, key_name): """ Create fcurves in action for desired conditions. """ # armature pose bone animation - if bonename: + if bone_name: fcurves = [ - action.fcurves.new(data_path=f'pose.bones["{bonename}"].{dtype}', index=i, action_group=bonename) + action.fcurves.new(data_path=f'pose.bones["{bone_name}"].{dtype}', index=i, action_group=bone_name) for i in drange] # shapekey pose bone animation - elif keyname: + elif key_name: fcurves = [ - action.fcurves.new(data_path=f'key_blocks["{keyname}"].{dtype}', index=0,) + action.fcurves.new(data_path=f'key_blocks["{key_name}"].{dtype}', index=0,) ] else: # Object animation (non-skeletal) is lumped into the "LocRotScale" action_group - if dtype in ("rotation_euler", "rotation_quaternion", "location", "scale"): + if dtype in (QUAT, EULER, LOC, SCALE): action_group = "LocRotScale" # Non-transformaing animations (eg. visibility or material anims) use no action groups else: @@ -141,13 +159,34 @@ def set_extrapolation(extend_type, fcurves): for fcurve in fcurves: fcurve.extrapolation = 'CONSTANT' - def add_key(self, fcurves, t, key, interp): + def add_keys(self, b_action, key_type, key_range, flags, times, keys, interp, bone_name=None, key_name=None): """ - Add a key (len=n) to a set of fcurves (len=n) at the given frame. Set the key's interpolation to interp. + Create needed fcurves and add a list of keys to an action. """ - frame = round(t * self.fps) - for fcurve, k in zip(fcurves, key): - fcurve.keyframe_points.insert(frame, k).interpolation = interp + samples = [round(t * self.fps) for t in times] + assert len(samples) == len(keys) + # get interpolation enum representation + ipo = bpy.types.Keyframe.bl_rna.properties['interpolation'].enum_items[interp].value + interpolations = [ipo for _ in range(len(samples))] + # import the keys + try: + fcurves = self.create_fcurves(b_action, key_type, key_range, flags, bone_name, key_name) + if len(key_range) == 1: + # flat key - make it zippable + key_per_fcurve = [keys] + else: + key_per_fcurve = zip(*keys) + for fcurve, fcu_keys in zip(fcurves, key_per_fcurve): + # add new points + fcurve.keyframe_points.add(count=len(fcu_keys)) + # populate points with keys for this curve + fcurve.keyframe_points.foreach_set("co", [x for co in zip(samples, fcu_keys) for x in co]) + fcurve.keyframe_points.foreach_set("interpolation", interpolations) + # update + fcurve.update() + except RuntimeError: + # blender throws F-Curve ... already exists in action ... + NifLog.warn(f"Could not add fcurve '{key_type}' to '{b_action.name}', already added before?") # import animation groups def import_text_keys(self, n_block, b_action): @@ -212,3 +251,4 @@ def set_frames_per_second(self, roots): self.fps = fps bpy.context.scene.render.fps = fps bpy.context.scene.frame_set(0) + diff --git a/io_scene_niftools/modules/nif_import/animation/material.py b/io_scene_niftools/modules/nif_import/animation/material.py index 60ec804a5..bb46dcb71 100644 --- a/io_scene_niftools/modules/nif_import/animation/material.py +++ b/io_scene_niftools/modules/nif_import/animation/material.py @@ -44,6 +44,11 @@ from io_scene_niftools.utils.singleton import NifOp from io_scene_niftools.utils.logging import NifLog +# indices for blender ShaderNodeMapping node +LOC_DP = 1 +SCALE_DP = 3 +MAPPING = "ShaderNodeMapping" + class MaterialAnimation(Animation): @@ -59,62 +64,128 @@ def import_material_controllers(self, n_geom, b_material): ("specular_color", NifFormat.TargetColor.TC_SPECULAR)): self.import_material_color_controller(b_material, n_material, b_channel, n_target_color) - self.import_material_uv_controller(b_material, n_geom) + self.import_uv_controller(b_material, n_geom) + self.import_tex_transform_controller(b_material, n_geom) def import_material_alpha_controller(self, b_material, n_material): # find alpha controller - n_alphactrl = math.find_controller(n_material, NifFormat.NiAlphaController) - if not n_alphactrl: + n_ctrl = math.find_controller(n_material, NifFormat.NiAlphaController) + if not n_ctrl: return NifLog.info("Importing alpha controller") b_mat_action = self.create_action(b_material, "MaterialAction") - fcurves = self.create_fcurves(b_mat_action, "niftools.emissive_alpha", range(3), n_alphactrl.flags) - interp = self.get_b_interp_from_n_interp(n_alphactrl.data.data.interpolation) - for key in n_alphactrl.data.data.keys: - self.add_key(fcurves, key.time, (key.value, key.value, key.value), interp) + n_ctrl_data = self.get_controller_data(n_ctrl) + interp = self.get_b_interp_from_n_interp(n_ctrl_data.interpolation) + times, keys = self.get_keys_values(n_ctrl_data.keys) + # key needs to be RGB due to current representation in blender + keys = [(v, v, v) for v in keys] + self.add_keys(b_mat_action, "niftools.emissive_alpha", range(3), n_ctrl.flags, times, keys, interp) def import_material_color_controller(self, b_material, n_material, b_channel, n_target_color): # find material color controller with matching target color - for ctrl in n_material.get_controllers(): - if isinstance(ctrl, NifFormat.NiMaterialColorController): - if ctrl.get_target_color() == n_target_color: - n_matcolor_ctrl = ctrl + for n_ctrl in n_material.get_controllers(): + if isinstance(n_ctrl, NifFormat.NiMaterialColorController): + if n_ctrl.get_target_color() == n_target_color: break else: return NifLog.info(f"Importing material color controller for target color {n_target_color} into blender channel {b_channel}") - - # import data as curves b_mat_action = self.create_action(b_material, "MaterialAction") + n_ctrl_data = self.get_controller_data(n_ctrl) + interp = self.get_b_interp_from_n_interp(n_ctrl_data.interpolation) + times, keys = self.get_keys_values(n_ctrl_data.keys) + self.add_keys(b_mat_action, b_channel, range(3), n_ctrl.flags, times, keys, interp) - fcurves = self.create_fcurves(b_mat_action, b_channel, range(3), n_matcolor_ctrl.flags) - interp = self.get_b_interp_from_n_interp(n_matcolor_ctrl.data.data.interpolation) - for key in n_matcolor_ctrl.data.data.keys: - self.add_key(fcurves, key.time, key.value.as_list(), interp) - - def import_material_uv_controller(self, b_material, n_geom): - """Import UV controller data.""" + def import_uv_controller(self, b_material, n_geom): + """Import UV controller data as a mapping node with animated values.""" # search for the block n_ctrl = math.find_controller(n_geom, NifFormat.NiUVController) if not n_ctrl: return NifLog.info("Importing UV controller") - b_mat_action = self.create_action(b_material, "MaterialAction") + n_ctrl_data = self.get_controller_data(n_ctrl) + if not any(n_uvgroup.keys for n_uvgroup in n_ctrl_data.uv_groups): + return + + b_mat_action, transform = self.insert_mapping_node(b_material) - dtypes = ("offset", 0), ("offset", 1), ("scale", 0), ("scale", 1) + # loc U, loc V, scale U, scale V + dtypes = (LOC_DP, 0), (LOC_DP, 1), (SCALE_DP, 0), (SCALE_DP, 1) for n_uvgroup, (data_path, array_ind) in zip(n_ctrl.data.uv_groups, dtypes): if n_uvgroup.keys: interp = self.get_b_interp_from_n_interp(n_uvgroup.interpolation) - # in blender, UV offset is stored per n_texture slot - # so we have to repeat the import for each used tex slot - for i, texture_slot in enumerate(b_material.texture_slots): - if texture_slot: - fcurves = self.create_fcurves(b_mat_action, f"texture_slots[{i}]." + data_path, (array_ind,), n_ctrl.flags) - for key in n_uvgroup.keys: - if "offset" in data_path: - self.add_key(fcurves, key.time, (-key.value,), interp) - else: - self.add_key(fcurves, key.time, (key.value,), interp) + times, keys = self.get_keys_values(n_uvgroup.keys) + # UV V coordinate is inverted in blender + if 1 == LOC_DP and array_ind == 1: + keys = [-key for key in keys] + self.add_keys(b_mat_action, f'nodes["{transform.name}"].inputs[{data_path}].default_value', (array_ind,), n_ctrl.flags, times, keys, interp) + + def import_tex_transform_controller(self, b_material, n_geom): + """Import UV controller data as a mapping node with animated values.""" + # search for the block + n_tex_prop = math.find_property(n_geom, NifFormat.NiTexturingProperty) + if not n_tex_prop: + return + for n_ctrl in math.controllers_iter(n_tex_prop, NifFormat.NiTextureTransformController): + NifLog.info("Importing Texture Transform controller") + + n_ctrl_data = self.get_controller_data(n_ctrl) + if not n_ctrl_data.keys: + return + # todo [material] get the mapping from enum to node, and standardize texture slot names everywhere + # the whole node logic needs to be refactored to seamlessly integrate this + # get tex slot + tex_slot = n_ctrl.texture_slot + times, keys = self.get_keys_values(n_ctrl_data.keys) + # get operation + operation = n_ctrl.operation + if operation == NifFormat.TexTransform.TT_TRANSLATE_U: + data_path = LOC_DP + array_ind = 0 + elif operation == NifFormat.TexTransform.TT_TRANSLATE_V: + data_path = LOC_DP + array_ind = 1 + # UV V coordinate is inverted in blender + keys = [-key for key in keys] + elif operation == NifFormat.TexTransform.TT_ROTATE: + # not sure, need example nif + NifLog.warn("Rotation in Texture Transform is not supported") + return + elif operation == NifFormat.TexTransform.TT_SCALE_U: + data_path = SCALE_DP + array_ind = 0 + elif operation == NifFormat.TexTransform.TT_SCALE_V: + data_path = SCALE_DP + array_ind = 1 + + # in example nif, no node tree exists, so this doesn't link the transform node + b_mat_action, transform = self.insert_mapping_node(b_material) + + interp = self.get_b_interp_from_n_interp(n_ctrl_data.interpolation) + self.add_keys(b_mat_action, f'nodes["{transform.name}"].inputs[{data_path}].default_value', (array_ind,), n_ctrl.flags, times, keys, interp) + + def insert_mapping_node(self, b_material): + b_mat_action = self.create_action(b_material.node_tree, f"{b_material.name}-MaterialNodesAction") + tree = b_material.node_tree + # reuse mapping node if one had been added before + for node in tree.nodes: + if node.type == "MAPPING": + return b_mat_action, node + transform = tree.nodes.new(MAPPING) + # get previous links + used_links = [] + for link in tree.links: + # get uv nodes + if link.from_node.type == "UVMAP": + used_links.append(link) + # link the node between previous uv node and texture node + for link in used_links: + from_socket = link.from_socket + to_socket = link.to_socket + tree.links.remove(link) + tree.links.new(from_socket, transform.inputs[0]) + tree.links.new(transform.outputs[0], to_socket) + return b_mat_action, transform diff --git a/io_scene_niftools/modules/nif_import/animation/morph.py b/io_scene_niftools/modules/nif_import/animation/morph.py index b61a138d1..59d83e26b 100644 --- a/io_scene_niftools/modules/nif_import/animation/morph.py +++ b/io_scene_niftools/modules/nif_import/animation/morph.py @@ -57,57 +57,55 @@ def __init__(self): def import_morph_controller(self, n_node, b_obj): """Import NiGeomMorpherController as shape keys for blender object.""" - n_morphCtrl = math.find_controller(n_node, NifFormat.NiGeomMorpherController) - if n_morphCtrl: + n_morph_ctrl = math.find_controller(n_node, NifFormat.NiGeomMorpherController) + if n_morph_ctrl: NifLog.debug("NiGeomMorpherController processed") b_mesh = b_obj.data - morphData = n_morphCtrl.data - if morphData.num_morphs: + morph_data = n_morph_ctrl.data + if morph_data.num_morphs: # get name for base key - keyname = morphData.morphs[0].frame_name.decode() - if not keyname: - keyname = 'Base' + morph = morph_data.morphs[0] + key_name = morph.frame_name.decode() + if not key_name: + key_name = 'Base' - # insert base key at frame 1, using relative keys - sk_basis = b_obj.shape_key_add(name=keyname) + # insert base key, using relative keys + sk_basis = b_obj.shape_key_add(name=key_name) # get base vectors and import all morphs - baseverts = morphData.morphs[0].vectors + base_verts = morph.vectors - shape_action = self.create_action(b_obj.data.shape_keys, b_obj.name + "-Morphs") + shape_action = self.create_action(b_obj.data.shape_keys, f"{b_obj.name}-Morphs") - for idxMorph in range(1, morphData.num_morphs): + for morph_i in range(1, morph_data.num_morphs): + morph = morph_data.morphs[morph_i] # get name for key - keyname = morphData.morphs[idxMorph].frame_name.decode() - if not keyname: - keyname = f'Key {idxMorph}' - NifLog.info(f"Inserting key '{keyname}'") + key_name = morph.frame_name.decode() + if not key_name: + key_name = f'Key {morph_i}' + NifLog.info(f"Inserting key '{key_name}'") # get vectors - morph_verts = morphData.morphs[idxMorph].vectors - self.morph_mesh(b_mesh, baseverts, morph_verts) - shape_key = b_obj.shape_key_add(name=keyname, from_mix=False) + morph_verts = morph.vectors + self.morph_mesh(b_mesh, base_verts, morph_verts) + shape_key = b_obj.shape_key_add(name=key_name, from_mix=False) - # first find the keys - # older versions store keys in the morphData - morph_data = morphData.morphs[idxMorph] + # find the keys + # older versions store keys in the morph_data # newer versions store keys in the controller - if not morph_data.keys: + if not morph.keys: try: - if n_morphCtrl.interpolators: - morph_data = n_morphCtrl.interpolators[idxMorph].data.data - elif n_morphCtrl.interpolator_weights: - morph_data = n_morphCtrl.interpolator_weights[idxMorph].interpolator.data.data + if n_morph_ctrl.interpolators: + morph = n_morph_ctrl.interpolators[morph_i].data.data + elif n_morph_ctrl.interpolator_weights: + morph = n_morph_ctrl.interpolator_weights[morph_i].interpolator.data.data except KeyError: - NifLog.info(f"Unsupported interpolator \"{type(n_morphCtrl.interpolator_weights['idxMorph'].interpolator)}\"") + NifLog.info(f"Unsupported interpolator '{type(n_morph_ctrl.interpolator_weights[morph_i].interpolator)}'") continue # get the interpolation mode - interp = self.get_b_interp_from_n_interp( morph_data.interpolation) - fcu = self.create_fcurves(shape_action, "value", (0,), flags=n_morphCtrl.flags, keyname=shape_key.name) - - # set keyframes - for key in morph_data.keys: - self.add_key(fcu, key.time, (key.value,), interp) + interp = self.get_b_interp_from_n_interp(morph.interpolation) + times, keys = self.get_keys_values(morph.keys) + self.add_keys(shape_action, "value", (0,), n_morph_ctrl.flags, times, keys, interp, key_name=shape_key.name) def import_egm_morphs(self, b_obj): """Import all EGM morphs as shape keys for blender object.""" @@ -119,8 +117,6 @@ def import_egm_morphs(self, b_obj): sk_basis = b_obj.shape_key_add(name="Basis") b_mesh.shape_keys.use_relative = False - # TODO: I'm not entirely sure that changing the morphs to f-strings won't - # TODO: break anything. They _shouldn't_. morphs = ([(morph, f"EGM SYM {i}") for i, morph in enumerate(sym_morphs)] + [(morph, f"EGM ASYM {i}") for i, morph in enumerate(asym_morphs)]) @@ -129,8 +125,7 @@ def import_egm_morphs(self, b_obj): # convert tuples into vector here so we can simply add in morph_mesh() for b_v_index, (bv, mv) in enumerate(zip(base_verts, morph_verts)): b_mesh.vertices[b_v_index].co = bv + mathutils.Vector(mv) - # TODO [animation] unused variable is it required - shape_key = b_obj.shape_key_add(name=key_name, from_mix=False) + b_obj.shape_key_add(name=key_name, from_mix=False) def morph_mesh(self, b_mesh, baseverts, morphverts): """Transform a mesh to be in the shape given by morphverts.""" diff --git a/io_scene_niftools/modules/nif_import/animation/object.py b/io_scene_niftools/modules/nif_import/animation/object.py index bafd94c5d..9799180f8 100644 --- a/io_scene_niftools/modules/nif_import/animation/object.py +++ b/io_scene_niftools/modules/nif_import/animation/object.py @@ -53,9 +53,8 @@ def import_visibility(self, n_node, b_obj): if not n_vis_ctrl: return NifLog.info("Importing vis controller") + b_obj_action = self.create_action(b_obj, f"{b_obj.name}-Anim") - b_obj_action = self.create_action(b_obj, b_obj.name + "-Anim") - - fcurves = self.create_fcurves(b_obj_action, "hide", (0,), n_vis_ctrl.flags) - for key in n_vis_ctrl.data.keys: - self.add_key(fcurves, key.time, (key.value,), "CONSTANT") + n_ctrl_data = self.get_controller_data(n_vis_ctrl) + times, keys = self.get_keys_values(n_ctrl_data.keys) + self.add_keys(b_obj_action, "hide_viewport", (0,), n_vis_ctrl.flags, times, keys, "CONSTANT") diff --git a/io_scene_niftools/modules/nif_import/animation/transform.py b/io_scene_niftools/modules/nif_import/animation/transform.py index ce62370e0..2b6eb2b4f 100644 --- a/io_scene_niftools/modules/nif_import/animation/transform.py +++ b/io_scene_niftools/modules/nif_import/animation/transform.py @@ -39,6 +39,7 @@ import bpy import mathutils +import time from functools import singledispatch from bisect import bisect_left @@ -49,6 +50,47 @@ from io_scene_niftools.utils import math from io_scene_niftools.utils.blocks import safe_decode from io_scene_niftools.utils.logging import NifLog +from io_scene_niftools.utils.consts import QUAT, EULER, LOC, SCALE + + +def as_b_quat(n_val): + return mathutils.Quaternion([n_val.w, n_val.x, n_val.y, n_val.z]) + + +def as_b_loc(n_val): + return mathutils.Vector([n_val.x, n_val.y, n_val.z]) + + +def as_b_scale(n_val): + return n_val, n_val, n_val + + +def as_b_euler(n_val): + return mathutils.Euler(n_val) + + +def correct_loc(key, n_bind_rot_inv, n_bind_trans): + return math.import_keymat(n_bind_rot_inv, mathutils.Matrix.Translation(key - n_bind_trans)).to_translation() + + +def correct_quat(key, n_bind_rot_inv, n_bind_trans): + return math.import_keymat(n_bind_rot_inv, key.to_matrix().to_4x4()).to_quaternion() + + +def correct_euler(key, n_bind_rot_inv, n_bind_trans): + return math.import_keymat(n_bind_rot_inv, key.to_matrix().to_4x4()).to_euler() + + +def correct_scale(key, n_bind_rot_inv, n_bind_trans): + return key + + +key_lut = { + QUAT: (as_b_quat, correct_quat, 4), + EULER: (as_b_euler, correct_euler, 3), + LOC: (as_b_loc, correct_loc, 3), + SCALE: (as_b_scale, correct_scale, 3), +} def interpolate(x_out, x_in, y_in): @@ -187,128 +229,94 @@ def import_keyframe_controller(self, n_kfc, b_armature, b_target, b_action_name) return NifLog.debug(f'Importing keyframe controller for {b_target.name}') - translations = [] - scales = [] - rotations = [] - eulers = [] n_kfd = None + # fallout, Loki - we set extrapolation according to the root NiControllerSequence.cycle_type + flags = None + n_bind_rot_inv = n_bind_trans = None + + # create or get the action + if b_armature and isinstance(b_target, bpy.types.PoseBone): + # action on armature, one per armature + b_action = self.create_action(b_armature, b_action_name) + if b_target.name in self.bind_data: + n_bind_rot_inv, n_bind_trans = self.bind_data[b_target.name] + bone_name = b_target.name + else: + # one action per object + b_action = self.create_action(b_target, f"{b_action_name}_{b_target.name}") + bone_name = None - # transform controllers (dartgun.nif) - if isinstance(n_kfc, NifFormat.NiTransformController): - if n_kfc.interpolator: - n_kfd = n_kfc.interpolator.data # B-spline curve import - elif isinstance(n_kfc, NifFormat.NiBSplineInterpolator): - # used by WLP2 (tiger.kf), but only for non-LocRotScale data - # eg. bone stretching - see controlledblock.get_variable_1() - # do not support this for now, no good representation in Blender + if isinstance(n_kfc, NifFormat.NiBSplineInterpolator): + # Bsplines are Bezier curves + interp = "BEZIER" if isinstance(n_kfc, NifFormat.NiBSplineCompFloatInterpolator): + # used by WLP2 (tiger.kf), but only for non-LocRotScale data + # eg. bone stretching - see controlledblock.get_variable_1() + # do not support this for now, no good representation in Blender # pyffi lacks support for this, but the following gets float keys # keys = list(kfc._getCompKeys(kfc.offset, 1, kfc.bias, kfc.multiplier)) return times = list(n_kfc.get_times()) - # just do these temp steps to avoid generating empty fcurves down the line - trans_temp = [mathutils.Vector(tup) for tup in n_kfc.get_translations()] - if trans_temp: - translations = zip(times, trans_temp) - rot_temp = [mathutils.Quaternion(tup) for tup in n_kfc.get_rotations()] - if rot_temp: - rotations = zip(times, rot_temp) - scale_temp = list(n_kfc.get_scales()) - if scale_temp: - scales = zip(times, scale_temp) - # Bsplines are Bezier curves - interp_rot = interp_loc = interp_scale = "BEZIER" + keys = list(n_kfc.get_translations()) + self.import_keys(LOC, b_action, bone_name, times, keys, flags, interp, n_bind_rot_inv, n_bind_trans) + keys = list(n_kfc.get_rotations()) + self.import_keys(QUAT, b_action, bone_name, times, keys, flags, interp, n_bind_rot_inv, n_bind_trans) + keys = list(n_kfc.get_scales()) + self.import_keys(SCALE, b_action, bone_name, times, keys, flags, interp, n_bind_rot_inv, n_bind_trans) elif isinstance(n_kfc, NifFormat.NiMultiTargetTransformController): # not sure what this is used for return - else: - # ZT2 & Fallout - n_kfd = n_kfc.data + n_kfd = self.get_controller_data(n_kfc) + # ZT2 - get extrapolation for every kfc + if isinstance(n_kfc, NifFormat.NiKeyframeController): + flags = n_kfc.flags if isinstance(n_kfd, NifFormat.NiKeyframeData): - interp_rot = self.get_b_interp_from_n_interp(n_kfd.rotation_type) - interp_loc = self.get_b_interp_from_n_interp(n_kfd.translations.interpolation) - interp_scale = self.get_b_interp_from_n_interp(n_kfd.scales.interpolation) if n_kfd.rotation_type == 4: b_target.rotation_mode = "XYZ" - # uses xyz rotation - if n_kfd.xyz_rotations[0].keys: - # euler keys need not be sampled at the same time in KFs - # but we need complete key sets to do the space conversion - # so perform linear interpolation to import all keys properly - - # get all the keys' times - times_x = [key.time for key in n_kfd.xyz_rotations[0].keys] - times_y = [key.time for key in n_kfd.xyz_rotations[1].keys] - times_z = [key.time for key in n_kfd.xyz_rotations[2].keys] - # the unique time stamps we have to sample all curves at - times_all = sorted(set(times_x + times_y + times_z)) - # the actual resampling - x_r = interpolate(times_all, times_x, [key.value for key in n_kfd.xyz_rotations[0].keys]) - y_r = interpolate(times_all, times_y, [key.value for key in n_kfd.xyz_rotations[1].keys]) - z_r = interpolate(times_all, times_z, [key.value for key in n_kfd.xyz_rotations[2].keys]) - eulers = zip(times_all, zip(x_r, y_r, z_r)) + # euler keys need not be sampled at the same time in KFs + # but we need complete key sets to do the space conversion + # so perform linear interpolation to import all keys properly + + # get all the times and keys for each coordinate + times_keys = [self.get_keys_values(euler.keys) for euler in n_kfd.xyz_rotations] + # the unique time stamps we have to sample all curves at + times_all = sorted(set(times_keys[0][0] + times_keys[1][0] + times_keys[2][0])) + # todo - this assumes that all three channels are keyframed, but it seems like this need not be the case + # resample each coordinate for all times + keys_res = [interpolate(times_all, times, keys) for times, keys in times_keys] + # for eulers, the actual interpolation type is apparently stored per channel + interp = self.get_b_interp_from_n_interp(n_kfd.xyz_rotations[0].interpolation) + self.import_keys(EULER, b_action, bone_name, times_all, zip(*keys_res), flags, interp, n_bind_rot_inv, n_bind_trans) else: b_target.rotation_mode = "QUATERNION" - rotations = [(key.time, key.value) for key in n_kfd.quaternion_keys] + times, keys = self.get_keys_values(n_kfd.quaternion_keys) + interp = self.get_b_interp_from_n_interp(n_kfd.rotation_type) + self.import_keys(QUAT, b_action, bone_name, times, keys, flags, interp, n_bind_rot_inv, n_bind_trans) + times, keys = self.get_keys_values(n_kfd.scales.keys) + interp = self.get_b_interp_from_n_interp(n_kfd.scales.interpolation) + self.import_keys(SCALE, b_action, bone_name, times, keys, flags, interp, n_bind_rot_inv, n_bind_trans) - if n_kfd.scales.keys: - scales = [(key.time, key.value) for key in n_kfd.scales.keys] - - if n_kfd.translations.keys: - translations = [(key.time, key.value) for key in n_kfd.translations.keys] - - # ZT2 - get extrapolation for every kfc - if isinstance(n_kfc, NifFormat.NiKeyframeController): - flags = n_kfc.flags - # fallout, Loki - we set extrapolation according to the root NiControllerSequence.cycle_type - else: - flags = None + times, keys = self.get_keys_values(n_kfd.translations.keys) + interp = self.get_b_interp_from_n_interp(n_kfd.translations.interpolation) + self.import_keys(LOC, b_action, bone_name, times, keys, flags, interp, n_bind_rot_inv, n_bind_trans) - # create or get the action - if b_armature and isinstance(b_target, bpy.types.PoseBone): - # action on armature, one per armature - b_action = self.create_action(b_armature, b_action_name) - if b_target.name in self.bind_data: - n_bind_rot_inv, n_bind_trans = self.bind_data[b_target.name] - bone_name = b_target.name - else: - # one action per object - b_action = self.create_action(b_target, f"{b_action_name}_{b_target.name}") - bone_name = None - - if eulers: - NifLog.debug('Rotation keys..(euler)') - fcurves = self.create_fcurves(b_action, "rotation_euler", range(3), flags, bone_name) - for t, val in eulers: - key = mathutils.Euler(val) - if bone_name: - key = math.import_keymat(n_bind_rot_inv, key.to_matrix().to_4x4()).to_euler() - self.add_key(fcurves, t, key, interp_rot) - elif rotations: - NifLog.debug('Rotation keys...(quaternions)') - fcurves = self.create_fcurves(b_action, "rotation_quaternion", range(4), flags, bone_name) - for t, val in rotations: - key = mathutils.Quaternion([val.w, val.x, val.y, val.z]) - if bone_name: - key = math.import_keymat(n_bind_rot_inv, key.to_matrix().to_4x4()).to_quaternion() - self.add_key(fcurves, t, key, interp_rot) - if translations: - NifLog.debug('Translation keys...') - fcurves = self.create_fcurves(b_action, "location", range(3), flags, bone_name) - for t, val in translations: - key = mathutils.Vector([val.x, val.y, val.z]) - if bone_name: - key = math.import_keymat(n_bind_rot_inv, mathutils.Matrix.Translation(key - n_bind_trans)).to_translation() - self.add_key(fcurves, t, key, interp_loc) - if scales: - NifLog.debug('Scale keys...') - fcurves = self.create_fcurves(b_action, "scale", range(3), flags, bone_name) - for t, val in scales: - key = (val, val, val) - self.add_key(fcurves, t, key, interp_scale) return b_action + def import_keys(self, key_type, b_action, bone_name, times, keys, flags, interp, n_bind_rot_inv, n_bind_trans): + """Imports key frames according to the specified key_type""" + if not keys: + return + # look up conventions by key type + key_func, key_corrector, key_dim = key_lut[key_type] + NifLog.debug(f'{key_type} keys...') + # convert nif keys to proper key type for blender + keys = [key_func(val) for val in keys] + # correct for bone space if target is an armature bone + if bone_name: + keys = [key_corrector(key, n_bind_rot_inv, n_bind_trans) for key in keys] + self.add_keys(b_action, key_type, range(key_dim), flags, times, keys, interp, bone_name=bone_name) + def import_transforms(self, n_block, b_obj, bone_name=None): """Loads an animation attached to a nif block.""" # find keyframe controller diff --git a/io_scene_niftools/modules/nif_import/object/types.py b/io_scene_niftools/modules/nif_import/object/types.py index 43ebd53e9..4eb645040 100644 --- a/io_scene_niftools/modules/nif_import/object/types.py +++ b/io_scene_niftools/modules/nif_import/object/types.py @@ -47,16 +47,14 @@ class NiTypes: @staticmethod def import_root_collision(n_node, b_obj): - """ Import a RootCollisionNode """ + """ Import a RootCollisionNode, which is usually attached to a root node and holds a NiTriShape""" if isinstance(n_node, NifFormat.RootCollisionNode): b_obj["type"] = "RootCollisionNode" b_obj.name = "RootCollisionNode" - b_obj.display_type = 'BOUNDS' - b_obj.show_wire = True - b_obj.display_bounds_type = 'BOX' - # b_obj.game.use_collision_bounds = True - # b_obj.game.collision_bounds_type = 'TRIANGLE_MESH' b_obj.niftools.flags = n_node.flags + for b_child in b_obj.children: + b_child.display_type = 'WIRE' + @staticmethod def import_range_lod_data(n_node, b_obj, b_children): @@ -97,6 +95,4 @@ def import_billboard(n_node, b_obj): def import_empty(n_block): """Creates and returns a grouping empty.""" b_empty = Object.create_b_obj(n_block, None) - # TODO [flags] Move out to generic processing - b_empty.niftools.flags = n_block.flags return b_empty diff --git a/io_scene_niftools/modules/nif_import/property/geometry/mesh.py b/io_scene_niftools/modules/nif_import/property/geometry/mesh.py index 2f5bf9d02..dfc2008e3 100644 --- a/io_scene_niftools/modules/nif_import/property/geometry/mesh.py +++ b/io_scene_niftools/modules/nif_import/property/geometry/mesh.py @@ -47,6 +47,7 @@ from io_scene_niftools.modules.nif_import.property.shader.bsshaderlightingproperty import BSShaderLightingPropertyProcessor from io_scene_niftools.modules.nif_import.property.shader.bsshaderproperty import BSShaderPropertyProcessor from io_scene_niftools.utils.logging import NifLog +from io_scene_niftools.utils.blocks import safe_decode class MeshPropertyProcessor: @@ -78,7 +79,7 @@ def process_property_list(self, n_block, b_obj): # just to avoid duped materials, a first pass, make sure a named material is created or retrieved for prop in props: if prop.name: - name = prop.name.decode() + name = safe_decode(prop.name) if name and name in bpy.data.materials: b_mat = bpy.data.materials[name] NifLog.debug(f"Retrieved already imported material {b_mat.name} from name {name}") @@ -88,7 +89,7 @@ def process_property_list(self, n_block, b_obj): break else: # bs shaders often have no name, so generate one from mesh name - name = n_block.name.decode() + "_nt_mat" + name = safe_decode(n_block.name) + "_nt_mat" b_mat = bpy.data.materials.new(name) NifLog.debug(f"Created material {name} to store properties in {b_mat.name}") diff --git a/io_scene_niftools/modules/nif_import/property/nodes_wrapper/__init__.py b/io_scene_niftools/modules/nif_import/property/nodes_wrapper/__init__.py index 1e10ac2ec..094a1589f 100644 --- a/io_scene_niftools/modules/nif_import/property/nodes_wrapper/__init__.py +++ b/io_scene_niftools/modules/nif_import/property/nodes_wrapper/__init__.py @@ -69,6 +69,10 @@ def __init__(self): self.diffuse_texture = None self.vcol = None + @staticmethod + def uv_node_name(uv_index): + return f"TexCoordIndex_{uv_index}" + def set_uv_map(self, b_texture_node, uv_index=0, reflective=False): """Attaches a vector node describing the desired coordinate transforms to the texture node's UV input.""" if reflective: @@ -76,7 +80,7 @@ def set_uv_map(self, b_texture_node, uv_index=0, reflective=False): self.tree.links.new(uv.outputs[6], b_texture_node.inputs[0]) # use supplied UV maps for everything else, if present else: - uv_name = "TexCoordIndex" + str(uv_index) + uv_name = self.uv_node_name(uv_index) existing_node = self.tree.nodes.get(uv_name) if not existing_node: uv = self.tree.nodes.new('ShaderNodeUVMap') @@ -85,53 +89,34 @@ def set_uv_map(self, b_texture_node, uv_index=0, reflective=False): else: uv = existing_node self.tree.links.new(uv.outputs[0], b_texture_node.inputs[0]) - # todo [texture/anim] if present in nifs, support it and move to anim sys - # if tex_transform or tex_anim: - # transform = tree.nodes.new('ShaderNodeMapping') - # # todo [texture] negate V coordinate - # if tex_transform: - # matrix_4x4 = mathutils.Matrix(tex_transform) - # transform.scale = matrix_4x4.to_scale() - # transform.rotation = matrix_4x4.to_euler() - # transform.translation = matrix_4x4.to_translation() - # transform.name = "TextureTransform" + str(i) - # if tex_anim: - # for j, dtype in enumerate(("offsetu", "offsetv")): - # for key in tex_anim[dtype]: - # transform.translation[j] = key[1] - # # note that since we are dealing with UV coordinates, V has to be negated - # if j == 1: transform.translation[j] *= -1 - # transform.keyframe_insert("translation", index=j, frame=int(key[0] * fps)) - # tree.links.new(uv.outputs[0], transform.inputs[0]) - # tree.links.new(transform.outputs[0], tex.inputs[0]) def global_uv_offset_scale(self, x_scale, y_scale, x_offset, y_offset, clamp_x, clamp_y): # get all uv nodes (by name, since we are importing they have the predefined name # and then we don't have to loop through every node uv_nodes = {} - i = 0 + uv_index = 0 while True: - uv_name = "TexCoordIndex" + str(i) + uv_name = self.uv_node_name(uv_index) uv_node = self.tree.nodes.get(uv_name) if uv_node and isinstance(uv_node, bpy.types.ShaderNodeUVMap): - uv_nodes[uv_name] = uv_node - i += 1 + uv_nodes[uv_index] = uv_node + uv_index += 1 else: break clip_texture = clamp_x and clamp_y - for uv_name, uv_node in uv_nodes.items(): + for uv_index, uv_node in uv_nodes.items(): # for each of those, create a new uv output node and relink split_node = self.tree.nodes.new("ShaderNodeSeparateXYZ") - split_node.name = "Separate UV" + uv_name[-1] + split_node.name = f"Separate UV{uv_index}" split_node.label = split_node.name combine_node = self.tree.nodes.new("ShaderNodeCombineXYZ") - combine_node.name = "Combine UV" + uv_name[-1] + combine_node.name = f"Combine UV{uv_index}" combine_node.label = combine_node.name x_node = self.tree.nodes.new("ShaderNodeMath") - x_node.name = "X offset and scale UV" + uv_name[-1] + x_node.name = f"X offset and scale UV{uv_index}" x_node.label = x_node.name x_node.operation = 'MULTIPLY_ADD' # only clamp on the math node when we're not clamping on both directions @@ -143,7 +128,7 @@ def global_uv_offset_scale(self, x_scale, y_scale, x_offset, y_offset, clamp_x, self.tree.links.new(x_node.outputs[0], combine_node.inputs[0]) y_node = self.tree.nodes.new("ShaderNodeMath") - y_node.name = "Y offset and scale UV" + uv_name[-1] + y_node.name = f"Y offset and scale UV{uv_index}" y_node.label = y_node.name y_node.operation = 'MULTIPLY_ADD' y_node.use_clamp = clamp_y and not clip_texture diff --git a/io_scene_niftools/modules/nif_import/property/object/__init__.py b/io_scene_niftools/modules/nif_import/property/object/__init__.py index 21b486f1c..a8adfa59e 100644 --- a/io_scene_niftools/modules/nif_import/property/object/__init__.py +++ b/io_scene_niftools/modules/nif_import/property/object/__init__.py @@ -42,17 +42,18 @@ from io_scene_niftools.properties.object import PRN_DICT from math import pi +from io_scene_niftools.utils.blocks import safe_decode + class ObjectProperty: # TODO [property] Add delegate processing def import_extra_datas(self, root_block, b_obj): """ Only to be called on nif and blender root objects! """ + niftools_scene = bpy.context.scene.niftools_scene # store type of root node if isinstance(root_block, NifFormat.BSFadeNode): - b_obj.niftools.rootnode = 'BSFadeNode' - else: - b_obj.niftools.rootnode = 'NiNode' + niftools_scene.rootnode = 'BSFadeNode' # store its flags b_obj.niftools.flags = root_block.flags # store extra datas @@ -83,9 +84,9 @@ def import_extra_datas(self, root_block, b_obj): elif isinstance(n_extra, NifFormat.BSXFlags): b_obj.niftools.bsxflags = n_extra.integer_data elif isinstance(n_extra, NifFormat.BSInvMarker): - b_obj.niftools_bs_invmarker.add() - b_obj.niftools_bs_invmarker[0].name = n_extra.name.decode() - b_obj.niftools_bs_invmarker[0].bs_inv_x = (-n_extra.rotation_x / 1000) % (2 * pi) - b_obj.niftools_bs_invmarker[0].bs_inv_y = (-n_extra.rotation_y / 1000) % (2 * pi) - b_obj.niftools_bs_invmarker[0].bs_inv_z = (-n_extra.rotation_z / 1000) % (2 * pi) - b_obj.niftools_bs_invmarker[0].bs_inv_zoom = n_extra.zoom + bs_inv_item = b_obj.niftools.bs_inv.add() + bs_inv_item.name = safe_decode(n_extra.name) + bs_inv_item.x = (-n_extra.rotation_x / 1000) % (2 * pi) + bs_inv_item.y = (-n_extra.rotation_y / 1000) % (2 * pi) + bs_inv_item.z = (-n_extra.rotation_z / 1000) % (2 * pi) + bs_inv_item.zoom = n_extra.zoom diff --git a/io_scene_niftools/modules/nif_import/property/texture/loader.py b/io_scene_niftools/modules/nif_import/property/texture/loader.py index f395e8708..5ef8d1302 100644 --- a/io_scene_niftools/modules/nif_import/property/texture/loader.py +++ b/io_scene_niftools/modules/nif_import/property/texture/loader.py @@ -36,16 +36,16 @@ # POSSIBILITY OF SUCH DAMAGE. # # ***** END LICENSE BLOCK ***** - from functools import reduce import operator +import traceback import os.path import bpy from pyffi.formats.dds import DdsFormat from pyffi.formats.nif import NifFormat -from io_scene_niftools.modules.nif_import.property import texture +from io_scene_niftools.utils.blocks import safe_decode from io_scene_niftools.utils.singleton import NifOp from io_scene_niftools.utils.logging import NifLog @@ -61,34 +61,36 @@ def get_pixeldata_stream_overide(self): elif isinstance(self, NifFormat.NiPixelData): if self.pixel_data: # used in older nif versions - return bytearray().join( - bytearray().join([bytearray([x]) for x in tex]) - for tex in self.pixel_data) + return bytearray(x for tex in self.pixel_data for x in tex) else: # used in newer nif versions return ''.join(self.pixel_data_matrix) else: - raise ValueError( - "cannot retrieve pixel data when saving pixel format %i as DDS") + raise ValueError(f"cannot retrieve pixel data when saving pixel format {self.pixel_format} as DDS") + + def save_as_dds_override(self, stream): data = DdsFormat.Data() header = data.header pixeldata = data.pixeldata + header.flags.caps = 1 + header.flags.height = 1 + header.flags.width = 1 + header.flags.pixel_format = 1 + header.flags.mipmap_count = 1 + header.mipmap_count = len(self.mipmaps) + header.height = self.mipmaps[0].height + header.width = self.mipmaps[0].width + header.caps_1.complex = 1 + header.caps_1.texture = 1 + header.caps_1.mipmap = 1 # create header, depending on the format if self.pixel_format in (NifFormat.PixelFormat.PX_FMT_RGB8, NifFormat.PixelFormat.PX_FMT_RGBA8): # uncompressed RGB(A) - header.flags.caps = 1 - header.flags.height = 1 - header.flags.width = 1 - header.flags.pixel_format = 1 - header.flags.mipmap_count = 1 header.flags.linear_size = 1 - header.height = self.mipmaps[0].height - header.width = self.mipmaps[0].width header.linear_size = len(self.pixel_data) - header.mipmap_count = len(self.mipmaps) header.pixel_format.flags.rgb = 1 header.pixel_format.bit_count = self.bits_per_pixel if not self.channels: @@ -109,62 +111,32 @@ def save_as_dds_override(self, stream): elif channel.type == NifFormat.ChannelType.CHNL_ALPHA: header.pixel_format.a_mask = mask bit_pos += channel.bits_per_channel - header.caps_1.complex = 1 - header.caps_1.texture = 1 - header.caps_1.mipmap = 1 - pixeldata.set_value(self.__get_pixeldata_stream()) - elif self.pixel_format == NifFormat.PixelFormat.PX_FMT_DXT1: - # format used in Megami Tensei: Imagine and Bully SE - header.flags.caps = 1 - header.flags.height = 1 - header.flags.width = 1 - header.flags.pixel_format = 1 - header.flags.mipmap_count = 1 - header.flags.linear_size = 0 - header.height = self.mipmaps[0].height - header.width = self.mipmaps[0].width - header.linear_size = 0 - header.mipmap_count = len(self.mipmaps) - header.pixel_format.flags.four_c_c = 1 - header.pixel_format.four_c_c = DdsFormat.FourCC.DXT1 - header.pixel_format.bit_count = 0 - header.pixel_format.r_mask = 0 - header.pixel_format.g_mask = 0 - header.pixel_format.b_mask = 0 - header.pixel_format.a_mask = 0 - header.caps_1.complex = 1 - header.caps_1.texture = 1 - header.caps_1.mipmap = 1 - pixeldata.set_value(self.__get_pixeldata_stream()) - elif self.pixel_format in (NifFormat.PixelFormat.PX_FMT_DXT5, + elif self.pixel_format in (NifFormat.PixelFormat.PX_FMT_DXT1, + NifFormat.PixelFormat.PX_FMT_DXT5, NifFormat.PixelFormat.PX_FMT_DXT5_ALT): - # format used in Megami Tensei: Imagine - header.flags.caps = 1 - header.flags.height = 1 - header.flags.width = 1 - header.flags.pixel_format = 1 - header.flags.mipmap_count = 1 + # format used in Megami Tensei: Imagine and Bully SE + header.flags.linear_size = 0 - header.height = self.mipmaps[0].height - header.width = self.mipmaps[0].width header.linear_size = 0 - header.mipmap_count = len(self.mipmaps) header.pixel_format.flags.four_c_c = 1 - header.pixel_format.four_c_c = DdsFormat.FourCC.DXT5 + if self.pixel_format in (NifFormat.PixelFormat.PX_FMT_DXT1,): + header.pixel_format.four_c_c = DdsFormat.FourCC.DXT1 + if self.pixel_format in (NifFormat.PixelFormat.PX_FMT_DXT5, + NifFormat.PixelFormat.PX_FMT_DXT5_ALT): + header.pixel_format.four_c_c = DdsFormat.FourCC.DXT5 header.pixel_format.bit_count = 0 header.pixel_format.r_mask = 0 header.pixel_format.g_mask = 0 header.pixel_format.b_mask = 0 header.pixel_format.a_mask = 0 - header.caps_1.complex = 1 - header.caps_1.texture = 1 - header.caps_1.mipmap = 1 - pixeldata.set_value(self.__get_pixeldata_stream()) else: - raise ValueError( - "cannot save pixel format %i as DDS" % self.pixel_format) + raise ValueError(f"cannot save pixel format {self.pixel_format} as DDS") + # pyffi pixeldata can complain about a too long value for perfectly fine data + pixeldata.set_value(b"") data.write(stream) + # so just dump the bytes directly + stream.write(self.__get_pixeldata_stream()) NifFormat.ATextureRenderData.__get_pixeldata_stream = get_pixeldata_stream_overide @@ -174,6 +146,8 @@ def save_as_dds_override(self, stream): class TextureLoader: + external_textures = set() + @staticmethod def load_image(tex_path): """Returns an image or a generated image if none was found""" @@ -184,7 +158,7 @@ def load_image(tex_path): except: NifLog.warn(f"Texture '{name}' not found or not supported and no alternate available") b_image = bpy.data.images.new(name=name, width=1, height=1, alpha=True) - b_image.filepath=tex_path + b_image.filepath = tex_path else: b_image = bpy.data.images[name] return b_image @@ -204,30 +178,38 @@ def import_texture_source(self, source): return self.import_external_source(source) def import_embedded_texture_source(self, source): + # first try to use the actual file name of this NiSourceTexture + tex_name = safe_decode(source.file_name) + tex_path = os.path.join(os.path.dirname(NifOp.props.filepath), tex_name) + # not set, then use generated sequence name + if not tex_name: + tex_path = self.generate_image_name() - fn, tex = self.generate_image_name() - - # save embedded texture as dds file - with open(tex, "wb") as stream: - try: - NifLog.info(f"Saving embedded texture as {tex}") - source.pixel_data.save_as_dds(stream) - except ValueError: - NifLog.warn(f"Pixel format not supported in embedded texture {tex}!") + # only save them once per run, obviously only useful if file_name was set + if tex_path not in self.external_textures: + # save embedded texture as dds file + with open(tex_path, "wb") as stream: + try: + NifLog.info(f"Saving embedded texture as {tex_path}") + source.pixel_data.save_as_dds(stream) + except ValueError: + NifLog.warn(f"Pixel format not supported in embedded texture {tex_path}!") + traceback.print_exc() + self.external_textures.add(tex_path) - return self.load_image(tex) + return self.load_image(tex_path) @staticmethod def generate_image_name(): """Find a file name (but avoid overwriting)""" n = 0 - while n < 1000: - fn = "image{:0>3d}.dds".format(n) + while n < 10000: + fn = f"image{n:0>4d}.dds" tex = os.path.join(os.path.dirname(NifOp.props.filepath), fn) if not os.path.exists(tex): break n += 1 - return fn, tex + return tex def import_external_source(self, source): # the texture uses an external image file diff --git a/io_scene_niftools/nif_import.py b/io_scene_niftools/nif_import.py index 1e0fca566..9a47dd0be 100644 --- a/io_scene_niftools/nif_import.py +++ b/io_scene_niftools/nif_import.py @@ -223,11 +223,10 @@ def import_branch(self, n_block, b_armature=None): else: # this is a fallback for a weird bug, when a node is child of a NiLodNode in a skeletal nif b_obj = self.objecthelper.create_b_obj(n_block, None, name=n_name) - b_obj.niftools.flags = n_block.flags - else: # import as an empty b_obj = NiTypes.import_empty(n_block) + b_obj.niftools.flags = n_block.flags # find children b_children = [] diff --git a/io_scene_niftools/operators/geometry.py b/io_scene_niftools/operators/geometry.py index d4f26c9b9..7ae69f19e 100644 --- a/io_scene_niftools/operators/geometry.py +++ b/io_scene_niftools/operators/geometry.py @@ -45,31 +45,31 @@ class BsInvMarkerAdd(Operator): """Adds BsInvMarker set""" - bl_idname = "object.niftools_bs_invmarker_add" + bl_idname = "object.bs_inv_marker_add" bl_label = "Add Inventory Marker" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): - obj = context.active_object - b_obj_invmarker = obj.niftools_bs_invmarker.add() - b_obj_invmarker.name = "INV" - b_obj_invmarker.bs_inv_x = 0 - b_obj_invmarker.bs_inv_y = 0 - b_obj_invmarker.bs_inv_z = 0 - b_obj_invmarker.bs_inv_zoom = 1 + bs_inv = context.object.niftools.bs_inv + bs_inv_item = bs_inv.add() + bs_inv_item.name = "INV" + bs_inv_item.bs_inv_x = 0 + bs_inv_item.bs_inv_y = 0 + bs_inv_item.bs_inv_z = 0 + bs_inv_item.bs_inv_zoom = 1 return {'FINISHED'} class BsInvMarkerRemove(bpy.types.Operator): """Removes BsInvMarker set""" - bl_idname = "object.niftools_bs_invmarker_remove" + bl_idname = "object.bs_inv_marker_remove" bl_label = "Remove Inventory Marker" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): - item = len(context.active_object.niftools_bs_invmarker) - 1 - obj = context.active_object - obj.niftools_bs_invmarker.remove(item) + bs_inv = context.object.niftools.bs_inv + item = len(bs_inv) - 1 + bs_inv.remove(item) return {'FINISHED'} diff --git a/io_scene_niftools/properties/object.py b/io_scene_niftools/properties/object.py index a35b09a17..dd4a651a2 100644 --- a/io_scene_niftools/properties/object.py +++ b/io_scene_niftools/properties/object.py @@ -97,17 +97,42 @@ class ExtraDataStore(PropertyGroup): ) -class ObjectProperty(PropertyGroup): +class BsInventoryMarker(PropertyGroup): + name: StringProperty( + name="", + default='INV' + ) - rootnode: EnumProperty( - name='Nif Root Node', - description='Type of property used to display meshes', - items=( - ('NiNode', 'NiNode', "", 0), - ('BSFadeNode', 'BSFadeNode', "", 1)), - default='NiNode', + x: FloatProperty( + name="X Rotation", + description="Rotation of object in inventory around the x axis", + default=0, + subtype="ANGLE" + ) + + y: FloatProperty( + name="Y Rotation", + description="Rotation of object in inventory around the y axis", + default=0, + subtype="ANGLE" ) + z: FloatProperty( + name="Z Rotation", + description="Rotation of object in inventory around the z axis", + default=0, + subtype="ANGLE" + ) + + zoom: FloatProperty( + name="Zoom", + description="Inventory object Zoom level", + default=1 + ) + + +class ObjectProperty(PropertyGroup): + prn_location: EnumProperty( name='Weapon Location', description='Attachment point of weapon, for Skyrim, FO3 & Oblivion', @@ -155,47 +180,14 @@ class ObjectProperty(PropertyGroup): description="The bone that acts as the root of the SkinInstance", ) - -class BsInventoryMarker(PropertyGroup): - - name: StringProperty( - name="", - default='INV' - ) - - bs_inv_x: FloatProperty( - name="Inv X value", - description="Rotation of object in inventory around the x axis", - default=0, - subtype = "ANGLE" - ) - - bs_inv_y: FloatProperty( - name="Inv Y value", - description="Rotation of object in inventory around the y axis", - default=0, - subtype = "ANGLE" - ) - - bs_inv_z: FloatProperty( - name="Inv Z value", - description="Rotation of object in inventory around the z axis", - default=0, - subtype = "ANGLE" - ) - - bs_inv_zoom: FloatProperty( - name="Inv Zoom Value", - description="Inventory object Zoom level", - default=1 - ) + bs_inv: bpy.props.CollectionProperty(type=BsInventoryMarker) CLASSES = [ + BsInventoryMarker, ExtraData, ExtraDataStore, ObjectProperty, - BsInventoryMarker ] @@ -203,12 +195,10 @@ def register(): register_classes(CLASSES, __name__) bpy.types.Object.niftools = bpy.props.PointerProperty(type=ObjectProperty) - bpy.types.Object.niftools_bs_invmarker = bpy.props.CollectionProperty(type=BsInventoryMarker) def unregister(): del bpy.types.Object.niftools - del bpy.types.Object.niftools_bs_invmarker unregister_classes(CLASSES, __name__) diff --git a/io_scene_niftools/properties/scene.py b/io_scene_niftools/properties/scene.py index 3b0c5fbdd..686946932 100644 --- a/io_scene_niftools/properties/scene.py +++ b/io_scene_niftools/properties/scene.py @@ -39,7 +39,7 @@ import bpy -from bpy.props import PointerProperty, IntProperty +from bpy.props import PointerProperty, IntProperty, EnumProperty, StringProperty, FloatProperty, CollectionProperty from bpy.types import PropertyGroup from pyffi.formats.nif import NifFormat @@ -63,7 +63,6 @@ def update_version_from_game(self, context): class Scene(PropertyGroup): - nif_version: IntProperty( name='Version', description="The Gamebryo Engine version used", @@ -94,6 +93,15 @@ class Scene(PropertyGroup): default='NONE', update=update_version_from_game) + rootnode: EnumProperty( + name='Root Node', + description='Type of property used to display meshes', + items=( + ('NiNode', 'NiNode', "", 0), + ('BSFadeNode', 'BSFadeNode', "", 1)), + default='NiNode', + ) + # Map game enum to nif version. VERSION = { _game_to_enum(game): versions[-1] diff --git a/io_scene_niftools/ui/object.py b/io_scene_niftools/ui/object.py index ef57f7184..9cbf9e6d1 100644 --- a/io_scene_niftools/ui/object.py +++ b/io_scene_niftools/ui/object.py @@ -42,13 +42,15 @@ from io_scene_niftools.utils.decorators import register_classes, unregister_classes -class ObjectPanel(Panel): - bl_label = "Niftools Object Property" - bl_idname = "NIFTOOLS_PT_ObjectPanel" - +class ObjectButtonsPanel(Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' bl_context = "object" + + +class ObjectPanel(ObjectButtonsPanel): + bl_label = "Niftools Object Property" + bl_idname = "NIFTOOLS_PT_ObjectPanel" # noinspection PyUnusedLocal @classmethod @@ -61,7 +63,6 @@ def draw(self, context): layout = self.layout row = layout.column() - row.prop(nif_obj_props, "rootnode") row.prop(nif_obj_props, "prn_location") row.prop(nif_obj_props, "upb") row.prop(nif_obj_props, "bsxflags") @@ -74,14 +75,10 @@ def draw(self, context): row.prop_search(nif_obj_props, "skeleton_root", parent.data, "bones") -class ObjectExtraData(Panel): +class ObjectExtraData(ObjectButtonsPanel): bl_label = "Niftools Object Extra Data" bl_idname = "NIFTOOLS_PT_ObjectExtraDataPanel" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = "object" - # noinspection PyUnusedLocal @classmethod def poll(cls, context): @@ -139,13 +136,10 @@ def draw_item(self, context, layout, data, item, icon, active_data, active_propn split.prop(item, "data", text="", emboss=False, translate=False, icon='BORDERMOVE') -class ObjectBSInvMarkerPanel(Panel): +class ObjectBSInvMarkerPanel(ObjectButtonsPanel): bl_label = "Niftools BS Inv Marker" bl_idname = "NIFTOOLS_PT_ObjectBSInvMarker" - - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = "object" + bl_parent_id = "NIFTOOLS_PT_ObjectPanel" # noinspection PyUnusedLocal @classmethod @@ -154,28 +148,26 @@ def poll(cls, context): def draw(self, context): layout = self.layout - nif_bsinv_props = context.object.niftools_bs_invmarker - row = layout.row() - if not context.object.niftools_bs_invmarker: - row.operator("object.niftools_bs_invmarker_add", icon='ZOOM_IN', text="") - if context.object.niftools_bs_invmarker: - row.operator("object.niftools_bs_invmarker_remove", icon='ZOOM_OUT', text="") - + bs_inv = context.object.niftools.bs_inv + if not bs_inv: + row.operator("object.bs_inv_marker_add", icon='ZOOM_IN', text="") + else: + row.operator("object.bs_inv_marker_remove", icon='ZOOM_OUT', text="") col = row.column(align=True) - for i, x in enumerate(nif_bsinv_props): - col.prop(nif_bsinv_props[i], "bs_inv_x", index=i) - col.prop(nif_bsinv_props[i], "bs_inv_y", index=i) - col.prop(nif_bsinv_props[i], "bs_inv_z", index=i) - col.prop(nif_bsinv_props[i], "bs_inv_zoom", index=i) + for i, x in enumerate(bs_inv): + col.prop(bs_inv[i], "x", index=i) + col.prop(bs_inv[i], "y", index=i) + col.prop(bs_inv[i], "z", index=i) + col.prop(bs_inv[i], "zoom", index=i) classes = [ - ObjectBSInvMarkerPanel, ObjectExtraDataList, ObjectExtraDataType, ObjectExtraData, - ObjectPanel + ObjectPanel, + ObjectBSInvMarkerPanel, ] diff --git a/io_scene_niftools/ui/scene.py b/io_scene_niftools/ui/scene.py index 16f1503cb..cfc603594 100644 --- a/io_scene_niftools/ui/scene.py +++ b/io_scene_niftools/ui/scene.py @@ -44,13 +44,13 @@ from io_scene_niftools.utils.decorators import register_classes, unregister_classes -class SceneButtonsPanel: +class SceneButtonsPanel(Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' bl_context = "scene" -class ScenePanel(SceneButtonsPanel, Panel): +class ScenePanel(SceneButtonsPanel): bl_label = "Niftools Scene Panel" bl_idname = "NIFTOOLS_PT_scene" @@ -65,9 +65,10 @@ def draw(self, context): layout = self.layout row = layout.column() row.prop(nif_scene_props, "game") + row.prop(nif_scene_props, "rootnode") -class SceneVersionInfoPanel(SceneButtonsPanel, Panel): +class SceneVersionInfoPanel(SceneButtonsPanel): bl_label = "Nif Version Info" bl_idname = "NIFTOOLS_PT_scene_version_info" bl_parent_id = "NIFTOOLS_PT_scene" @@ -90,6 +91,7 @@ def draw(self, context): col = flow.column() col.prop(nif_scene_props, "user_version_2") + # class SceneAuthorInfoPanel(SceneButtonsPanel, Panel): # bl_label = "Nif Author Info" # bl_idname = "NIFTOOLS_PT_scene_author_info" diff --git a/io_scene_niftools/utils/blocks.py b/io_scene_niftools/utils/blocks.py index 64533df58..6d95d60aa 100644 --- a/io_scene_niftools/utils/blocks.py +++ b/io_scene_niftools/utils/blocks.py @@ -1,7 +1,10 @@ """ Nif Utilities, stores common code that is used across the code base""" -def safe_decode(b: bytes) -> str: - try: - return b.decode() - except UnicodeDecodeError: - return b.decode("shift-jis", errors="surrogateescape") \ No newline at end of file + +def safe_decode(b: bytes, encodings=('ascii', 'utf8', 'latin1', 'shift-jis')) -> str: + for encoding in encodings: + try: + return b.decode(encoding) + except UnicodeDecodeError: + pass + return b.decode("ascii", errors="surrogateescape") diff --git a/io_scene_niftools/utils/consts.py b/io_scene_niftools/utils/consts.py index c4dd53c42..22ec76837 100644 --- a/io_scene_niftools/utils/consts.py +++ b/io_scene_niftools/utils/consts.py @@ -87,3 +87,9 @@ class EmptyObject: TEX_SLOTS.DECAL_2 = "Decal 2" TEX_SLOTS.SPECULAR = "Specular" TEX_SLOTS.NORMAL = "Normal" + +# fcurve data types for blender +QUAT = "rotation_quaternion" +EULER = "rotation_euler" +LOC = "location" +SCALE = "scale" diff --git a/io_scene_niftools/utils/math.py b/io_scene_niftools/utils/math.py index 74c10735a..70edad10b 100644 --- a/io_scene_niftools/utils/math.py +++ b/io_scene_niftools/utils/math.py @@ -198,6 +198,16 @@ def find_controller(n_block, controller_type): ctrl = ctrl.next_controller +def controllers_iter(n_block, controller_type): + """Find a controller.""" + ctrl = n_block.controller + while ctrl: + if isinstance(ctrl, controller_type): + if ctrl.data or ctrl.interpolator: + yield ctrl + ctrl = ctrl.next_controller + + def find_extra(n_block, extratype): # TODO: 3.0 - Optimise