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