from __future__ import annotations from dataclasses import dataclass import bpy import os import re import math CHUNK_SIZE = 250.0 @dataclass class Vec3: x: float y: float z: float def __add__(self, other): return Vec3(self.x + other.x, self.y + other.y, self.z + other.z) def __mul__(self, scalar: float): return Vec3(self.x * scalar, self.y * scalar, self.z * scalar) def dot(self, other): return self.x*other.x + self.y*other.y + self.z*other.z def cross(self, other): return Vec3( self.y*other.z - self.z*other.y, self.z*other.x - self.x*other.z, self.x*other.y - self.y*other.x ) @staticmethod def min(a: Vec3, b: Vec3): return Vec3( a.x if a.x < b.x else b.x, a.y if a.y < b.y else b.y, a.z if a.z < b.z else b.z ) @staticmethod def max(a: Vec3, b: Vec3): return Vec3( a.x if a.x > b.x else b.x, a.y if a.y > b.y else b.y, a.z if a.z > b.z else b.z ) def norm(self): return math.sqrt(self.dot(self)) class Vertex: position: tuple[float, float, float] normal: tuple[float, float, float] uv: tuple[float, float] color: tuple[float, float, float] | None bone_indices: tuple[int, int, int, int] | None bone_weights: tuple[float, float, float, float] | None def __init__(self, position, normal, uv, color=None, bone_indices=None, bone_weights=None): self.position = tuple(round(x, 6) for x in position) self.normal = tuple(round(x, 6) for x in normal) self.uv = tuple(round(x, 6) for x in uv) self.color = tuple(round(c, 3) for c in color) if color else None self.bone_indices = bone_indices self.bone_weights = tuple(round(w, 5) for w in bone_weights) if bone_weights else None def __hash__(self): return hash((self.position, self.normal, self.uv, self.color, self.bone_indices, self.bone_weights)) def __eq__(self, other): return (self.position, self.normal, self.uv, self.color, self.bone_indices, self.bone_weights) == (other.position, other.normal, other.uv, other.color, other.bone_indices, other.bone_weights) class Surface: tris: list[tuple[int, int, int]] texture: str twosided: bool ocolor: bool blend: str|None def __init__(self, name: str): self.tris = [] self.texture = name self.twosided = False self.ocolor = False self.blend = None class Model: skeleton: Skeleton|None vertices: list[Vertex] vertex_map: dict[Vertex, int] materials: dict[str, Surface] make_col_trimesh: bool make_convex_hull: bool cols: list[tuple[str, Transform, float, float]] centerofmass: tuple[float, float, float]|None params: list[tuple[str, str]] def __init__(self, skeleton=None): self.skeleton = skeleton self.vertices = [] self.vertex_map = {} self.materials = {} self.make_col_trimesh = False self.make_convex_hull = False self.cols = [] self.centerofmass = None self.params = [] def add_vertex(self, vertex: Vertex) -> int: if vertex in self.vertex_map: return self.vertex_map[vertex] index = len(self.vertices) self.vertices.append(vertex) self.vertex_map[vertex] = index return index def add_triangle(self, material_name: str, v1: int, v2: int, v3: int): # if material_name not in self.materials: # self.materials[material_name] = Surface(material_name) self.materials[material_name].tris.append((v1, v2, v3)) class Transform: position: tuple[float, float, float] rotation: tuple[float, float, float, float] # quat xyzw scale: float def __init__(self, position, rotation, scale): self.position = tuple(round(x, 6) for x in position) self.rotation = tuple(round(x, 6) for x in rotation) self.scale = round(scale, 6) class GraphNode: pos: tuple[float, float, float] def __init__(self, pos): self.pos = tuple(round(x, 6) for x in pos) class GraphEdge: nodes: tuple[int, int] def __init__(self, node1, node2): self.nodes = (node1, node2) class Graph: name: str nodes: list[GraphNode] edges: list[GraphEdge] def __init__(self, name: str): self.name = name self.nodes = [] self.edges = [] def add_node(self, node: GraphNode) -> int: index = len(self.nodes) self.nodes.append(node) return index def add_edge(self, node1_index: int, node2_index: int): edge = GraphEdge(node1_index, node2_index) self.edges.append(edge) class Chunk: coord: tuple[int, int] # x,y shifted_coord: tuple[int, int] aabb_min: Vec3 aabb_max: Vec3 static_objects: list[tuple[str, Transform]] surface_ranges: list[tuple[str, int, int]] # name, first, count def __init__(self, coord: tuple[int, int]): self.coord = coord self.shifted_coord = (0, 0) self.static_objects = [] self.surface_ranges = [] self.aabb_min = Vec3(math.inf, math.inf, math.inf) self.aabb_max = Vec3(-math.inf, -math.inf, -math.inf) def extend_aabb(self, pos: Vec3): self.aabb_min = Vec3.min(self.aabb_min, pos) self.aabb_max = Vec3.max(self.aabb_max, pos) class Map: basemodel: Model basemodel_name: str static_objects: list[tuple[str, Transform]] graphs: list[Graph] chunks: dict[tuple[int, int], Chunk] max_chunk: tuple[int, int] def __init__(self): self.basemodel = Model() self.basemodel_name = "" self.static_objects = [] self.graphs = [] self.chunks = None self.max_chunk = (0, 0) def create_chunks(self): self.chunks = {} def get_chunk(coord: tuple[int,int]) -> Chunk: if not coord in self.chunks: chunk = Chunk(coord) self.chunks[coord] = chunk return chunk return self.chunks[coord] def pos_to_chunk(pos: Vec3) -> tuple[int,int]: return int(math.floor(pos.x / CHUNK_SIZE)), int(math.floor(pos.y / CHUNK_SIZE)) def tri_to_chunk(tri: tuple[int, int, int]) -> tuple[int, int]: p0, p1, p2 = [Vec3(*self.basemodel.vertices[i].position) for i in tri] return pos_to_chunk((p0 + p1 + p2) * (1/3)) # surfaces for name, surface in self.basemodel.materials.items(): tris = [(tri, tri_to_chunk(tri)) for tri in surface.tris] tris.sort(key=lambda x: x[1]) #sort by chunk coord cur_chunk: Chunk|None = None start = 0 i = 0 def finish_cur_chunk(): if cur_chunk is None or i - start < 1: return count = i - start cur_chunk.surface_ranges.append((name, start, count)) # extend aabb wtih tris for j in range(start, i): p0, p1, p2 = [Vec3(*self.basemodel.vertices[k].position) for k in tris[j][0]] cur_chunk.extend_aabb(p0) cur_chunk.extend_aabb(p1) cur_chunk.extend_aabb(p2) surface.tris.clear() for tri_coord in tris: tri, coord = tri_coord surface.tris.append(tri) if cur_chunk is None or coord != cur_chunk.coord: finish_cur_chunk() cur_chunk = get_chunk(coord) start = i i += 1 finish_cur_chunk() # objects for obj in self.static_objects: name, trans = obj pos = Vec3(*trans.position) chunk = get_chunk(pos_to_chunk(pos)) chunk.extend_aabb(pos) chunk.static_objects.append(obj) # min/max min_chunk = (100000, 100000) max_chunk = (-100000, -100000) for coord in self.chunks: min_chunk = ( min_chunk[0] if min_chunk[0] < coord[0] else coord[0], min_chunk[1] if min_chunk[1] < coord[1] else coord[1] ) max_chunk = ( max_chunk[0] if max_chunk[0] > coord[0] else coord[0], max_chunk[1] if max_chunk[1] > coord[1] else coord[1] ) for coord, chunk in self.chunks.items(): chunk.shifted_coord = ( coord[0] - min_chunk[0], coord[1] - min_chunk[1], ) self.max_chunk = ( max_chunk[0] - min_chunk[0], max_chunk[1] - min_chunk[1], ) class Wheel: type: str model_name: str position: tuple[float, float, float] radius: float class Vehicle: basemodel_name: str wheels: list[Wheel] # type, model, transform locations: list[tuple[str, Transform]] def __init__(self): self.wheels = [] self.locations = [] class Bone: name: str parent: str|None trans: Transform def __init__(self, name): self.name = name class AnimChannel: name: str frames: list[tuple[int, Transform]] def __init__(self, name): self.name = name self.frames = [] class Animation: name: str fps: float frames: int channels: list[AnimChannel] def __init__(self, name: str, fps: float, frames: int): self.name = name self.fps = fps self.frames = frames self.channels = [] class Skeleton: name: str bones: list[Bone] bone_indices: dict[str, int] anims: list[Animation] def __init__(self, name, armature): self.name = name self.bones = [] self.bone_indices = {} self.armature = armature self.anims = [] class Exporter: skeletons: dict[str, Skeleton] def __init__(self): self.blend_dir = os.path.dirname(bpy.data.filepath) self.out_path = os.path.join(self.blend_dir, "export") self.skeletons = {} @staticmethod def add_mesh_to_model(obj, model: Model): if obj.type != "MESH": print("warning: tried to export object that is not a mesh") return print(f" processing mesh: {obj.name}") mesh = obj.data mesh.calc_loop_triangles() # Prepare UV layers uv_layer = mesh.uv_layers.active.data for tri in mesh.loop_triangles: face_indices = [] material_index = tri.material_index # Get the material from the object's material slots if material_index < len(obj.material_slots): material = obj.material_slots[material_index].material material_name = material.name if material else "Unknown" else: material_name = "Unknown" mat_type, mat_name, mat_params = Exporter.extract_name(material_name) if mat_type != "MAT": continue # skip non material for loop_index in tri.loops: loop = mesh.loops[loop_index] vertex = mesh.vertices[loop.vertex_index] pos = tuple(c for c in vertex.co) normal = tuple(n for n in loop.normal) uv = tuple(c for c in uv_layer[loop_index].uv) # get color from named attribute color = None color_attr = mesh.color_attributes.get("vertex_color") if color_attr: color_data = color_attr.data[loop_index] color = tuple(c for c in color_data.color[:3]) # ignore alpha bone_indices = None bone_weights = None if model.skeleton is not None: deform_bones = [] for vert_group in vertex.groups: weight = round(vert_group.weight, 3) if weight < 0.001: continue group = obj.vertex_groups[vert_group.group] if group.name not in model.skeleton.bone_indices: continue deform_bones.append((group.name, weight)) deform_bones.sort(key=lambda x: x[1], reverse=True) deform_bones = deform_bones[:4] weight_sum = sum(w for _, w in deform_bones) bone_indices = tuple(model.skeleton.bone_indices[name] for name, _ in deform_bones) bone_weights = tuple(w / weight_sum for _, w in deform_bones) vert = Vertex(position=pos, normal=normal, uv=uv, color=color, bone_indices=bone_indices, bone_weights=bone_weights) vert_index = model.add_vertex(vert) face_indices.append(vert_index) if mat_name not in model.materials: surface = Surface(mat_name) surface.twosided = "2S" in mat_params surface.ocolor = "OCOLOR" in mat_params blend = mat_params.get("BLEND") if isinstance(blend, str): surface.blend = blend texture = mat_params.get("T") if isinstance(texture, str): surface.texture = texture model.materials[mat_name] = surface model.add_triangle(mat_name, *face_indices) @staticmethod def add_mesh_to_graph(obj, graph: Graph): mesh = obj.data # Ensure we have access to the 'G_flip' attribute flip_layer = None if "G_flip" in mesh.attributes: flip_layer = mesh.attributes["G_flip"] # Add nodes for v in mesh.vertices: pos = tuple(c for c in v.co) graph.add_node(GraphNode(pos)) # Add edges for e in mesh.edges: v1, v2 = e.vertices[0], e.vertices[1] if flip_layer and flip_layer.data[e.index].value: graph.add_edge(v2, v1) else: graph.add_edge(v1, v2) def rad2deg(self, rad: float) -> float: return rad * (180.0 / 3.141592653589793) def transform_str(self, transform: Transform): # rx, ry, rz = transform.rotation # dx, dy, dz = self.rad2deg(rx), self.rad2deg(ry), self.rad2deg(rz) t, r, s = transform.position, transform.rotation, transform.scale return f"{t[0]:.6f} {t[1]:.6f} {t[2]:.6f} {r[0]:.6f} {r[1]:.6f} {r[2]:.6f} {r[3]:.6f} {s:.6f}" def export_mdl(self, model: Model, filepath: str): with open(filepath, "w") as f: if model.make_col_trimesh: f.write("makecoltrimesh\n") if model.make_convex_hull: f.write("makeconvexhull\n") if model.centerofmass is not None: x, y, z = model.centerofmass f.write(f"centerofmass {x:.3f} {y:.3f} {z:.3f}\n") for col in model.cols: coltype, trans, sy, sz = col f.write(f"col {coltype} {self.transform_str(trans)} {sy:.6f} {sz:.6f}\n") if model.skeleton is not None: f.write(f"skeleton {model.skeleton.name}\n") for param_name, param_value in model.params: f.write(f"param {param_name} {param_value}\n") for v in model.vertices: color_str = f" {v.color[0]} {v.color[1]} {v.color[2]}" if v.color else "" bones_str = f" {len(v.bone_indices)} " + " ".join(f"{i} {w}" for i, w in zip(v.bone_indices, v.bone_weights)) if model.skeleton is not None else "" f.write(f"v {v.position[0]} {v.position[1]} {v.position[2]} {v.normal[0]} {v.normal[1]} {v.normal[2]} {v.uv[0]} {v.uv[1]}{color_str}{bones_str}\n") for mat_name, surface in model.materials.items(): f.write(f"surface {mat_name} +texture {surface.texture}") if surface.twosided: f.write(" +2sided") if surface.ocolor: f.write(" +ocolor") if surface.blend is not None: f.write(f" +blend {surface.blend}") f.write("\n") for tri in surface.tris: f.write(f"f {tri[0]} {tri[1]} {tri[2]}\n") def export_map(self, map: Map, filepath: str): with open(filepath, "w") as f: f.write(f"basemodel {map.basemodel_name}\n") # graphs for graph in map.graphs: f.write(f"graph {graph.name}\n") for node in graph.nodes: f.write(f"n {node.pos[0]} {node.pos[1]} {node.pos[2]}\n") for edge in graph.edges: f.write(f"e {edge.nodes[0]} {edge.nodes[1]}\n") # # static # for obj_name, transform in map.static_objects: # f.write(f"static {obj_name} {Exporter.transform_str(transform)}\n") f.write(f"chunks {map.max_chunk[0]} {map.max_chunk[1]}\n") chunks_sorted = [c[1] for c in map.chunks.items()] chunks_sorted.sort(key=lambda c: c.shifted_coord) for chunk in chunks_sorted: f.write(f"chunk {chunk.shifted_coord[0]} {chunk.shifted_coord[1]} {chunk.aabb_min.x} {chunk.aabb_min.y} {chunk.aabb_min.z} {chunk.aabb_max.x} {chunk.aabb_max.y} {chunk.aabb_max.z}\n") for name, first, count in chunk.surface_ranges: f.write(f"surface {name} {first} {count}\n") for obj_name, transform in chunk.static_objects: f.write(f"static {obj_name} {self.transform_str(transform)}\n") def export_veh(self, veh: Vehicle, filepath: str): with open(filepath, "w") as f: f.write(f"basemodel {veh.basemodel_name}\n") for wheel in veh.wheels: f.write(f"wheel {wheel.type} {wheel.model_name} {wheel.position[0]} {wheel.position[1]} {wheel.position[2]} {wheel.radius}\n") for name, trans in veh.locations: f.write(f"loc {name} {self.transform_str(trans)}\n") def export_sk(self, sk: Skeleton, filepath: str): with open(filepath, "w") as f: for bone in sk.bones: parent_str = bone.parent if bone.parent is not None else "NONE" f.write(f"b {bone.name} {parent_str} {self.transform_str(bone.trans)}\n") for anim in sk.anims: f.write(f"anim {anim.name} {sk.name}_{anim.name}\n") def export_anim(self, anim: Animation, filepath: str): with open(filepath, 'w') as f: f.write(f"frames {anim.frames}\n") f.write(f"fps {anim.fps}\n") for chan in anim.channels: f.write(f"ch {chan.name}\n") for idx, trans in chan.frames: f.write(f"f {idx} {self.transform_str(trans)}\n") @staticmethod def extract_name(fullname: str) -> tuple[str|None, str, dict[str, str|bool]]: match = re.match(rf"^(\w+)\/([\w]+)\/?([^\.]*)", fullname) if not match: return None, fullname, {} type, name, all_params_str = match.group(1), match.group(2), match.group(3) # extract params params_str = all_params_str.split("/") params: dict[str, str|bool] = {} for str_param in params_str: if str_param.find("=") == -1: params[str_param] = True continue k, v = str_param.split("=") params[k] = v return type, name, params def get_obj_transform(self, obj) -> Transform: mat = obj.matrix_world # position = obj.location # rotation = (obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w) # scale = obj.scale[0] # assume uniform scale # return Transform(position, rotation, scale) return Transform(*self.matrix_decompose(mat)) def process_MDL(self, col, name, params): print(f"exporting MDL: {name}") model = Model() if "C" in params: Cs = params["C"].split(",") for C in Cs: if C == "tri": model.make_col_trimesh = True elif C == "convex": model.make_convex_hull = True for obj in col.objects: type, obj_name, obj_params = self.extract_name(obj.name) if type == "M": self.add_mesh_to_model(obj, model) elif type == "COL": t, r, s = obj.matrix_world.decompose() trans = Transform((t.x, t.y, t.z), (r.x, r.y, r.z, r.w), s.x) if obj_name == "box": model.cols.append(("box", trans, s.y, s.z)) elif type == "COM": trans = self.get_obj_transform(obj) model.centerofmass = trans.position elif type == "P": if not "V" in obj_params: continue model.params.append((obj_name, obj_params["V"])) mdl_filepath = os.path.join(self.out_path, f"{name}.mdl") self.export_mdl(model, mdl_filepath) def process_MAP(self, col, name, params): print(f"exporting MAP: {name}") map = Map() map.basemodel_name = name map.basemodel.make_col_trimesh = True def proc_col(col): for obj in col.objects: type, obj_name, _ = self.extract_name(obj.name) if type == "M": self.add_mesh_to_model(obj, map.basemodel) elif type == "OBJ": transform = self.get_obj_transform(obj) map.static_objects.append((obj_name, transform)) elif type == "GRAPH": graph = Graph(obj_name) self.add_mesh_to_graph(obj, graph) map.graphs.append(graph) for child_col in col.children: proc_col(child_col) proc_col(col) map.create_chunks() mdl_filepath = os.path.join(self.out_path, f"{name}.mdl") self.export_mdl(map.basemodel, mdl_filepath) map_filepath = os.path.join(self.out_path, f"{name}.map") self.export_map(map, map_filepath) def process_VEH(self, col, name, params): print(f"exporting VEH: {name}") veh = Vehicle() veh.basemodel_name = name for obj in col.objects: type, obj_name, params = self.extract_name(obj.name) if type == "WHEEL": wheel_type = params["W"] if "W" in params else "" radius = float(params["R"].replace(",", ".")) if "R" in params else 0.5 transform = self.get_obj_transform(obj) wheel = Wheel() wheel.type = wheel_type wheel.model_name = obj_name wheel.position = transform.position wheel.radius = radius veh.wheels.append(wheel) elif type == "LOC": transform = self.get_obj_transform(obj) veh.locations.append((obj_name, transform)) veh.wheels.sort(key=lambda w: w.type) veh.locations.sort(key=lambda l: l[0]) veh_filepath = os.path.join(self.out_path, f"{name}.veh") self.export_veh(veh, veh_filepath) def get_armature_keep_bones(self, armature): keep_bones = set() for bone in armature.data.bones: #bone_name = bone.name if bone.use_deform: # or is_tag: keep_bones.add(bone) while bone.parent: bone = bone.parent keep_bones.add(bone) return keep_bones def matrix_decompose(self, matrix): t, r, s = matrix.decompose() return (t.x, t.y, t.z), (r.x, r.y, r.z, r.w), s.x def process_SK(self, obj, name, params): if obj.type != "ARMATURE": print("SK object not armature!") return sk = Skeleton(name, obj) keep_bones = self.get_armature_keep_bones(obj) keep_bones_str = ",".join([bone.name for bone in keep_bones]) print(f"Keep bones: {keep_bones_str}") print(f"Num bones: {len(keep_bones)}") for bone in obj.data.bones: if not bone in keep_bones: continue xbone = Bone(bone.name) parent = bone.parent xbone.parent = parent.name if parent else None bind_matrix = bone.matrix_local xbone.trans = Transform(*self.matrix_decompose(bind_matrix)) sk.bones.append(xbone) sk.bone_indices[xbone.name] = len(sk.bones) - 1 self.skeletons[name] = sk # export meshes for obj in obj.children: type, obj_name, _ = self.extract_name(obj.name) if type == "SKM": model = Model(sk) self.add_mesh_to_model(obj, model) mdl_filepath = os.path.join(self.out_path, f"{obj_name}.mdl") self.export_mdl(model, mdl_filepath) def process_A(self, action, name, params): if not "_" in name: print(f"{name}: required format skeleton_animname") return sk_name, anim_name = name.split("_", 1) if not sk_name in self.skeletons: print(f"{anim_name}: unknown anim skeleton {sk_name}") return sk = self.skeletons[sk_name] original_action = sk.armature.animation_data.action original_frame = bpy.context.scene.frame_current sk.armature.animation_data.action = action bone_frames = {bonename: [] for bonename in sk.bone_indices} _, end = map(int, action.frame_range) fps = bpy.context.scene.render.fps def vectors_similar(v1, v2, threshold): return (v1 - v2).length < threshold def quats_similar(q1, q2, threshold=1e-4): return q1.rotation_difference(q2).angle < threshold def frames_similar(f1, f2, threshold=0.00001): _, t1, r1, s1, _ = f1 _, t2, r2, s2, _ = f2 if not vectors_similar(t1, t2, threshold): return False if not quats_similar(r1, r2): return False if not vectors_similar(s1, s2, threshold): return False return True for frame in range(0, end): bpy.context.scene.frame_set(frame) bpy.context.view_layer.update() for bonename in sk.bone_indices: pose_bone = sk.armature.pose.bones.get(bonename) if not pose_bone: continue matrix = (pose_bone.parent.matrix.inverted() @ pose_bone.matrix) if pose_bone.parent else pose_bone.matrix.copy() translation, rotation, scale = matrix.decompose() current_frame = (frame, translation, rotation, scale, matrix) frame_list = bone_frames[bonename] if len(frame_list) > 0: last_frame = frame_list[-1] if frames_similar(last_frame, current_frame): continue frame_list.append(current_frame) anim = Animation(anim_name, fps, end) for bone_name, frames in bone_frames.items(): chan = AnimChannel(bone_name) for frame in frames: idx, _, _, _, matrix = frame chan.frames.append((idx, Transform(*self.matrix_decompose(matrix)))) anim.channels.append(chan) anim.channels.sort(key=lambda ch: ch.name) bpy.context.scene.frame_set(original_frame) sk.armature.animation_data.action = original_action sk.anims.append(anim) def run(self): print("=== EXPORT STARTED ===") os.makedirs(self.out_path, exist_ok=True) # collections for col in bpy.context.scene.collection.children: type, name, params = self.extract_name(col.name) if type == "MDL": self.process_MDL(col, name, params) elif type == "MAP": self.process_MAP(col, name, params) elif type == "VEH": self.process_VEH(col, name, params) # skeletons for obj in bpy.context.scene.collection.objects: type, name, params = self.extract_name(obj.name) if type == "SK": self.process_SK(obj, name, params) # animations for action in bpy.data.actions: type, name, params = self.extract_name(action.name) if type == "A": self.process_A(action, name, params) # export skeletons & anims for name, sk in self.skeletons.items(): sk.anims.sort(key=lambda a: a.name) for anim in sk.anims: self.export_anim(anim, os.path.join(self.out_path, f"{name}_{anim.name}.anim")) self.export_sk(sk, os.path.join(self.out_path, f"{name}.sk")) Exporter().run()