"""Reproducible Blender -> FBX export for the Unity port (docs/port/U2-WORLD.md, U2A). Runs headless on the Blender sources of the Godot reference, applies the per-asset export rules of tools/blender/export_manifest.json, checks the result and writes, per asset: Assets/Luneraria/Art//.fbx the model for Unity Assets/Luneraria/Art//.export.json facts and checks (read by Unity's importer) Checks: units and scale, up and forward axis, object transforms, mesh names, triangle counts, material slots, UVs, normals, tangent prerequisites, vertex colours, root bone, bone names, clips (names, lengths, loop flags, contact events), bounds. Expectations come from the Godot manifests of the GLBs the reference game uses, so the FBX must describe the same model. After writing, the FBX is imported into an empty scene and checked again (round trip). Usage (from the Unity project root): sh tools/blender/export_fbx.sh [id,id,...] (runs blender --background --factory-startup --python-use-system-env with PYTHONHASHSEED=0 --python tools/blender/lune_fbx_export.py -- --godot ../saumwacht-godot --ids ) Exit code 0 only if every check of every asset passed. """ import bpy import hashlib import json import math import sys from pathlib import Path from mathutils import Matrix, Vector UNITY_ROOT = Path(__file__).resolve().parents[2] EXPORTER_VERSION = 10 # Tangents are not written: Unity computes them with MikkTSpace like Blender (import rule # CalculateMikk); per-corner tangents and binormals in double precision doubled the file size. # One export setting for every asset. The file keeps Blender's Z-up axes; Unity's importer # converts them with "Bake Axis Conversion" as Unity (x, y, z) = Blender (x, z, y), so no root # carries a rotation and animation data is converted with the meshes (Blender's own "Apply # Transform" is documented as broken for armatures). Before export every asset is turned by 180 # degrees about Blender's Z and the turn is applied (face_unity_forward): the front (Blender -Y, # glTF/Godot +Z) then becomes Unity +Z, and Unity = (-x, y, z) of the Godot coordinates. FBX_SETTINGS = dict( use_selection=True, use_visible=False, use_active_collection=False, global_scale=1.0, apply_unit_scale=True, apply_scale_options='FBX_SCALE_UNITS', axis_forward='Y', axis_up='Z', bake_space_transform=False, object_types={'EMPTY', 'MESH', 'ARMATURE'}, use_mesh_modifiers=True, use_mesh_modifiers_render=True, mesh_smooth_type='OFF', use_subsurf=False, use_mesh_edges=False, use_tspace=False, use_triangles=False, colors_type='LINEAR', prioritize_active_color=True, use_custom_props=False, add_leaf_bones=False, primary_bone_axis='Y', secondary_bone_axis='X', use_armature_deform_only=True, armature_nodetype='NULL', bake_anim=True, bake_anim_use_all_bones=True, bake_anim_use_nla_strips=False, bake_anim_use_all_actions=True, bake_anim_force_startend_keying=True, bake_anim_step=1.0, bake_anim_simplify_factor=0.0, path_mode='STRIP', embed_textures=False, batch_mode='OFF', ) def args(): argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [] result = {'manifest': str(UNITY_ROOT / 'tools/blender/export_manifest.json'), 'godot': str(UNITY_ROOT.parent / 'saumwacht-godot'), 'ids': ''} for i in range(0, len(argv) - 1, 2): result[argv[i].lstrip('-')] = argv[i + 1] return result class Report: def __init__(self, spec): self.data = {'id': spec['id'], 'group': spec['group'], 'source': spec['source'], 'exporter_version': EXPORTER_VERSION, 'blender': bpy.app.version_string, 'fbx_settings': {k: (sorted(v) if isinstance(v, set) else v) for k, v in FBX_SETTINGS.items()}, 'checks': []} def check(self, name, ok, detail=''): self.data['checks'].append({'check': name, 'ok': bool(ok), 'detail': str(detail)}) return ok @property def ok(self): return all(c['ok'] for c in self.data['checks']) def godot_expectations(spec, godot_root): """Facts of the GLB the reference uses, from its manifest (triangles, nodes, bounds, ...).""" source = spec.get('godot_manifest') if not source: return {} data = json.loads((godot_root / source['file']).read_text()) assets = data['assets'] if isinstance(data, dict) and 'assets' in data else data if isinstance(assets, dict): assets = [assets] for entry in assets: if source.get('id') is not None and entry.get('id') == source['id']: return entry if source.get('id') is None and entry.get('source') == spec['source']: return entry raise KeyError('no entry %s in %s' % (source.get('id'), source['file'])) def triangles(mesh): return sum(len(p.vertices) - 2 for p in mesh.polygons) def evaluated_mesh(obj, depsgraph): evaluated = obj.evaluated_get(depsgraph) return evaluated, evaluated.to_mesh() def prepare(spec, report): """Applies the export rules of the manifest to the opened .blend (never saved).""" rules = spec.get('rules', {}) skip = tuple(rules.get('skip_modifiers', ())) removed = 0 if skip: for obj in bpy.data.objects: for modifier in list(obj.modifiers): if modifier.name.startswith(skip): obj.modifiers.remove(modifier) removed += 1 report.data['removed_modifiers'] = removed if rules.get('logical_names'): logical_names(report) join = rules.get('join') if join: parts = [o for o in bpy.context.scene.objects if o.type == 'MESH' and o.parent and o.parent.name == join['parent']] bpy.ops.object.select_all(action='DESELECT') for obj in parts: obj.select_set(True) bpy.context.view_layer.objects.active = parts[0] bpy.ops.object.join() bpy.context.object.name = join['into'] bpy.context.object.data.name = join['into'] report.data['joined_parts'] = len(parts) face_unity_forward(report) chunk = rules.get('chunk') if chunk: chunk_objects(chunk, report) rest_action = rules.get('rest_action') for obj in bpy.context.scene.objects: if obj.type == 'ARMATURE' and obj.animation_data: # Export the bind pose, not the last evaluated frame of some action. obj.animation_data.action = bpy.data.actions.get(rest_action) if rest_action else None bpy.context.scene.frame_set(0) def face_unity_forward(report): """Turns every root 180 degrees about Z and applies the turn to all objects (meshes, empties, armatures with their rest pose). Bone animation is stored relative to the rest pose and stays valid. Shared mesh data is made single-user first.""" turn = Matrix.Rotation(math.pi, 4, 'Z') scene_objects = [o for o in bpy.context.scene.objects if o.type in ('MESH', 'ARMATURE', 'EMPTY')] for obj in scene_objects: if obj.parent is None: obj.matrix_world = turn @ obj.matrix_world bpy.ops.object.select_all(action='DESELECT') for obj in scene_objects: obj.hide_set(False) obj.hide_select = False obj.select_set(True) bpy.context.view_layer.objects.active = scene_objects[0] bpy.ops.object.transform_apply(location=False, rotation=True, scale=False, isolate_users=True) turned = [o.name for o in scene_objects if o.matrix_world.to_euler().z != 0.0 and abs(o.matrix_world.to_euler().z) > 1e-5] report.check('turn_applied', not turned, turned[:8]) def chunk_objects(size, report): """Runtime merge of the Verdant Rift (assets-source/phase10/build_sanctuary.py): mesh objects joined by 16 m cell and first material, named Chunk___.""" import math as m groups = {} for obj in [o for o in bpy.context.scene.objects if o.type == 'MESH']: center = obj.matrix_world.translation key = (int(m.floor(center.x / size)), int(m.floor(center.y / size)), obj.data.materials[0].name) groups.setdefault(key, []).append(obj) for key, parts in groups.items(): bpy.ops.object.select_all(action='DESELECT') for obj in parts: obj.select_set(True) bpy.context.view_layer.objects.active = parts[0] if len(parts) > 1: bpy.ops.object.join() parts[0].name = 'Chunk_%d_%d_%s' % key report.data['chunks'] = len(groups) def logical_names(report): """Phase-4 sources keep their logical names in custom properties (part_name, material_name); Blender may have suffixed the datablocks (.004). The Godot pipeline renamed the GLB nodes the same way (assets-source/phase4/blender/pipeline.py, _canonical_glb_names). Materials that share a logical name are one material and are merged.""" renamed = 0 for obj in list(bpy.data.objects): if 'part_name' in obj.keys(): obj.name = '__lune_tmp_' + obj.name for obj in list(bpy.data.objects): if 'part_name' in obj.keys(): obj.name = obj['part_name'] renamed += int(obj.name == obj['part_name']) groups = {} for material in list(bpy.data.materials): logical = material.get('material_name') if logical: groups.setdefault(logical, []).append(material) merged = 0 for logical, materials in groups.items(): keep = materials[0] for other in materials[1:]: other.user_remap(keep) merged += 1 for material in materials[1:]: bpy.data.materials.remove(material) keep.name = '__lune_tmp_' + logical for logical in groups: bpy.data.materials['__lune_tmp_' + logical].name = logical collisions = [o.name for o in bpy.data.objects if 'part_name' in o.keys() and o.name != o['part_name']] report.check('logical_names_unique', not collisions, collisions[:12]) report.data['logical_names'] = {'objects': renamed, 'materials': len(groups), 'merged_materials': merged} def export_objects(spec): include = spec.get('rules', {}).get('objects') prefix = spec.get('rules', {}).get('objects_prefix') objects = [] for obj in bpy.context.scene.objects: if obj.type not in ('MESH', 'ARMATURE', 'EMPTY'): continue if include and obj.name not in include: continue if prefix and not obj.name.startswith(prefix): continue if obj.hide_render: continue objects.append(obj) return objects def to_unity(v): """Blender (x, y, z), after the turn of face_unity_forward -> Unity (x, y, z) = (x, z, y).""" return Vector((v.x, v.z, v.y)) def facts(objects, depsgraph): """Scene facts in Unity coordinates: meshes, materials, bounds, bones, actions.""" meshes = {} low = Vector((math.inf,) * 3) high = Vector((-math.inf,) * 3) for obj in objects: if obj.type != 'MESH': continue evaluated, mesh = evaluated_mesh(obj, depsgraph) world = evaluated.matrix_world # Skinned meshes: bounds of the rest pose (armature modifier is evaluated in the bind pose). mesh_low = Vector((math.inf,) * 3) mesh_high = Vector((-math.inf,) * 3) for vertex in mesh.vertices: p = to_unity(world @ vertex.co) mesh_low = Vector(map(min, mesh_low, p)) mesh_high = Vector(map(max, mesh_high, p)) low = Vector(map(min, low, mesh_low)) high = Vector(map(max, high, mesh_high)) invalid = {i for i, n in enumerate(mesh.corner_normals) if abs(n.vector.length - 1.0) >= 1e-3} bad_normals = len(invalid) mesh.calc_loop_triangles() # Triangles Unity may drop on import: zero area, or a corner without a valid normal. degenerate = sum(1 for t in mesh.loop_triangles if t.area < 1e-10 or any(l in invalid for l in t.loops)) normals_ok = bad_normals == 0 meshes[obj.name] = { 'mesh': obj.data.name, 'triangles': triangles(mesh), 'vertices': len(mesh.vertices), 'ngons': sum(1 for p in mesh.polygons if len(p.vertices) > 4), 'materials': [slot.material.name if slot.material else None for slot in obj.material_slots], 'uv_layers': [uv.name for uv in mesh.uv_layers], 'color_attributes': [c.name for c in mesh.color_attributes], 'active_color': mesh.color_attributes.active_color.name if mesh.color_attributes.active_color else None, 'custom_normals': obj.data.has_custom_normals, 'normals_unit_length': normals_ok, 'bad_normals': bad_normals, 'degenerate_triangles': degenerate, 'shape_keys': len(obj.data.shape_keys.key_blocks) - 1 if obj.data.shape_keys else 0, 'skinned': any(m.type == 'ARMATURE' for m in obj.modifiers), 'scale': [round(s, 6) for s in obj.matrix_world.to_scale()], 'bounds_unity': {'min': [round(c, 4) for c in mesh_low], 'max': [round(c, 4) for c in mesh_high]}, } evaluated.to_mesh_clear() armatures = {} for obj in objects: if obj.type == 'ARMATURE': bones = [b for b in obj.data.bones if b.use_deform] armatures[obj.name] = {'bones': [b.name for b in bones], 'roots': [b.name for b in bones if b.parent is None or not b.parent.use_deform]} empties = {o.name: [round(c, 4) for c in to_unity(o.matrix_world.translation)] for o in objects if o.type == 'EMPTY'} return {'meshes': meshes, 'armatures': armatures, 'empties': empties, 'bounds_unity': {'min': [round(c, 4) for c in low], 'max': [round(c, 4) for c in high]} if meshes else None} def clip_facts(fps): clips = [] for action in bpy.data.actions: start, end = action.frame_range clips.append({'name': action.name, 'frames': [round(start, 3), round(end, 3)], 'seconds': round((end - start) / fps, 4), 'loop': bool(action.get('loop', action.name in ('idle', 'walk'))), 'contact_seconds': float(action.get('contact_seconds', -1.0))}) return clips def godot_to_unity_bounds(bounds): """Godot/glTF bounds (x, y, z) -> Unity (-x, y, z).""" lo, hi = bounds['min'], bounds['max'] return {'min': [-hi[0], lo[1], lo[2]], 'max': [-lo[0], hi[1], hi[2]]} def run_checks(spec, report, before, clips, expect): scene = bpy.context.scene report.check('units_metric', scene.unit_settings.system == 'METRIC', scene.unit_settings.system) report.check('unit_scale_1', abs(scene.unit_settings.scale_length - 1.0) < 1e-9, scene.unit_settings.scale_length) meshes = before['meshes'] bad_scale = {n: m['scale'] for n, m in meshes.items() if any(abs(s - 1.0) > 1e-4 for s in m['scale'])} negative = {n: s for n, s in bad_scale.items() if any(c < 0 for c in s)} report.check('no_negative_scale', not negative, negative) report.check('object_scale_1', not bad_scale or spec.get('rules', {}).get('allow_object_scale', False), bad_scale) names = [m['mesh'] for m in meshes.values()] report.check('mesh_names_unique', len(set(meshes)) == len(meshes), sorted(meshes)) empty_slots = {n: m['materials'] for n, m in meshes.items() if None in m['materials'] or not m['materials']} report.check('material_slots_filled', not empty_slots, empty_slots) # Known source defects may be allowed per mesh with an exact count (allow_bad_normals). allowed = spec.get('rules', {}).get('allow_bad_normals', {}) bad = {n: m['bad_normals'] for n, m in meshes.items() if m['bad_normals'] > allowed.get(n, 0)} report.check('normals_unit_length', not bad, bad) if allowed: report.data['allowed_bad_normals'] = {n: meshes[n]['bad_normals'] for n in allowed if n in meshes} # Tangents: Unity triangulates on import and computes them with MikkTSpace where UVs exist # (checked on the Unity side). N-gons are therefore fine; they are recorded. Meshes without # UVs are shaded triplanar or unlit and carry no tangents. report.data['meshes_with_ngons'] = {n: m['ngons'] for n, m in meshes.items() if m['ngons']} report.data['meshes_without_uv'] = sorted(n for n, m in meshes.items() if not m['uv_layers']) # Blend shapes: none of the sources uses them; Unity imports them only when they exist. shapes = {n: m['shape_keys'] for n, m in meshes.items() if m['shape_keys']} report.check('no_blend_shapes', not shapes or spec.get('rules', {}).get('blend_shapes', False), shapes) total = sum(m['triangles'] for m in meshes.values()) report.data['triangles'] = total if 'triangles' in expect: report.check('triangles_match_godot', total == expect['triangles'], '%d fbx / %d godot' % (total, expect['triangles'])) if 'nodes' in expect: report.check('mesh_names_match_godot', sorted(meshes) == sorted(expect['nodes']), sorted(set(meshes) ^ set(expect['nodes']))) if 'mesh_count' in expect: report.check('mesh_count_match_godot', len(meshes) == expect['mesh_count'], '%d / %d' % (len(meshes), expect['mesh_count'])) if 'parts' in expect and spec.get('rules', {}).get('check_parts', True): # Godot's part list holds the meshes and named empties (anchors), not the root or the marker. marker = spec.get('rules', {}).get('forward_marker', 'Forward') named = set(meshes) | {n for n in before['empties'] if not n.startswith('Model_') and not n.startswith(marker)} report.check('part_names_match_godot', sorted(named) == sorted(expect['parts']), sorted(named ^ set(expect['parts']))[:12]) if 'materials' in expect: used = sorted({name for m in meshes.values() for name in m['materials'] if name}) report.check('materials_match_godot', used == sorted(expect['materials']), sorted(set(used) ^ set(expect['materials']))) if expect.get('bounds_godot') and before['bounds_unity']: # The Godot manifests measure the intact model (towers: without damaged upper part, # debris, ruin and cloth); bounds_exclude names those parts. exclude = tuple(spec.get('rules', {}).get('bounds_exclude', ())) chosen = [m['bounds_unity'] for n, m in meshes.items() if not (exclude and n.startswith(exclude))] got = {'min': [min(b['min'][i] for b in chosen) for i in range(3)], 'max': [max(b['max'][i] for b in chosen) for i in range(3)]} want = godot_to_unity_bounds(expect['bounds_godot']) worst = max(abs(a - b) for key in ('min', 'max') for a, b in zip(got[key], want[key])) report.check('bounds_match_godot', worst < 2e-3, 'largest difference %.5f m' % worst) armatures = before['armatures'] if spec['kind'] == 'skinned': report.check('one_armature', len(armatures) == 1, sorted(armatures)) for name, arm in armatures.items(): report.check('single_root_bone', len(arm['roots']) == 1, arm['roots']) want_root = spec.get('rules', {}).get('root_bone', 'Root') report.check('root_bone_name', arm['roots'] == [want_root], arm['roots']) if 'bone_names' in expect: report.check('bone_names_match_godot', sorted(arm['bones']) == sorted(expect['bone_names']), sorted(set(arm['bones']) ^ set(expect['bone_names']))) if 'bones' in expect: report.check('bone_count_match_godot', len(arm['bones']) == expect['bones'], '%d / %d' % (len(arm['bones']), expect['bones'])) bad = [b for b in arm['bones'] if not b.replace('.', '').replace('_', '').isalnum()] report.check('bone_names_portable', not bad, bad) report.check('skinned_meshes_present', any(m['skinned'] for m in meshes.values()), '') names = sorted(c['name'] for c in clips) if 'clips' in expect: report.check('clips_match_godot', names == sorted(expect['clips']), sorted(set(names) ^ set(expect['clips']))) required = spec.get('rules', {}).get('required_clips', []) report.check('required_clips_present', all(c in names for c in required), [c for c in required if c not in names]) if 'clip_seconds' in expect: worst = max(abs(c['seconds'] - expect['clip_seconds'][c['name']]) for c in clips if c['name'] in expect['clip_seconds']) report.check('clip_lengths_match_godot', worst < 1e-3, 'largest difference %.4f s' % worst) else: report.check('no_armature', not armatures, sorted(armatures)) forward = spec.get('rules', {}).get('forward_marker') if forward: marker = next((p for n, p in before['empties'].items() if n.startswith(forward)), None) report.check('forward_marker_unity_plus_z', marker is not None and marker[2] > 0.1 and abs(marker[0]) < 1e-3, marker) def reproducible_fbx(): """Byte-identical FBX for identical sources: Blender's FBX writer stamps the current time into the header and derives object ids from Python's string hash. The clock is fixed here; the hash seed comes from PYTHONHASHSEED=0 with --python-use-system-env (tools/blender/export_fbx.sh).""" import datetime import io_scene_fbx.export_fbx_bin as writer class FixedClock(datetime.datetime): @classmethod def now(cls, tz=None): return cls(2026, 1, 1, 0, 0, 0) class Clock: datetime = FixedClock writer.datetime = Clock def export_fbx(path, objects): bpy.ops.object.select_all(action='DESELECT') for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[0] path.parent.mkdir(parents=True, exist_ok=True) bpy.ops.export_scene.fbx(filepath=str(path), **FBX_SETTINGS) def round_trip(path, spec, report, before, clips): """Imports the written FBX into an empty file and compares names and counts.""" bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=str(path), use_custom_normals=True, use_anim=True, automatic_bone_orientation=False) depsgraph = bpy.context.evaluated_depsgraph_get() imported = [o for o in bpy.context.scene.objects] meshes = {o.name: o for o in imported if o.type == 'MESH'} report.check('roundtrip_mesh_names', sorted(meshes) == sorted(before['meshes']), sorted(set(meshes) ^ set(before['meshes']))) tri = 0 for obj in meshes.values(): evaluated, mesh = evaluated_mesh(obj, depsgraph) tri += triangles(mesh) evaluated.to_mesh_clear() report.check('roundtrip_triangles', tri == report.data['triangles'], '%d / %d' % (tri, report.data['triangles'])) materials = sorted({s.material.name for o in meshes.values() for s in o.material_slots if s.material}) want = sorted({name for m in before['meshes'].values() for name in m['materials'] if name}) report.check('roundtrip_materials', materials == want, sorted(set(materials) ^ set(want))) if spec['kind'] == 'skinned': arm = next(o for o in imported if o.type == 'ARMATURE') bones = sorted(b.name for b in arm.data.bones) want_bones = sorted(next(iter(before['armatures'].values()))['bones']) report.check('roundtrip_bones', bones == want_bones, sorted(set(bones) ^ set(want_bones))) takes = sorted(a.name.split('|')[-1] for a in bpy.data.actions) report.check('roundtrip_clips', takes == sorted(c['name'] for c in clips), takes) def run_generator(spec, godot_root, source): """Runs a Blender generator (a source without a saved .blend) in a temporary copy of the reference layout, so its own outputs (GLB, manifests) never touch the reference repository.""" import shutil import tempfile root = Path(tempfile.mkdtemp(prefix='lune_generator_')) target = root / spec['source'] target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) for extra in spec['generator'].get('inputs', []): (root / extra).parent.mkdir(parents=True, exist_ok=True) shutil.copy2(godot_root / extra, root / extra) code = compile(target.read_text(), str(target), 'exec') exec(code, {'__file__': str(target), '__name__': '__lune_generator__'}) shutil.rmtree(root, ignore_errors=True) def copy_textures(spec, godot_root, report): """Images the FBX materials reference by file name go to Assets/Luneraria/Art/Textures.""" import shutil copied = [] for image in spec.get('rules', {}).get('textures', []): target = UNITY_ROOT / 'Assets/Luneraria/Art/Textures' / Path(image).name target.parent.mkdir(parents=True, exist_ok=True) if not target.exists() or target.read_bytes() != (godot_root / image).read_bytes(): shutil.copy2(godot_root / image, target) copied.append(str(target.relative_to(UNITY_ROOT))) if copied: report.data['textures'] = copied def export_asset(spec, godot_root): report = Report(spec) source = godot_root / spec['source'] if spec.get('generator'): run_generator(spec, godot_root, source) else: bpy.ops.wm.open_mainfile(filepath=str(source)) report.data['source_sha256'] = hashlib.sha256(source.read_bytes()).hexdigest() copy_textures(spec, godot_root, report) expect = godot_expectations(spec, godot_root) expect.update(spec.get('expect', {})) fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base prepare(spec, report) objects = export_objects(spec) # Facts of the bind pose (rest), like the Godot manifests; the animation bake needs POSE again. armatures = [o for o in objects if o.type == 'ARMATURE'] for arm in armatures: arm.data.pose_position = 'REST' bpy.context.view_layer.update() depsgraph = bpy.context.evaluated_depsgraph_get() before = facts(objects, depsgraph) for arm in armatures: arm.data.pose_position = 'POSE' bpy.context.view_layer.update() clips = clip_facts(fps) if spec['kind'] == 'skinned' else [] report.data.update({'kind': spec['kind'], 'fps': fps, 'objects': len(objects), 'meshes': before['meshes'], 'armatures': before['armatures'], 'empties': before['empties'], 'bounds_unity': before['bounds_unity'], 'clips': clips, 'expected_from_godot': {k: expect[k] for k in ('triangles', 'bounds_godot', 'bones', 'clips', 'resource_path') if k in expect}}) run_checks(spec, report, before, clips, expect) out = UNITY_ROOT / 'Assets/Luneraria/Art' / spec['group'] / (spec['id'] + '.fbx') export_fbx(out, objects) report.data['fbx'] = str(out.relative_to(UNITY_ROOT)) report.data['fbx_bytes'] = out.stat().st_size round_trip(out, spec, report, before, clips) report.data['ok'] = report.ok (out.with_suffix('.export.json')).write_text(json.dumps(report.data, indent=1) + '\n') failed = [c for c in report.data['checks'] if not c['ok']] print('LUNE_EXPORT %s %s: %d checks, %d failed%s' % ('OK' if not failed else 'FAIL', spec['id'], len(report.data['checks']), len(failed), ''.join('\n FAIL %s: %s' % (c['check'], c['detail']) for c in failed))) return report.ok def main(): reproducible_fbx() options = args() manifest = json.loads(Path(options['manifest']).read_text()) godot_root = Path(options['godot']).resolve() wanted = [i for i in options['ids'].split(',') if i] specs = [s for s in manifest['assets'] if not wanted or s['id'] in wanted] missing = set(wanted) - {s['id'] for s in specs} ok = not missing if missing: print('LUNE_EXPORT FAIL unknown ids', sorted(missing)) for spec in specs: try: ok = export_asset(spec, godot_root) and ok except Exception as error: # a broken asset must not stop the batch import traceback traceback.print_exc() print('LUNE_EXPORT FAIL %s: %s' % (spec['id'], error)) ok = False print('LUNE_EXPORT_DONE ok=%s assets=%d' % (ok, len(specs))) sys.exit(0 if ok else 1) main()