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>
794 lines
32 KiB
Python
794 lines
32 KiB
Python
"""
|
||
converter.py — RobloxModel → Rublox project_data.
|
||
|
||
Принимает дерево Instance'ов из parser.py и превращает в JSON-схему которую
|
||
понимает движок Rublox (студия и плеер).
|
||
|
||
Маппинг основных классов Roblox → Rublox:
|
||
Part(Shape=Block) → primitive: cube
|
||
Part(Shape=Ball) → primitive: sphere
|
||
Part(Shape=Cylinder) → primitive: cylinder
|
||
WedgePart → primitive: wedge
|
||
CornerWedgePart → primitive: cornerwedge
|
||
MeshPart → glbModels entry (ссылка на наш сконвертированный glb)
|
||
UnionOperation → glbModels entry (если есть CSG)
|
||
SpawnLocation → scene.spawnPoint
|
||
Script/LocalScript → scripts entry (kind='roblox-lua', raw lua source)
|
||
Lighting → scene.environment
|
||
Sound → scene.sounds entry
|
||
Folder/Model → scene.folders entry
|
||
Texture/Decal → не отдельные объекты, прикрепляются к Part'у
|
||
|
||
Координаты:
|
||
Roblox: правая Y-up, 1 stud ≈ 0.28 м.
|
||
Rublox/Babylon: правая Y-up, 1 unit = 1 м.
|
||
→ масштабируем все координаты на 0.28 (можно настроить через scale_factor).
|
||
|
||
CFrame → position + rotationX/Y/Z (Euler XYZ в радианах).
|
||
|
||
Скрипты: сохраняются как kind='roblox-lua' с raw lua_source. Lua-runtime
|
||
(asset_proxy + RobloxLuaWorker.js в плеере) исполняет их потом.
|
||
"""
|
||
from dataclasses import dataclass, field, asdict
|
||
from typing import List, Dict, Any, Optional, Tuple
|
||
import math
|
||
import logging
|
||
|
||
from rbxl_parser import RobloxModel, Instance
|
||
from rbxl_types import (
|
||
CFrame, Color3, Vector3, EnumValue, BrickColor,
|
||
OptionalCFrame, PhysicalProperties,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ────── константы маппинга ──────
|
||
|
||
# Roblox stud → метры (примерно 0.28). Можно поменять при импорте.
|
||
DEFAULT_SCALE = 0.28
|
||
|
||
# Маппинг Material enum → Rublox material strings.
|
||
# Roblox Enum.Material:
|
||
# 0=Plastic, 256=Plastic (default), 272=Wood, 288=Slate, 304=Concrete,
|
||
# 320=CorrodedMetal, 336=DiamondPlate, 352=Foil, 368=Grass, 384=Ice,
|
||
# 400=Marble, 416=Granite, 432=Brick, 448=Pebble, 464=Sand, 480=Fabric,
|
||
# 496=SmoothPlastic, 512=Metal, 528=WoodPlanks, 784=Neon, 1024=Glass,
|
||
# 1280=ForceField, ...
|
||
# https://create.roblox.com/docs/reference/engine/enums/Material
|
||
ROBLOX_MATERIAL_TO_RUBLOX = {
|
||
0: 'glossy', # Plastic
|
||
256: 'glossy', # Plastic (legacy)
|
||
272: 'matte', # Wood
|
||
288: 'matte', # Slate
|
||
304: 'matte', # Concrete
|
||
320: 'metal', # CorrodedMetal
|
||
336: 'metal', # DiamondPlate
|
||
352: 'metal', # Foil
|
||
368: 'matte', # Grass
|
||
384: 'glass', # Ice
|
||
400: 'matte', # Marble
|
||
416: 'matte', # Granite
|
||
432: 'matte', # Brick
|
||
448: 'matte', # Pebble
|
||
464: 'matte', # Sand
|
||
480: 'matte', # Fabric
|
||
496: 'glossy', # SmoothPlastic
|
||
512: 'metal', # Metal
|
||
528: 'matte', # WoodPlanks
|
||
784: 'neon', # Neon
|
||
1024: 'glass', # Glass
|
||
1280: 'neon', # ForceField — синий полупрозрачный
|
||
1296: 'matte', # Cobblestone
|
||
1328: 'metal', # Aluminum
|
||
1344: 'matte', # CrackedLava
|
||
1360: 'metal', # Rubber
|
||
1376: 'matte', # Pavement
|
||
}
|
||
|
||
# Roblox Part.Shape enum (PartType): 0=Ball, 1=Block, 2=Cylinder, ...
|
||
SHAPE_TO_PRIMITIVE = {
|
||
0: 'sphere', # Ball
|
||
1: 'cube', # Block
|
||
2: 'cylinder', # Cylinder
|
||
3: 'cube', # Wedge — но WedgePart это отдельный класс
|
||
4: 'cornerwedge',
|
||
}
|
||
|
||
|
||
# ────── BrickColor таблица (упрощённая) ──────
|
||
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
|
||
BRICKCOLOR_TO_HEX = {
|
||
1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea',
|
||
21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e',
|
||
28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32',
|
||
101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a',
|
||
105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50',
|
||
111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76',
|
||
141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91',
|
||
199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8',
|
||
1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
|
||
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
|
||
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
|
||
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
|
||
1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80',
|
||
}
|
||
|
||
|
||
# ──── вспомогательные функции ────
|
||
|
||
def cframe_to_pos_rot(cf: Optional[CFrame], scale: float) -> Tuple[Dict, Dict]:
|
||
"""Конвертит CFrame в позицию и Euler ротацию.
|
||
|
||
Roblox координаты: правая Y-up. Babylon: правая Y-up. То есть совпадают.
|
||
Возвращает ({x,y,z}, {rx,ry,rz}).
|
||
"""
|
||
if cf is None:
|
||
return ({'x': 0.0, 'y': 0.0, 'z': 0.0},
|
||
{'rx': 0.0, 'ry': 0.0, 'rz': 0.0})
|
||
pos = {'x': cf.position.x * scale, 'y': cf.position.y * scale, 'z': cf.position.z * scale}
|
||
rx, ry, rz = cf.to_euler_xyz()
|
||
rot = {'rx': rx, 'ry': ry, 'rz': rz}
|
||
return pos, rot
|
||
|
||
|
||
def color3_to_hex(c: Optional[Color3]) -> str:
|
||
if c is None:
|
||
return '#cccccc'
|
||
return c.to_hex()
|
||
|
||
|
||
def brickcolor_to_hex(b: Optional[BrickColor]) -> str:
|
||
if b is None:
|
||
return '#cccccc'
|
||
return BRICKCOLOR_TO_HEX.get(b.code, '#cccccc')
|
||
|
||
|
||
def material_to_string(m: Optional[EnumValue]) -> str:
|
||
if m is None:
|
||
return 'glossy'
|
||
return ROBLOX_MATERIAL_TO_RUBLOX.get(m.value, 'glossy')
|
||
|
||
|
||
def get_part_color(props: Dict[str, Any]) -> str:
|
||
"""Достаёт цвет Part: сначала Color3uint8, потом BrickColor, потом дефолт."""
|
||
if 'Color3uint8' in props:
|
||
return color3_to_hex(props['Color3uint8'])
|
||
if 'Color' in props:
|
||
return color3_to_hex(props['Color'])
|
||
if 'BrickColor' in props:
|
||
return brickcolor_to_hex(props['BrickColor'])
|
||
return '#cccccc'
|
||
|
||
|
||
def name_or_default(props: Dict[str, Any], default: str) -> str:
|
||
return str(props.get('Name', default))
|
||
|
||
|
||
# ────── Конвертеры классов ──────
|
||
|
||
|
||
@dataclass
|
||
class ConversionStats:
|
||
primitives_created: int = 0
|
||
glb_models_created: int = 0
|
||
scripts_collected: int = 0
|
||
scripts_skipped: int = 0
|
||
parts_dropped: int = 0
|
||
skipped_classes: Dict[str, int] = field(default_factory=dict)
|
||
warnings: List[str] = field(default_factory=list)
|
||
asset_ids_needed: List[int] = field(default_factory=list) # rbx_asset_id'ы для скачки
|
||
|
||
|
||
class Converter:
|
||
"""Главный конвертер. Работает на одной RobloxModel за раз."""
|
||
|
||
def __init__(
|
||
self,
|
||
model: RobloxModel,
|
||
scale: float = DEFAULT_SCALE,
|
||
asset_url_resolver=None, # callable(rbx_asset_id) -> str|None (public URL после скачки)
|
||
):
|
||
self.model = model
|
||
self.scale = scale
|
||
self.stats = ConversionStats()
|
||
self.asset_url_resolver = asset_url_resolver
|
||
self._next_primitive_id = 1
|
||
self._next_glb_id = 1
|
||
self._next_script_id = 1
|
||
self._instance_to_primitive_id: Dict[int, int] = {}
|
||
|
||
# ──── главный entry-point ────
|
||
|
||
def convert(self) -> Dict[str, Any]:
|
||
"""Возвращает project_data dict готовый к сохранению как JSON."""
|
||
scene = {
|
||
'blocks': [],
|
||
'models': [],
|
||
'primitives': [],
|
||
'userModels': [],
|
||
'terrain': [],
|
||
'robloxTerrain': self._default_roblox_terrain(),
|
||
'decorations': [],
|
||
'folders': [],
|
||
'gui': [],
|
||
'inventory': [],
|
||
'spawnPoint': {'x': 0, 'y': 2, 'z': 0},
|
||
'playerModelType': 'default',
|
||
'worldSize': 100,
|
||
'floorEnabled': True,
|
||
'jumpPowerMul': 1.0,
|
||
'cameraMode': 'thirdPerson',
|
||
'crosshair': 'default',
|
||
'shadowQuality': 'medium',
|
||
'environment': {
|
||
'preset': 'day',
|
||
'timeOfDay': 14,
|
||
'dayDurationMin': 5,
|
||
'nightDurationMin': 3,
|
||
'fogEnabled': False,
|
||
'fogColor': [0.7, 0.8, 0.9],
|
||
'fogDensity': 0.01,
|
||
},
|
||
'audio': {},
|
||
'assets': [],
|
||
'sounds': [],
|
||
'glbModels': [],
|
||
'scripts': [],
|
||
}
|
||
|
||
# Обходим все instances и конвертим
|
||
for inst in self.model.instances:
|
||
self._convert_one(inst, scene)
|
||
|
||
# Финальный отчёт о скипнутых классах
|
||
for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
|
||
self.stats.warnings.append(f"skipped {n}× {cls}")
|
||
|
||
return {
|
||
'version': 1,
|
||
'scene': scene,
|
||
'editorCamera': {'x': 20, 'y': 15, 'z': 20, 'targetX': 0, 'targetY': 0, 'targetZ': 0},
|
||
'settings': {
|
||
'isGd': False,
|
||
'importedFrom': 'roblox',
|
||
'importStats': asdict(self.stats),
|
||
},
|
||
}
|
||
|
||
# ──── per-class конвертеры ────
|
||
|
||
def _convert_one(self, inst: Instance, scene: Dict) -> None:
|
||
cls = inst.class_name
|
||
try:
|
||
if cls == 'Part':
|
||
self._convert_part(inst, scene)
|
||
elif cls == 'WedgePart':
|
||
self._convert_wedge(inst, scene)
|
||
elif cls == 'CornerWedgePart':
|
||
self._convert_cornerwedge(inst, scene)
|
||
elif cls == 'TrussPart':
|
||
self._convert_truss(inst, scene)
|
||
elif cls == 'MeshPart':
|
||
self._convert_meshpart(inst, scene)
|
||
elif cls == 'UnionOperation':
|
||
self._convert_union(inst, scene)
|
||
elif cls == 'SpecialMesh':
|
||
# SpecialMesh — child объект Part'а, меняет визуал родителя
|
||
# Обработка делается в _convert_part через children, тут не нужно
|
||
pass
|
||
elif cls == 'SpawnLocation':
|
||
self._convert_spawn(inst, scene)
|
||
elif cls == 'Script' or cls == 'LocalScript' or cls == 'ModuleScript':
|
||
self._convert_script(inst, scene)
|
||
elif cls == 'Sound':
|
||
self._convert_sound(inst, scene)
|
||
elif cls == 'PointLight' or cls == 'SpotLight' or cls == 'SurfaceLight':
|
||
self._convert_light(inst, scene)
|
||
elif cls == 'Folder' or cls == 'Model':
|
||
self._convert_folder(inst, scene)
|
||
elif cls in ('Decal', 'Texture'):
|
||
# Прикрепляются к Part'у — обрабатываются при конверте родителя
|
||
pass
|
||
elif cls == 'Lighting':
|
||
self._convert_lighting(inst, scene)
|
||
elif cls == 'Workspace':
|
||
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
|
||
pass
|
||
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
|
||
'StarterPack', 'StarterCharacterScripts', 'Players',
|
||
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
|
||
'SoundService', 'TweenService', 'RunService',
|
||
'UserInputService', 'HttpService', 'DataStoreService',
|
||
'TeleportService', 'BadgeService', 'MarketplaceService',
|
||
'ContentProvider', 'NetworkClient', 'NetworkServer',
|
||
'Chat', 'Stats', 'Debris', 'AnalyticsService',
|
||
'CSGDictionaryService', 'NonReplicatedCSGDictionaryService'):
|
||
# Системные сервисы — игнорируем (Lua-runtime создаст mock'и)
|
||
pass
|
||
else:
|
||
self.stats.skipped_classes[cls] = self.stats.skipped_classes.get(cls, 0) + 1
|
||
except Exception as e:
|
||
self.stats.warnings.append(f"convert {cls} (referent={inst.referent}) failed: {e!r}")
|
||
self.stats.parts_dropped += 1
|
||
|
||
# ─── Part / WedgePart / etc ───
|
||
|
||
def _convert_part(self, inst: Instance, scene: Dict) -> None:
|
||
props = inst.properties
|
||
cf = props.get('CFrame')
|
||
size = props.get('size') or props.get('Size')
|
||
pos, rot = cframe_to_pos_rot(cf, self.scale)
|
||
|
||
# Roblox Part.Shape: 0=Ball, 1=Block, 2=Cylinder
|
||
# PartType enum value — берётся из props.get('shape') или props.get('Shape')
|
||
shape = props.get('Shape')
|
||
if isinstance(shape, EnumValue):
|
||
ptype = SHAPE_TO_PRIMITIVE.get(shape.value, 'cube')
|
||
else:
|
||
ptype = 'cube'
|
||
|
||
# Размер
|
||
if size:
|
||
sx = abs(size.x) * self.scale
|
||
sy = abs(size.y) * self.scale
|
||
sz = abs(size.z) * self.scale
|
||
else:
|
||
sx = sy = sz = 1.0 * self.scale
|
||
|
||
# Проверяем есть ли child SpecialMesh — он переопределяет визуал
|
||
for child in inst.children:
|
||
if child.class_name == 'SpecialMesh':
|
||
mesh_type = child.properties.get('MeshType')
|
||
if isinstance(mesh_type, EnumValue):
|
||
# MeshType: 0=Head, 1=Torso, 2=Wedge, 3=Sphere, 4=Cylinder, 5=FileMesh, 6=Brick
|
||
mt = mesh_type.value
|
||
if mt == 3: ptype = 'sphere'
|
||
elif mt == 4: ptype = 'cylinder'
|
||
|
||
prim_id = self._next_primitive_id
|
||
self._next_primitive_id += 1
|
||
self._instance_to_primitive_id[inst.referent] = prim_id
|
||
|
||
primitive = {
|
||
'id': prim_id,
|
||
'type': ptype,
|
||
'name': name_or_default(props, 'Part'),
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'color': get_part_color(props),
|
||
'material': material_to_string(props.get('Material')),
|
||
'canCollide': bool(props.get('CanCollide', True)),
|
||
'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True,
|
||
'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)),
|
||
'anchored': bool(props.get('Anchored', False)),
|
||
'mass': 1.0,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
}
|
||
scene['primitives'].append(primitive)
|
||
self.stats.primitives_created += 1
|
||
|
||
def _convert_wedge(self, inst: Instance, scene: Dict) -> None:
|
||
props = inst.properties
|
||
cf = props.get('CFrame')
|
||
size = props.get('size') or props.get('Size')
|
||
pos, rot = cframe_to_pos_rot(cf, self.scale)
|
||
|
||
if size:
|
||
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
|
||
else:
|
||
sx = sy = sz = 1.0
|
||
|
||
prim_id = self._next_primitive_id
|
||
self._next_primitive_id += 1
|
||
self._instance_to_primitive_id[inst.referent] = prim_id
|
||
|
||
scene['primitives'].append({
|
||
'id': prim_id, 'type': 'wedge',
|
||
'name': name_or_default(props, 'Wedge'),
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'color': get_part_color(props),
|
||
'material': material_to_string(props.get('Material')),
|
||
'canCollide': bool(props.get('CanCollide', True)),
|
||
'visible': True,
|
||
'anchored': bool(props.get('Anchored', False)),
|
||
'mass': 1.0,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
})
|
||
self.stats.primitives_created += 1
|
||
|
||
def _convert_cornerwedge(self, inst: Instance, scene: Dict) -> None:
|
||
props = inst.properties
|
||
cf = props.get('CFrame')
|
||
size = props.get('size') or props.get('Size')
|
||
pos, rot = cframe_to_pos_rot(cf, self.scale)
|
||
if size:
|
||
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
|
||
else:
|
||
sx = sy = sz = 1.0
|
||
|
||
prim_id = self._next_primitive_id
|
||
self._next_primitive_id += 1
|
||
self._instance_to_primitive_id[inst.referent] = prim_id
|
||
|
||
scene['primitives'].append({
|
||
'id': prim_id, 'type': 'cornerwedge',
|
||
'name': name_or_default(props, 'CornerWedge'),
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'color': get_part_color(props),
|
||
'material': material_to_string(props.get('Material')),
|
||
'canCollide': bool(props.get('CanCollide', True)),
|
||
'visible': True,
|
||
'anchored': bool(props.get('Anchored', False)),
|
||
'mass': 1.0,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
})
|
||
self.stats.primitives_created += 1
|
||
|
||
def _convert_truss(self, inst: Instance, scene: Dict) -> None:
|
||
"""TrussPart — Roblox-специфичный леса. Конвертим в cube с пометкой."""
|
||
props = inst.properties
|
||
cf = props.get('CFrame')
|
||
size = props.get('size') or props.get('Size')
|
||
pos, rot = cframe_to_pos_rot(cf, self.scale)
|
||
if size:
|
||
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
|
||
else:
|
||
sx, sy, sz = 0.5, 5.0, 0.5
|
||
|
||
prim_id = self._next_primitive_id
|
||
self._next_primitive_id += 1
|
||
self._instance_to_primitive_id[inst.referent] = prim_id
|
||
|
||
scene['primitives'].append({
|
||
'id': prim_id, 'type': 'cube',
|
||
'name': name_or_default(props, 'Truss'),
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'color': get_part_color(props),
|
||
'material': 'metal',
|
||
'canCollide': True,
|
||
'visible': True,
|
||
'anchored': bool(props.get('Anchored', True)),
|
||
'mass': 1.0,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
})
|
||
self.stats.primitives_created += 1
|
||
|
||
# ─── MeshPart / UnionOperation ───
|
||
|
||
def _convert_meshpart(self, inst: Instance, scene: Dict) -> None:
|
||
"""MeshPart → glbModels entry с ссылкой на сконвертированный GLB."""
|
||
props = inst.properties
|
||
cf = props.get('CFrame')
|
||
size = props.get('size') or props.get('Size')
|
||
pos, rot = cframe_to_pos_rot(cf, self.scale)
|
||
|
||
# MeshId — это rbxassetid:// строка
|
||
mesh_id_str = props.get('MeshID') or props.get('MeshId') or ''
|
||
rbx_id = self._parse_asset_id(mesh_id_str)
|
||
if rbx_id:
|
||
self.stats.asset_ids_needed.append(rbx_id)
|
||
|
||
glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
|
||
|
||
if size:
|
||
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
|
||
else:
|
||
sx = sy = sz = 1.0
|
||
|
||
# Если GLB не доступен — fallback на bbox cube
|
||
if not glb_url:
|
||
prim_id = self._next_primitive_id
|
||
self._next_primitive_id += 1
|
||
self._instance_to_primitive_id[inst.referent] = prim_id
|
||
scene['primitives'].append({
|
||
'id': prim_id, 'type': 'cube',
|
||
'name': name_or_default(props, 'MeshPart'),
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'color': get_part_color(props),
|
||
'material': material_to_string(props.get('Material')),
|
||
'canCollide': bool(props.get('CanCollide', True)),
|
||
'visible': True,
|
||
'anchored': bool(props.get('Anchored', False)),
|
||
'mass': 1.0,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
'note': f'MeshPart (no GLB) rbxid={rbx_id}',
|
||
})
|
||
self.stats.primitives_created += 1
|
||
self.stats.parts_dropped += 1
|
||
return
|
||
|
||
glb_id = self._next_glb_id
|
||
self._next_glb_id += 1
|
||
|
||
scene['glbModels'].append({
|
||
'id': glb_id,
|
||
'name': name_or_default(props, 'MeshPart'),
|
||
'url': glb_url,
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
'color': get_part_color(props),
|
||
'canCollide': bool(props.get('CanCollide', True)),
|
||
'anchored': bool(props.get('Anchored', False)),
|
||
'origin': 'roblox-meshpart',
|
||
'rbxAssetId': rbx_id,
|
||
})
|
||
self.stats.glb_models_created += 1
|
||
|
||
def _convert_union(self, inst: Instance, scene: Dict) -> None:
|
||
"""UnionOperation — CSG объединение. Берём AssetId если есть, иначе bbox."""
|
||
props = inst.properties
|
||
cf = props.get('CFrame')
|
||
size = props.get('size') or props.get('Size')
|
||
pos, rot = cframe_to_pos_rot(cf, self.scale)
|
||
|
||
if size:
|
||
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
|
||
else:
|
||
sx = sy = sz = 1.0
|
||
|
||
# AssetId Union'а — это его CSG-mesh
|
||
asset_id_str = props.get('AssetId') or ''
|
||
rbx_id = self._parse_asset_id(asset_id_str)
|
||
if rbx_id:
|
||
self.stats.asset_ids_needed.append(rbx_id)
|
||
|
||
glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
|
||
|
||
if not glb_url:
|
||
# Fallback: cube
|
||
prim_id = self._next_primitive_id
|
||
self._next_primitive_id += 1
|
||
self._instance_to_primitive_id[inst.referent] = prim_id
|
||
scene['primitives'].append({
|
||
'id': prim_id, 'type': 'cube',
|
||
'name': name_or_default(props, 'Union'),
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'color': get_part_color(props),
|
||
'material': material_to_string(props.get('Material')),
|
||
'canCollide': bool(props.get('CanCollide', True)),
|
||
'visible': True,
|
||
'anchored': bool(props.get('Anchored', False)),
|
||
'mass': 1.0,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
'note': f'Union (no CSG GLB) rbxid={rbx_id}',
|
||
})
|
||
self.stats.primitives_created += 1
|
||
return
|
||
|
||
glb_id = self._next_glb_id
|
||
self._next_glb_id += 1
|
||
scene['glbModels'].append({
|
||
'id': glb_id,
|
||
'name': name_or_default(props, 'Union'),
|
||
'url': glb_url,
|
||
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
|
||
'sx': sx, 'sy': sy, 'sz': sz,
|
||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||
'color': get_part_color(props),
|
||
'canCollide': bool(props.get('CanCollide', True)),
|
||
'anchored': bool(props.get('Anchored', False)),
|
||
'origin': 'roblox-union',
|
||
'rbxAssetId': rbx_id,
|
||
})
|
||
self.stats.glb_models_created += 1
|
||
|
||
# ─── Spawn ───
|
||
|
||
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
|
||
props = inst.properties
|
||
cf = props.get('CFrame')
|
||
pos, _ = cframe_to_pos_rot(cf, self.scale)
|
||
# Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита,
|
||
# юзер появляется на её верхней грани.
|
||
scene['spawnPoint'] = {
|
||
'x': pos['x'],
|
||
'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите
|
||
'z': pos['z'],
|
||
}
|
||
|
||
# ─── Scripts ───
|
||
|
||
def _convert_script(self, inst: Instance, scene: Dict) -> None:
|
||
"""Lua-script сохраняем raw с пометкой kind='roblox-lua'.
|
||
|
||
Plus, attached to конкретному примитиву если у скрипта есть Parent
|
||
с известным referent.
|
||
"""
|
||
props = inst.properties
|
||
source = props.get('Source', '')
|
||
if not source or not isinstance(source, str):
|
||
self.stats.scripts_skipped += 1
|
||
return
|
||
|
||
# Цель — primitive id предка если есть
|
||
target = None
|
||
if inst.parent_referent and inst.parent_referent in self._instance_to_primitive_id:
|
||
target = self._instance_to_primitive_id[inst.parent_referent]
|
||
|
||
script_id = f'rbx_{self._next_script_id}'
|
||
self._next_script_id += 1
|
||
|
||
scene['scripts'].append({
|
||
'id': script_id,
|
||
'name': name_or_default(props, inst.class_name),
|
||
'kind': 'roblox-lua',
|
||
'target': target,
|
||
'code': '', # JS-эквивалент пока нет (заполнит Lua-runtime в плеере)
|
||
'lua_source': source,
|
||
'roblox_class': inst.class_name, # Script | LocalScript | ModuleScript
|
||
'enabled': bool(props.get('Disabled', False) is False),
|
||
})
|
||
self.stats.scripts_collected += 1
|
||
|
||
# ─── Sound ───
|
||
|
||
def _convert_sound(self, inst: Instance, scene: Dict) -> None:
|
||
props = inst.properties
|
||
sound_id_str = props.get('SoundId') or ''
|
||
rbx_id = self._parse_asset_id(sound_id_str)
|
||
if rbx_id:
|
||
self.stats.asset_ids_needed.append(rbx_id)
|
||
|
||
url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
|
||
scene['sounds'].append({
|
||
'id': f'sound_{rbx_id or inst.referent}',
|
||
'name': name_or_default(props, 'Sound'),
|
||
'url': url or '',
|
||
'volume': float(props.get('Volume', 1.0) or 1.0),
|
||
'loop': bool(props.get('Looped', False)),
|
||
'autoplay': bool(props.get('Playing', False)),
|
||
'rbxAssetId': rbx_id,
|
||
})
|
||
|
||
# ─── Light ───
|
||
|
||
def _convert_light(self, inst: Instance, scene: Dict) -> None:
|
||
"""Roblox PointLight / SpotLight / SurfaceLight → primitive type='light'."""
|
||
props = inst.properties
|
||
# Position берём от parent Part (если есть)
|
||
x = y = z = 0.0
|
||
parent = self.model.by_referent.get(inst.parent_referent) if inst.parent_referent else None
|
||
if parent and 'CFrame' in parent.properties:
|
||
pcf = parent.properties['CFrame']
|
||
x, y, z = pcf.position.x * self.scale, pcf.position.y * self.scale, pcf.position.z * self.scale
|
||
|
||
prim_id = self._next_primitive_id
|
||
self._next_primitive_id += 1
|
||
|
||
scene['primitives'].append({
|
||
'id': prim_id, 'type': 'light',
|
||
'name': name_or_default(props, inst.class_name),
|
||
'x': x, 'y': y, 'z': z,
|
||
'sx': 1, 'sy': 1, 'sz': 1,
|
||
'color': color3_to_hex(props.get('Color')),
|
||
'material': 'glossy',
|
||
'canCollide': False,
|
||
'visible': True,
|
||
'anchored': True,
|
||
'mass': 0,
|
||
'rotationX': 0, 'rotationY': 0, 'rotationZ': 0,
|
||
'lightRange': float(props.get('Range', 8.0) or 8.0) * self.scale,
|
||
'lightBrightness': float(props.get('Brightness', 1.0) or 1.0),
|
||
})
|
||
self.stats.primitives_created += 1
|
||
|
||
# ─── Folder / Model ───
|
||
|
||
def _convert_folder(self, inst: Instance, scene: Dict) -> None:
|
||
props = inst.properties
|
||
scene['folders'].append({
|
||
'id': f'rbx_folder_{inst.referent}',
|
||
'name': name_or_default(props, inst.class_name),
|
||
'parent': inst.parent_referent,
|
||
'origin': 'roblox-' + inst.class_name.lower(),
|
||
})
|
||
|
||
# ─── Lighting ───
|
||
|
||
def _convert_lighting(self, inst: Instance, scene: Dict) -> None:
|
||
"""Roblox Lighting service — мапим на scene.environment."""
|
||
props = inst.properties
|
||
env = scene['environment']
|
||
|
||
ambient = props.get('Ambient')
|
||
if isinstance(ambient, Color3):
|
||
env['ambientColor'] = ambient.to_hex()
|
||
brightness = props.get('Brightness')
|
||
if isinstance(brightness, (int, float)):
|
||
env['brightness'] = float(brightness)
|
||
clock = props.get('ClockTime')
|
||
if isinstance(clock, (int, float)):
|
||
env['timeOfDay'] = float(clock)
|
||
fog_color = props.get('FogColor')
|
||
if isinstance(fog_color, Color3):
|
||
env['fogColor'] = [fog_color.r, fog_color.g, fog_color.b]
|
||
fog_end = props.get('FogEnd')
|
||
if isinstance(fog_end, (int, float)) and fog_end < 999999:
|
||
env['fogEnabled'] = True
|
||
env['fogDensity'] = 1.0 / max(fog_end, 1.0)
|
||
|
||
# ─── utility ───
|
||
|
||
def _default_roblox_terrain(self) -> Dict:
|
||
"""Минимальный плоский ландшафт чтобы импорт не падал.
|
||
|
||
Real Roblox terrain потребует отдельной конвертации (voxel grid)
|
||
— оставлю на потом.
|
||
"""
|
||
return {
|
||
'format': 'robloxterrain-v1',
|
||
'origin': {'x': -22, 'y': 0, 'z': -22},
|
||
'size': {'x': 44, 'y': 24, 'z': 44},
|
||
'palette': ['', 'grass', 'rock', 'sand'],
|
||
'mat': '',
|
||
'density': '',
|
||
}
|
||
|
||
def _parse_asset_id(self, s: Any) -> Optional[int]:
|
||
"""rbxassetid://12345 или rbxasset://12345 или просто 12345 → int."""
|
||
if not s:
|
||
return None
|
||
s = str(s).strip()
|
||
if not s:
|
||
return None
|
||
if s.startswith('rbxassetid://'):
|
||
s = s[len('rbxassetid://'):]
|
||
elif s.startswith('rbxasset://'):
|
||
s = s[len('rbxasset://'):]
|
||
elif s.startswith('http://www.roblox.com/asset/?id='):
|
||
s = s.split('=')[-1]
|
||
try:
|
||
return int(s)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
# ────── CLI ──────
|
||
|
||
if __name__ == '__main__':
|
||
import sys
|
||
import json
|
||
sys.path.insert(0, '/opt/rbxl-importer/src')
|
||
from rbxl_parser import parse
|
||
|
||
path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl'
|
||
out = sys.argv[2] if len(sys.argv) > 2 else '/tmp/converted.json'
|
||
|
||
with open(path, 'rb') as f:
|
||
blob = f.read()
|
||
logging.basicConfig(level=logging.INFO)
|
||
print(f'parsing {path} ({len(blob)} bytes)...')
|
||
model = parse(blob)
|
||
print(f' → {model.instance_count} instances')
|
||
|
||
print(f'\nconverting...')
|
||
conv = Converter(model)
|
||
project_data = conv.convert()
|
||
|
||
with open(out, 'w', encoding='utf-8') as f:
|
||
json.dump(project_data, f, ensure_ascii=False, indent=2)
|
||
print(f'\nwritten to {out} ({sum(1 for _ in open(out))} lines)')
|
||
|
||
print('\n=== stats ===')
|
||
for k, v in asdict(conv.stats).items():
|
||
if k == 'warnings' and isinstance(v, list):
|
||
print(f' warnings: {len(v)} total')
|
||
for w in v[:5]:
|
||
print(f' - {w}')
|
||
elif k == 'skipped_classes':
|
||
top = sorted(v.items(), key=lambda x: -x[1])[:10]
|
||
print(f' skipped_classes (top 10):')
|
||
for c, n in top:
|
||
print(f' {n:>4d} {c}')
|
||
elif k == 'asset_ids_needed':
|
||
print(f' asset_ids_needed: {len(v)} (deduped: {len(set(v))})')
|
||
else:
|
||
print(f' {k}: {v}')
|