#!/usr/bin/env python3 """Convert Ogre3D .mesh.xml + .skeleton.xml to glTF 2.0 (.glb) with all animations.""" import xml.etree.ElementTree as ET import json import struct import math import os # ── Math ──────────────────────────────────────────────────────────────────── def axis_angle_to_quat(ax, ay, az, angle): s = math.sin(angle / 2) return (ax * s, ay * s, az * s, math.cos(angle / 2)) def mat4_from_trs(tx, ty, tz, qx, qy, qz, qw, sx, sy, sz): x, y, z, w = qx, qy, qz, qw xx, yy, zz = x*x, y*y, z*z xy, xz, yz = x*y, x*z, y*z wx, wy, wz = w*x, w*y, w*z return [ (1 - 2*(yy + zz))*sx, (2*(xy + wz))*sx, (2*(xz - wy))*sx, 0, (2*(xy - wz))*sy, (1 - 2*(xx + zz))*sy, (2*(yz + wx))*sy, 0, (2*(xz + wy))*sz, (2*(yz - wx))*sz, (1 - 2*(xx + yy))*sz, 0, tx, ty, tz, 1, ] def mat4_mult(a, b): r = [0]*16 for i in range(4): for j in range(4): for k in range(4): r[j*4 + i] += a[k*4 + i] * b[j*4 + k] return r def lerp_keyframes(kf_times, kf_vals, query_times): """Linearly interpolate keyframe values to query times.""" if len(kf_times) == 1: v = kf_vals[0] result = [] for _ in query_times: result.extend(v) return result result = [] for qt in query_times: if qt <= kf_times[0]: result.extend(kf_vals[0]) elif qt >= kf_times[-1]: result.extend(kf_vals[-1]) else: for i in range(len(kf_times) - 1): if kf_times[i] <= qt <= kf_times[i+1]: a = (qt - kf_times[i]) / (kf_times[i+1] - kf_times[i]) v = [kf_vals[i][j] + a*(kf_vals[i+1][j] - kf_vals[i][j]) for j in range(len(kf_vals[0]))] result.extend(v) break else: result.extend(kf_vals[-1]) return result def pad(data, alignment=4): """Pad binary data with null bytes to alignment (for BIN chunk).""" while len(data) % alignment: data += b'\x00' return data # ── Parsing ───────────────────────────────────────────────────────────────── def parse_skeleton(filepath): tree = ET.parse(filepath) root = tree.getroot() bones = [] for b in root.find('bones').findall('bone'): bid = int(b.get('id', '0')) name = b.get('name', '') pos = b.find('position') px, py, pz = float(pos.get('x','0')), float(pos.get('y','0')), float(pos.get('z','0')) rot = b.find('rotation') angle = float(rot.get('angle','0')) axis = rot.find('axis') ax, ay, az = float(axis.get('x','1')), float(axis.get('y','0')), float(axis.get('z','0')) bones.append({'id': bid, 'name': name, 'pos': (px, py, pz), 'rot_axis': (ax, ay, az), 'rot_angle': angle, 'scale': (1,1,1)}) hierarchy = {} hier = root.find('bonehierarchy') if hier is not None: for bp in hier.findall('boneparent'): hierarchy[bp.get('bone')] = bp.get('parent') animations = {} anims = root.find('animations') if anims is not None: for anim in anims.findall('animation'): name = anim.get('name') tracks = {} for track in anim.find('tracks').findall('track'): bn = track.get('bone') kfs = [] for kf in track.find('keyframes').findall('keyframe'): t = float(kf.get('time')) tr = kf.find('translate') tx, ty, tz = float(tr.get('x','0')), float(tr.get('y','0')), float(tr.get('z','0')) ro = kf.find('rotate') ang = float(ro.get('angle','0')) ax_e = ro.find('axis') ax, ay, az = float(ax_e.get('x','1')), float(ax_e.get('y','0')), float(ax_e.get('z','0')) q = axis_angle_to_quat(ax, ay, az, ang) sc = kf.find('scale') sx = float(sc.get('x','1')) if sc is not None else 1.0 sy = float(sc.get('y','1')) if sc is not None else 1.0 sz = float(sc.get('z','1')) if sc is not None else 1.0 kfs.append((t, tx, ty, tz, q[0], q[1], q[2], q[3], sx, sy, sz)) tracks[bn] = kfs animations[name] = tracks return bones, hierarchy, animations def parse_mesh(filepath): tree = ET.parse(filepath) root = tree.getroot() verts = [] sg = root.find('sharedgeometry') if sg is not None: for vb in sg.findall('vertexbuffer'): for v in vb.findall('vertex'): pos = v.find('position') norm = v.find('normal') vt = {'x': float(pos.get('x','0')), 'y': float(pos.get('y','0')), 'z': float(pos.get('z','0')), 'nx': 0, 'ny': 0, 'nz': 1} if norm is not None: vt['nx'] = float(norm.get('x','0')); vt['ny'] = float(norm.get('y','0')); vt['nz'] = float(norm.get('z','0')) verts.append(vt) faces = [] for sm in root.findall('.//submesh'): for fe in sm.findall('faces'): for f in fe.findall('face'): faces.append((int(f.get('v1','0')), int(f.get('v2','0')), int(f.get('v3','0')))) bw = [{} for _ in range(len(verts))] ba = root.find('boneassignments') if ba is not None: for a in ba.findall('vertexboneassignment'): vi = int(a.get('vertexindex','0')) bi = int(a.get('boneindex','0')) w = float(a.get('weight','1.0')) if vi < len(bw): bw[vi][bi] = w return verts, faces, bw # ── glTF builder ──────────────────────────────────────────────────────────── def build_gltf(mesh_path, skeleton_path, output_path): verts, faces, bw = parse_mesh(mesh_path) bones, hierarchy, animations = parse_skeleton(skeleton_path) # Topological sort of bones bone_by_name = {b['name']: b for b in bones} bone_by_id = {b['id']: b for b in bones} roots = [b for b in bones if b['name'] not in hierarchy] order = [] visited = set() def dfs(name): if name in visited: return visited.add(name) order.append(name) for child, parent in hierarchy.items(): if parent == name: dfs(child) for root in roots: dfs(root['name']) name_to_idx = {name: i for i, name in enumerate(order)} # glTF nodes (bones only for now) nodes = [] for name in order: b = bone_by_name[name] q = axis_angle_to_quat(*b['rot_axis'], b['rot_angle']) nodes.append({ 'name': name, 'translation': list(b['pos']), 'rotation': [q[0], q[1], q[2], q[3]], 'scale': list(b['scale']), }) for child, parent in hierarchy.items(): if child in name_to_idx and parent in name_to_idx: ci, pi = name_to_idx[child], name_to_idx[parent] nodes[pi].setdefault('children', []).append(ci) # Add a skeleton root node (identity matrix, all bones as children) skeleton_root_idx = len(nodes) nodes.append({'name': '__skeleton_root__', 'children': []}) # Only add bones that are NOT children of other bones in hierarchy for i, name in enumerate(order): is_child = any(i in nodes[idx].get('children', []) for idx in range(len(nodes))) if not is_child: nodes[skeleton_root_idx]['children'].append(i) # Inverse bind matrices def world_transform(name): b = bone_by_name[name] q = axis_angle_to_quat(*b['rot_axis'], b['rot_angle']) local = mat4_from_trs(b['pos'][0], b['pos'][1], b['pos'][2], q[0], q[1], q[2], q[3], 1, 1, 1) parent = hierarchy.get(name) if parent and parent in bone_by_name: return mat4_mult(world_transform(parent), local) return local ibm_flat = [] for name in order: w = world_transform(name) # Invert 4x4 matrix m = [w[i + j*4] for j in range(4) for i in range(4)] # transpose to row-major for inversion inv = invert_4x4(m) ibm_flat.extend(inv) # back in row-major? No - glTF uses column-major # Actually glTF stores matrices column-major in the linear buffer # The inverse bind matrix in column-major form: each 4 floats = 1 column ibm_col_major = [] for name in order: w = world_transform(name) # w is column-major: [c0x, c0y, c0z, c0w, c1x, ..., c3w] # Invert it inv = invert_4x4_colmajor(w) ibm_col_major.extend(inv) # Joint indices for skin skin_joints = [name_to_idx[name] for name in order] # Vertex data positions = [] nrm = [] joints0 = [] weights0 = [] for i, v in enumerate(verts): positions.extend([v['x'], v['y'], v['z']]) nrm.extend([v['nx'], v['ny'], v['nz']]) wmap = bw[i] if i < len(bw) else {} sw = sorted(wmap.items(), key=lambda x: x[1], reverse=True)[:4] j = [0, 0, 0, 0] wgt = [0.0, 0.0, 0.0, 0.0] for k, (bid, weight) in enumerate(sw): if bid in bone_by_id: jname = bone_by_id[bid]['name'] j[k] = name_to_idx.get(jname, 0) wgt[k] = weight total = sum(wgt) if total > 0: wgt = [x / total for x in wgt] joints0.extend(j) weights0.extend(wgt) indices = [] for f in faces: indices.extend(list(f)) # Bounding box all_x = [v['x'] for v in verts] all_y = [v['y'] for v in verts] all_z = [v['z'] for v in verts] bbox_min = [min(all_x), min(all_y), min(all_z)] bbox_max = [max(all_x), max(all_y), max(all_z)] # Model node (has mesh and skin) model_node_idx = len(nodes) nodes.append({'name': os.path.basename(mesh_path).replace('.mesh.xml', '') + '_mesh', 'mesh': 0, 'skin': 0}) # Scene root scene_node_idx = len(nodes) nodes.append({'name': 'scene_root', 'children': [skeleton_root_idx, model_node_idx]}) # ── Build buffer ──────────────────────────────────────────────────────── pos_bytes = pad(struct.pack(f'<{len(positions)}f', *positions)) nrm_bytes = pad(struct.pack(f'<{len(nrm)}f', *nrm)) idx_bytes = pad(struct.pack(f'<{len(indices)}H', *indices)) jnt_bytes = pad(struct.pack(f'<{len(joints0)}H', *joints0)) wgt_bytes = pad(struct.pack(f'<{len(weights0)}f', *weights0)) ibm_bytes = pad(struct.pack(f'<{len(ibm_col_major)}f', *ibm_col_major)) # Base data end base_end = len(pos_bytes) + len(nrm_bytes) + len(idx_bytes) + len(jnt_bytes) + len(wgt_bytes) + len(ibm_bytes) # ── Animations ────────────────────────────────────────────────────────── anim_data = bytearray() gltf_animations = [] buf_views = [] accessors_list = [] # Fill static buffer views and accessors first off = 0 bv_pos = {'buffer': 0, 'byteOffset': off, 'byteLength': len(pos_bytes), 'target': 34962}; off += len(pos_bytes) bv_nrm = {'buffer': 0, 'byteOffset': off, 'byteLength': len(nrm_bytes), 'target': 34962}; off += len(nrm_bytes) bv_idx = {'buffer': 0, 'byteOffset': off, 'byteLength': len(idx_bytes), 'target': 34963}; off += len(idx_bytes) bv_jnt = {'buffer': 0, 'byteOffset': off, 'byteLength': len(jnt_bytes), 'target': 34962}; off += len(jnt_bytes) bv_wgt = {'buffer': 0, 'byteOffset': off, 'byteLength': len(wgt_bytes), 'target': 34962}; off += len(wgt_bytes) bv_ibm = {'buffer': 0, 'byteOffset': off, 'byteLength': len(ibm_bytes)}; off += len(ibm_bytes) buffer_views = [bv_pos, bv_nrm, bv_idx, bv_jnt, bv_wgt, bv_ibm] accessors = [ {'bufferView': 0, 'componentType': 5126, 'count': len(verts), 'type': 'VEC3', 'min': bbox_min, 'max': bbox_max}, {'bufferView': 1, 'componentType': 5126, 'count': len(verts), 'type': 'VEC3'}, {'bufferView': 2, 'componentType': 5123, 'count': len(indices), 'type': 'SCALAR'}, {'bufferView': 3, 'componentType': 5123, 'count': len(verts), 'type': 'VEC4'}, {'bufferView': 4, 'componentType': 5126, 'count': len(verts), 'type': 'VEC4'}, {'bufferView': 5, 'componentType': 5126, 'count': len(order), 'type': 'MAT4'}, ] anim_base = off # animations start here for anim_name, tracks in animations.items(): all_times = set() for bn, kfs in tracks.items(): for t, *_ in kfs: all_times.add(t) sorted_times = sorted(all_times) if len(sorted_times) < 2: continue time_data = pad(struct.pack(f'<{len(sorted_times)}f', *sorted_times)) time_off = len(anim_data) anim_data.extend(time_data) # Buffer view for time tv_idx = len(buffer_views) buffer_views.append({'buffer': 0, 'byteOffset': anim_base + time_off, 'byteLength': len(time_data)}) # Accessor for time (shared per animation) t_acc_idx = len(accessors) accessors.append({'bufferView': tv_idx, 'componentType': 5126, 'count': len(sorted_times), 'type': 'SCALAR', 'min': [sorted_times[0]], 'max': [sorted_times[-1]]}) samplers = [] channels = [] for bone_name, keyframes in tracks.items(): if bone_name not in name_to_idx: continue node_idx = name_to_idx[bone_name] kf_times = [t for t, *_ in keyframes] kf_trans = [list(kf[1:4]) for kf in keyframes] kf_rot = [list(kf[4:8]) for kf in keyframes] kf_scale = [list(kf[8:11]) for kf in keyframes] for data, acc_type, path in [ (lerp_keyframes(kf_times, kf_trans, sorted_times), 'VEC3', 'translation'), (lerp_keyframes(kf_times, kf_rot, sorted_times), 'VEC4', 'rotation'), (lerp_keyframes(kf_times, kf_scale, sorted_times), 'VEC3', 'scale'), ]: raw = pad(struct.pack(f'<{len(data)}f', *data)) off2 = len(anim_data) anim_data.extend(raw) dv_idx = len(buffer_views) buffer_views.append({'buffer': 0, 'byteOffset': anim_base + off2, 'byteLength': len(raw)}) acc_idx = len(accessors) accessors.append({'bufferView': dv_idx, 'componentType': 5126, 'count': len(sorted_times), 'type': acc_type}) samp_idx = len(samplers) samplers.append({'input': t_acc_idx, 'interpolation': 'LINEAR', 'output': acc_idx}) channels.append({'sampler': samp_idx, 'target': {'node': node_idx, 'path': path}}) if samplers and channels: gltf_animations.append({'name': anim_name, 'samplers': samplers, 'channels': channels}) anim_bin = bytes(anim_data) # Combine all_bin = pos_bytes + nrm_bytes + idx_bytes + jnt_bytes + wgt_bytes + ibm_bytes + anim_bin # ── Build JSON ────────────────────────────────────────────────────────── meshes = [{'name': 'mesh', 'primitives': [{ 'attributes': {'POSITION': 0, 'NORMAL': 1, 'JOINTS_0': 3, 'WEIGHTS_0': 4}, 'indices': 2, 'material': 0, }]}] materials = [{ 'name': 'default', 'pbrMetallicRoughness': { 'baseColorFactor': [1.0, 1.0, 1.0, 1.0], 'metallicFactor': 0.0, 'roughnessFactor': 0.5, }, }] skin = [{'inverseBindMatrices': 5, 'joints': skin_joints, 'name': 'skin'}] gltf = { 'asset': {'version': '2.0', 'generator': 'ogre2gltf.py'}, 'scene': 0, 'scenes': [{'nodes': [scene_node_idx]}], 'nodes': nodes, 'meshes': meshes, 'materials': materials, 'skins': skin, 'animations': gltf_animations, 'accessors': accessors, 'bufferViews': buffer_views, 'buffers': [{'byteLength': len(all_bin)}], } # ── Write GLB ─────────────────────────────────────────────────────────── gltf_json = json.dumps(gltf, separators=(',', ':'), allow_nan=False).encode('utf-8') # GLB spec: JSON chunk padded with spaces (0x20), BIN chunk padded with nulls (0x00) while len(gltf_json) % 4: gltf_json += b' ' while len(all_bin) % 4: all_bin += b'\x00' header = struct.pack(' {output_path} ({len(verts)}v {len(faces)}f {len(bones)}b {len(gltf_animations)}a)') # ── Matrix inversion ─────────────────────────────────────────────────────── def invert_4x4(m): """Invert 4x4 row-major matrix, return row-major.""" a, b, c, d, e, f, g, h, i, j, k, l, m_, n, o, p = m det = (a * (f*k*p + g*l*n + h*j*o - h*k*n - f*l*o - g*j*p) - b * (e*k*p + g*l*m_ + h*i*o - h*k*m_ - e*l*o - g*i*p) + c * (e*j*p + f*l*m_ + h*i*n - h*j*m_ - e*l*n - f*i*p) - d * (e*j*o + f*k*m_ + g*i*n - g*j*m_ - e*k*n - f*i*o)) if abs(det) < 1e-12: return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1] inv_det = 1.0 / det return [ (f*k*p + g*l*n + h*j*o - h*k*n - f*l*o - g*j*p) * inv_det, (b*k*p + c*l*n + d*j*o - d*k*n - b*l*o - c*j*p) * inv_det, (b*g*p + c*h*n + d*f*o - d*g*n - b*h*o - c*f*p) * inv_det, (b*g*l + c*h*j + d*f*k - d*g*j - b*h*k - c*f*l) * inv_det, (e*k*p + g*l*m_ + h*i*o - h*k*m_ - e*l*o - g*i*p) * inv_det, (a*k*p + c*l*m_ + d*i*o - d*k*m_ - a*l*o - c*i*p) * inv_det, (a*g*p + c*h*m_ + d*e*o - d*g*m_ - a*h*o - c*e*p) * inv_det, (a*g*l + c*h*i + d*e*k - d*g*i - a*h*k - c*e*l) * inv_det, (e*j*p + f*l*m_ + h*i*n - h*j*m_ - e*l*n - f*i*p) * inv_det, (a*j*p + b*l*m_ + d*i*n - d*j*m_ - a*l*n - b*i*p) * inv_det, (a*f*p + b*h*m_ + d*e*n - d*f*m_ - a*h*n - b*e*p) * inv_det, (a*f*l + b*h*i + d*e*j - d*f*i - a*h*j - b*e*l) * inv_det, (e*j*o + f*k*m_ + g*i*n - g*j*m_ - e*k*n - f*i*o) * inv_det, (a*j*o + b*k*m_ + c*i*n - c*j*m_ - a*k*n - b*i*o) * inv_det, (a*f*o + b*g*m_ + c*e*n - c*f*m_ - a*g*n - b*e*o) * inv_det, (a*f*k + b*g*i + c*e*j - c*f*i - a*g*j - b*e*k) * inv_det, ] def invert_4x4_colmajor(m): """Invert 4x4 column-major matrix, return column-major.""" # Convert col-major to row-major, invert, convert back rowm = [m[i + j*4] for j in range(4) for i in range(4)] inv_rowm = invert_4x4(rowm) return [inv_rowm[j + i*4] for i in range(4) for j in range(4)] # ── Main ──────────────────────────────────────────────────────────────────── def main(): base = '/var/home/nico/Gamedev/Wackelpeter/extracted/Models' out = '/var/home/nico/Gamedev/Wackelpeter/web/public/models' for name, mesh, skel in [ ('blob', 'blob/Blob', 'blob/Blob'), ('sword', 'sword/sword', 'sword/sword'), ('shield', 'shield/shield', 'shield/shield'), ]: mf = os.path.join(base, f'{mesh}.mesh.xml') sf = os.path.join(base, f'{skel}.skeleton.xml') if os.path.exists(mf) and os.path.exists(sf): print(f'Converting {name}...') build_gltf(mf, sf, os.path.join(out, f'{name}.glb')) else: print(f'Skipping {name} (missing XML)') if __name__ == '__main__': main()