diff --git a/__init__.py b/__init__.py index ac2b4af..28b1c22 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,5 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== # ##### BEGIN GPL LICENSE BLOCK ##### @@ -24,7 +24,7 @@ bl_info = { "name": "Import OMSI map/cfg/sco/o3d files", "author": "Adam/Thomas Mathieson", - "version": (1, 1, 4), + "version": (1, 2, 2), "blender": (3, 1, 0), "location": "File > Import-Export", "description": "Import OMSI model .map, .cfg, .sco, and .o3d files along with their meshes, UVs, and materials", @@ -35,7 +35,7 @@ "category": "Import-Export" } -from .o3d_io import io_o3d_import, io_o3d_export, io_omsi_tile +from .o3d_io import io_o3d_import, io_o3d_export, io_omsi_tile, io_omsi_map_panel import bpy from mathutils import Matrix @@ -91,6 +91,13 @@ class ImportModelCFG(bpy.types.Operator, ImportHelper): # ImportHelper mixin class uses this filename_ext = ".o3d" + import_custom_normals = BoolProperty( + name="Import custom normals", + description="Import the mesh normals as Blender custom split normals. This allows the original normal data to " + "be preserved correctly but can be harder to edit later.", + default=True + ) + filter_glob = StringProperty( default="*.cfg;*.sco;*.o3d;*.rdy", options={'HIDDEN'}, @@ -120,6 +127,13 @@ class ImportModelCFG(bpy.types.Operator, ImportHelper): default=True, ) + parent_collection = StringProperty( + name="Parent collection (leave blank for default)", + description="When set, this will place all the imported objects in the specified collection. If a collection " + "with the specified name does not exist, it will be created.", + default="" + ) + # Selected files files = CollectionProperty(type=bpy.types.PropertyGroup) @@ -130,7 +144,25 @@ def execute(self, context): :return: success message """ context.window.cursor_set('WAIT') - io_o3d_import.do_import(self.filepath, context, self.import_x, self.override_text_encoding, self.hide_lods) + + # Find or create the parent collection + parent_collection = None + if self.parent_collection != "": + if bpy.app.version < (2, 80): + if self.parent_collection not in bpy.data.groups: + parent_collection = bpy.data.groups.new(self.parent_collection) + else: + parent_collection = bpy.data.groups[self.parent_collection] + else: + if self.parent_collection not in bpy.data.collections: + parent_collection = bpy.data.collections.new(self.parent_collection) + bpy.context.scene.collection.children.link(parent_collection) + else: + parent_collection = bpy.data.collections[self.parent_collection] + + io_o3d_import.do_import(self.filepath, context, self.import_x, self.override_text_encoding, self.hide_lods, + import_lods=True, parent_collection=parent_collection, + split_normals=self.import_custom_normals) context.window.cursor_set('DEFAULT') return {'FINISHED'} @@ -150,6 +182,12 @@ class ExportModelCFG(bpy.types.Operator, ExportHelper): default="*.cfg;*.sco;*.o3d", options={'HIDDEN'}, ) + export_custom_normals = BoolProperty( + name="Export custom normals", + description="Export Blender custom split normals as the mesh normals. This allows for higher fidelity normals " + "to be exported.", + default=True + ) use_selection = BoolProperty( name="Selection Only", description="Export selected objects only", @@ -172,7 +210,8 @@ def execute(self, context): context.window.cursor_set('WAIT') global_matrix = Matrix.Scale(self.global_scale, 4) - io_o3d_export.do_export(self.filepath, context, global_matrix, self.use_selection, self.o3d_version) + io_o3d_export.do_export(self.filepath, context, global_matrix, self.use_selection, self.o3d_version, + self.export_custom_normals) context.window.cursor_set('DEFAULT') @@ -238,6 +277,25 @@ class ImportOMSITile(bpy.types.Operator, ImportHelper): default=True, ) + centre_x = IntProperty( + name="Centre Tile X", + description="When loading a global.cfg file, the x coordinate of the first tile to load.", + default=0 + ) + centre_y = IntProperty( + name="Centre Tile Y", + description="When loading a global.cfg file, the y coordinate of the first tile to load.", + default=0 + ) + load_radius = IntProperty( + name="Load Radius", + description="When loading a global.cfg file, how many tiles around the centre tile to load. Set to 0 to only " + "load the centre tile, 1 loads the centre tile and it's 4 neighbours, 2 loads the centre tile and " + "it's 8 neighbours...", + default=999999, + min=0 + ) + # Selected files files = CollectionProperty(type=bpy.types.PropertyGroup) @@ -249,7 +307,7 @@ def execute(self, context): """ context.window.cursor_set('WAIT') io_omsi_tile.do_import(context, self.filepath, self.import_scos, self.import_splines, self.spline_tess_dist, - self.spline_curve_sag, self.import_x) + self.spline_curve_sag, self.import_x, self.centre_x, self.centre_y, self.load_radius) context.window.cursor_set('DEFAULT') return {'FINISHED'} @@ -275,9 +333,17 @@ def menu_func_import_tile(self, context): def register(): - for cls in classes: - cls = make_annotations(cls) - bpy.utils.register_class(cls) + all_classes = classes[:] + all_classes.extend(io_omsi_map_panel.get_classes()) + log("Registering Blender-O3D-IO version: {0}...".format(bl_info["version"])) + for cls in all_classes: + try: + cls = make_annotations(cls) + bpy.utils.register_class(cls) + except: + log("Failed to register {0}".format(cls)) + + io_omsi_map_panel.register() # Compat with 2.7x and 3.x if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: @@ -291,8 +357,15 @@ def register(): def unregister(): - for cls in classes: - bpy.utils.unregister_class(cls) + all_classes = classes[:] + all_classes.extend(io_omsi_map_panel.get_classes()) + for cls in all_classes[::-1]: + try: + bpy.utils.unregister_class(cls) + except: + log("Failed to unregister {0}".format(cls)) + + io_omsi_map_panel.unregister() # Compat with 2.7x and 3.x if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: diff --git a/o3d_io/__init__.py b/o3d_io/__init__.py index 284f507..1b086db 100644 --- a/o3d_io/__init__.py +++ b/o3d_io/__init__.py @@ -1,3 +1,3 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== diff --git a/o3d_io/blender_texture_io.py b/o3d_io/blender_texture_io.py index 08229b3..aeda975 100644 --- a/o3d_io/blender_texture_io.py +++ b/o3d_io/blender_texture_io.py @@ -1,5 +1,5 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== import os @@ -29,7 +29,7 @@ def __init__(self, image): self.texture = TextureSlotWrapper.TextureWrapper(image) -def load_image(base_file_path, texture_path, abs_path=False): +def find_image_path(base_file_path, texture_path, abs_path=False, find_cfg=False): if base_file_path[-3:] == "sco": tex_file = os.path.join(os.path.dirname(base_file_path), "texture", texture_path.lower()) elif base_file_path[-3:] == "map": @@ -63,11 +63,25 @@ def load_image(base_file_path, texture_path, abs_path=False): if os.path.ismount(tex_dir) or last_dir == tex_dir: break + if find_cfg: + tex_file += ".cfg" + if os.path.isfile(tex_file): + return tex_file + else: + return None + + +def load_image(base_file_path, texture_path, abs_path=False): + tex_file = find_image_path(base_file_path, texture_path, abs_path, False) + + if tex_file is not None and os.path.isfile(tex_file): # TODO: Alpha_8_UNORM DDS files are not supported by Blender image = bpy.data.images.load(tex_file, check_existing=True) + is_dds = tex_file[:-3] == "dds" + if not image.has_data and is_dds: # image.has_data doesn't necessarily mean Blender can't load it (sometimes it's deferred), but there's a # good chance it failed. diff --git a/o3d_io/dds_loader/__init__.py b/o3d_io/dds_loader/__init__.py index e69de29..5105fc5 100644 --- a/o3d_io/dds_loader/__init__.py +++ b/o3d_io/dds_loader/__init__.py @@ -0,0 +1,4 @@ +# ============================================================================== +# Copyright (c) 2023 Thomas Mathieson. +# ============================================================================== + diff --git a/o3d_io/io_o3d_export.py b/o3d_io/io_o3d_export.py index 233ff69..37515cb 100644 --- a/o3d_io/io_o3d_export.py +++ b/o3d_io/io_o3d_export.py @@ -1,5 +1,5 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== import os @@ -20,7 +20,7 @@ def log(*args): print("[O3D_Export]", *args) -def export_mesh(filepath, context, blender_obj, mesh, transform_matrix, materials, o3d_version): +def export_mesh(filepath, context, blender_obj, mesh, transform_matrix, materials, o3d_version, export_custom_normals): # Create o3d file os.makedirs(os.path.dirname(filepath), exist_ok=True) with open(filepath, "wb") as f: @@ -52,10 +52,10 @@ def export_mesh(filepath, context, blender_obj, mesh, transform_matrix, material else: v_uv = (0, 0) - if (v_co, v_nrm) in vert_map: - face_inds.append(vert_map[(v_co, v_nrm)]) + if (v_co, v_nrm, v_uv) in vert_map: + face_inds.append(vert_map[(v_co, v_nrm, v_uv)]) else: - vert_map[(v_co, v_nrm)] = vert_count + vert_map[(v_co, v_nrm, v_uv)] = vert_count verts.append( [v_co[0], v_co[1], v_co[2], v_nrm[0], v_nrm[1], v_nrm[2], @@ -77,26 +77,32 @@ def export_mesh(filepath, context, blender_obj, mesh, transform_matrix, material tris.append((face_inds[1], face_inds[3], face_inds[2], face.material_index)) else: mesh.calc_loop_triangles() + if export_custom_normals and mesh.has_custom_normals: + # mesh.polygons.foreach_set("use_smooth", [False] * len(mesh.polygons)) + mesh.use_auto_smooth = True + else: + mesh.free_normals_split() + mesh.calc_normals_split() for tri_loop in mesh.loop_triangles: tri = [] tris.append(tri) - for tri_vert, loop in zip(tri_loop.vertices, tri_loop.loops): + for tri_vert, loop, normal in zip(tri_loop.vertices, tri_loop.loops, tri_loop.split_normals): vert = mesh.vertices[tri_vert] v_co = vert.co[:] - v_nrm = vert.normal[:] + v_nrm = mesh.loops[loop].normal[:] if uv_layer is not None: v_uv = uv_layer[loop].uv[:2] else: v_uv = (0, 0) - if (v_co, v_nrm) in vert_map: - tri.append(vert_map[(v_co, v_nrm)]) + if (v_co, v_nrm, v_uv) in vert_map: + tri.append(vert_map[(v_co, v_nrm, v_uv)]) else: - vert_map[(v_co, v_nrm)] = vert_count + vert_map[(v_co, v_nrm, v_uv)] = vert_count verts.append( [v_co[0], v_co[1], v_co[2], - v_nrm[0], v_nrm[1], v_nrm[2], + -v_nrm[0], -v_nrm[1], -v_nrm[2], v_uv[0], 1 - v_uv[1]]) tri.append(vert_count) vert_count += 1 @@ -159,7 +165,7 @@ def export_mesh(filepath, context, blender_obj, mesh, transform_matrix, material invert_triangle_winding=True) -def do_export(filepath, context, global_matrix, use_selection, o3d_version): +def do_export(filepath, context, global_matrix, use_selection, o3d_version, export_custom_normals=True): """ Exports the selected CFG/SCO/O3D file :param o3d_version: O3D version to export the file as @@ -224,15 +230,17 @@ def do_export(filepath, context, global_matrix, use_selection, o3d_version): )) if bpy.app.version < (2, 80): - o3d_matrix = axis_conversion_matrix * ob.matrix_world + o3d_matrix = axis_conversion_matrix * ob.matrix_world * axis_conversion_matrix else: - o3d_matrix = axis_conversion_matrix @ ob.matrix_world + o3d_matrix = axis_conversion_matrix @ ob.matrix_world @ axis_conversion_matrix o3d_matrix.transpose() me.transform(ob.matrix_world) me.transform(axis_conversion_matrix) if ob.matrix_world.is_negative: me.flip_normals() + log("Exported matrix: \n{0}".format(o3d_matrix)) + bm = bmesh.new() bm.from_mesh(me) @@ -264,7 +272,8 @@ def do_export(filepath, context, global_matrix, use_selection, o3d_version): # Export the mesh if it hasn't already been exported if path not in exported_paths: exported_paths.add(path) - export_mesh(path, context, ob_eval, me, [x for y in o3d_matrix for x in y], ob_eval.material_slots, o3d_version) + export_mesh(path, context, ob_eval, me, [x for y in o3d_matrix for x in y], ob_eval.material_slots, + o3d_version, export_custom_normals) index += 1 diff --git a/o3d_io/io_o3d_import.py b/o3d_io/io_o3d_import.py index 993e0cb..26aa6ab 100644 --- a/o3d_io/io_o3d_import.py +++ b/o3d_io/io_o3d_import.py @@ -1,5 +1,5 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== import math import time @@ -10,7 +10,7 @@ import bpy import os -if not (bpy.app.version[0] < 3 and bpy.app.version[1] < 80): +if bpy.app.version >= (2, 80): # from bpy_extras import node_shader_utils from . import o3d_node_shader_utils from . import o3dconvert @@ -24,6 +24,7 @@ def log(*args): print("[O3D_Import]", *args) + def platform_related_path(path): """ Return a string with correct path separators for the current platform @@ -32,7 +33,9 @@ def platform_related_path(path): """ return path.replace("/", os.sep).replace("\\", os.sep) -def do_import(filepath, context, import_x, override_text_encoding, hide_lods): + +def do_import(filepath, context, import_x, override_text_encoding, hide_lods, import_lods=True, parent_collection=None, + split_normals=True): """ Imports the selected CFG/SCO/O3D file :param override_text_encoding: the text encoding to use to read the file instead of utf8/cp1252 @@ -40,6 +43,8 @@ def do_import(filepath, context, import_x, override_text_encoding, hide_lods): :param hide_lods: whether additional LODs should be hidden by default :param filepath: the path to the file to import :param context: blender context + :param import_lods: whether additional LODs should be loaded + :param parent_collection: which current_collection to parent the imported objects to, use None for the scene current_collection :return: success message """ obj_root = os.path.dirname(filepath) @@ -57,323 +62,455 @@ def do_import(filepath, context, import_x, override_text_encoding, hide_lods): else: (cfg_data, obj_root) = read_cfg(filepath, override_text_encoding) - files = [(cfg_data[lod]["meshes"][mesh]["path"], mesh, lod) for lod in cfg_data for mesh in cfg_data[lod]["meshes"]] - bpy.context.window_manager.progress_begin(0, len(files)) + n_meshes = len([0 for lod in cfg_data for mesh in cfg_data[lod]["meshes"]]) + bpy.context.window_manager.progress_begin(0, n_meshes) - highest_lod = sorted(cfg_data.keys(), reverse=True)[0] + sorted_lods = sorted(cfg_data.keys(), reverse=True) + + # Work out where to put any custom data belonging to the root of the CFG file + # In this case we CAN'T use the scene collection since it doesn't expose custom data in the editor for some reason + custom_data_object = parent_collection + if custom_data_object is None \ + or (bpy.app.version >= (2, 80) and custom_data_object == context.scene.collection): + custom_data_object = bpy.data.scenes[context.scene.name] # Iterate through the selected files blender_objs = [] mat_counter = 0 - for index, current_file in enumerate(files): - # Generate full path to file - path_to_file = current_file[0] - bpy.context.window_manager.progress_update(index) - - if path_to_file[-1:] == "x": - if not import_x: - bpy.ops.object.select_all(action='DESELECT') + mesh_index = 0 + is_highest_lod = True + current_collection = None + for current_lod in sorted_lods: + if not import_lods and not is_highest_lod: + continue + + if bpy.app.version < (2, 80): + if current_lod == -1 or not import_lods: + current_collection = parent_collection + else: + collection_name = "LOD_{0}".format(current_lod) + if collection_name not in bpy.data.groups: + current_collection = bpy.data.groups.new(collection_name) + if parent_collection is not None: + parent_collection.objects.link(current_collection) + else: + current_collection = bpy.data.groups[collection_name] + else: + if current_lod == -1 or not import_lods: + current_collection = bpy.context.scene.collection + if parent_collection is not None: + current_collection = parent_collection + else: + collection_name = "LOD_{0}".format(current_lod) + if collection_name not in bpy.data.collections: + current_collection = bpy.data.collections.new(collection_name) + if parent_collection is not None: + parent_collection.children.link(current_collection) + else: + bpy.context.scene.collection.children.link(current_collection) + else: + current_collection = bpy.data.collections[collection_name] + + for current_mesh in cfg_data[current_lod]["meshes"]: + # Generate full path to file + path_to_file = cfg_data[current_lod]["meshes"][current_mesh]["path"] + path_to_file = platform_related_path(path_to_file) + bpy.context.window_manager.progress_update(mesh_index) + log("[{0:.2f}%] Loading {1}...".format((mesh_index + 1) / n_meshes * 100, path_to_file)) + + if path_to_file[-1:] == "x": + if not import_x: + bpy.ops.object.select_all(action='DESELECT') + continue + + # X files are not supported by this importer + try: + x_file_path = {"name": os.path.basename(path_to_file)} + # Clunky solution to work out what has been imported because the x importer doesn't set selection + old_objs = set(context.scene.objects) + # For now the x file importer doesn't handle omsi x files very well, materials aren't imported + # correctly + bpy.ops.object.select_all(action='DESELECT') + bpy.ops.import_scene.x(filepath=path_to_file, files=[x_file_path], axis_forward='Y', axis_up='Z', + use_split_objects=False, use_split_groups=False, parented=False, + quickmode=True) + # mat_counter = generate_materials(cfg_materials, filepath, mat_counter, materials, mesh, + # object_directory, path_to_file) + new_objs = set(context.scene.objects) - old_objs + + for o in new_objs: + if current_collection is not None: + if bpy.app.version > (2, 79): + if current_collection != bpy.context.scene.collection: + bpy.context.scene.collection.objects.unlink(o) + if current_collection in o.users_collection: + continue + # else: + # bpy.context.scene.objects.unlink(o) + current_collection.objects.link(o) + + # Create lights + create_lights(blender_objs, cfg_data[current_lod]["meshes"][current_mesh], filepath, + current_collection) + blender_objs.extend(new_objs) + continue + except Exception as e: + log("WARNING: {0} was not imported! A compatible X importer was not found! Please use: " + "https://github.com/Poikilos/io_import_x\n" + "Exception: {1}".format(path_to_file, e)) + continue + + # Load mesh + materials, matl_ids, mesh, o3d, o3d_transform_matrix = load_o3d(obj_root, path_to_file, split_normals) + if mesh is None: continue - # X files are not supported by this importer - try: - x_file_path = {"name": os.path.basename(path_to_file)} - # Clunky solution to work out what has been imported because the x importer doesn't set selection - old_objs = set(context.scene.objects) - # For now the x file importer doesn't handle omsi x files very well, materials aren't imported correctly - bpy.ops.object.select_all(action='DESELECT') - bpy.ops.import_scene.x(filepath=path_to_file, files=[x_file_path], axis_forward='Z', axis_up='Y', - use_split_objects=False, use_split_groups=False, parented=False, - quickmode=True) - # mat_counter = generate_materials(cfg_materials, filepath, mat_counter, materials, mesh, obj_root, - # path_to_file) - new_objs = set(context.scene.objects) - old_objs - blender_objs.extend(new_objs) - - # Generate materials - # mat_counter = generate_materials(cfg_materials, filepath, mat_counter, materials, mesh, obj_root, - # path_to_file) - # - # # Populate remaining properties - # key = (path_to_file[len(obj_root):], "null_mat") - # # We can only populate properties on objects defined in the cfg file - # if key in cfg_materials: - # for prop in cfg_materials[key].keys(): - # if prop[0] == "[" and prop[-1] == "]": - # # This must be an unparsed property, therefore we add it to the custom properties of the - # # mesh - # bpy.data.meshes[mesh.name][prop] = cfg_materials[key][prop] + # Create blender object + blender_obj = None + if bpy.app.version < (2, 80): + blender_obj = bpy.data.objects.new(path_to_file[len(obj_root):-4], mesh) + blender_obj["export_path"] = path_to_file[len(obj_root):] + blender_objs.append(blender_obj) + scene = bpy.context.scene + scene.objects.link(blender_obj) - continue - except: - log("WARNING: {0} was not imported! A compatible X importer was not found! Please use: " - "https://github.com/Poikilos/io_import_x".format(path_to_file)) - continue + # In this case current_collection is actually a group and not a collection, but they're functionally + # the same + if current_collection is not None: + current_collection.objects.link(blender_obj) - # Load mesh - path_to_file = platform_related_path(path_to_file) - with open(path_to_file, "rb") as f: - o3d_bytes = f.read() - log("[{0:.2f}%] Loading {1}...".format((index + 1) / len(files) * 100, path_to_file)) - o3d = o3dconvert.import_o3d(o3d_bytes) - verts = o3d[1] - edges = [] - faces = o3d[2] - mesh = bpy.data.meshes.new(name=path_to_file[len(obj_root):-4]) - - vertex_pos = [x[0] for x in verts] - normals = [x[1] for x in verts] # [(x[1][0], x[1][2], x[1][1]) for x in verts] - uvs = [(x[2][0], 1 - x[2][1]) for x in verts] - face_list = [x[0] for x in faces] - matl_ids = [x[1] for x in faces] - materials = o3d[3] - - mesh.from_pydata(vertex_pos, edges, face_list) - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: - mesh.uv_textures.new("UV Map") - else: - mesh.uv_layers.new(name="UV Map") - - axis_conversion_matrix = Matrix(( - (1, 0, 0, 0), - (0, 0, 1, 0), - (0, 1, 0, 0), - (0, 0, 0, 1) - )) - o3d_transform_matrix = Matrix(o3d[5]) - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: - mesh_matrix = o3d_transform_matrix.inverted() - o3d_transform_matrix = axis_conversion_matrix * o3d_transform_matrix - else: - mesh_matrix = o3d_transform_matrix.inverted() - o3d_transform_matrix = axis_conversion_matrix @ o3d_transform_matrix + if hide_lods and not is_highest_lod: + # Check this works in 2.79... + blender_obj.hide = True + blender_obj.hide_render = True + else: + blender_obj = bpy.data.objects.new(path_to_file[len(obj_root):-4], mesh) + blender_obj["export_path"] = path_to_file[len(obj_root):] + blender_objs.append(blender_obj) - mesh.create_normals_split() - mesh.polygons.foreach_set("use_smooth", [True] * len(mesh.polygons)) - mesh.normals_split_custom_set_from_vertices(normals) + current_collection.objects.link(blender_obj) + + if hide_lods and not is_highest_lod: + blender_obj.hide_set(True) + blender_obj.hide_render = True + + # Transform object + blender_obj.matrix_basis = o3d_transform_matrix + + # Generate materials + cfg_mats = cfg_data[current_lod]["meshes"][current_mesh].get("matls", {}) + mat_counter = generate_materials(cfg_mats, filepath, + mat_counter, materials, mesh) + + # Populate remaining properties on the mesh + bpy.data.meshes[mesh.name]["cfg_data"] = cfg_data[current_lod]["meshes"][current_mesh].get("cfg_data", {}) + # for prop in cfg_data[current_file[2]]["meshes"][current_file[1]].items(): + # if prop[0][0] == "[" and prop[0][-1] == "]": + # # This must be an unparsed property, therefore we add it to the custom properties of the mesh + # bpy.data.meshes[mesh.name][prop[0]] = prop[1] + + for ind, tri in enumerate(mesh.polygons): + tri.material_index = matl_ids[ind] - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: - mesh.update(calc_tessface=True) - else: mesh.update() - mesh.transform(mesh_matrix) - if mesh_matrix.is_negative: - mesh.flip_normals() - - for face in mesh.polygons: - for vert_idx, loop_idx in zip(face.vertices, face.loop_indices): - mesh.uv_layers[0].data[loop_idx].uv = uvs[vert_idx] - - # Create object - blender_obj = None - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: - blender_obj = bpy.data.objects.new(path_to_file[len(obj_root):-4], mesh) - blender_obj["export_path"] = path_to_file[len(obj_root):] - blender_objs.append(blender_obj) - scene = bpy.context.scene - scene.objects.link(blender_obj) - # For objects with LODs (ie: lod != default value of -1) add them to a new group - if current_file[2] != -1: - group = bpy.data.groups.new("LOD_{0}".format(current_file[2])) - group.objects.link(blender_obj) - - if hide_lods and current_file[2] != highest_lod: - # Check this works in 2.79... - blender_obj.hide = True - blender_obj.hide_render = True - else: - blender_obj = bpy.data.objects.new(path_to_file[len(obj_root):-4], mesh) - blender_obj["export_path"] = path_to_file[len(obj_root):] - blender_objs.append(blender_obj) - view_layer = context.view_layer - # For objects with no LODs (ie: lod = default value of -1) add them to the current collection - # Otherwise, create a new collection for the LOD - if current_file[2] == -1: - collection = view_layer.active_layer_collection.collection + # Generate bones + for bone in o3d[4]: + blender_obj.vertex_groups.new(name=bone[0]) + for vert in bone[1]: + blender_obj.vertex_groups[bone[0]].add([vert[0]], vert[1], "REPLACE") + + mesh_index += 1 + + # Remove the is_highest_lod flag once we've imported the highest LOD, note that LOD -1 should always import + # first, but isn't the highest LOD (unless no other LODs are loaded) + if current_lod != -1: + is_highest_lod = False + + # Create lights + create_lights(blender_objs, cfg_data[current_lod], filepath, current_collection) + + # Populate remaining properties on the cfg + if "cfg_data" in cfg_data[-1]: + custom_data_object["cfg_data"] = cfg_data[-1]["cfg_data"] + if "groups" in cfg_data[-1]: + custom_data_object["groups"] = cfg_data[-1]["groups"] + if "friendlyname" in cfg_data[-1]: + custom_data_object["friendlyname"] = cfg_data[-1]["friendlyname"] + if "editor_only" in cfg_data[-1]: + custom_data_object["editor_only"] = cfg_data[-1]["editor_only"] + if "tree" in cfg_data[-1]: + custom_data_object["tree"] = cfg_data[-1]["tree"] + + bpy.ops.object.select_all(action='DESELECT') + try: + for x in blender_objs: + if bpy.app.version < (2, 80): + x.select = True else: - collection_name = "LOD_{0}".format(current_file[2]) - if collection_name not in bpy.data.collections: - collection = bpy.data.collections.new(collection_name) - bpy.context.scene.collection.children.link(collection) - else: - collection = bpy.data.collections[collection_name] + x.select_set(True) + except Exception as e: + log("WARNING: Couldn't select loaded objects:", e) - collection.objects.link(blender_obj) + if n_meshes == 0: + log("WARNING: 0 models loaded! File:", filepath) - if hide_lods and current_file[2] != highest_lod: - blender_obj.hide_set(True) - blender_obj.hide_render = True + log("Loaded {0} models in {1} seconds!".format(n_meshes, time.time() - start_time)) + return blender_objs - # Transform object - blender_obj.matrix_world = o3d_transform_matrix - # Generate materials - cfg_mats = cfg_data[current_file[2]]["meshes"][current_file[1]].get("matls", {}) - mat_counter = generate_materials(cfg_mats, filepath, - mat_counter, materials, mesh, obj_root, path_to_file) +def create_lights(blender_objs, cfg_data, filepath, collection): + interior_lights = cfg_data.get("interior_lights", []) + for light_ind, light_cfg in enumerate(interior_lights): + light_name = "::interior_light_{0}".format(light_ind) - # Populate remaining properties on the mesh - bpy.data.meshes[mesh.name]["cfg_data"] = cfg_data[current_file[2]]["meshes"][current_file[1]].get("cfg_data", {}) - # for prop in cfg_data[current_file[2]]["meshes"][current_file[1]].items(): - # if prop[0][0] == "[" and prop[0][-1] == "]": - # # This must be an unparsed property, therefore we add it to the custom properties of the mesh - # bpy.data.meshes[mesh.name][prop[0]] = prop[1] + if bpy.app.version < (2, 80): + light_data = bpy.data.lamps.new(name=light_name, type='POINT') + light_data.distance = light_cfg["range"] + light_data.energy = 0.04 + light_data.color = (light_cfg["red"], light_cfg["green"], light_cfg["blue"]) + light_data["variable"] = light_cfg["variable"] + light_data["cfg_type"] = "interiorlight" - for ind, tri in enumerate(mesh.polygons): - tri.material_index = matl_ids[ind] + light_object = bpy.data.objects.new(name=light_name, object_data=light_data) - mesh.update() + bpy.context.scene.objects.link(light_object) + if collection is not None: + collection.objects.link(light_object) - # Generate bones - for bone in o3d[4]: - blender_obj.vertex_groups.new(name=bone[0]) - for vert in bone[1]: - blender_obj.vertex_groups[bone[0]].add([vert[0]], vert[1], "REPLACE") + else: + light_data = bpy.data.lights.new(name=light_name, type='POINT') + light_data.energy = light_cfg["range"] * 10.0 + light_data.color = (light_cfg["red"], light_cfg["green"], light_cfg["blue"]) + light_data.shadow_soft_size = 0.02 + light_data["variable"] = light_cfg["variable"] + light_data["cfg_type"] = "interiorlight" - # Create lights - interior_lights = cfg_data[current_file[2]]["meshes"][current_file[1]].get("interior_lights", {}) - for light_ind in interior_lights: - light_cfg = interior_lights[light_ind] - light_name = "::interior_light_{0}".format(light_ind) + light_object = bpy.data.objects.new(name=light_name, object_data=light_data) - if bpy.app.version < (2, 80): - light_data = bpy.data.lamps.new(name=light_name, type='POINT') - light_data.distance = light_cfg["range"] - light_data.energy = 0.04 - light_data.color = (light_cfg["red"], light_cfg["green"], light_cfg["blue"]) - light_data["variable"] = light_cfg["variable"] + collection.objects.link(light_object) - light_object = bpy.data.objects.new(name=light_name, object_data=light_data) + blender_objs.append(light_object) + # light_object.parent = blender_obj + # Change light position + light_object.location = (light_cfg["x_pos"], light_cfg["y_pos"], light_cfg["z_pos"]) - scene = bpy.context.scene - scene.objects.link(light_object) - else: - light_data = bpy.data.lights.new(name=light_name, type='POINT') - light_data.energy = light_cfg["range"] * 10.0 - light_data.color = (light_cfg["red"], light_cfg["green"], light_cfg["blue"]) - light_data.shadow_soft_size = 0.02 - light_data["variable"] = light_cfg["variable"] + spotlights = cfg_data.get("spotlights", {}) + for light_ind, light_cfg in enumerate(spotlights): + light_name = "::spotlight_{0}".format(light_ind) - light_object = bpy.data.objects.new(name=light_name, object_data=light_data) + if bpy.app.version < (2, 80): + light_data = bpy.data.lamps.new(name=light_name, type='SPOT') + light_data.distance = light_cfg["range"] + light_data.energy = 0.04 + light_data.color = (light_cfg["col_r"], light_cfg["col_g"], light_cfg["col_b"]) - bpy.context.collection.objects.link(light_object) + light_data.spot_size = math.radians(light_cfg["outer_angle"]) + light_data.spot_blend = light_cfg["inner_angle"] / light_cfg["outer_angle"] + light_data["cfg_type"] = "spotlight" - blender_objs.append(light_object) - light_object.parent = blender_obj - # Change light position - light_object.location = (light_cfg["x_pos"], light_cfg["y_pos"], light_cfg["z_pos"]) + light_object = bpy.data.objects.new(name=light_name, object_data=light_data) - spotlights = cfg_data[current_file[2]]["meshes"][current_file[1]].get("spotlights", {}) - for light_ind in spotlights: - light_cfg = spotlights[light_ind] - light_name = "::spotlight_{0}".format(light_ind) + bpy.context.scene.objects.link(light_object) + if collection is not None: + collection.objects.link(light_object) + else: + light_data = bpy.data.lights.new(name=light_name, type='SPOT') + light_data.energy = light_cfg["range"] * 10.0 + light_data.color = (light_cfg["col_r"], light_cfg["col_g"], light_cfg["col_b"]) + light_data.shadow_soft_size = 0.02 + light_data.spot_size = math.radians(light_cfg["outer_angle"]) + light_data.spot_blend = light_cfg["inner_angle"] / light_cfg["outer_angle"] + light_data["cfg_type"] = "spotlight" - if bpy.app.version < (2, 80): - light_data = bpy.data.lamps.new(name=light_name, type='SPOT') - light_data.distance = light_cfg["range"] - light_data.energy = 0.04 - light_data.color = (light_cfg["col_r"], light_cfg["col_g"], light_cfg["col_b"]) + light_object = bpy.data.objects.new(name=light_name, object_data=light_data) - light_data.spot_size = math.radians(light_cfg["outer_angle"]) - light_data.spot_blend = light_cfg["inner_angle"] / light_cfg["outer_angle"] + collection.objects.link(light_object) - light_object = bpy.data.objects.new(name=light_name, object_data=light_data) + blender_objs.append(light_object) + # light_object.parent = blender_obj - scene = bpy.context.scene - scene.objects.link(light_object) - else: - light_data = bpy.data.lights.new(name=light_name, type='SPOT') - light_data.energy = light_cfg["range"] * 10.0 - light_data.color = (light_cfg["col_r"], light_cfg["col_g"], light_cfg["col_b"]) - light_data.shadow_soft_size = 0.02 - light_data.spot_size = math.radians(light_cfg["outer_angle"]) - light_data.spot_blend = light_cfg["inner_angle"] / light_cfg["outer_angle"] + # Change light position + light_object.location = (light_cfg["x_pos"], light_cfg["y_pos"], light_cfg["z_pos"]) - light_object = bpy.data.objects.new(name=light_name, object_data=light_data) + v0 = Vector((0, 0, 1)) + v1 = Vector((light_cfg["x_fwd"], light_cfg["y_fwd"], -light_cfg["z_fwd"])) - bpy.context.collection.objects.link(light_object) + light_object.rotation_euler = v1.rotation_difference(v0).to_euler() - blender_objs.append(light_object) - light_object.parent = blender_obj + maplights = cfg_data.get("maplights", []) + for light_ind, light_cfg in enumerate(maplights): + light_name = "::maplight_{0}".format(light_ind) - # Change light position - light_object.location = (light_cfg["x_pos"], light_cfg["y_pos"], light_cfg["z_pos"]) + if bpy.app.version < (2, 80): + light_data = bpy.data.lamps.new(name=light_name, type='POINT') + light_data.distance = light_cfg["range"] + light_data.energy = 0.20 + light_data.color = (light_cfg["r"], light_cfg["g"], light_cfg["b"]) + light_data["cfg_type"] = "maplight" - v0 = Vector((0, 0, 1)) - v1 = Vector((light_cfg["x_fwd"], light_cfg["y_fwd"], -light_cfg["z_fwd"])) + light_object = bpy.data.objects.new(name=light_name, object_data=light_data) - light_object.rotation_euler = v1.rotation_difference(v0).to_euler() + bpy.context.scene.objects.link(light_object) + if collection is not None: + collection.objects.link(light_object) - # Create lens flares - flares = cfg_data[current_file[2]]["meshes"][current_file[1]].get("light_flares", []) - for flare_ind, flare in enumerate(flares): - flare_name = "::light_enh{0}_{1}".format("" if flare["type"] == "[light_enh]" else "_2", flare_ind) - flare_obj = bpy.data.objects.new(flare_name, None) - if bpy.app.version < (2, 80): - bpy.context.scene.objects.link(flare_obj) + else: + light_data = bpy.data.lights.new(name=light_name, type='POINT') + light_data.energy = light_cfg["range"] * 50.0 + light_data.color = (light_cfg["r"], light_cfg["g"], light_cfg["b"]) + light_data.shadow_soft_size = 0.3 + light_data["cfg_type"] = "maplight" - flare_obj.empty_draw_size = flare["size"] - flare_obj.empty_draw_type = "IMAGE" - else: - bpy.context.collection.objects.link(flare_obj) + light_object = bpy.data.objects.new(name=light_name, object_data=light_data) - flare_obj.empty_display_size = flare["size"] - flare_obj.empty_display_type = "IMAGE" - flare_obj.use_empty_image_alpha = True + collection.objects.link(light_object) - flare_obj.empty_image_offset = (-0.5, -0.5) - flare_obj.color = (flare["col_r"], flare["col_g"], flare["col_b"], 0.5) - flare_obj.parent = blender_obj - flare_obj.location = (flare["x_pos"], flare["y_pos"], flare["z_pos"]) - flare_obj.rotation_euler = (-math.pi / 2, 0, 0) + blender_objs.append(light_object) + # light_object.parent = blender_obj - if "texture" in flare and flare["texture"] != "": - tex_path = flare["texture"] - else: - tex_path = "licht.bmp" - image = load_image(filepath, tex_path) - flare_obj.data = image - - # Copy across all the parameters' blender can't render... - flare_obj["type"] = flare["type"] - if flare["type"] == "[light_enh_2]": - flare_obj["forward_vector"] = (flare["x_fwd"], flare["y_fwd"], flare["z_fwd"]) - flare_obj["rotation_axis"] = (flare["x_rot"], flare["y_rot"], flare["z_rot"]) - flare_obj["omnidirectional"] = flare["omni"] - flare_obj["rotating"] = flare["rotating"] - flare_obj["max_brightness_angle"] = flare["max_brightness_angle"] - flare_obj["min_brightness_angle"] = flare["min_brightness_angle"] - flare_obj["cone_effect"] = flare["cone_effect"] - flare_obj["brightness_var"] = flare["brightness_var"] - flare_obj["brightness"] = flare["brightness"] - flare_obj["z_offset"] = flare["z_offset"] - flare_obj["effect"] = flare["effect"] - flare_obj["ramp_time"] = flare["ramp_time"] + # Change light position + light_object.location = (light_cfg["x"], light_cfg["y"], light_cfg["z"]) - # Populate remaining properties on the cfg - if "cfg_data" in cfg_data[-1]: - bpy.data.scenes[context.scene.name]["cfg_data"] = cfg_data[-1]["cfg_data"] - if "groups" in cfg_data[-1]: - bpy.data.scenes[context.scene.name]["groups"] = cfg_data[-1]["groups"] - if "friendlyname" in cfg_data[-1]: - bpy.data.scenes[context.scene.name]["friendlyname"] = cfg_data[-1]["friendlyname"] + # Create lens flares + flares = cfg_data.get("light_flares", []) + for flare_ind, flare in enumerate(flares): + flare_name = "::light_enh{0}_{1}".format("" if flare["type"] == "[light_enh]" else "_2", flare_ind) + flare_obj = bpy.data.objects.new(flare_name, None) + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(flare_obj) + if collection is not None: + collection.objects.link(flare_obj) - bpy.ops.object.select_all(action='DESELECT') - for x in blender_objs: - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: - x.select = True + flare_obj.empty_draw_size = flare["size"] + flare_obj.empty_draw_type = "IMAGE" else: - x.select_set(True) + collection.objects.link(flare_obj) - if len(files) == 0: - log("WARNING: 0 models loaded! File:", filepath) + flare_obj.empty_display_size = flare["size"] + flare_obj.empty_display_type = "IMAGE" + flare_obj.use_empty_image_alpha = True - log("Loaded {0} models in {1} seconds!".format(len(files), time.time() - start_time)) - return blender_objs + flare_obj.empty_image_offset = (-0.5, -0.5) + flare_obj.color = (flare["col_r"], flare["col_g"], flare["col_b"], 0.5) + # flare_obj.parent = blender_obj + flare_obj.location = (flare["x_pos"], flare["y_pos"], flare["z_pos"]) + flare_obj.rotation_euler = (-math.pi / 2, 0, 0) + + if "texture" in flare and flare["texture"] != "": + tex_path = flare["texture"] + else: + tex_path = "licht.bmp" + image = load_image(filepath, tex_path) + flare_obj.data = image + + # Copy across all the parameters' blender can't render... + flare_obj["type"] = flare["type"] + if flare["type"] == "[light_enh_2]": + flare_obj["forward_vector"] = (flare["x_fwd"], flare["y_fwd"], flare["z_fwd"]) + flare_obj["rotation_axis"] = (flare["x_rot"], flare["y_rot"], flare["z_rot"]) + flare_obj["omnidirectional"] = flare["omni"] + flare_obj["rotating"] = flare["rotating"] + flare_obj["max_brightness_angle"] = flare["max_brightness_angle"] + flare_obj["min_brightness_angle"] = flare["min_brightness_angle"] + flare_obj["cone_effect"] = flare["cone_effect"] + flare_obj["brightness_var"] = flare["brightness_var"] + flare_obj["brightness"] = flare["brightness"] + flare_obj["z_offset"] = flare["z_offset"] + flare_obj["effect"] = flare["effect"] + flare_obj["ramp_time"] = flare["ramp_time"] + + blender_objs.append(flare_obj) + + +def load_o3d(object_directory, path_to_file, split_normals): + """ + Imports an o3d file and creates a Blender mesh for it. + :param object_directory: path to the root directory of the model + :param path_to_file: path to the o3d file to load + :return: + """ + try: + with open(path_to_file, "rb") as f: + o3d_bytes = f.read() + except IOError: + log("WARNING: Couldn't open {0}!".format(path_to_file)) + return [], [], None, None, None + + o3d = o3dconvert.import_o3d(o3d_bytes) + verts = o3d[1] + edges = [] + faces = o3d[2] + + mesh = bpy.data.meshes.new(name=path_to_file[len(object_directory):-4]) + + vertex_pos = [x[0] for x in verts] + normals = [x[1] for x in verts] # [(x[1][0], x[1][2], x[1][1]) for x in verts] + uvs = [(x[2][0], 1 - x[2][1]) for x in verts] + face_list = [x[0] for x in faces] + matl_ids = [x[1] for x in faces] + materials = o3d[3] + + mesh.from_pydata(vertex_pos, edges, face_list) + + if bpy.app.version < (2, 80): + mesh.uv_textures.new("UV Map") + else: + mesh.uv_layers.new(name="UV Map") + + axis_conversion_matrix = Matrix(( + (1, 0, 0, 0), + (0, 0, 1, 0), + (0, 1, 0, 0), + (0, 0, 0, 1) + )) + # log("Imported matrix (pre-rounding): \n{0}".format(o3d[5])) + o3d_transform = [tuple(0 if -1e-7 < x < 1e-7 else x for x in y) for y in o3d[5]] + o3d_transform_matrix = Matrix(o3d_transform) + o3d_transform_matrix.transpose() + # log("Imported matrix: \n{0}".format(o3d_transform_matrix)) + if bpy.app.version < (2, 80): + o3d_transform_matrix = axis_conversion_matrix * o3d_transform_matrix * axis_conversion_matrix + i_o3d_transform_matrix = o3d_transform_matrix.inverted() + mesh_matrix = i_o3d_transform_matrix * axis_conversion_matrix + else: + o3d_transform_matrix = axis_conversion_matrix @ o3d_transform_matrix @ axis_conversion_matrix + i_o3d_transform_matrix = o3d_transform_matrix.inverted() + mesh_matrix = i_o3d_transform_matrix @ axis_conversion_matrix + # log("Converted matrix: \n{0}".format(o3d_transform_matrix)) + # log("Inverted matrix: \n{0}".format(i_o3d_transform_matrix)) + # log("Mesh matrix: \n{0}".format(mesh_matrix)) + + if split_normals: + mesh.create_normals_split() + mesh.polygons.foreach_set("use_smooth", [True] * len(mesh.polygons)) + mesh.normals_split_custom_set_from_vertices(normals) + mesh.use_auto_smooth = True + else: + mesh.polygons.foreach_set("use_smooth", [True] * len(mesh.polygons)) + + if bpy.app.version < (2, 80): + mesh.update(calc_tessface=True) + mesh.calc_normals_split() + else: + mesh.update() + mesh.calc_loop_triangles() + mesh.calc_normals_split() + + mesh.validate(verbose=False, clean_customdata=True) + + mesh.transform(mesh_matrix) + if mesh_matrix.is_negative: + mesh.flip_normals() + + for face in mesh.polygons: + for vert_idx, loop_idx in zip(face.vertices, face.loop_indices): + mesh.uv_layers[0].data[loop_idx].uv = uvs[vert_idx] + + return materials, matl_ids, mesh, o3d, o3d_transform_matrix -def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mesh, obj_root, current_file_path): +def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mesh): """ Generates Blender materials for an o3d file. @@ -382,8 +519,6 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes :param mat_counter: the global material counter :param materials: the o3d material definition :param mesh: the Blender mesh to generate materials for - :param obj_root: the mesh name and file extension - :param current_file_path: the path to the current mesh :return: the new global material counter """ @@ -400,11 +535,8 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes emit_r = matl[2][0] emit_g = matl[2][0] emit_b = matl[2][0] - spec_i = matl[3] spec_h = matl[3] - # matls.append(()) - # if bpy.data.materials.get("{0}".format(matl[7])) is None: mat_blender = bpy.data.materials.new("{0}-{1}".format(matl[4], str(mat_counter))) if bpy.app.version < (2, 80): mat = mat_blender @@ -429,11 +561,12 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes # Load the diffuse texture and assign it to a new texture slot diff_tex = load_texture_into_new_slot(cfg_file_path, matl[4], mat) if diff_tex: - if not (bpy.app.version[0] < 3 and bpy.app.version[1] < 80): + if bpy.app.version >= (2, 80): mat.base_color_texture.image = diff_tex.texture.image # In some versions of Blender the colourspace isn't correctly detected, force it to sRGB for diffuse diff_tex.texture.image.colorspace_settings.name = 'sRGB' + diff_tex.texture.image.alpha_mode = "NONE" # Read the material config to see if we need to apply transparency key = matl[4].lower() # cfg_materials should always contain an entry for the key, but if no [matl] tag is defined it won't @@ -441,7 +574,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes if key in cfg_materials and "diffuse" in cfg_materials[key]: if "alpha" in cfg_materials[key] and cfg_materials[key]["alpha"][0] > 0: # Material uses alpha stored in diffuse texture alpha channel - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): mat.use_transparency = True diff_tex.use_map_alpha = True diff_tex.alpha_factor = 1 @@ -458,7 +591,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes if "transmap" in cfg_materials[key]: # Material uses dedicated transparency texture - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): diff_tex.use_map_alpha = False else: # Set the specular texture to the alpha channel of the diffuse texture @@ -466,7 +599,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes # Load the new transmap trans_map = load_texture_into_new_slot(cfg_file_path, cfg_materials[key]["transmap"][0], mat) if trans_map: - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): trans_map.texture.image.use_alpha = False trans_map.use_map_alpha = True trans_map.alpha_factor = 1 @@ -479,7 +612,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes mat.alpha = 1 if "envmap" in cfg_materials[key]: - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): mat.specular_intensity = cfg_materials[key]["envmap"][0] ** 2 mat.specular_hardness = 1 / 0.01 else: @@ -491,7 +624,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes envmap_mask = load_texture_into_new_slot(cfg_file_path, cfg_materials[key]["envmap_mask"][0], mat) if envmap_mask: - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): # TODO: Blender 2.79 compat for envmap masks pass else: @@ -499,7 +632,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes mat.specular_texture.image = envmap_mask.texture.image if "bumpmap" in cfg_materials[key]: - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): # TODO: Blender 2.79 compat for bump maps # mat.specular_intensity = cfg_materials[key]["envmap"][0] ** 2 # mat.specular_hardness = 1 / 0.01 @@ -539,7 +672,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes if "nightmap" in cfg_materials[key]: # A nightmap is an emission texture which is automatically toggled at night - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): # TODO: Blender 2.79 compat for nightmaps cfg_materials[key]["cfg_data"].append([ "[matl_nightmap]", @@ -555,7 +688,7 @@ def generate_materials(cfg_materials, cfg_file_path, mat_counter, materials, mes if "lightmap" in cfg_materials[key]: # A lightmap is an emission texture which is controlled by a script_var - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): # TODO: Blender 2.79 compat for lightmaps cfg_materials[key]["cfg_data"].append([ "[matl_lightmap]", diff --git a/o3d_io/io_omsi_map_panel.py b/o3d_io/io_omsi_map_panel.py new file mode 100644 index 0000000..11878e9 --- /dev/null +++ b/o3d_io/io_omsi_map_panel.py @@ -0,0 +1,485 @@ +# ============================================================================== +# Copyright (c) 2023 Thomas Mathieson. +# ============================================================================== +import math +import os +import time +from datetime import date + +import bmesh +import bpy + +from bpy.props import (BoolProperty, + StringProperty, + CollectionProperty, + IntProperty, + FloatProperty + ) + +import mathutils +from . import io_omsi_spline, o3d_node_shader_utils +from .o3d_cfg_parser import read_generic_cfg_file + + +def log(*args): + print("[OMSI_Spline_Import]", *args) + + +class OMSIMapProps(bpy.types.PropertyGroup): + bl_idname = "propgroup.OMSIMapProps" + map_path = bpy.props.StringProperty(name="", subtype="FILE_PATH") + centre_x = bpy.props.IntProperty(name="Centre Tile X", default=0) + centre_y = bpy.props.IntProperty(name="Centre Tile Y", default=0) + load_radius = bpy.props.IntProperty(name="Load Radius", default=999999, min=1) + import_scos = BoolProperty(name="Import SCOs", default=True) + import_x = BoolProperty( + name="Import .x Files", + description="Attempt to import .x files, this can be buggy and only works if you have the correct .x importer " + "already installed.", + default=True, + ) + import_splines = BoolProperty( + name="Import Splines", + description="Import the map's splines", + default=False + ) + spline_tess_dist = FloatProperty( + name="Spline Tesselation Precision", + description="The minimum distance between spline segments", + min=0.1, + max=1000.0, + default=6.0, + ) + spline_curve_sag = FloatProperty( + name="Spline Tesselation Curve Precision", + description="The minimum sag distance between the curve and the tessellated segment. Note that this is used in " + "combination with the above setting, whichever is lower is used by the tessellator.", + min=0.0005, + max=1.0, + default=0.005, + ) + spline_preview_quality = FloatProperty( + name="Spline Preview Quality", + min=0.01, + max=10.0, + default=0.2, + ) + + +class GenerateMapPreviewOp(bpy.types.Operator): + """Imports an OMSI global.cfg file and generates a preview of it""" + bl_idname = "import_scene.omsi_map_preview" + bl_label = "Import OMSI global.cfg" + bl_options = {'PRESET', 'UNDO'} + + filepath = StringProperty( + name="File Path" + ) + + import_scos = BoolProperty( + name="Import SCOs", + description="Import the SCO files", + default=False + ) + + import_splines = BoolProperty( + name="Import Splines", + description="Import the map's splines", + default=False + ) + + roadmap_mode = BoolProperty( + name="Roadmap Mode", + description="Imports trees, water and full quality splines for roadmap rendering", + default=False + ) + + spline_preview_quality = FloatProperty( + name="Spline Preview Quality", + min=0.01, + max=20 + ) + + clear = BoolProperty( + name="Clear Preview", + default=False + ) + + centre_x = IntProperty( + name="Centre Tile X", + description="When loading a global.cfg file, the x coordinate of the first tile to load.", + default=0 + ) + centre_y = IntProperty( + name="Centre Tile Y", + description="When loading a global.cfg file, the y coordinate of the first tile to load.", + default=0 + ) + load_radius = IntProperty( + name="Load Radius", + description="When loading a global.cfg file, how many tiles around the centre tile to load. Set to 0 to only " + "load the centre tile, 1 loads the centre tile and it's 4 neighbours, 2 loads the centre tile and " + "it's 8 neighbours...", + default=9999, + min=0 + ) + + @staticmethod + def generate_terrain_mesh(map_path, parent_collection): + new_mesh = bpy.data.meshes.new("terrain_mesh-" + os.path.basename(map_path)) + verts = [ + [0, 0, 0], + [300, 0, 0], + [0, 300, 0], + [300, 300, 0], + ] + faces = [ + [0, 1, 3], + [0, 3, 2] + ] + new_mesh.from_pydata(verts, [], faces) + if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + new_mesh.uv_textures.new("UV Map") + else: + new_mesh.uv_layers.new(name="UV Map") + new_mesh.update(calc_edges=True) + + o = bpy.data.objects.new("terrain-" + os.path.basename(map_path), new_mesh) + + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(o) + parent_collection.objects.link(o) + + o.select = True + else: + parent_collection.objects.link(o) + o.select_set(True) + + return o + + def import_tile(self, collection, map_path, import_scos, global_cfg, import_splines, spline_tess_dist, + roadmap_mode): + map_file = read_generic_cfg_file(map_path) + + objs = [self.generate_terrain_mesh(map_path, collection)] + objs[0].color = (.25, 0.65, 0.15, 1.) + objs[0].data.materials.append(o3d_node_shader_utils.generate_solid_material((.15, 0.5, 0.15, 1.))) + + if import_splines: + objs.extend(io_omsi_spline.import_map_preview_splines(map_path, map_file, spline_tess_dist, collection, + roadmap_mode)) + + return objs + + def create_cameras(self, tile_coords, collection): + cams = [] + for tile_coord in tile_coords: + camera = bpy.data.cameras.new("map_cam-{0}".format(tile_coord)) + camera_obj = bpy.data.objects.new("map_cam-{0}".format(tile_coord), camera) + camera.type = "ORTHO" + camera.ortho_scale = 300 + camera.clip_start = 1 + camera.clip_end = 10000 + camera_obj.location = mathutils.Vector((tile_coord[0] * 300+150, tile_coord[1] * 300+150, 500)) + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(camera_obj) + if collection is not None: + collection.objects.link(camera_obj) + else: + collection.objects.link(camera_obj) + + cams.append(camera_obj) + + rv = bpy.context.scene.render.views.new("map_cam-{0}".format(tile_coord)) + rv.camera_suffix = "{0}".format(tile_coord) + + return cams + + def execute(self, context): + context.window.cursor_set('WAIT') + if self.clear: + if bpy.app.version < (2, 80): + if "Blender-O3D-IO-Map-Preview" not in bpy.data.groups: + context.window.cursor_set('DEFAULT') + return {"FINISHED"} + + col = bpy.data.groups["Blender-O3D-IO-Map-Preview"] + bpy.context.window_manager.progress_begin(0, len(col.objects)) + i = 0 + for obj in col.objects: + bpy.data.objects.remove(obj, do_unlink=True) + bpy.context.window_manager.progress_update(i) + i += 1 + bpy.data.groups.remove(col) + else: + if "Blender-O3D-IO-Map-Preview" not in bpy.data.collections: + context.window.cursor_set('DEFAULT') + return {"FINISHED"} + + col = bpy.data.collections["Blender-O3D-IO-Map-Preview"] + bpy.context.window_manager.progress_begin(0, len(col.objects)) + i = 0 + for obj in col.objects: + bpy.data.objects.remove(obj, do_unlink=True) + bpy.context.window_manager.progress_update(i) + i += 1 + bpy.data.collections.remove(col) + + context.window.cursor_set('DEFAULT') + return {"FINISHED"} + + start_time = time.time() + + if bpy.app.version < (2, 80): + collection = bpy.data.groups.new("Blender-O3D-IO-Map-Preview") + else: + collection = bpy.data.collections.new("Blender-O3D-IO-Map-Preview") + bpy.context.scene.collection.children.link(collection) + + global_cfg = read_generic_cfg_file(os.path.join(os.path.dirname(self.filepath), "global.cfg")) + + bpy.context.window_manager.progress_begin(0, len(global_cfg["[map]"])) + i = 0 + + working_dir = os.path.dirname(self.filepath) + objs = [] + map_coords = [] + for map_file in global_cfg["[map]"]: + x = int(map_file[0]) + y = int(map_file[1]) + path = map_file[2] + map_coords.append((x, y)) + + diff = (self.centre_x - x, self.centre_y - y) + dist = math.sqrt(diff[0]*diff[0] + diff[1] * diff[1]) + if dist > self.load_radius*0.5+0.5: + continue + + tile_objs = self.import_tile(collection, os.path.join(working_dir, path), self.import_scos, global_cfg, + self.import_splines, 1/self.spline_preview_quality, self.roadmap_mode) + + # bpy.ops.object.select_all(action='DESELECT') + # if bpy.app.version < (2, 80): + # for o in tile_objs: + # if o.parent is None: + # o.select = True + # else: + # for o in tile_objs: + # if o.parent is None: + # o.select_set(True) + + bpy.ops.transform.translate(value=(x * 300, y * 300, 0)) + + objs.extend(bpy.context.selected_objects) + bpy.ops.object.select_all(action='DESELECT') + log("Loaded preview tile {0}_{1}!".format(x, y)) + bpy.context.window_manager.progress_update(i) + i += 1 + + log("Loaded preview tiles in {0:.3f} seconds".format(time.time() - start_time)) + + if self.roadmap_mode: + self.create_cameras(map_coords, collection) + + if len(global_cfg["[entrypoints]"]) > 0: + entrypoints_cfg = global_cfg["[entrypoints]"][0] + entrypoints_cfg = iter(entrypoints_cfg) + n_entrypoints = int(next(entrypoints_cfg)) + entrypoint_names = set() + for i in range(n_entrypoints): + entrypoint = { + "ob_index": int(next(entrypoints_cfg)), + "obj_id": int(next(entrypoints_cfg)), + "inst_index": int(next(entrypoints_cfg)), + "pos_x": float(next(entrypoints_cfg)), + "pos_y": float(next(entrypoints_cfg)), + "pos_z": float(next(entrypoints_cfg)), + "rot_x": float(next(entrypoints_cfg)), + "rot_y": float(next(entrypoints_cfg)), + "rot_z": float(next(entrypoints_cfg)), + "rot_w": float(next(entrypoints_cfg)), + "map_tile": int(next(entrypoints_cfg)), + "name": next(entrypoints_cfg).strip(), + } + + if entrypoint["name"] in entrypoint_names: + continue + entrypoint_names.update(entrypoint["name"]) + + entrypoint_name = "::entrypoint_{0}".format(entrypoint["name"]) + entry_obj = bpy.data.objects.new(entrypoint_name, None) + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(entry_obj) + if collection is not None: + collection.objects.link(entry_obj) + + entry_obj.empty_draw_type = "SINGLE_ARROW" + entry_obj.empty_draw_size = 200 if self.roadmap_mode else 500 + else: + collection.objects.link(entry_obj) + + entry_obj.empty_display_type = "SINGLE_ARROW" + entry_obj.empty_display_size = 200 if self.roadmap_mode else 500 + + location = mathutils.Vector((entrypoint['pos_x'], entrypoint['pos_z'], 0 if self.roadmap_mode else entrypoint['pos_y'])) + map_coord = map_coords[entrypoint['map_tile']] + location += mathutils.Vector((map_coord[0] * 300, map_coord[1] * 300, 0)) + entry_obj.location = location + entry_obj.rotation_quaternion = mathutils.Quaternion((entrypoint['rot_x'], entrypoint['rot_y'], + entrypoint['rot_z'], entrypoint['rot_w'])) + + if self.roadmap_mode: + dot_mesh = bpy.data.meshes.new(entrypoint_name + "_dot") + dot_obj = bpy.data.objects.new(entrypoint_name + "_dot", dot_mesh) + bm = bmesh.new() + bm.from_mesh(dot_mesh) + if bpy.app.version < (2, 80): + geom = bmesh.ops.create_circle(bm, + cap_ends=True, + segments=16, + diameter=8) + else: + geom = bmesh.ops.create_circle(bm, + cap_ends=True, + segments=16, + radius=4) + bm.to_mesh(dot_mesh) + dot_obj.location = location+mathutils.Vector((0, 0, 2)) + + dot_mesh.materials.append( + o3d_node_shader_utils.generate_solid_material((0, 0, 0, 1))) + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(dot_obj) + if collection is not None: + collection.objects.link(dot_obj) + else: + collection.objects.link(dot_obj) + + entry_text = bpy.data.curves.new(type="FONT", name=entrypoint_name + "_text") + entry_text.body = entrypoint["name"] + if self.roadmap_mode: + entry_text.size = 20 + entry_text.extrude = 0 + if os.name == "nt": + entry_text.font = bpy.data.fonts.load(filepath="C:/Windows/Fonts/GOTHICB.TTF") + else: + entry_text.size = 200 + entry_text.extrude = 8 + entry_text.offset = 0 + entry_text.space_character = 0.92 + entry_text_obj = bpy.data.objects.new(entrypoint_name + "_text", entry_text) + entry_text_obj.color = (0.01, 0.02, 0.6, 1.) + entry_text_obj.data.materials.append( + o3d_node_shader_utils.generate_solid_material((0.0025, 0.005, 0.3, 1.))) + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(entry_text_obj) + collection.objects.link(entry_text_obj) + else: + collection.objects.link(entry_text_obj) + entry_text_obj.location = location + mathutils.Vector((0, 0, 2.5 if self.roadmap_mode else 500)) + if not self.roadmap_mode: + entry_text_obj.rotation_euler = (math.radians(90), 0, 0) + + objs.append(entry_obj) + objs.append((entry_text_obj)) + + if self.roadmap_mode: + bpy.context.scene.render.resolution_x = 1024 + bpy.context.scene.render.resolution_y = 1024 + + context.window.cursor_set('DEFAULT') + return {"FINISHED"} + + +class ImportMapCFGPanel(bpy.types.Panel): + bl_idname = 'VIEW3D_PT_Omsi_Map' + bl_label = 'Import Omsi Map' + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + + def draw(self, context): + self.layout.label( + text="Tile coords x: " + (str(int(bpy.context.active_object.location[0]//300)) + if bpy.context.active_object else "N/A") + + " y: " + (str(int(bpy.context.active_object.location[1]//300)) + if bpy.context.active_object else "N/A")) + + self.layout.separator() + col_props = self.layout.column(align=True) + + layout_row = col_props.row(align=True) + layout_row.label(text="Map Path:") + layout_row.prop(context.scene.omsi_map_data, "map_path") + + col_props.separator() + + col_props.prop(context.scene.omsi_map_data, "centre_x") + col_props.prop(context.scene.omsi_map_data, "centre_y") + col_props.prop(context.scene.omsi_map_data, "load_radius") + col_props.prop(context.scene.omsi_map_data, "import_scos") + col_props.prop(context.scene.omsi_map_data, "import_x") + col_props.prop(context.scene.omsi_map_data, "import_splines") + col_props.prop(context.scene.omsi_map_data, "spline_tess_dist") + col_props.prop(context.scene.omsi_map_data, "spline_curve_sag") + col_props.prop(context.scene.omsi_map_data, "spline_preview_quality") + + col_props.separator() + layout_row = col_props.row(align=True) + op = layout_row.operator(GenerateMapPreviewOp.bl_idname, text="Preview map", icon="PLUS") + op.filepath = context.scene.omsi_map_data.map_path + op.centre_x = context.scene.omsi_map_data.centre_x + op.centre_y = context.scene.omsi_map_data.centre_y + op.load_radius = context.scene.omsi_map_data.load_radius + op.import_scos = context.scene.omsi_map_data.import_scos + op.import_splines = context.scene.omsi_map_data.import_splines + op.spline_preview_quality = context.scene.omsi_map_data.spline_preview_quality + op.clear = False + op = layout_row.operator(GenerateMapPreviewOp.bl_idname, text="Clear map preview", icon="CANCEL") + op.clear = True + + op = col_props.operator(GenerateMapPreviewOp.bl_idname, text="Load for roadmap", icon="WORLD") + op.roadmap_mode = True + op.filepath = context.scene.omsi_map_data.map_path + op.centre_x = context.scene.omsi_map_data.centre_x + op.centre_y = context.scene.omsi_map_data.centre_y + op.load_radius = context.scene.omsi_map_data.load_radius + op.import_scos = context.scene.omsi_map_data.import_scos + op.import_splines = context.scene.omsi_map_data.import_splines + op.spline_preview_quality = context.scene.omsi_map_data.spline_preview_quality + op.clear = False + + self.layout.separator() + col = self.layout.row(align=True) + op = col.operator("import_scene.omsi_tile", text="Load tiles", icon="PLUS") + op.filepath = context.scene.omsi_map_data.map_path + op.centre_x = context.scene.omsi_map_data.centre_x + op.centre_y = context.scene.omsi_map_data.centre_y + op.load_radius = context.scene.omsi_map_data.load_radius + op.import_scos = context.scene.omsi_map_data.import_scos + op.import_x = context.scene.omsi_map_data.import_x + op.import_splines = context.scene.omsi_map_data.import_splines + op.spline_tess_dist = context.scene.omsi_map_data.spline_tess_dist + op.spline_curve_sag = context.scene.omsi_map_data.spline_curve_sag + + self.layout.separator() + self.layout.label(text="© Thomas Mathieson " + date.today().year.__str__()) + + +classes = [ + OMSIMapProps, + ImportMapCFGPanel, + GenerateMapPreviewOp +] + + +def get_classes(): + return classes[:] + + +def register(): + # Classes are registered and unregistered by __init__.py + bpy.types.Scene.omsi_map_data = bpy.props.PointerProperty(type=OMSIMapProps) + + +def unregister(): + del bpy.types.Scene.omsi_map_data diff --git a/o3d_io/io_omsi_spline.py b/o3d_io/io_omsi_spline.py index 9930011..bd2e862 100644 --- a/o3d_io/io_omsi_spline.py +++ b/o3d_io/io_omsi_spline.py @@ -1,11 +1,12 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== import os import bpy from . import o3d_node_shader_utils -from .blender_texture_io import load_texture_into_new_slot +from .blender_texture_io import load_texture_into_new_slot, find_image_path +from .o3d_cfg_parser import read_generic_cfg_file from mathutils import Vector import mathutils @@ -28,8 +29,9 @@ def clamp(x, _min, _max): class Spline: def __init__(self, sli_path, spline_id, next_id, prev_id, pos, rot, length, radius, - start_grad, end_grad, cant_start, cant_end, skew_start, skew_end, - mirror): + start_grad, end_grad, use_delta_height, delta_height, + cant_start, cant_end, skew_start, skew_end, + mirror, local_id): self.sli_path = sli_path self.id = spline_id self.next_id = next_id @@ -40,11 +42,14 @@ def __init__(self, sli_path, spline_id, next_id, prev_id, self.radius = radius self.start_grad = start_grad self.end_grad = end_grad + self.use_delta_height = use_delta_height + self.delta_height = delta_height self.cant_start = cant_start self.cant_end = cant_end self.skew_start = skew_start self.skew_end = skew_end self.mirror = mirror + self.local_id = local_id def __str__(self) -> str: return "Spline {0}: sli_path = {1}; id = {2}; next_id = {3}; " \ @@ -57,7 +62,219 @@ def __str__(self) -> str: self.cant_start, self.cant_end, self.skew_start, self.skew_end, self.mirror) - def generate_mesh(self, sli_cache, spline_tess_dist, spline_curve_sag): + def _compute_spline_gradient_coeffs(self): + """ + Computes the hermite spline coefficients for this spline from the gradient and delta height properties. + :return: the coefficients c, a, and b representing the z^3, z^2, and z terms respectively + """ + b = self.start_grad/100 + if self.use_delta_height: + c = (self.end_grad-self.start_grad)/100 * self.length - 2 * (self.delta_height - self.start_grad/100 * self.length) + c /= self.length * self.length * self.length + a = -(self.end_grad-self.start_grad)/100 * self.length + 3 * (self.delta_height - self.start_grad/100 * self.length) + a /= self.length * self.length + else: + c = 0 + a = (self.end_grad-self.start_grad)/100/(2*self.length) + + return c, a, b + + def generate_tesselation_points(self, spline_tess_dist, spline_curve_sag): + """ + Generates a set of z coordinates along the spline which represents an "ideal" sampling of the spline. + + :param spline_tess_dist: + :param spline_curve_sag: + :return: + """ + """ + This is a non-trivial problem to solve, and hence the answer is somewhat approximate here. Our spline is + essentially a hermite curve along the y axis and an arc along the xz plane. For ease of implementation we + consider the sampling of these separately and then merge and weld the samplings together. + + The Y axis can be described by the following function: + (see https://www.desmos.com/calculator/7xfjwmqtpz for my calculator) + f_y(z, x) = z^3*c + z^2*a + z*b + x*(z*cant_delta + cant_0) {0 <= z <= length} + To approximate the curvature of the spline we take the arctangent of the first derivative (essentially + converting the gradient of the function into an angle): + f_dy(z, x) = 3z^2*c + 2z*a + b + x*cant_delta {0 <= z <= length} + f_curvature(z, x) = abs(atan(f_dy(z, x))) * (1 + 1/r) + We want to sample this at regular horizontal slices of the curvature equation (ie the higher the gradient of + the curvature equation the more samples to generate), we do this by taking the inverse of the curvature equation + and sampling it at regular intervals along the x axis. This is represented by the following expression: + f_icurvature(z, x) = (-a +- sqrt(a^2 - 3c(b + x*cant_delta) +- 3c*tan(z)) / 3c {0 <= z <= pi/2} + Because of the two +- operations in the above equation, we can actually get up to four points per iteration. + """ + samples = [] + n_grad_samples = 0 + if self.start_grad != self.end_grad or self.use_delta_height: + c, a, b = self._compute_spline_gradient_coeffs() + if a != 0 or c != 0: + cant_delta = (self.cant_end - self.cant_start) / self.length / 100 + # Maybe we should consider the value of x? + x = 0 + _3c = c*3 + if self.use_delta_height: + a23cbxc = a*a - _3c*(b + x * cant_delta) + else: + bxc2a = (-b - x * cant_delta)/(2*a) + z = 0 + if (math.pi/2) / spline_curve_sag > 10000: + log("[ERROR] Spline tesselation would take too long, please increase the spline curve sag distance!") + # Return a basic tesselation... + n_segments = min(max(math.ceil(self.length / spline_tess_dist), 1), 100) + dx = self.length / n_segments + return [i * dx for i in range(n_segments + 1)] + + while z <= math.pi/2: + if self.use_delta_height: + _3ctanz = _3c * math.tan(z) + ic00, ic01, ic10, ic11 = -1, -1, -1, -1 + if a23cbxc + _3ctanz >= 0: + ic00 = (-a + math.sqrt(a23cbxc + _3ctanz))/_3c + ic01 = (-a - math.sqrt(a23cbxc + _3ctanz))/_3c + if a23cbxc - _3ctanz >= 0: + ic10 = (-a + math.sqrt(a23cbxc - _3ctanz))/_3c + ic11 = (-a - math.sqrt(a23cbxc - _3ctanz))/_3c + + if 0 <= ic00 <= self.length: + samples.append(ic00) + if 0 <= ic01 <= self.length: + samples.append(ic01) + if 0 <= ic10 <= self.length: + samples.append(ic10) + if 0 <= ic11 <= self.length: + samples.append(ic11) + else: + # f_icurvature2(z, x) = (-b -x*c_d)/(2a) +- (tan z)/(2a) + t2a = math.tan(z)/(2*a) + ic20 = bxc2a + t2a + ic21 = bxc2a - t2a + + if 0 <= ic20 <= self.length: + samples.append(ic20) + if 0 <= ic21 <= self.length: + samples.append(ic21) + + z += spline_curve_sag * 10 + + n_grad_samples = len(samples) + + # Now append the samples from the arc on the xz plane (spline radius) + radius = abs(self.radius) + dr = float("inf") + if radius > 0: + revs = min(self.length / radius, math.pi * 2) + # Arc distance based angle increment + dr_a = spline_tess_dist / radius + # Sag distance based angle increment, clamped to 0.06deg < x < 90deg + dr_b = 2 * math.acos(1 - clamp(spline_curve_sag / radius, 0.001, 0.294)) + dr = min(dr_a, dr_b) + n_segments = max(math.ceil(revs / dr), 1) + dr = revs / n_segments + samples.extend([i*dr*radius for i in range(n_segments+1)]) + else: + # Now append the samples from the constant tesselation factor + # Note that the arc segment already include the constant tesselation factor + n_segments = max(math.ceil(self.length / spline_tess_dist), 1) + dx = self.length / n_segments + samples.extend([i*dx for i in range(n_segments+1)]) + + # Now weld the samples together based on a heuristic + if len(samples) > 1: + samples.sort() + # This tuple stores the sample position, and it's weight (used for averaging); when a sample is consumed its + # weight is set to 0 and the sample it's merged with has its weight incremented + samples_weighted = [(s, 1) for s in samples] + last_s = 0 + # log(f"spline-{self.id}\tmerge_dist: grad={(self.length/max(float(n_grad_samples), 0.1)/3):3f} " + # f"const={spline_tess_dist:3f} arc={dr*max(radius, 0.01)/2:3f} length={self.length*0.9:3f}") + merge_dist = min((self.length/max(float(n_grad_samples), 0.1)/3), + spline_tess_dist, + dr*max(radius, 0.01)/2, + self.length*0.9) + for i in range(1, len(samples_weighted)-1): + d = samples_weighted[i][0] - samples_weighted[last_s][0] + if d < merge_dist: + sl = samples_weighted[last_s] + samples_weighted[last_s] = ((sl[0]*sl[1]+samples_weighted[i][0]) / (sl[1]+1), sl[1]+1) + samples_weighted[i] = (0, 0) + else: + last_s = i + samples = [s[0] for s in samples_weighted if s[1] > 0] + # Make sure the start and end points are fixed + samples[0] = 0 + samples[-1] = self.length + + return samples + + def evaluate_spline(self, pos_offset, apply_rot=False, world_space=False): + """ + Computes a position along a spline given offset coordinates. + + :param world_space: whether the position should be relative to the tile or relative to the origin of the + spline + :param apply_rot: whether the spline segment's rotation should also be applied to the spline + :param pos_offset: position offset along the spline; y is forward along the spline, x is across the + width of the spline + :return: (the computed position, the computed rotation) + """ + + # Split the split evaluation into separate gradient and radius steps + # Gradient + # Evaluate: f_z(y, x) = y^3*c + y^2*a + y*b + x*(y*cant_delta + cant_0) {0 <= y <= length} + # The rotation is given by: + # f_rx(y, x) = atan(3y^2*c + 2y*a + b + x*cant_delta) {0 <= y <= length} + ox, oy, oz = pos_offset + cant_delta = (self.cant_end-self.cant_start)/100/self.length + c, a, b = self._compute_spline_gradient_coeffs() + pz = oy*oy*oy*c + oy*oy*a + oy*b + rx = math.atan(3*oy*oy*c + 2*oy*a + b + ox*(oy*cant_delta + self.cant_start/100)) + + pz += oz + + # Cant + ry = math.atan(oy*cant_delta + self.cant_start/100) + # TODO: The x coordinate should be clamped to the width of the spline + pz += -ox*(oy*cant_delta + self.cant_start/100) + + # Radius + if abs(self.radius) > 0: + rz = oy/self.radius + k = ox - self.radius + px = k*math.cos(rz) + self.radius + py = -k*math.sin(rz) + rz = -rz + else: + rz = 0 + px = ox + py = oy + + # World space + pos = Vector((px, py, pz)) + rot = Vector((rx, ry, rz)) + if apply_rot or world_space: + if bpy.app.version < (2, 80): + pos = pos * mathutils.Matrix.Rotation(math.radians(self.rot), 4, "Z") + else: + pos = pos @ mathutils.Matrix.Rotation(math.radians(self.rot), 4, "Z") + rot.z += math.radians(-self.rot) + + if world_space: + pos += Vector(self.pos).xzy + + return pos, rot + + def generate_mesh(self, sli_cache, spline_tess_dist, spline_curve_sag, apply_xform = False): + """ + Generates a Blender mesh for this spline. + + :param sli_cache: the dictionary of spline profile definitions + :param spline_tess_dist: the distance between tesselation segments + :param spline_curve_sag: the maximum amount of curve sag for the tessellated segments + :param apply_xform: + :return: a reference to the Blender mesh for this spline + """ sli = sli_cache[self.sli_path] verts = [] uvs = [] @@ -68,88 +285,38 @@ def generate_mesh(self, sli_cache, spline_tess_dist, spline_curve_sag): if self.length == 0: return verts, tris, mat_ids, uvs - # Work out what tessellation increment we should use, this will always be greater than or equal to the one - # specified but needs to be the correct size to divide the length into a whole number of segments - n_segments = max(math.ceil(self.length / spline_tess_dist), 1) - dx = self.length / n_segments - # How much to rotate at each step in radians such that the average arc distance ~= spline_tess_dist - rot_dir = math.copysign(1, self.radius) - self.radius = abs(self.radius) - if self.radius > 0: - revs = min(self.length / self.radius, math.pi * 2) - # Arc distance based angle increment - dr_a = spline_tess_dist / self.radius - # Sag distance based angle increment, clamped to 0.06deg < x < 90deg - dr_b = 2 * math.acos(1 - clamp(spline_curve_sag / self.radius, 0.001, 0.294)) - dr = min(dr_a, dr_b) - n_segments = max(math.ceil(revs / dr), 1) - dr = revs / n_segments - # log(f"len={self.length:.3f} rad={self.radius:.3f} revs={revs*180/math.pi:.3f} " - # f"dr_a={dr_a*180/math.pi:.3f} dr_b={dr_b:.3f} dbg={1 - spline_curve_sag / self.radius}" - # f"dr={dr*180/math.pi:.3f}") - else: - dr = 0 + segment_samples = self.generate_tesselation_points(spline_tess_dist, spline_curve_sag) for profile_part in sli[0]: profile_len = len(profile_part) - pos_offset = mathutils.Vector((0, dx if self.radius <= 0 else 0, 0)) - curr_len = 0 - curr_pos = np.array((0.0, 0.0, 0.0)) - curr_rot = [0.09 * self.start_grad, -0.09 * self.cant_start, 0] - curve_rot_ang = 0 m_verts = [] - for i in range(n_segments + 1): - skew = (self.skew_start * (1 - curr_len / self.length) + self.skew_end * curr_len / self.length) + for y in segment_samples: + skew = (self.skew_start * (1 - y / self.length) + self.skew_end * y / self.length) + if self.mirror: + skew = -skew line = [np.array([profile_part[p][0], skew * profile_part[p][0], profile_part[p][1]]) for p in range(profile_len)] - # Transform the slice - # rot_matrix = R.from_euler("xyz", curr_rot, degrees=True) - rot_matrix = mathutils.Euler((math.radians(curr_rot[0]), math.radians(curr_rot[1]), - 0), 'XYZ') if self.mirror: for p in range(profile_len): line[p][0] = -line[p][0] + l_pos, l_rot = self.evaluate_spline([0, y, 0], apply_xform, apply_xform) + + # To prevent there from being a gap between profile segments, we don't rotate the segments in the x + # direction + l_rot.x = 0 + # Transform the profile lines by the evaluated position along the spline + rot_matrix = mathutils.Euler(l_rot, 'XYZ') r_lines = [mathutils.Vector(line[p]) for p in range(profile_len)] [r_line.rotate(rot_matrix) for r_line in r_lines] - - if self.radius > 0: - if bpy.app.version < (2, 80): - curve_rot = mathutils.Matrix.Translation((self.radius * rot_dir, 0, 0)) \ - * mathutils.Matrix.Rotation(curve_rot_ang, 4, "Z") \ - * mathutils.Matrix.Translation((-self.radius * rot_dir, 0, 0)) - r_lines = [curve_rot * r_line for r_line in r_lines] - else: - curve_rot = mathutils.Matrix.Translation((self.radius * rot_dir, 0, 0)) \ - @ mathutils.Matrix.Rotation(curve_rot_ang, 4, "Z") \ - @ mathutils.Matrix.Translation((-self.radius * rot_dir, 0, 0)) - r_lines = [curve_rot @ r_line for r_line in r_lines] - - line = [np.array(r_line) + curr_pos for r_line in r_lines] + line = [r_line + mathutils.Vector(l_pos) for r_line in r_lines] # Add to the vert list m_verts.extend(line) # Create UVs - uvs.extend([(profile_part[p][2], curr_len * profile_part[p][3]) for p in range(profile_len)]) - - # Increment position and rotation - pos_offset.rotate(rot_matrix) - curr_pos += np.array(pos_offset) - curve_rot_ang -= dr * rot_dir - # if self.radius > 0: - # curr_rot[2] += 180 / math.pi * dx / self.radius - - curr_rot[0] = 0.09 * ( - self.start_grad * (1 - curr_len / self.length) + self.end_grad * curr_len / self.length) - curr_rot[1] = -0.09 * ( - self.cant_start * (1 - curr_len / self.length) + self.cant_end * curr_len / self.length) - - if self.radius > 0: - curr_len += dr * self.radius * rot_dir - else: - curr_len += dx + uvs.extend([(profile_part[p][2], (y+skew*profile_part[p][0]) * profile_part[p][3]) for p in range(profile_len)]) # Construct triangles for the current strip (ie the current [profile]) # The vertex array should look like this for a [profile] with three points: @@ -162,37 +329,46 @@ def generate_mesh(self, sli_cache, spline_tess_dist, spline_curve_sag): # | / | / | | / | # 0---1---2 0---1 # {Start} /\ For a profile with 2 points - for x in range(n_segments): + for x in range(len(segment_samples) - 1): for y in range(profile_len - 1): if self.mirror: # CW winding - tris.append((y + v_count + x * profile_len, # 0 / 0 - y + v_count + (x + 1) * profile_len + 1, # 4 / 3 - y + v_count + x * profile_len + 1)) # 1 / 1 - tris.append((y + v_count + x * profile_len, # 0 / 0 - y + v_count + (x + 1) * profile_len, # 3 / 2 + tris.append((y + v_count + x * profile_len, # 0 / 0 + y + v_count + (x + 1) * profile_len + 1, # 4 / 3 + y + v_count + x * profile_len + 1)) # 1 / 1 + tris.append((y + v_count + x * profile_len, # 0 / 0 + y + v_count + (x + 1) * profile_len, # 3 / 2 y + v_count + (x + 1) * profile_len + 1)) # 4 / 3 else: # CCW winding - tris.append((y + v_count + x * profile_len + 1, # 1 / 1 - y + v_count + (x + 1) * profile_len + 1, # 4 / 3 - y + v_count + x * profile_len)) # 0 / 0 - tris.append((y + v_count + (x + 1) * profile_len + 1, # 4 / 3 - y + v_count + (x + 1) * profile_len, # 3 / 2 - y + v_count + x * profile_len)) # 0 / 0 + tris.append((y + v_count + x * profile_len + 1, # 1 / 1 + y + v_count + (x + 1) * profile_len + 1, # 4 / 3 + y + v_count + x * profile_len)) # 0 / 0 + tris.append((y + v_count + (x + 1) * profile_len + 1, # 4 / 3 + y + v_count + (x + 1) * profile_len, # 3 / 2 + y + v_count + x * profile_len)) # 0 / 0 - mat_ids.extend([profile_part[0][4] for x in range(n_segments * (profile_len - 1) * 2)]) + mat_ids.extend([profile_part[0][4] for x in range((len(segment_samples) - 1) * (profile_len - 1) * 2)]) verts.extend(m_verts) v_count += len(m_verts) - self.radius *= rot_dir return verts, tris, mat_ids, uvs def load_spline(sli_path, omsi_dir): + """ + Loads a spline profile from a sli file. + + :param sli_path: + :param omsi_dir: + :return: (points: list(profile: list(point: list(x, z, uvx, y_tile, mat_id))), matls) + """ # Spline profiles are defined as a list of pairs of points. Each pair can have a different material and uvs. # We usually assume that the pairs join up with each other, but it is not required... + if not os.path.isfile(os.path.join(omsi_dir, sli_path)): + sli_path = os.path.join("Splines", "invis_street.sli") + log("[WARNING] Spline profile {0} does not exist! Replacing with invis_street.sli instead...") with open(os.path.join(omsi_dir, sli_path), "r", encoding="utf-8", errors="replace") as f: curr_mat_idx = -1 points = [] @@ -210,6 +386,12 @@ def load_spline(sli_path, omsi_dir): curr_matl = next(lines).rstrip().lower() matls[curr_matl] = {} matls[curr_matl]["diffuse"] = curr_matl + tex_cfg = find_image_path(sli_path, curr_matl, False, True) + if tex_cfg is not None: + tex_cfg = read_generic_cfg_file(tex_cfg) + if "[terrainmapping]" in tex_cfg: + matls[curr_matl]["diffuse"]["terrainmapping"] = True + elif line.rstrip() == "[matl_alpha]": matls[curr_matl]["alpha"] = int(next(lines)) elif line.rstrip() == "[patchwork_chain]": @@ -223,26 +405,56 @@ def load_spline(sli_path, omsi_dir): return points, matls -def load_spline_defs(map_file): +def load_spline_defs(map_file: dict): spline_defs = [] - for lines in map_file.get("[spline]", []) + map_file.get("[spline_h]", []): - spline_defs.append(Spline( - lines[1], # path - int(lines[2]), # spline_id - int(lines[3]), # next_id - int(lines[4]), # prev_id - [float(lines[5 + x]) for x in range(3)], # pos - float(lines[8]), # rot - float(lines[9]), # length - float(lines[10]), # radius - float(lines[11]), # start_grad - float(lines[12]), # end_gra - float(lines[13]), # cant_start - float(lines[14]), # cant_en - float(lines[15]), # skew_start - float(lines[16]), # skew_end - lines[18] == "mirror" # mirror - )) + splines = map_file.get("[spline]", []) + map_file.get("[spline_h]", []) + splines.sort(key=lambda x: int(x[-2])) + for local_id, lines in enumerate(splines): + if lines[-1] == "[spline]": + spline_defs.append(Spline( + lines[1], # path + int(lines[2]), # spline_id + int(lines[3]), # next_id + int(lines[4]), # prev_id + [float(lines[5 + x]) for x in range(3)], # pos + float(lines[8]), # rot + float(lines[9]), # length + float(lines[10]), # radius + float(lines[11]), # start_grad + float(lines[12]), # end_grad + False, + 0, + float(lines[13]), # cant_start + float(lines[14]), # cant_end + float(lines[15]), # skew_start + float(lines[16]), # skew_end + # lines[17], # length_accum, the accumulated length from all previous segments in this spline + lines[18] == "mirror", # mirror + local_id # local_id + )) + else: + spline_defs.append(Spline( + lines[1], # path + int(lines[2]), # spline_id + int(lines[3]), # next_id + int(lines[4]), # prev_id + [float(lines[5 + x]) for x in range(3)], # pos + float(lines[8]), # rot + float(lines[9]), # length + float(lines[10]), # radius + float(lines[11]), # start_grad + float(lines[12]), # end_gra + True, + float(lines[13]), # delta_height + float(lines[14]), # cant_start + float(lines[15]), # cant_en + float(lines[16]), # skew_start + float(lines[17]), # skew_end + # lines[18], # length_accum, the accumulated length from all previous segments in this spline + lines[19] == "mirror", # mirror + local_id # local_id + )) + log("Loaded {0} splines!".format(len(spline_defs))) return spline_defs @@ -250,7 +462,7 @@ def load_spline_defs(map_file): def generate_materials(mesh, sli_path, matls): for matl in matls.values(): mat_blender = bpy.data.materials.new(matl["diffuse"]) - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): mat = mat_blender mat.diffuse_color = (1, 1, 1) mat.specular_hardness = 0.1 @@ -266,14 +478,14 @@ def generate_materials(mesh, sli_path, matls): # Load the diffuse texture and assign it to a new texture slot diff_tex = load_texture_into_new_slot(sli_path, matl["diffuse"], mat) if diff_tex: - if not (bpy.app.version[0] < 3 and bpy.app.version[1] < 80): + if bpy.app.version >= (2, 80): mat.base_color_texture.image = diff_tex.texture.image diff_tex.texture.image.colorspace_settings.name = 'sRGB' if "alpha" in matl and matl["alpha"] > 0: # Material uses alpha stored in diffuse texture alpha channel - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): mat.use_transparency = True diff_tex.use_map_alpha = True diff_tex.alpha_factor = 1 @@ -282,7 +494,7 @@ def generate_materials(mesh, sli_path, matls): mat.alpha = 0 - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): pass else: mat_blender.use_backface_culling = True @@ -292,7 +504,16 @@ def generate_materials(mesh, sli_path, matls): mesh.materials.append(mat_blender) -def import_map_splines(filepath, map_file, spline_tess_dist, spline_curve_sag): +def import_map_splines(filepath, map_file, spline_tess_dist, spline_curve_sag, parent_collection): + """ + Imports all the splines in a given map tile and generates meshes for them. + :param filepath: + :param map_file: + :param spline_tess_dist: + :param spline_curve_sag: + :param parent_collection: + :return: (an array of Blender objects, a list of Spline definitions) + """ spline_defs = load_spline_defs(map_file) omsi_dir = os.path.abspath(os.path.join(os.path.dirname(filepath), os.pardir, os.pardir)) @@ -308,11 +529,14 @@ def import_map_splines(filepath, map_file, spline_tess_dist, spline_curve_sag): name = "spline-{0}".format(spline.id) mesh = bpy.data.meshes.new(name) mesh.from_pydata(verts, [], tris) - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: + if bpy.app.version < (2, 80): mesh.uv_textures.new("UV Map") else: mesh.uv_layers.new(name="UV Map") + values = [True] * len(mesh.polygons) + mesh.polygons.foreach_set("use_smooth", values) + mesh.update(calc_edges=True) for i, face in enumerate(mesh.polygons): @@ -325,25 +549,121 @@ def import_map_splines(filepath, map_file, spline_tess_dist, spline_curve_sag): generate_materials(mesh, os.path.join(omsi_dir, spline.sli_path), spline_cache[spline.sli_path][1]) # Create a blender object from the mesh and set its transform - o = bpy.data.objects.new(name, mesh) - objs.append(o) + create_spline_obj(mesh, name, objs, parent_collection, spline) - # Cache spline creation params - bpy.data.objects[o.name]["spline_def"] = spline.__dict__ + return objs, spline_defs - o.location = [spline.pos[0], spline.pos[2], spline.pos[1]] - o.rotation_euler = [0, 0, math.radians(-spline.rot)] - if bpy.app.version[0] < 3 and bpy.app.version[1] < 80: - scene = bpy.context.scene - scene.objects.link(o) +def import_map_preview_splines(filepath, map_file, spline_tess_dist, parent_collection, mesh_gen=False): + """ + Imports all the splines in a given map tile and generates preview curves for them. + :param filepath: + :param map_file: + :param spline_tess_dist: + :param parent_collection: + :param mesh_gen: + :return: (an array of Blender objects, a list of Spline definitions) + """ + spline_defs = load_spline_defs(map_file) - o.select = True - else: - view_layer = bpy.context.view_layer - collection = view_layer.active_layer_collection.collection + omsi_dir = os.path.abspath(os.path.join(os.path.dirname(filepath), os.pardir, os.pardir)) + for spline in spline_defs: + spline.use_delta_height = False + spline.end_grad = 0 + spline.start_grad = 0 + spline.cant_start = 0 + spline.cant_end = 0 + spline.delta_height = 0 + spline.pos[1] = 0 - collection.objects.link(o) - o.select_set(True) + spline_cache = {} + if mesh_gen: + for obj in spline_defs: + if obj.sli_path not in spline_cache: + sli_points, matls = load_spline(obj.sli_path, omsi_dir) + matls = list(matls.values()) + + # Simplify the spline, determine the total width of the spline and just use that as + min_x = 0 + max_x = 0 + for profile in sli_points: + if len(profile) > 0: + mat_id = profile[0][4] + if mat_id >= len(matls) or "terrainmapping" in matls[mat_id]: + continue + + for point in profile: + min_x = min(min_x, point[0]) + max_x = max(max_x, point[0]) + sli_points = [[[min_x, 0.25, 0, 1, 0], [max_x, 0.25, 1, 1, 0]]] + + spline_cache[obj.sli_path] = (sli_points, matls) - return objs + objs = [] + if mesh_gen: + spline_bdata = bpy.data.meshes.new("merged-splines") + else: + spline_bdata = bpy.data.curves.new("merged-splines", type='CURVE') + spline_bdata.dimensions = '3D' + spline_bdata.resolution_u = 2 + + # Create a blender object from the mesh and set its transform + o = bpy.data.objects.new("merged-splines", spline_bdata) + objs.append(o) + + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(o) + parent_collection.objects.link(o) + + o.select = True + else: + parent_collection.objects.link(o) + o.select_set(True) + + verts = [] + tris = [] + for spline in spline_defs: + if mesh_gen: + verts_inst, tris_inst, mat_ids, uvs = spline.generate_mesh(spline_cache, spline_tess_dist, 1000, + apply_xform=True) + tris_inst = np.add(tris_inst, len(verts)) + verts.extend(verts_inst) + tris.extend(tris_inst) + else: + # map coords to spline + polyline = spline_bdata.splines.new('POLY') + if spline.length > 0: + n_points = max(math.floor(spline.length/spline_tess_dist), 2) + polyline.points.add(n_points-1) + for i in range(n_points): + x, y, z = spline.evaluate_spline((0, i/(n_points-1)*spline.length, 0), True, True)[0] + polyline.points[i].co = (x, y, z, 1) + + if mesh_gen: + spline_bdata.from_pydata(verts, [], tris) + + values = [True] * len(spline_bdata.polygons) + spline_bdata.polygons.foreach_set("use_smooth", values) + + spline_bdata.update(calc_edges=True) + + spline_bdata.materials.append(o3d_node_shader_utils.generate_solid_material((0.8, 0.8, 0.8, 1.))) + + return objs, spline_defs + + +def create_spline_obj(mesh, name, objs, parent_collection, spline): + o = bpy.data.objects.new(name, mesh) + objs.append(o) + # Cache spline creation params + bpy.data.objects[o.name]["spline_def"] = spline.__dict__ + o.location = [spline.pos[0], spline.pos[2], spline.pos[1]] + o.rotation_euler = [0, 0, math.radians(-spline.rot)] + if bpy.app.version < (2, 80): + bpy.context.scene.objects.link(o) + parent_collection.objects.link(o) + + o.select = True + else: + parent_collection.objects.link(o) + o.select_set(True) diff --git a/o3d_io/io_omsi_tile.py b/o3d_io/io_omsi_tile.py index 53d8b3c..4d00a20 100644 --- a/o3d_io/io_omsi_tile.py +++ b/o3d_io/io_omsi_tile.py @@ -1,3 +1,7 @@ +# ============================================================================== +# Copyright (c) 2023 Thomas Mathieson. +# ============================================================================== + import math import os.path import time @@ -5,17 +9,20 @@ import bpy import struct -from . import o3d_node_shader_utils, io_omsi_spline +import mathutils +from . import o3d_node_shader_utils, io_omsi_spline, io_o3d_import from .blender_texture_io import load_texture_into_new_slot +from .o3d_cfg_parser import read_generic_cfg_file def log(*args): print("[OMSI_Tile_Import]", *args) -def do_import(context, filepath, import_scos, import_splines, spline_tess_dist, spline_tess_angle, import_x): +def do_import(context, filepath, import_scos, import_splines, spline_tess_dist, spline_tess_angle, import_x, + centre_x, centre_y, load_radius): # Read global.cfg - global_cfg = read_cfg_file(os.path.join(os.path.dirname(filepath), "global.cfg")) + global_cfg = read_generic_cfg_file(os.path.join(os.path.dirname(filepath), "global.cfg")) if filepath[-3:] == "map": import_tile(context, filepath, import_scos, global_cfg, import_splines, spline_tess_dist, spline_tess_angle, @@ -25,23 +32,33 @@ def do_import(context, filepath, import_scos, import_splines, spline_tess_dist, working_dir = os.path.dirname(filepath) objs = [] tiles = 0 + loaded_objs_cache = {} for map_file in global_cfg["[map]"]: x = int(map_file[0]) y = int(map_file[1]) path = map_file[2] - log("### Loading " + path) + diff = (centre_x - x, centre_y - y) + dist = math.sqrt(diff[0]*diff[0] + diff[1] * diff[1]) + if dist > load_radius*0.5+0.5: + continue - import_tile(context, os.path.join(working_dir, path), import_scos, global_cfg, import_splines, - spline_tess_dist, spline_tess_angle, import_x) + log("### Loading " + path) - bpy.ops.transform.translate(value=(x * 300, y * 300, 0)) + tile_objs = import_tile(context, os.path.join(working_dir, path), import_scos, global_cfg, import_splines, + spline_tess_dist, spline_tess_angle, import_x, loaded_objs_cache) - collection = bpy.data.collections.new(path) - bpy.context.scene.collection.children.link(collection) + bpy.ops.object.select_all(action='DESELECT') + if bpy.app.version < (2, 80): + for o in tile_objs: + if o.parent is None: + o.select = True + else: + for o in tile_objs: + if o.parent is None: + o.select_set(True) - for o in bpy.context.selected_objects: - collection.objects.link(o) + bpy.ops.transform.translate(value=(x * 300, y * 300, 0)) objs.extend(bpy.context.selected_objects) bpy.ops.object.select_all(action='DESELECT') @@ -52,34 +69,45 @@ def do_import(context, filepath, import_scos, import_splines, spline_tess_dist, def import_tile(context, filepath, import_scos, global_cfg, import_splines, spline_tess_dist, spline_tess_angle, - import_x): + import_x, loaded_objs_cache=None): start_time = time.time() - map_file = read_cfg_file(filepath) + if bpy.app.version < (2, 80): + collection = bpy.data.groups.new(os.path.basename(filepath[:-4])) + else: + collection = bpy.data.collections.new(os.path.basename(filepath[:-4])) + bpy.context.scene.collection.children.link(collection) + + map_file = read_generic_cfg_file(filepath) # Make terrain mesh terrain_obj, terr_heights = import_terrain_mesh(filepath, global_cfg) blender_insts = [] + spline_defs = None + if import_splines: + splines = io_omsi_spline.import_map_splines(filepath, map_file, spline_tess_dist, spline_tess_angle, collection) + blender_insts.extend(splines[0]) + spline_defs = splines[1] + else: + spline_defs = io_omsi_spline.load_spline_defs(map_file) + if import_scos: - blender_insts = import_map_objects(filepath, map_file, terr_heights, import_x) + blender_insts.extend(import_map_objects(filepath, map_file, terr_heights, import_x, collection, + spline_defs, loaded_objs_cache)) - if import_splines: - blender_insts.extend(io_omsi_spline.import_map_splines(filepath, map_file, spline_tess_dist, spline_tess_angle)) + blender_insts.append(terrain_obj) # Make collection if bpy.app.version < (2, 80): - scene = bpy.context.scene - scene.objects.link(terrain_obj) - else: - view_layer = context.view_layer - collection = view_layer.active_layer_collection.collection - collection.objects.link(terrain_obj) + bpy.context.scene.objects.link(terrain_obj) + + collection.objects.link(terrain_obj) bpy.ops.object.select_all(action='DESELECT') if bpy.app.version < (2, 80): - terrain_obj = True + terrain_obj.select = True bpy.ops.object.shade_smooth() for o in blender_insts: o.select = True @@ -89,39 +117,10 @@ def import_tile(context, filepath, import_scos, global_cfg, import_splines, spli for o in blender_insts: o.select_set(True) - log("Loaded tile {0} in {1} seconds!".format(filepath, time.time() - start_time)) - - -def read_cfg_file(cfg_path): - with open(cfg_path, 'r', encoding="utf-16-le", errors="replace") as f: - lines = [l.rstrip() for l in f.readlines()] - - cfg_data = {} + bpy.data.scenes[context.scene.name]["map_data"] = map_file - current_command = None - param_ind = -1 - for i, line in enumerate(lines): - if len(line) > 2 and line[0] == "[" and line[-1] == "]": - current_command = line - param_ind = -1 - else: - param_ind += 1 - - # if current_command == "[LOD]": - # if param_ind == 0: - # current_lod = (float(line), i) - - if current_command is not None: - # Current command is not currently parsed - if param_ind == -1: - if current_command in cfg_data: - cfg_data[current_command].append([]) - else: - cfg_data[current_command] = [[]] - if param_ind >= 0: - cfg_data[current_command][-1].append(line) - - return cfg_data + log("Loaded tile {0} in {1} seconds!".format(filepath, time.time() - start_time)) + return blender_insts def is_int(x): @@ -289,65 +288,269 @@ def get_interpolated_height(terr_heights, x, y): return lerp(il, ih, y_frac) -def import_map_objects(filepath, map_file, terr_heights, import_x): - objs = [] +def import_map_objects(filepath, map_file, terr_heights, import_x, parent_collection, spline_defs, loaded_objs=None): + if loaded_objs is None: + loaded_objs = {} blender_insts = [] omsi_dir = os.path.abspath(os.path.join(os.path.dirname(filepath), os.pardir, os.pardir)) # log("Assuming OMSI directory of: ", omsi_dir) - if "[object]" not in map_file: - return blender_insts - - for lines in map_file["[object]"]: - path = lines[1] - obj_id = int(lines[2]) - pos = [float(lines[3 + x]) for x in range(3)] - rot = [float(lines[6 + x]) for x in range(3)] # ZYX (Z-Up) - - objs.append({"path": os.path.join(omsi_dir, path), "id": obj_id, "pos": pos, "rot": rot}) + objs = parse_map_data(map_file, omsi_dir) log("Loaded {0} objects!".format(len(objs))) - loaded_objs = {} for obj in objs: - pos = obj["pos"] + pos = mathutils.Vector(obj["pos"]) path = obj["path"] - rot = [-x / 180 * 3.14159265 for x in obj["rot"]] + rot = mathutils.Vector([-math.radians(x) for x in obj["rot"]]).zyx + obj_name = os.path.basename(path)[:-4] + + if "spline" in obj: + # Weird edge case in OMSI where spline_attachments with a negative spline distance + if pos.z < 0 or pos.z > spline_defs[obj["spline"]].length: + continue if path in loaded_objs: # Save time by duplicating existing objects - if bpy.app.version < (2, 80): - for o in bpy.context.selected_objects: - o.select = False - for o in loaded_objs[path]: - o.select = True - else: - for o in bpy.context.selected_objects: - o.select_set(False) - for o in loaded_objs[path]: - o.select_set(True) - # TODO: Replace this with a low level method to prevent slow down - # See: https://blenderartists.org/t/python-slowing-down-over-time/569534 - bpy.ops.object.duplicate_move_linked() - + container_obj, new_objs = clone_object(loaded_objs, obj_name, parent_collection, path) + blender_insts.extend(new_objs) else: # bpy.ops.mesh.primitive_cube_add(location=pos) + imported_objs = [] try: - bpy.ops.import_scene.omsi_model_cfg(filepath=path, import_x=import_x) + imported_objs = io_o3d_import.do_import(path, bpy.context, import_x, "", True, False, parent_collection) except: log("Exception encountered loading: " + path) - loaded_objs[path] = [o for o in bpy.context.selected_objects] + container_obj = bpy.data.objects.new(obj_name, None) + parent_collection.objects.link(container_obj) + + loaded_objs[path] = [o for o in imported_objs] + for o in imported_objs: + o.parent = container_obj + + imported_objs.append(container_obj) + + blender_insts.extend(imported_objs) + + align_tangent = "tangent_to_spline" in obj + if obj["tree_data"] is not None: + # TODO: Handle tree replacement + container_obj["tree_data"] = obj["tree_data"] + if "attach" in obj: + container_obj["attach"] = obj["attach"] + + # TODO: Copy across any [object] specific parameters to the container bl_object + + if "spline" in obj: + container_obj["cfg_data"] = obj + container_obj["spline_id"] = spline_defs[obj["spline"]].id + container_obj["rep_distance"] = obj["rep_distance"] + container_obj["rep_range"] = obj["rep_range"] + container_obj["tangent_to_spline"] = obj["tangent_to_spline"] + if "repeater_x" in obj: + container_obj["repeater_x"] = obj["repeater_x"] + container_obj["repeater_y"] = obj["repeater_y"] + # log(f"Rep {obj['id']} {obj['rep_distance']} {obj['rep_range']}\t:::") + d = obj["rep_distance"] + while d < obj["rep_range"] and (d + pos.z < spline_defs[obj["spline"]].length): + # Clone the object and transform it + container_obj_rep, new_objs = clone_object(loaded_objs, obj_name, parent_collection, path) + blender_insts.extend(new_objs) + + pos_s = pos.xzy + pos_s += mathutils.Vector((0, d, 0)) + pos_rep, spl_rot = spline_defs[obj["spline"]].evaluate_spline(pos_s, True, True) + if align_tangent: + spl_rot.x = -spl_rot.x + spl_rot.y = -spl_rot.y + else: + spl_rot.x = 0 + spl_rot.y = 0 + + # log(f"\t\t inst:{pos_s} -> {pos_rep}") + container_obj_rep.location = pos_rep + container_obj_rep.rotation_euler = rot + spl_rot + container_obj_rep["rep_parent"] = obj - if len(bpy.context.selected_objects) > 0: - if "[surface]" in bpy.context.selected_objects[0].data \ - and not bpy.context.selected_objects[0].data["[surface]"]: - pos[2] += get_interpolated_height(terr_heights, pos[0], pos[1]) + d += obj["rep_distance"] + + # Position is relative to the spline + pos, spl_rot = spline_defs[obj["spline"]].evaluate_spline(pos.xzy, True, True) + if align_tangent: + spl_rot.x = -spl_rot.x + spl_rot.y = -spl_rot.y + else: + spl_rot.x = 0 + spl_rot.y = 0 + rot += spl_rot + else: + pos.z += get_interpolated_height(terr_heights, pos.x, pos.y) - for loaded in bpy.context.selected_objects: - loaded.location = pos - loaded.rotation_euler = rot[::-1] - blender_insts.append(loaded) + container_obj.location = pos + container_obj.rotation_euler = rot return blender_insts + + +def clone_object(loaded_objs, obj_name, parent_collection, path): + blender_insts = [] + if bpy.app.version < (2, 80): + container_obj = bpy.data.objects.new(obj_name, None) + parent_collection.objects.link(container_obj) + blender_insts.append(container_obj) + for o in loaded_objs[path]: + cop = o.copy() + bpy.context.scene.objects.link(cop) + parent_collection.objects.link(cop) + cop.parent = container_obj + blender_insts.append(cop) + else: + container_obj = bpy.data.objects.new(obj_name, None) + parent_collection.objects.link(container_obj) + blender_insts.append(container_obj) + for o in loaded_objs[path]: + cop = o.copy() + parent_collection.objects.link(cop) + cop.parent = container_obj + blender_insts.append(cop) + return container_obj, blender_insts + + +def parse_map_data(map_file, omsi_dir): + objs = [] + + if "[object]" in map_file: + for lines in map_file["[object]"]: + # I'm not sure what line 0, it's almost always a 0, rarely it's a 1 and only on Berlin is it ever 2 + path = lines[1] + obj_id = int(lines[2]) + pos = [float(lines[3 + x]) for x in range(3)] + rot = [float(lines[6 + x]) for x in range(3)] # ZYX (Z-Up) + + try: + obj_type = int(lines[9]) + except ValueError: + obj_type = 0 + + tree_data = None + if obj_type == 4: + tree_data = { + "texture": lines[10], + "height": float(lines[11]), + "aspect": float(lines[12]) + } + elif obj_type == 7: + pass # Bus stop + elif obj_type == 1: + pass # Label + + objs.append({ + "cfg_type": "object", + "path": os.path.join(omsi_dir, path), + "id": obj_id, + "pos": pos, "rot": rot, + "tree_data": tree_data + }) + + if "[attachObj]" in map_file: + for lines in map_file["[attachObj]"]: + path = lines[1] + obj_id = int(lines[2]) + pos = [float(lines[4 + x]) for x in range(3)] + rot = [float(lines[5 + x]) for x in range(3)] # ZYX (Z-Up) + + try: + obj_type = int(lines[10]) + except ValueError: + obj_type = 0 + + tree_data = None + if obj_type == 4: + tree_data = { + "cfg_type": "attachObj", + "texture": lines[11], + "height": float(lines[12]), + "aspect": float(lines[13]) + } + elif obj_type == 7: + pass # Bus stop + elif obj_type == 1: + pass # Label + + objs.append({ + "path": os.path.join(omsi_dir, path), + "id": obj_id, + "pos": pos, "rot": rot, + "tree_data": tree_data, + "attach": int(lines[3]) + }) + + if "[splineAttachement]" in map_file: + for lines in map_file["[splineAttachement]"]: + try: + obj_type = int(lines[13]) + except ValueError: + obj_type = 0 + + tree_data = None + if obj_type == 4: + tree_data = { + "texture": lines[14], + "height": float(lines[15]), + "aspect": float(lines[16]) + } + elif obj_type == 7: + pass # Bus stop + elif obj_type == 1: + pass # Label + + objs.append({ + "cfg_type": "splineAttachement", + "path": os.path.join(omsi_dir, lines[1]), + "id": int(lines[2]), + "pos": [float(lines[4 + x]) for x in range(3)], + "rot": [float(lines[7 + x]) for x in range(3)], + "tree_data": tree_data, + "spline": int(lines[3]), + "rep_distance": float(lines[10]), + "rep_range": float(lines[11]), + "tangent_to_spline": int(lines[12]) == 1 + }) + + if "[splineAttachement_repeater]" in map_file: + for lines in map_file["[splineAttachement_repeater]"]: + try: + obj_type = int(lines[15]) + except ValueError: + obj_type = 0 + + tree_data = None + if obj_type == 4: + tree_data = { + "texture": lines[16], + "height": float(lines[17]), + "aspect": float(lines[18]) + } + elif obj_type == 7: + pass # Bus stop + elif obj_type == 1: + pass # Label + + objs.append({ + "cfg_type": "splineAttachement_repeater", + "path": os.path.join(omsi_dir, lines[3]), + "id": int(lines[4]), + "pos": [float(lines[6 + x]) for x in range(3)], + "rot": [float(lines[9 + x]) for x in range(3)], + "tree_data": tree_data, + "spline": int(lines[5]), + "rep_distance": float(lines[12]), + "rep_range": float(lines[13]), + "tangent_to_spline": int(lines[14]) == 1, + "repeater_x": int(lines[1]), # I still don't know what these do + "repeater_y": int(lines[2]), # I still don't know what these do + }) + + return objs diff --git a/o3d_io/o3d_cfg_parser.py b/o3d_io/o3d_cfg_parser.py index 2d235e9..352703d 100644 --- a/o3d_io/o3d_cfg_parser.py +++ b/o3d_io/o3d_cfg_parser.py @@ -1,5 +1,5 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== import math import os @@ -15,6 +15,56 @@ def log(*args): print("[O3D_CFG_Parser]", *args) +def read_generic_cfg_file(cfg_path): + """ + Loads and parses a generic cfg file into a dictionary + :param cfg_path: the absolute file path to load the cfg from + :return: a dict with the cfg command (including square brackets) as the key and a list of instances (which are + lists of lines) as values + """ + try: + with open(cfg_path, 'r', encoding="utf-8") as f: + lines = [l.rstrip() for l in f.readlines()] + except: + with open(cfg_path, 'r', encoding="utf-16-le", errors="replace") as f: + lines = [l.rstrip() for l in f.readlines()] + + cfg_data = {} + + current_command = None + current_command_start = None + param_ind = -1 + for i, line in enumerate(lines): + if len(line) > 2 and line[0] == "[" and line[-1] == "]": + # Append the line number to the end of the arguments list for order sensitive commands + if current_command is not None: + cfg_data[current_command][-1].append(current_command_start) + cfg_data[current_command][-1].append(current_command) + + current_command = line + current_command_start = str(i) + param_ind = -1 + else: + param_ind += 1 + + if current_command is not None: + # Current command is not currently parsed + if param_ind == -1: + if current_command in cfg_data: + cfg_data[current_command].append([]) + else: + cfg_data[current_command] = [[]] + if param_ind >= 0: + cfg_data[current_command][-1].append(line) + + # Append the line number to the end of the arguments list for order sensitive commands + if current_command is not None: + cfg_data[current_command][-1].append(current_command_start) + cfg_data[current_command][-1].append(current_command) + + return cfg_data + + def read_cfg(filepath, override_text_encoding): """ Reads the CFG file for a mesh and parses relevant information such as materials. @@ -45,8 +95,7 @@ def read_cfg(filepath, override_text_encoding): """ # get the folder folder = (os.path.dirname(filepath)) - if filepath[-3:] == "sco": - folder = os.path.join(folder, "model") + folder_model = os.path.join(folder, "model") # log("Loading " + filepath) encoding = override_text_encoding if override_text_encoding.strip() != "" else "1252" @@ -62,10 +111,13 @@ def read_cfg(filepath, override_text_encoding): -1: { "meshes": {}, "surface": False, - "cfg_data": [] + "cfg_data": [], + "maplights": [], + "light_flares": [], + "interior_lights": [], + "spotlights": [], } } - files = [] lights = [] current_command = None @@ -75,7 +127,8 @@ def read_cfg(filepath, override_text_encoding): param_ind = -1 interior_light_ind = 0 spotlight_ind = 0 - for i, line in enumerate(lines): + iter_lines = iter(enumerate(lines)) + for i, line in iter_lines: if len(line) > 2 and line[0] == "[" and line[-1] == "]": current_command = line param_ind = -1 @@ -90,7 +143,11 @@ def read_cfg(filepath, override_text_encoding): cfg_data[current_lod] = { "meshes": {}, "surface": False, - "cfg_data": [] + "cfg_data": [], + "maplights": [], + "light_flares": [], + "interior_lights": [], + "spotlights": [], } elif current_command == "[groups]": @@ -108,24 +165,41 @@ def read_cfg(filepath, override_text_encoding): elif current_command == "[surface]": cfg_data[current_lod]["surface"] = True + elif current_command == "[editor_only]": + cfg_data[current_lod]["editor_only"] = True + + elif current_command == "[maplight]": + if param_ind == -1: + cfg_data[current_lod]["maplights"].append({}) + cfg_data[current_lod]["maplights"][-1]["x"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["maplights"][-1]["y"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["maplights"][-1]["z"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["maplights"][-1]["r"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["maplights"][-1]["g"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["maplights"][-1]["b"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["maplights"][-1]["range"] = float(next(iter_lines)[1]) + lights.append(cfg_data[current_lod]["maplights"][-1]) + + elif current_command == "[tree]": + if param_ind == -1: + cfg_data[current_lod]["tree"] = {} + cfg_data[current_lod]["tree"]["texture"] = next(iter_lines)[1] + cfg_data[current_lod]["tree"]["min_height"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["tree"]["max_height"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["tree"]["min_aspect"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["tree"]["max_aspect"] = float(next(iter_lines)[1]) + elif current_command == "[mesh]": if param_ind == 0: current_mat = None mesh_path = os.path.join(folder, line) - if line[-4:] == ".o3d": - if os.path.isfile(mesh_path): - files.append((mesh_path, current_lod)) - if line[-2:] == ".x": - if os.path.isfile(mesh_path): - files.append((mesh_path, current_lod)) + if not os.path.isfile(mesh_path): + mesh_path = os.path.join(folder_model, line) current_mesh = line cfg_data[current_lod]["meshes"][current_mesh] = { "path": mesh_path, "matls": {}, - "light_flares": [], - "interior_lights": {}, - "spotlights": {}, "cfg_data": [] } @@ -136,144 +210,83 @@ def read_cfg(filepath, override_text_encoding): elif current_command == "[interiorlight]": if param_ind == -1: interior_light_ind += 1 - cfg_data[current_lod]["meshes"][current_mesh]["interior_lights"][interior_light_ind] = {} - - lights.append(cfg_data[current_lod]["meshes"][current_mesh]["interior_lights"][interior_light_ind]) - - m_light = cfg_data[current_lod]["meshes"][current_mesh]["interior_lights"][interior_light_ind] - if param_ind == 0: - m_light["variable"] = line - elif param_ind == 1: - m_light["range"] = float(line) - elif param_ind == 2: - m_light["red"] = float(line) / 255 - elif param_ind == 3: - m_light["green"] = float(line) / 255 - elif param_ind == 4: - m_light["blue"] = float(line) / 255 - elif param_ind == 5: - m_light["x_pos"] = float(line) - elif param_ind == 6: - m_light["y_pos"] = float(line) - elif param_ind == 7: - m_light["z_pos"] = float(line) + cfg_data[current_lod]["interior_lights"].append({}) + lights.append(cfg_data[current_lod]["interior_lights"][-1]) + cfg_data[current_lod]["interior_lights"][-1]["variable"] = next(iter_lines)[1] + cfg_data[current_lod]["interior_lights"][-1]["range"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["interior_lights"][-1]["red"] = float(next(iter_lines)[1]) / 255 + cfg_data[current_lod]["interior_lights"][-1]["green"] = float(next(iter_lines)[1]) / 255 + cfg_data[current_lod]["interior_lights"][-1]["blue"] = float(next(iter_lines)[1]) / 255 + cfg_data[current_lod]["interior_lights"][-1]["x_pos"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["interior_lights"][-1]["y_pos"] = float(next(iter_lines)[1]) + cfg_data[current_lod]["interior_lights"][-1]["z_pos"] = float(next(iter_lines)[1]) elif current_command == "[spotlight]": if param_ind == -1: spotlight_ind += 1 - cfg_data[current_lod]["meshes"][current_mesh]["spotlights"][spotlight_ind] = {} - - lights.append(cfg_data[current_lod]["meshes"][current_mesh]["spotlights"][spotlight_ind]) - - m_light = cfg_data[current_lod]["meshes"][current_mesh]["spotlights"][spotlight_ind] - if param_ind == 0: - m_light["x_pos"] = float(line) - elif param_ind == 1: - m_light["y_pos"] = float(line) - elif param_ind == 2: - m_light["z_pos"] = float(line) - elif param_ind == 3: - m_light["x_fwd"] = float(line) - elif param_ind == 4: - m_light["y_fwd"] = float(line) - elif param_ind == 5: - m_light["z_fwd"] = float(line) - elif param_ind == 6: - m_light["col_r"] = float(line) / 255 - elif param_ind == 7: - m_light["col_g"] = float(line) / 255 - elif param_ind == 8: - m_light["col_b"] = float(line) / 255 - elif param_ind == 9: - m_light["range"] = float(line) - elif param_ind == 10: - m_light["inner_angle"] = float(line) - elif param_ind == 11: - m_light["outer_angle"] = float(line) + cfg_data[current_lod]["spotlights"].append({}) + lights.append(cfg_data[current_lod]["spotlights"][-1]) + + m_light = cfg_data[current_lod]["spotlights"][-1] + m_light["x_pos"] = float(next(iter_lines)[1]) + m_light["y_pos"] = float(next(iter_lines)[1]) + m_light["z_pos"] = float(next(iter_lines)[1]) + m_light["x_fwd"] = float(next(iter_lines)[1]) + m_light["y_fwd"] = float(next(iter_lines)[1]) + m_light["z_fwd"] = float(next(iter_lines)[1]) + m_light["col_r"] = float(next(iter_lines)[1]) / 255 + m_light["col_g"] = float(next(iter_lines)[1]) / 255 + m_light["col_b"] = float(next(iter_lines)[1]) / 255 + m_light["range"] = float(next(iter_lines)[1]) + m_light["inner_angle"] = float(next(iter_lines)[1]) + m_light["outer_angle"] = float(next(iter_lines)[1]) elif current_command == "[light_enh]": if param_ind == -1: - cfg_data[current_lod]["meshes"][current_mesh]["light_flares"].append({"type": "[light_enh]"}) - light_flare = cfg_data[current_lod]["meshes"][current_mesh]["light_flares"][-1] - if param_ind == 0: - light_flare["x_pos"] = float(line) - elif param_ind == 1: - light_flare["y_pos"] = float(line) - elif param_ind == 2: - light_flare["z_pos"] = float(line) - elif param_ind == 3: - light_flare["col_r"] = float(line) / 255 - elif param_ind == 4: - light_flare["col_g"] = float(line) / 255 - elif param_ind == 5: - light_flare["col_b"] = float(line) / 255 - elif param_ind == 6: - light_flare["size"] = float(line) - elif param_ind == 7: - light_flare["brightness_var"] = line - elif param_ind == 8: - light_flare["brightness"] = float(line) - elif param_ind == 9: - light_flare["z_offset"] = float(line) - elif param_ind == 10: - light_flare["effect"] = int(line) - elif param_ind == 11: - light_flare["ramp_time"] = float(line) - elif param_ind == 12: - light_flare["texture"] = line + cfg_data[current_lod]["light_flares"].append({"type": "[light_enh]"}) + light_flare = cfg_data[current_lod]["light_flares"][-1] + light_flare["x_pos"] = float(next(iter_lines)[1]) + light_flare["y_pos"] = float(next(iter_lines)[1]) + light_flare["z_pos"] = float(next(iter_lines)[1]) + light_flare["col_r"] = float(next(iter_lines)[1]) / 255 + light_flare["col_g"] = float(next(iter_lines)[1]) / 255 + light_flare["col_b"] = float(next(iter_lines)[1]) / 255 + light_flare["size"] = float(next(iter_lines)[1]) + light_flare["brightness_var"] = next(iter_lines)[1] + light_flare["brightness"] = float(next(iter_lines)[1]) + light_flare["z_offset"] = float(next(iter_lines)[1]) + light_flare["effect"] = int(next(iter_lines)[1]) + light_flare["ramp_time"] = float(next(iter_lines)[1]) + light_flare["texture"] = next(iter_lines)[1] elif current_command == "[light_enh_2]": if param_ind == -1: - cfg_data[current_lod]["meshes"][current_mesh]["light_flares"].append({"type": "[light_enh_2]"}) - light_flare = cfg_data[current_lod]["meshes"][current_mesh]["light_flares"][-1] - if param_ind == 0: - light_flare["x_pos"] = float(line) - elif param_ind == 1: - light_flare["y_pos"] = float(line) - elif param_ind == 2: - light_flare["z_pos"] = float(line) - elif param_ind == 3: - light_flare["x_fwd"] = float(line) - elif param_ind == 4: - light_flare["y_fwd"] = float(line) - elif param_ind == 5: - light_flare["z_fwd"] = float(line) - elif param_ind == 6: - light_flare["x_rot"] = float(line) - elif param_ind == 7: - light_flare["y_rot"] = float(line) - elif param_ind == 8: - light_flare["z_rot"] = float(line) - elif param_ind == 9: - light_flare["omni"] = int(line) == 1 - elif param_ind == 10: - light_flare["rotating"] = int(line) - elif param_ind == 11: - light_flare["col_r"] = float(line) / 255 - elif param_ind == 12: - light_flare["col_g"] = float(line) / 255 - elif param_ind == 13: - light_flare["col_b"] = float(line) / 255 - elif param_ind == 14: - light_flare["size"] = float(line) - elif param_ind == 15: - light_flare["max_brightness_angle"] = float(line) - elif param_ind == 16: - light_flare["min_brightness_angle"] = float(line) - elif param_ind == 17: - light_flare["brightness_var"] = line - elif param_ind == 18: - light_flare["brightness"] = float(line) - elif param_ind == 19: - light_flare["z_offset"] = float(line) - elif param_ind == 20: - light_flare["effect"] = int(line) - elif param_ind == 21: - light_flare["cone_effect"] = int(line) == 1 - elif param_ind == 22: - light_flare["ramp_time"] = float(line) - elif param_ind == 23: - light_flare["texture"] = line + cfg_data[current_lod]["light_flares"].append({"type": "[light_enh_2]"}) + light_flare = cfg_data[current_lod]["light_flares"][-1] + light_flare["x_pos"] = float(next(iter_lines)[1]) + light_flare["y_pos"] = float(next(iter_lines)[1]) + light_flare["z_pos"] = float(next(iter_lines)[1]) + light_flare["x_fwd"] = float(next(iter_lines)[1]) + light_flare["y_fwd"] = float(next(iter_lines)[1]) + light_flare["z_fwd"] = float(next(iter_lines)[1]) + light_flare["x_rot"] = float(next(iter_lines)[1]) + light_flare["y_rot"] = float(next(iter_lines)[1]) + light_flare["z_rot"] = float(next(iter_lines)[1]) + light_flare["omni"] = int(next(iter_lines)[1]) == 1 + light_flare["rotating"] = int(next(iter_lines)[1]) + light_flare["col_r"] = float(next(iter_lines)[1]) / 255 + light_flare["col_g"] = float(next(iter_lines)[1]) / 255 + light_flare["col_b"] = float(next(iter_lines)[1]) / 255 + light_flare["size"] = float(next(iter_lines)[1]) + light_flare["max_brightness_angle"] = float(next(iter_lines)[1]) + light_flare["min_brightness_angle"] = float(next(iter_lines)[1]) + light_flare["brightness_var"] = next(iter_lines)[1] + light_flare["brightness"] = float(next(iter_lines)[1]) + light_flare["z_offset"] = float(next(iter_lines)[1]) + light_flare["effect"] = int(next(iter_lines)[1]) + light_flare["cone_effect"] = int(next(iter_lines)[1]) == 1 + light_flare["ramp_time"] = float(next(iter_lines)[1]) + light_flare["texture"] = next(iter_lines)[1] elif current_command == "[matl]" or current_command == "[matl_change]": if param_ind == 0: @@ -699,6 +712,13 @@ def write_cfg(filepath, objs, context, selection_only): # Populate any cfg data in the scene write_additional_cfg_props(scene, f) + if "tree" in scene: + tree = scene["tree"] + f.write("[tree]\n{0}\n{1}\n{2}\n{3}\n{4}\n\n".format(*tree)) + + if "editor_only" in scene and scene["editor_only"]: + f.write("[editor_only]\n\n") + f.write("\n###############################################################\n" "\t\tBEGIN MODEL DATA\n" "###############################################################\n\n") diff --git a/o3d_io/o3d_node_shader_utils.py b/o3d_io/o3d_node_shader_utils.py index 9534dfb..75055f7 100644 --- a/o3d_io/o3d_node_shader_utils.py +++ b/o3d_io/o3d_node_shader_utils.py @@ -1,11 +1,12 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== # SPDX-License-Identifier: GPL-2.0-or-later # +import bpy from mathutils import Color, Vector __all__ = ( @@ -914,3 +915,27 @@ def base_color_n_textures_set(self, n): self._mix_node_links.append(node_mix) base_color_n_textures = property(lambda self: len(self.base_color_textures_get()), base_color_n_textures_set) + + +def generate_solid_material(diffuse, spec=(0, 0, 0), roughness=1): + mat_blender = bpy.data.materials.new("solid_mat-{0}".format(diffuse)) + if bpy.app.version < (2, 80): + mat = mat_blender + mat.diffuse_color = diffuse[:3] + mat.specular_hardness = 1 - roughness + mat.specular_intensity = 1 + mat.specular_color = spec + mat.alpha = diffuse[3] + else: + mat_blender.use_nodes = True + mat = PrincipledBSDFWrapper(mat_blender, is_readonly=False) + mat.base_color = diffuse[:3] + mat.specular = spec[0] + mat.roughness = roughness + mat.alpha = diffuse[3] + + mat_blender.use_backface_culling = True + mat_blender.blend_method = "HASHED" + mat_blender.shadow_method = "HASHED" + + return mat_blender diff --git a/o3d_io/o3dconvert.py b/o3d_io/o3dconvert.py index 43830e6..f3e1c7c 100644 --- a/o3d_io/o3dconvert.py +++ b/o3d_io/o3dconvert.py @@ -1,5 +1,5 @@ # ============================================================================== -# Copyright (c) 2022 Thomas Mathieson. +# Copyright (c) 2022-2023 Thomas Mathieson. # ============================================================================== import struct @@ -168,10 +168,10 @@ def import_transform(buff, offset): m = struct.unpack_from("