""" mesh_converter.py — Roblox .mesh parser + GLB writer. Roblox .mesh формат: v1.00, v1.01: ASCII текстовый — лёгкий парсинг. v2.00, v3.00, v4.00, v5.00: бинарный. Все версии начинаются с ASCII строки "version X.YY\n" (12+ байт), затем зависимо от версии — либо текст, либо binary. Документация формата (community-reverse-engineered): https://devforum.roblox.com/t/roblox-mesh-format/326114 https://github.com/MaximumADHD/Roblox-File-Format/blob/main/Plugins/MeshImporter.cs Этот модуль: 1. parse_roblox_mesh(blob) → MeshData (vertices, normals, uvs, faces) 2. build_glb(mesh_data) → bytes (готовый .glb файл) 3. convert(input_path, output_path) — высокоуровневая обёртка """ import struct import io import re from dataclasses import dataclass, field from typing import List, Tuple import logging logger = logging.getLogger(__name__) @dataclass class Vertex: x: float; y: float; z: float nx: float = 0.0; ny: float = 0.0; nz: float = 0.0 u: float = 0.0; v: float = 0.0 # Vertex color (RGBA в 0-1) r: float = 1.0; g: float = 1.0; b: float = 1.0; a: float = 1.0 @dataclass class MeshData: version: str # 'v1.00', 'v2.00', ... vertices: List[Vertex] = field(default_factory=list) faces: List[Tuple[int, int, int]] = field(default_factory=list) # индексы треугольников has_normals: bool = True has_uvs: bool = True class MeshParseError(Exception): pass # ────────────────────────────────────────────────────────────────────── # Главный диспатчер # ────────────────────────────────────────────────────────────────────── def parse_roblox_mesh(blob: bytes) -> MeshData: """Парсит Roblox .mesh файл любой версии. Бросает MeshParseError.""" # Все версии начинаются со строки "version X.YY\n" nl_pos = blob.find(b'\n') if nl_pos < 0: raise MeshParseError("no newline in first line — not a roblox mesh") first_line = blob[:nl_pos].decode('ascii', errors='replace').strip() m = re.match(r'version (\d+\.\d+)', first_line) if not m: raise MeshParseError(f"bad first line: {first_line!r}") version = 'v' + m.group(1) rest = blob[nl_pos+1:] if version in ('v1.00', 'v1.01'): return _parse_v1(rest, version) elif version == 'v2.00': return _parse_v2(rest, version) elif version == 'v3.00': return _parse_v3(rest, version) elif version in ('v4.00', 'v4.01'): return _parse_v4(rest, version) elif version == 'v5.00': return _parse_v5(rest, version) else: raise MeshParseError(f"unsupported mesh version: {version}") # ────────────────────────────────────────────────────────────────────── # v1.00 / v1.01 — ASCII текст # ────────────────────────────────────────────────────────────────────── def _parse_v1(rest: bytes, version: str) -> MeshData: """v1: ASCII text format. Структура: \n [x,y,z][nx,ny,nz][u,v,unused] [x,y,z][nx,ny,nz][u,v,unused] [x,y,z][nx,ny,nz][u,v,unused] ... Каждое лицо — 3 vertex'а подряд. Vertices не дедуплицируются (поэтому лиц = num_faces, vertices = num_faces*3). Для GLB сделаем dedup потом. В v1 vertices использовали диапазон [-0.5, 0.5] (нормализованный). """ text = rest.decode('ascii', errors='replace').strip() parts = text.split('\n', 1) try: num_faces = int(parts[0].strip()) except ValueError: raise MeshParseError(f"v1: bad face count: {parts[0]!r}") # Парсим все [x,y,z] tuples body = parts[1] if len(parts) > 1 else '' tuples = re.findall(r'\[([^\]]+)\]', body) # На каждый vertex — 3 tuple ([pos][normal][uv]) if len(tuples) < num_faces * 9: raise MeshParseError( f"v1: expected {num_faces*9} bracketed tuples, got {len(tuples)}" ) vertices = [] for i in range(num_faces * 3): pos = list(map(float, tuples[i*3].split(','))) norm = list(map(float, tuples[i*3 + 1].split(','))) uv = list(map(float, tuples[i*3 + 2].split(','))) # v1 имеет позиции в [-0.5, 0.5]; для отображения масштабируем vertices.append(Vertex( x=pos[0] * 2.0, y=pos[1] * 2.0, z=pos[2] * 2.0, nx=norm[0], ny=norm[1], nz=norm[2], u=uv[0], v=1.0 - uv[1], # GLTF V-flip )) faces = [(i*3, i*3 + 1, i*3 + 2) for i in range(num_faces)] return MeshData(version=version, vertices=vertices, faces=faces) # ────────────────────────────────────────────────────────────────────── # v2.00 — binary # ────────────────────────────────────────────────────────────────────── def _parse_v2(rest: bytes, version: str) -> MeshData: """v2: бинарный формат. Структура: header_size (uint16, обычно 12) vertex_size (uint8, обычно 40 — 9 float + 4 byte color) face_size (uint8, обычно 12 — 3 uint32) num_vertices (uint32) num_faces (uint32) vertices... (num_vertices * vertex_size байт) faces... (num_faces * face_size байт) vertex (40 байт): pos (3 * float32) normal (3 * float32) uv (2 * float32) color (4 * uint8) — RGBA """ r = io.BytesIO(rest) header_size = struct.unpack(' 12: r.read(header_size - 12) vertices = [] for _ in range(num_vertices): v_data = r.read(vertex_size) # pos + normal + uv x, y, z = struct.unpack('<3f', v_data[0:12]) nx, ny, nz = struct.unpack('<3f', v_data[12:24]) u, vv = struct.unpack('<2f', v_data[24:32]) # color если есть if vertex_size >= 40: cr, cg, cb, ca = v_data[32:36] vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv, cr/255.0, cg/255.0, cb/255.0, ca/255.0) else: vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv) vertices.append(vert) faces = [] for _ in range(num_faces): f_data = r.read(face_size) a, b, c = struct.unpack('<3I', f_data[0:12]) faces.append((a, b, c)) return MeshData(version=version, vertices=vertices, faces=faces) # ────────────────────────────────────────────────────────────────────── # v3, v4 — расширенные форматы с LOD и skinning # ────────────────────────────────────────────────────────────────────── def _parse_v3(rest: bytes, version: str) -> MeshData: """v3: v2 + LOD support. Header: header_size (uint16) = 16 vertex_size (uint8) face_size (uint8) lod_type (uint16) num_vertices (uint32) num_faces (uint32) num_lods (uint16) ... Для импорта нам нужен только LOD 0 (highest quality), остальные пропускаем. """ r = io.BytesIO(rest) header_size = struct.unpack(' consumed: r.read(header_size - consumed) # vertices vertices = [] for _ in range(num_vertices): v_data = r.read(vertex_size) x, y, z = struct.unpack('<3f', v_data[0:12]) nx, ny, nz = struct.unpack('<3f', v_data[12:24]) u, vv = struct.unpack('<2f', v_data[24:32]) if vertex_size >= 40: cr, cg, cb, ca = v_data[32:36] vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv, cr/255.0, cg/255.0, cb/255.0, ca/255.0) else: vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv) vertices.append(vert) faces = [] for _ in range(num_faces): a, b, c = struct.unpack('<3I', r.read(12)) faces.append((a, b, c)) # LOD info (пропускаем — нам нужен только полный mesh) # LOD-таблица: num_lods+1 uint32 (face offsets) for _ in range(num_lods + 1): r.read(4) return MeshData(version=version, vertices=vertices, faces=faces) def _parse_v4(rest: bytes, version: str) -> MeshData: """v4: v3 + bones + envelope (skinning). Header (v4.00): header_size (uint16) lod_type (uint16) num_vertices (uint32) num_faces (uint32) num_lods (uint16) num_bones (uint16) bone_names_size (uint32) num_subsets (uint16) num_high_quality_lods (uint8) unused (uint8) ... Для импорта в Rublox bones мы пока не поддерживаем — забираем только vertices+faces. """ r = io.BytesIO(rest) header_size = struct.unpack(' consumed: r.read(header_size - consumed) # v4 vertex = 40 байт (как v2) + envelope в отдельном блоке vertex_size = 40 vertices = [] for _ in range(num_vertices): v_data = r.read(vertex_size) x, y, z = struct.unpack('<3f', v_data[0:12]) nx, ny, nz = struct.unpack('<3f', v_data[12:24]) u, vv = struct.unpack('<2f', v_data[24:32]) cr, cg, cb, ca = v_data[32:36] vertices.append(Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv, cr/255.0, cg/255.0, cb/255.0, ca/255.0)) # envelope (skinning weights) — пропускаем if num_bones > 0: # envelope = 8 байт на vertex (4 bone indexes + 4 weights) r.read(num_vertices * 8) faces = [] for _ in range(num_faces): a, b, c = struct.unpack('<3I', r.read(12)) faces.append((a, b, c)) return MeshData(version=version, vertices=vertices, faces=faces) def _parse_v5(rest: bytes, version: str) -> MeshData: """v5: v4 + facial animation + расширенный LOD. Header структура почти такая же как v4, плюс несколько полей в конце. """ # Парсим как v4 — для базовой геометрии этого достаточно return _parse_v4(rest, version) # ────────────────────────────────────────────────────────────────────── # GLB writer — конвертер MeshData → GLB бинарь # ────────────────────────────────────────────────────────────────────── def build_glb(mesh: MeshData) -> bytes: """Собирает GLB (glTF 2.0 binary) из MeshData. GLB структура: magic 'glTF' (4 bytes) version (uint32) = 2 length (uint32) — total file size JSON chunk: length (uint32) type 'JSON' JSON payload (UTF-8) BIN chunk: length (uint32) type 'BIN\0' binary payload (vertex data + index data) """ import json n_verts = len(mesh.vertices) n_faces = len(mesh.faces) if n_verts == 0 or n_faces == 0: raise MeshParseError("empty mesh — cannot build GLB") # Бинарные данные: POSITION (3f) + NORMAL (3f) + TEXCOORD_0 (2f) + indices (uint32) pos_buf = bytearray() norm_buf = bytearray() uv_buf = bytearray() for v in mesh.vertices: pos_buf.extend(struct.pack('<3f', v.x, v.y, v.z)) norm_buf.extend(struct.pack('<3f', v.nx, v.ny, v.nz)) uv_buf.extend(struct.pack('<2f', v.u, v.v)) idx_buf = bytearray() for a, b, c in mesh.faces: idx_buf.extend(struct.pack('<3I', a, b, c)) # Все буферы конкатенируются в один большой # Каждый должен начинаться с 4-byte alignment def pad4(buf): while len(buf) % 4 != 0: buf.append(0) return buf pos_offset = 0 pos_size = len(pos_buf) pad4(pos_buf) norm_offset = len(pos_buf) norm_size = len(norm_buf) pad4(norm_buf) uv_offset = norm_offset + len(norm_buf) uv_size = len(uv_buf) pad4(uv_buf) idx_offset = uv_offset + len(uv_buf) idx_size = len(idx_buf) pad4(idx_buf) bin_total = pos_buf + norm_buf + uv_buf + idx_buf # Mins/maxes — нужны GLTF спекой для POSITION xs = [v.x for v in mesh.vertices] ys = [v.y for v in mesh.vertices] zs = [v.z for v in mesh.vertices] pos_min = [min(xs), min(ys), min(zs)] pos_max = [max(xs), max(ys), max(zs)] gltf = { "asset": {"version": "2.0", "generator": "rublox-rbxl-importer"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0}], "meshes": [{ "primitives": [{ "attributes": { "POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, }, "indices": 3, "mode": 4, # TRIANGLES }], }], "buffers": [{ "byteLength": len(bin_total), }], "bufferViews": [ {"buffer": 0, "byteOffset": pos_offset, "byteLength": pos_size, "target": 34962}, # ARRAY_BUFFER {"buffer": 0, "byteOffset": norm_offset, "byteLength": norm_size, "target": 34962}, {"buffer": 0, "byteOffset": uv_offset, "byteLength": uv_size, "target": 34962}, {"buffer": 0, "byteOffset": idx_offset, "byteLength": idx_size, "target": 34963}, # ELEMENT_ARRAY_BUFFER ], "accessors": [ {"bufferView": 0, "componentType": 5126, "count": n_verts, "type": "VEC3", "min": pos_min, "max": pos_max}, # FLOAT {"bufferView": 1, "componentType": 5126, "count": n_verts, "type": "VEC3"}, {"bufferView": 2, "componentType": 5126, "count": n_verts, "type": "VEC2"}, {"bufferView": 3, "componentType": 5125, "count": n_faces * 3, "type": "SCALAR"}, # UNSIGNED_INT ], } json_bytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8') while len(json_bytes) % 4 != 0: json_bytes += b' ' # GLB header total_len = 12 + 8 + len(json_bytes) + 8 + len(bin_total) out = bytearray() out.extend(b'glTF') out.extend(struct.pack(' dict: """Конвертит .mesh → .glb. Возвращает dict с metadata.""" with open(input_path, 'rb') as f: blob = f.read() mesh = parse_roblox_mesh(blob) glb = build_glb(mesh) with open(output_path, 'wb') as f: f.write(glb) return { 'version': mesh.version, 'vertices': len(mesh.vertices), 'faces': len(mesh.faces), 'input_size': len(blob), 'output_size': len(glb), } if __name__ == '__main__': import sys if len(sys.argv) < 3: print("usage: mesh_converter.py input.mesh output.glb") sys.exit(1) info = convert(sys.argv[1], sys.argv[2]) for k, v in info.items(): print(f" {k}: {v}")