All checks were successful
Тест-фича для МИНа. Полное описание в rbxl-importer/INFO_PROCESS.md. Backend (rbxl-importer/ на VM 130 S1): - Python-парсер Roblox Binary (28+ типов значений) - Asset downloader через Marfusha proxy + .ROBLOSECURITY cookie - Mesh→GLB конвертер (v1-v5) - Converter Roblox-классов → project_data - Flask API: /analyze + /create Frontend: - API.js + components/RbxlImportModal.jsx (drag-n-drop) Тестовый импорт Easy Obby: project_id 2697, 2244 primitives + 742 lua-scripts + 5 ассетов. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
482 lines
18 KiB
Python
482 lines
18 KiB
Python
"""
|
||
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.
|
||
|
||
Структура:
|
||
<num_faces>\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('<H', r.read(2))[0]
|
||
vertex_size = struct.unpack('<B', r.read(1))[0]
|
||
face_size = struct.unpack('<B', r.read(1))[0]
|
||
num_vertices = struct.unpack('<I', r.read(4))[0]
|
||
num_faces = struct.unpack('<I', r.read(4))[0]
|
||
# пропускаем оставшиеся байты header'а если он больше 12
|
||
if header_size > 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('<H', r.read(2))[0]
|
||
vertex_size = struct.unpack('<B', r.read(1))[0]
|
||
face_size = struct.unpack('<B', r.read(1))[0]
|
||
_lod_type = struct.unpack('<H', r.read(2))[0]
|
||
num_vertices = struct.unpack('<I', r.read(4))[0]
|
||
num_faces = struct.unpack('<I', r.read(4))[0]
|
||
num_lods = struct.unpack('<H', r.read(2))[0]
|
||
# если header больше — дочитаем
|
||
consumed = 16
|
||
if header_size > 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('<H', r.read(2))[0]
|
||
_lod_type = struct.unpack('<H', r.read(2))[0]
|
||
num_vertices = struct.unpack('<I', r.read(4))[0]
|
||
num_faces = struct.unpack('<I', r.read(4))[0]
|
||
num_lods = struct.unpack('<H', r.read(2))[0]
|
||
num_bones = struct.unpack('<H', r.read(2))[0]
|
||
bone_names_size = struct.unpack('<I', r.read(4))[0]
|
||
num_subsets = struct.unpack('<H', r.read(2))[0]
|
||
_hq_lods = struct.unpack('<B', r.read(1))[0]
|
||
_unused = struct.unpack('<B', r.read(1))[0]
|
||
consumed = 22
|
||
if header_size > 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('<I', 2)) # version
|
||
out.extend(struct.pack('<I', total_len)) # length
|
||
|
||
# JSON chunk
|
||
out.extend(struct.pack('<I', len(json_bytes)))
|
||
out.extend(b'JSON')
|
||
out.extend(json_bytes)
|
||
|
||
# BIN chunk
|
||
out.extend(struct.pack('<I', len(bin_total)))
|
||
out.extend(b'BIN\x00')
|
||
out.extend(bin_total)
|
||
|
||
return bytes(out)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# CLI
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def convert(input_path: str, output_path: str) -> 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}")
|