├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── __init__.py ├── lithtech_dat_import.py ├── test.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "kaitai_lithtech_dat_struct"] 2 | path = kaitai_lithtech_dat_struct 3 | url = https://github.com/leoschur/kaitai_lithtech_dat_struct.git 4 | branch = main 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GLWTS(Good Luck With That Shit) Public License 2 | Copyright (c) Every-fucking-one, except the Author 3 | 4 | Everyone is permitted to copy, distribute, modify, merge, sell, publish, 5 | sublicense or whatever the fuck they want with this software but at their 6 | OWN RISK. 7 | 8 | Preamble 9 | 10 | The author has absolutely no fucking clue what the code in this project 11 | does. It might just fucking work or not, there is no third option. 12 | 13 | GOOD LUCK WITH THAT SHIT PUBLIC LICENSE 14 | 15 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION, AND MODIFICATION 16 | 17 | 0. You just DO WHATEVER THE FUCK YOU WANT TO as long as you NEVER LEAVE 18 | A FUCKING TRACE TO TRACK THE AUTHOR of the original product to blame for 19 | or hold responsible. 20 | 21 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 24 | DEALINGS IN THE SOFTWARE. 25 | 26 | Good luck and Godspeed. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blender Lithtech DAT Importer 2 | 3 | ⚠ Status Experimental ⚠ 4 | 5 | This is a blender addon to import Lithtech \*.dat (DAT) map files. The plugin is developed with Blender v(4.2.2) and "works" for Lithtech DAT files with version 85. 6 | Keep in mind that these are compiled engine-specific game files. 7 | Some things may not translate well in Blender, so details may be lost. 8 | However, I try to do my best to make sense of the available data and display it meaningfully in Blender. 9 | The Plugin is developed and tested for Combat Arms maps, but should work with every Lithtech game, provided that the version matches. 10 | 11 | ## Installation 12 | 13 | 1. Download the latest zip `blender-lithtech-dat-import.zip` archive from the [release page](https://github.com/leoschur/blender-lithtech-dat-import/releases) 14 | 2. In Blender navigate to: `Edit -> Preferences -> Add-ons -> Install` 15 | 3. Select the downloaded `blender-lithtech-dat-import.zip` file 16 | 4. Enable the Plugin with the checkbox 17 | 18 | or 19 | 20 | 1. Clone this repository into the Blender Add-on folder. When cloning, don't forget to clone recursive to include the required submodules. 21 | ```bat 22 | cd "~\AppData\Roaming\Blender Foundation\Blender\3.4\scripts\addons" 23 | git clone --recurse-submodules https://github.com/leoschur/blender-lithtech-dat-import 24 | ``` 25 | 2. Start Blender navigate to: `Edit -> Preferences -> Add-ons` 26 | 3. Search for `Lithtech DAT Map Format (.dat)` and enable the Plugin with the checkbox 27 | 28 | ## Usage 29 | 30 | After installing you can import the file in Blender with `File -> Import -> Import Lithtech DAT map (.dat)`. 31 | 32 | Alternatively with python: 33 | 34 | ```py 35 | bpy.ops.import_scene.lithtech_dat(filepath) 36 | ``` 37 | 38 | Or over the Blender quick search with F3 `Import Lithtech Dat map (.dat)` 39 | 40 | If you want to reinstall the Add-on or get a newer version 41 | 42 | 1. **Disable** the Add-on first in `Edit -> Preferences -> Add-ons -> Import-Export: Lithtech DAT Map Format (.dat)` by clicking on the checkbox 43 | 2. Click on the arrow on the left of the Add-on entry 44 | 3. Click on Remove 45 | 4. Restart Blender 46 | 5. Only than you can reinstall the Add-on again 47 | 48 | ### Note: 49 | 50 | Currently a texture directory must be specified upon the Import dialog. 51 | This needs to contain the "Textures" directory with the original folder-/file-structure from the game. 52 | The images need to be converted from `*.DTX`/ `*.dtx` into [`*.tga` file format](https://en.wikipedia.org/wiki/Truevision_TGA) beforehand. 53 | 54 | ## Current Limitation 55 | 56 | - WorldObjects are not yet supported 57 | - Only some shaders are considered 58 | - WorldModels are created only partially 59 | - The RenderNodes are created in a rather basic way 60 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import bmesh 3 | from .lithtech_dat_import import ImportLithtechDat 4 | from mathutils import Vector 5 | 6 | bl_info = { 7 | "name": "Lithtech DAT Map Format (.dat)", 8 | "author": "leos", 9 | "description": "This plugins allows you to import Lithtech Map files.", 10 | "blender": (3, 4, 1), 11 | "version": (0, 0, 3), 12 | "location": "File > Import > Lithtech DAT", 13 | "warning": "", 14 | "category": "Import-Export" 15 | } 16 | 17 | # Register and add to the "file selector" menu (required to use F3 search) 18 | 19 | 20 | def menu_func_import(self, context): 21 | self.layout.operator(ImportLithtechDat.bl_idname, 22 | text="Import Lithtech DAT map (.dat)") 23 | 24 | 25 | def register(): 26 | bpy.utils.register_class(ImportLithtechDat) 27 | bpy.types.TOPBAR_MT_file_import.append(menu_func_import) 28 | 29 | 30 | def unregister(): 31 | bpy.utils.unregister_class(ImportLithtechDat) 32 | bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) 33 | 34 | 35 | if __name__ == "__main__": 36 | register() 37 | 38 | # test call 39 | bpy.ops.import_scene.lithtech_dat('INVOKE_DEFAULT') 40 | -------------------------------------------------------------------------------- /lithtech_dat_import.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import bmesh 3 | import numpy as np 4 | import os.path as path 5 | from itertools import accumulate 6 | from bpy_extras.io_utils import ImportHelper 7 | from bpy.props import StringProperty, BoolProperty, EnumProperty 8 | from bpy.types import Operator, Collection 9 | from bpy.app.handlers import persistent 10 | from .kaitai_lithtech_dat_struct import LithtechDat 11 | from mathutils import Vector 12 | from .utils import vec3_to_xzy, int32_to_rgba, decompress_lm_data 13 | 14 | 15 | class ImportLithtechDat(Operator, ImportHelper): 16 | """Import a Lithtech DAT map file 17 | This importer is developed and tested for Combat Arms maps. 18 | It should work for all Lithtech engine map files, but no guarantees! 19 | If you encounter any issue feel free to open a issue on GitHub. 20 | """ 21 | 22 | # # important since its how bpy.ops.import_scene.lithtech_dat is constructed 23 | bl_idname = "import_scene.lithtech_dat" 24 | bl_label = "Import Lithtech DAT map (.dat)" 25 | bl_context = "material" 26 | 27 | # ImportHelper mixin class uses this 28 | filename_ext = ".dat" 29 | 30 | filter_glob: StringProperty( 31 | default="*.dat", 32 | options={"HIDDEN"}, 33 | maxlen=255, # Max internal buffer length, longer would be clamped. 34 | ) # type: ignore 35 | 36 | # List of operator properties, the attributes will be assigned 37 | # to the class instance from the operator settings before calling. 38 | # use_setting: BoolProperty( 39 | # name="Example Boolean", 40 | # description="Example Tooltip", 41 | # default=True, 42 | # ) 43 | 44 | # type: EnumProperty( 45 | # name="Example Enum", 46 | # description="Choose between two items", 47 | # items=( 48 | # ('OPT_A', "First Option", "Description one"), 49 | # ('OPT_B', "Second Option", "Description two"), 50 | # ), 51 | # default='OPT_A', 52 | # ) 53 | 54 | def createTexture(self, rel_path): 55 | """Create a image_texture if it doesn't exist already 56 | Positional arguments: 57 | rel_path: [str] relative path to texture starting with textures or TEXTURES (root of texture folder) 58 | Returns: 59 | material: [bpy.types.Material] either newly created material or existing material 60 | """ 61 | tex_path = rel_path.replace(".dtx", "") 62 | tex_name = tex_path.replace("textures\\", "").replace("TEXTURES\\", "") 63 | img_path = path.join(self.texture_dir_path, *tex_name.upper().split('\\')) + ".tga" 64 | mat = None 65 | if tex_name in bpy.data.materials: 66 | mat = bpy.data.materials[tex_name] 67 | pass 68 | elif path.isfile(img_path): 69 | mat = bpy.data.materials.new(tex_name) 70 | mat.use_nodes = True 71 | nodes = mat.node_tree.nodes 72 | links = mat.node_tree.links 73 | sn_om = nodes.get("Material Output") 74 | sn_bsdfp = nodes.get("Principled BSDF") 75 | links.new(sn_bsdfp.outputs["BSDF"], sn_om.inputs["Surface"]) 76 | sn_tex = nodes.new(type="ShaderNodeTexImage") 77 | # TODO file exists at this point, but this might still fail for some reason 78 | img = bpy.data.images.load(img_path) 79 | sn_tex.image = img 80 | links.new(sn_tex.outputs["Color"], sn_bsdfp.inputs["Base Color"]) 81 | links.new(sn_tex.outputs["Alpha"], sn_bsdfp.inputs["Alpha"]) 82 | mat.blend_method = "CLIP" 83 | sn_uv = nodes.new(type="ShaderNodeUVMap") 84 | # TODO outsource uv_map 85 | sn_uv.uv_map = "uv0" 86 | links.new(sn_uv.outputs["UV"], sn_tex.inputs["Vector"]) 87 | pass 88 | else: 89 | # TODO raise file not found 90 | pass 91 | return mat 92 | 93 | def createWorldModelNew(self, parent: Collection, wm: LithtechDat.WorldModel): 94 | # TODO leftof here 95 | # mesh 96 | # - points (missung uvs?) 97 | # - polygons 98 | # - - plane 99 | # - - surface 100 | wm_name = f"WM_{wm.world_name.data}" if wm.world_name.num_data else "WM" 101 | node_collection = bpy.data.collections.new(wm_name) 102 | for i, node in enumerate(wm.nodes): 103 | bm = bmesh.new() 104 | node_name = f"Node_{i:05}" 105 | # 106 | poly = wm.polygons[node.poly_index] 107 | plane = wm.planes[poly.plane_index] 108 | surf = wm.surfaces[poly.surface_index] 109 | points = [bm.verts.new((wm.points[j].x * 0.01, wm.points[j].z * 0.01, wm.points[j].y * 0.01)) 110 | for j in poly.vertices_indices] 111 | # 112 | m = bpy.data.meshes.new(f"{node_name}_Mesh") 113 | bm.to_mesh(m) 114 | o = bpy.data.objects.new(f"{node_name}", m) 115 | node_collection.objects.link(o) 116 | parent.children.link(node_collection) 117 | pass 118 | 119 | def createWorldModel(self, parent: Collection, wm: LithtechDat.WorldModel): 120 | """Create a collection for the worldmodel 121 | Postional arguments: 122 | parent: [bpy.types.Collection] parent collection 123 | wm: [kaitai_lithtech_dat_struct.lithtech_dat.LithtechDat.WorldModel] worldmodel to create 124 | """ 125 | bm = bmesh.new() 126 | if wm.num_points and wm.num_polygons: 127 | verts = [bm.verts.new((p.x * 0.01, p.z * 0.01, p.y * 0.01)) 128 | for p in wm.points] 129 | bm.verts.index_update() 130 | bm.verts.ensure_lookup_table() 131 | for p in wm.polygons: 132 | try: 133 | bm.faces.new([verts[vi] for vi in p.vertices_indices]) 134 | pass 135 | except ValueError: 136 | pass 137 | continue 138 | bm.faces.index_update() 139 | bm.faces.ensure_lookup_table() 140 | # 141 | name = wm.world_name.data if wm.world_name.num_data else "WM" 142 | m = bpy.data.meshes.new(f"{name}_Mesh") 143 | bm.to_mesh(m) 144 | o = bpy.data.objects.new(name, m) 145 | parent.objects.link(o) 146 | pass 147 | return 148 | 149 | def createRenderNode(self, parent, rn_name, rn): 150 | """Create a collection for the render node and all objects within 151 | Positional arguments: 152 | parent: parent collection the node gets appended to 153 | rn_name: name for the render node collection 154 | rn: the data for the render node from type LithtechDat.RenderNode 155 | """ 156 | o = None 157 | # 158 | # render nodes has no vertices 159 | # TODO for now only render triangles that have a texture 160 | if 0 < rn.num_vertices and 0 < rn.num_triangles: 161 | # section beginnings 162 | t_till = list(accumulate([s.triangle_count for s in rn.sections])) 163 | # section endings 164 | t_from = [0] + t_till[:-1] 165 | for si, (s, tf, tt) in enumerate(zip(rn.sections, t_from, t_till)): 166 | match s.shader_code: # EPCShaderType 167 | case 0: # No shading 168 | pass 169 | case 1: # Textured and vertex-lit 170 | # Might have a texture, might not have one 171 | pass 172 | case 2: # Base light map 173 | # This can be skipped, as lightmaps are not used 174 | lm_name = f"LightMap_{rn_name}_S{si:04}" 175 | img = bpy.data.images.new( 176 | lm_name, width=s.lm_width, height=s.lm_height 177 | ) 178 | img.pixels = decompress_lm_data(s.lm_data) 179 | mat = bpy.data.materials.new(lm_name) 180 | mat.use_nodes = True 181 | nodes = mat.node_tree.nodes 182 | links = mat.node_tree.links 183 | sn_om = nodes.get("Material Output") 184 | sn_bsdfp = nodes.get("Principled BSDF") 185 | links.new(sn_bsdfp.outputs["BSDF"], 186 | sn_om.inputs["Surface"]) 187 | sn_tex = nodes.new(type="ShaderNodeTexImage") 188 | sn_tex.image = img 189 | links.new(sn_tex.outputs["Color"], 190 | sn_bsdfp.inputs["Base Color"]) 191 | sn_uv = nodes.new(type="ShaderNodeUVMap") 192 | sn_uv.uv_map = "uv1" 193 | links.new(sn_uv.outputs["UV"], sn_tex.inputs["Vector"]) 194 | pass 195 | case 1 | 4 | 8 | 9: # Texturing pass of lightmapping 196 | # TODO this actually handles only case 4, other ones are slightly incorrect 197 | # yet creating these this way is better than not handling them at all 198 | bm = bmesh.new() 199 | tris = np.sort([t.t for t in rn.triangles[tf:tt]]) 200 | # create the vertices for the current section 201 | verts = np.unique(tris).tolist() 202 | # actual vert index = np.searchsorted(verts, vert) 203 | lay_col = bm.verts.layers.color.new("color") 204 | lay_nor = bm.verts.layers.float_vector.new("normal") 205 | lay_bin = bm.verts.layers.float_vector.new("binormal") 206 | lay_tan = bm.verts.layers.float_vector.new("tangent") 207 | for vi in verts: 208 | v = rn.vertices[vi] 209 | bm_vert = bm.verts.new( 210 | [v.v_pos.x * 0.01, v.v_pos.z * 211 | 0.01, v.v_pos.y * 0.01] 212 | ) 213 | bm_vert.normal = Vector( 214 | (v.v_normal.x, v.v_normal.y, v.v_normal.z) 215 | ) 216 | bm_vert[lay_col] = int32_to_rgba(v.color) 217 | bm_vert[lay_nor] = Vector( 218 | (v.v_normal.x, v.v_normal.y, v.v_normal.z) 219 | ) 220 | bm_vert[lay_bin] = Vector( 221 | (v.v_binormal.x, v.v_binormal.y, v.v_binormal.z) 222 | ) 223 | bm_vert[lay_tan] = Vector( 224 | (v.v_tangent.x, v.v_tangent.y, v.v_tangent.z) 225 | ) 226 | continue 227 | bm.verts.ensure_lookup_table() 228 | bm.verts.index_update() 229 | 230 | # ignore poly_index for now 231 | # lay_poly_index = bm.faces.layers.int.new("poly_index") 232 | # create the triangles 233 | for tri in tris: 234 | # TODO this might raise value error for duplicate faces 235 | try: 236 | bm.faces.new([bm.verts[verts.index(i)] 237 | for i in tri]) 238 | pass 239 | except ValueError: 240 | pass 241 | continue 242 | bm.faces.ensure_lookup_table() 243 | bm.faces.index_update() 244 | 245 | lay_uv0 = bm.loops.layers.uv.new("uv0") 246 | lay_uv1 = bm.loops.layers.uv.new("uv1") 247 | for face in bm.faces: 248 | for loop in face.loops: 249 | # resolve index to grep lithtech vertex for uv data 250 | v = rn.vertices[verts[loop.vert.index]] 251 | loop[lay_uv0].uv = Vector((v.uv0.x, -v.uv0.y)) 252 | loop[lay_uv1].uv = Vector((v.uv1.x, -v.uv1.y)) 253 | continue 254 | face.normal_update() 255 | continue 256 | 257 | # create object 258 | m = bpy.data.meshes.new( 259 | f"{rn_name}_Section{si:04}_Mesh") 260 | bm.to_mesh(m) 261 | o = bpy.data.objects.new( 262 | f"{rn_name}_Section{si:04}", m) 263 | o.data['Shader Code'] = s.shader_code 264 | parent.objects.link(o) 265 | 266 | # create and apply textures 267 | if s.texture_name[0].num_data: 268 | # TODO check if the ending can be '.DTX' as well 269 | # tex_name = s.texture_name[0].data.replace('.dtx', '') 270 | tex_path = s.texture_name[0].data.replace( 271 | ".dtx", "") 272 | tex_name = tex_path.replace("textures\\", "").replace( 273 | "TEXTURES\\", "" 274 | ) 275 | mat = None 276 | img_path = path.join(self.texture_dir_path, *tex_name.upper().split('\\')) + ".tga" 277 | # check if material already exists 278 | if tex_name in bpy.data.materials: 279 | mat = bpy.data.materials[tex_name] 280 | pass 281 | elif path.isfile(img_path): # TODO handle if img_path doesn't exists 282 | # if not create material with texture 283 | mat = bpy.data.materials.new(tex_name) 284 | mat.use_nodes = True 285 | nodes = mat.node_tree.nodes 286 | links = mat.node_tree.links 287 | sn_om = nodes.get("Material Output") 288 | sn_bsdfp = nodes.get("Principled BSDF") 289 | links.new( 290 | sn_bsdfp.outputs["BSDF"], sn_om.inputs["Surface"]) 291 | sn_tex = nodes.new(type="ShaderNodeTexImage") 292 | # TODO file exists at this point, but this might still fail for some reason 293 | img = bpy.data.images.load(img_path) 294 | sn_tex.image = img 295 | links.new( 296 | sn_tex.outputs["Color"], sn_bsdfp.inputs["Base Color"] 297 | ) 298 | links.new( 299 | sn_tex.outputs["Alpha"], sn_bsdfp.inputs["Alpha"]) 300 | mat.blend_method = "CLIP" 301 | sn_uv = nodes.new(type="ShaderNodeUVMap") 302 | # This is hardcoded and corresponds to the previously created UV layer 303 | sn_uv.uv_map = "uv0" 304 | links.new(sn_uv.outputs["UV"], 305 | sn_tex.inputs["Vector"]) 306 | pass 307 | else: 308 | self.report({'ERROR'}, f"File not found: {img_path}") 309 | # append texture to object if not already done 310 | if tex_name not in o.data.materials: 311 | o.data.materials.append(mat) 312 | pass 313 | # find triangles and set material_index to corresponding texture 314 | mat_idx = o.data.materials.find(tex_name) 315 | # TODO handle when material was not found // could be because of case sensitivity 316 | if 0 < mat_idx: 317 | for face in bm.faces: 318 | face.material_index = mat_idx 319 | continue 320 | pass 321 | # update the mesh after modifying it!!! 322 | bm.to_mesh(m) 323 | pass 324 | pass 325 | case 5: # Skypan 326 | # TODO should refer to the Skybox in Blender 327 | pass 328 | case 6: # Skyportal 329 | # TODO should refer to the Area Lights/ Portal Lights in Blender 330 | # https://docs.blender.org/manual/en/latest/render/cycles/light_settings.html#area-lights 331 | # skyportal data contained within the "outer" rendernode? 332 | pass 333 | case 7: # Occluder 334 | # occluder data contained within the "outer" rendernode? 335 | pass 336 | case 8: # Gouraud shaded dual texture 337 | pass 338 | case 9: # Texture stage of lightmap shaded dual texture 339 | # (can) contains two textures/ textureNames 340 | pass 341 | case _: 342 | pass 343 | continue 344 | pass 345 | # no vertices available only create empty object 346 | else: 347 | o = bpy.data.objects.new(f"{rn_name}_Object", None) 348 | o.empty_display_type = "CUBE" 349 | o.location = Vector((rn.v_center.x, rn.v_center.z, rn.v_center.y)) 350 | o.empty_display_size = max( 351 | [rn.v_half_dims.x, rn.v_half_dims.z, rn.v_half_dims.y] 352 | ) 353 | parent.objects.link(o) 354 | pass 355 | return 356 | 357 | # requires createRenderNode 358 | 359 | def createWMRenderNode(self, parent, wmrn): 360 | """create a World Model Render Node 361 | Positional Arguments: 362 | parent: collection the render node is going to be append to 363 | wmrn: dat_importer.lithtech_dat.LithtechDat.RenderNode node to be created 364 | """ 365 | node_collection = bpy.data.collections.new(f"WMRN_{wmrn.name.data}") 366 | for i, render_node in enumerate(wmrn.render_nodes): 367 | self.createRenderNode( 368 | node_collection, f"{wmrn.name.data}_RN_{i}", render_node 369 | ) 370 | continue 371 | parent.children.link(node_collection) 372 | return 373 | 374 | # requires createRenderNode 375 | 376 | def createRenderNodes(self, parent, lt_dat: LithtechDat): 377 | """create Render Nodes 378 | Positional Arguments: 379 | parent: collection the render node group is append to 380 | world: dat_importer.lithtech_dat.LithtechDat imported map file 381 | """ 382 | render_nodes = bpy.data.collections.new("RenderNodes") 383 | for i, render_node in enumerate(lt_dat.render_data.render_nodes): 384 | self.createRenderNode( 385 | render_nodes, f"RenderNode_{i:03}", render_node 386 | ) 387 | continue 388 | parent.children.link(render_nodes) 389 | return 390 | 391 | # requires createRenderNode 392 | # requires createWMRenderNode 393 | 394 | def createWMRenderNodes(self, parent, lt_dat: LithtechDat): 395 | """Create the World Model Render Nodes 396 | Positional Arguments: 397 | parent: the parent collection the world model render node group is append to 398 | world: dat_importer.lithtech_dat.LithtechDat imported map file 399 | """ 400 | wm_render_nodes = bpy.data.collections.new("WMRenderNodes") 401 | for node in lt_dat.render_data.world_model_render_nodes: 402 | self.createWMRenderNode(wm_render_nodes, node) 403 | continue 404 | parent.children.link(wm_render_nodes) 405 | return 406 | 407 | def createPhysicsData(self, parent, lt_dat: LithtechDat): 408 | """Create the physics/ collision data 409 | Positional Arguments: 410 | parent: collection the data physics collection is append to 411 | world: dat_importer.lithtech_dat.LithtechDat imported map file 412 | """ 413 | physics = lt_dat.collision_data.polygons 414 | poly_vertices = [] 415 | poly_triangles = [] 416 | index = 0 417 | scale = 0.01 418 | for poly in physics: 419 | poly_triangles.append([]) 420 | for vec in poly.vertices_pos: 421 | poly_vertices.append( 422 | [vec.x * scale, vec.z * scale, vec.y * scale]) 423 | poly_triangles[-1].append(index) 424 | index += 1 425 | continue 426 | continue 427 | m = bpy.data.meshes.new("CollisionData") 428 | m.from_pydata(poly_vertices, [], poly_triangles) 429 | o = bpy.data.objects.new("CollisionData", m) 430 | parent.objects.link(o) 431 | return 432 | 433 | def createWorldInfo(self, world_name: str, lt_world: LithtechDat.World): 434 | """createWorldInfo 435 | currently only creates the ambient light 436 | Positional Arguments: 437 | world_name: Name for the created blender world 438 | lt_world: kaitai_lithtech_dat_struct.LithtechDat.World extracted struct 439 | """ 440 | try: 441 | self.report({'INFO'}, 'Trying to extract World Info ambientlight') 442 | world_attributes = lt_world.world_info_string.data.split(';') 443 | ambient_light = next( 444 | filter(lambda s: s.startswith('ambientlight'), world_attributes)) 445 | ambient_light = ambient_light.removeprefix('ambientlight ') 446 | ambient_light = ambient_light.split(' ') 447 | ambient_light = ( 448 | *(int(s) / 255 for s in ambient_light), 1.) 449 | except: 450 | self.report( 451 | {'WARNING'}, 'World Info Ambient Light could not be parsed') 452 | else: 453 | self.report({'INFO'}, 'Creating new Blender World') 454 | world = bpy.data.worlds.new(name=world_name) 455 | world.use_nodes = True 456 | # assuming default nodes set to "Background" -> "World Output" 457 | world.node_tree.nodes['Background'].inputs['Color'].default_value = ambient_light 458 | self.C.scene.world = world 459 | return 460 | 461 | texture_dir_path: StringProperty( 462 | name="Texture Directory", 463 | subtype="DIR_PATH", 464 | description="Select the folder containing the TEXTURES directory that contains the texture files in '*.tga' file format with the original folder structure!", 465 | ) # type: ignore 466 | 467 | # https://blender.stackexchange.com/a/8732 468 | @persistent 469 | def import_data(self): 470 | print("running read_some_data...") 471 | lt_dat = LithtechDat.from_file(self.filepath) 472 | name = path.basename(path.splitext(self.filepath)[0]) 473 | 474 | self.createWorldInfo(name, lt_dat.world) 475 | map = bpy.data.collections.new(name) 476 | self.createRenderNodes(map, lt_dat) 477 | self.createWMRenderNodes(map, lt_dat) 478 | self.createPhysicsData(map, lt_dat) 479 | self.C.collection.children.link(map) 480 | 481 | return {"FINISHED"} 482 | 483 | def execute(self, context): 484 | self.C = context 485 | # TODO check self.filepath 486 | # TODO check self.texture_dir_path 487 | return self.import_data() 488 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leoschur/blender-lithtech-dat-import/7f2bbb8a56eb6778e0a3d6dd16469dfe5a2d09f2/test.py -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from .kaitai_lithtech_dat_struct import LithtechDat 2 | from mathutils import Vector 3 | 4 | 5 | def vec3_to_xzy(p: LithtechDat.Vec3): 6 | """Convert a LithtechDat Vec3 into a Unity Coordinate 7 | Positional arguments: 8 | p: [LithtechDat.Vec3] Point 9 | Returns: 10 | Coordinate: [typing.Tuple] 11 | """ 12 | return (p.x * 0.01, p.z * 0.01, p.y * 0.01) 13 | 14 | 15 | def int32_to_rgba(color_int32): 16 | """Convert an int32 color value to an RGBA vector. 17 | Args: 18 | color_int32 (int): The color value stored as an int32. 19 | Returns: 20 | mathutils.Vector: A Vector of the form (red, green, blue, alpha=1) containing the RGB values. 21 | """ 22 | r = (color_int32 >> 16) & 0xFF 23 | g = (color_int32 >> 8) & 0xFF 24 | b = color_int32 & 0xFF 25 | return Vector((r, g, b, 1)) 26 | 27 | 28 | def decompress_lm_data(compressed): 29 | """RLE decompression 30 | Arts: 31 | compressed (byte array): Compressed data 32 | Returns: 33 | decompressed (list): Decompressed data 34 | """ 35 | decompressed = [] 36 | i = 0 37 | while i < len(compressed): 38 | tag = compressed[i] 39 | i += 1 40 | # see if it is a run or a span 41 | is_run = True if tag & 0x80 else False # (tag & 0x80) != 0 42 | # blit the color span 43 | run_len = (tag & 0x7F) + 1 44 | j = 0 45 | while j < run_len: 46 | j += 1 47 | decompressed.append(compressed[i]) # r 48 | decompressed.append(compressed[i + 1]) # g 49 | decompressed.append(compressed[i + 2]) # b 50 | decompressed.append(1) # a this is for the transparancy 51 | if not is_run: 52 | i += 3 53 | pass 54 | continue 55 | if is_run: 56 | i += 3 57 | pass 58 | continue 59 | return decompressed 60 | --------------------------------------------------------------------------------