studio/rbxl-importer/src/mesh_converter.py
min c375ae01ac
All checks were successful
CI / Lint (pull_request) Successful in 1m6s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(rbxl-import): импорт Roblox .rbxl карт в Rublox-проекты
Тест-фича для МИНа. Полное описание в 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>
2026-06-07 18:24:27 +03:00

482 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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}")