diff --git a/addons/bitsquid/__init__.py b/addons/bitsquid/__init__.py index 04538d6..7c4fda1 100644 --- a/addons/bitsquid/__init__.py +++ b/addons/bitsquid/__init__.py @@ -28,10 +28,12 @@ bl_info = { # Reload sub modules if they are already loaded if "bpy" in locals(): import importlib - if "unit_export" in locals(): - importlib.reload(unit_export) - if "material_export" in locals(): - importlib.reload(material_export) + if "export_unit" in locals(): + importlib.reload(export_unit) + if "export_material" in locals(): + importlib.reload(export_material) + if "import_bsi" in locals(): + importlib.reload(import_bsi) import bpy @@ -47,13 +49,14 @@ from bpy.props import ( PointerProperty, ) from bpy_extras.io_utils import ( - ExportHelper, + ImportHelper, axis_conversion, orientation_helper, path_reference_mode, ) -from bitsquid.unit import export as unit_export -from bitsquid.material import export as material_export +from bitsquid.unit import export as export_unit +from bitsquid.material import export as export_material +from bitsquid import import_bsi class BitsquidSettings(PropertyGroup): @@ -110,9 +113,9 @@ class OBJECT_OT_bitsquid_export(Operator): object = context.active_object if object.bitsquid.export_materials: for material_slot in object.material_slots.values(): - material_export.save(self, context, material_slot.material) + export_material.save(self, context, material_slot.material) - return unit_export.save(self, context, object) + return export_unit.save(self, context, object) class OBJECT_PT_bitsquid(Panel): @@ -159,7 +162,7 @@ class MATERIAL_OT_bitsquid_export(Operator): def execute(self, context): material = context.active_material - return unit_export.save(self, context, material) + return export_unit.save(self, context, material) class MATERIAL_PT_bitsquid(Panel): @@ -183,8 +186,42 @@ class MATERIAL_PT_bitsquid(Panel): layout.operator("object.bitsquid_export_material", text="Export .material") +@orientation_helper(axis_forward='-Z', axis_up='Y') +class ImportBSI(bpy.types.Operator, ImportHelper): + """Load a Bitsquid .bsi File""" + bl_idname = "import_scene.bsi" + bl_label = "Import BSI" + bl_options = {'PRESET', 'UNDO'} + + filename_ext = ".bsi" + filter_glob: StringProperty( + default="*.bsi;*.bsiz", + options={'HIDDEN'}, + ) + + def execute(self, context): + keywords = self.as_keywords(ignore=("axis_forward", + "axis_up", + "filter_glob", + )) + + if bpy.data.is_saved and context.preferences.filepaths.use_relative_paths: + import os + keywords["relpath"] = os.path.dirname(bpy.data.filepath) + + return import_bsi.load(self, context, **keywords) + + def draw(self, context): + pass + + +def menu_func_import(self, context): + self.layout.operator(ImportBSI.bl_idname, text="Bitsquid Object (.bsi)") + + # Register classes = [ + ImportBSI, BitsquidSettings, SCENE_PT_bitsquid, BitsquidObjectSettings, @@ -201,6 +238,8 @@ def register(): for cls in classes: register_class(cls) + bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + bpy.types.Scene.bitsquid = PointerProperty(type=BitsquidSettings) bpy.types.Object.bitsquid = PointerProperty(type=BitsquidObjectSettings) bpy.types.Material.bitsquid = PointerProperty(type=BitsquidMaterialSettings) diff --git a/addons/bitsquid/import_bsi.py b/addons/bitsquid/import_bsi.py new file mode 100644 index 0000000..d0325f7 --- /dev/null +++ b/addons/bitsquid/import_bsi.py @@ -0,0 +1,289 @@ +# Bitsquid Blender Tools +# Copyright (C) 2021 Lucas Schwiderski +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import zlib +import json +import bpy +import mathutils + +from bpy_extras.io_utils import unpack_list + + +def parse_sjson(file_path, skip_editor_data=True): + """ + Translate Bitsquid .bsi SJSON data to plain JSON, + then parse into a python dictionary. + + Taken from `bsi_import` in the Vermintide 2 SDK. + """ + return_dict = {} + try: + with open(file_path, 'rb') as f: + data = f.read() + + if data[:4] == 'bsiz': + data = zlib.decompress(data[8:]) + + data = data.decode("utf-8") + + file_lines = ['{\n'] + inside_list = False + check_editor_data = 1 if skip_editor_data else 0 + + for line in data.splitlines(): + if check_editor_data: + if check_editor_data > 1: + if line[0] == '}': + check_editor_data = 0 + + continue + elif line[:18] == 'editor_metadata = ': + check_editor_data = 2 + continue + + if not line.strip(): + continue + + # Strip trailing whitespace, + # including line break and carriage return + line = line.rstrip() + if line[-1] in ['\n', '\r']: + line = line[:-1] + + if ' = ' in line: + line_parts = line.split(' = ') + + if '[ ' in line_parts[-1] and ' ]' in line_parts[-1]: + new_end = '' + end_parts = line_parts[-1].split(' ]') + short_len = len(end_parts) - 1 + + for i, end_part in enumerate(end_parts): + if not end_part: + if i < short_len: + new_end += ' ]' + continue + + if '[ ' not in end_part: + new_end += end_part + ' ]' + continue + + sub_part_pre = end_part.rpartition('[ ') + new_end = ''.join(( + new_end, + sub_part_pre[0], + '[ ', + ', '.join(sub_part_pre[-1].split(' ')), + ' ]' + )) + + if i < short_len and end_parts[i + 1] and end_parts[i + 1][:2] == ' [': + new_end += ',' + + line_parts[-1] = new_end + + # Handle indentation + if '\t' in line_parts[0]: + line_start = line_parts[0].rpartition('\t') + else: + tab_len = len(line_parts[0]) - len(line_parts[0].lstrip()) + + if tab_len > 4: + line_start = [line_parts[0][:tab_len - 4], '' , line_parts[0].lstrip()] + elif tab_len > 0: + line_start = ['', '', line_parts[0].lstrip()] + else: + line_start = line_parts[0].rpartition('\t') + + if line_start[-1][0] == '"': + new_line = ''.join(( + ''.join(line_start[:-1]), + line_start[-1], + ': ', + ''.join(line_parts[1:]) + )) + else: + new_line = ''.join(( + ''.join(line_start[:-1]), + '"', + line_start[-1], + '": ', + ''.join(line_parts[1:]) + )) + + if not line_parts[-1][-1] == ',': + new_line += ',' + elif ']' in line or '}' in line: + new_line = line + ',' + if '} {' in new_line: + new_line = new_line.replace('} {', '}, {') + if file_lines[-1][-2] == ',': + file_lines[-1] = file_lines[-1][:-2] + '\n' + elif inside_list and ']' not in line: + tab_len = len(line) - len(line.lstrip()) + new_line = line[:tab_len] + ', '.join([x for x in line[tab_len:].split(' ') if x]) + else: + new_line = line + + if new_line[-2:] in ['[,', '{,'] or new_line[-3:] in ['[ ,', '{ ,']: + new_line = new_line[:-1] + + if inside_list: + if ']' in line: + inside_list = False + elif not new_line[-1] in ['[', '{', ',']: + new_line += ',' + elif '[' in line: + inside_list = True + + file_lines.append(''.join(('\t', new_line.lstrip(), '\n'))) + + # To save memory... + if len(file_lines) > 100000: + file_lines = [''.join(file_lines)] + + if file_lines[-1][-2] == ',': + file_lines[-1] = file_lines[-1][:-2] + '\n' + + file_lines.append('}\n') + return_dict = json.loads(''.join(file_lines)) + except ValueError: + print(file_path.replace('\\', '/') + ': SJSON file contains a syntax error') + return return_dict + + +def find(arr, f): + for key, val in arr.items(): + if f(val, key): + return val, key + + +def create_object(self, context, name, node_data, geometries): + print("[create_object]", name, node_data) + vertices = [] + faces = [] + edges = [] + + tri_count = int(geometries["indices"]["size"]) / 3 + + for i, index_stream in enumerate(geometries["indices"]["streams"]): + data_stream = geometries["streams"][i] + stride = data_stream["stride"] / 4 + + for channel in data_stream['channels']: + stream_data = data_stream["data"] + if channel['name'] == 'POSITION': + if stride != 3: + raise RuntimeError("stride != 3 cannot be handled") + + # Get vertex positions. + # Iterate over data in sets of three values that represent + # `x`, `y` and `z`. + for j in range(0, len(stream_data), 3): + vertices.append(( + stream_data[j], + stream_data[j + 1], + stream_data[j + 2], + )) + + # Get face definitions. Values are vertex indices. + # Iteration works just like vertices above. + for j in range(0, len(index_stream), 3): + faces.append(( + index_stream[j], + index_stream[j + 1], + index_stream[j + 2], + )) + else: + self.report( + {'WARNING'}, + "Unknown channel name: {}".format(channel["name"]) + ) + + print(vertices, faces) + + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(vertices, edges, faces) + + return mesh + + +def import_node(self, context, name, node_data, global_data): + 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) + print(mesh) + + obj = bpy.data.objects.new(mesh.name, mesh) + # TODO: Apply `local` tranformation + else: + print("[import_node] Adding empty for '{}'".format(name)) + # TODO: Extract position and rotation from `local` matrix, + # if it's not the identity matrix + # NOTE: Low priority, Fatshark seems to stick with + # identity matrices here + obj = bpy.data.objects.new(name, None) + + if "children" in node_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) + ) + + 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 + 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 + global_matrix = mathutils.Matrix() + + for name, node_data in global_data["nodes"].items(): + obj = import_node(self, context, name, node_data, global_data) + + view_layer.objects.active = obj + obj.select_set(True) + + # we could apply this anywhere before scaling. + obj.matrix_world = global_matrix + + return {'FINISHED'}