champions-of-luneraria/tools/blender/lune_fbx_export.py
Martin Stickelbroeck 4744a47114 U2 Exporter: pfadneutraler FBX-Kopf (Version 11)
Blenders FBX-Schreiber trug den absoluten Pfad der geöffneten .blend-Datei
in den Dateikopf ein (ApplicationNativeFile, z. B. /Users/martin/Documents/…).
Die Bytes hingen damit vom Ort des Checkouts ab; nach dem Umzug nach
~/Developer galt die byteidentische Reproduzierbarkeit für die eingecheckten
Dateien nicht mehr.

Ab Version 11 steht dort der relative Quellpfad aus dem Manifest (Generatoren:
leer). Geprüft an der Grenzwacht: zwei Läufe byteidentisch, kein absoluter Pfad,
Prüfprotokoll gleich bis auf Version und Größe. Die eingecheckten FBX-Dateien
bleiben Version 10 bis zum nächsten Gesamtexport (spätestens U5); Inhalt
unverändert. Dokumentiert in U2-WORLD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:57:13 +02:00

564 lines
28 KiB
Python

"""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/<group>/<id>.fbx the model for Unity
Assets/Luneraria/Art/<group>/<id>.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 <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 = 11
# 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_<x>_<y>_<material>."""
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)
# Source file named in the FBX header (ApplicationNativeFile): the manifest path relative to the
# reference repository, set per asset; generators have no saved file.
NATIVE_FILE = {'path': ''}
def reproducible_fbx():
"""Byte-identical FBX for identical sources: Blender's FBX writer stamps the current time into
the header, writes the absolute path of the open .blend file (ApplicationNativeFile) and derives
object ids from Python's string hash. The clock is fixed and the path is the manifest's relative
source path here (since exporter version 11; version 10 wrote the absolute path, so its files
depend on the checkout location); 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
class Data:
"""bpy.data with a neutral filepath; everything else is the real data of the open file."""
@property
def filepath(self):
return NATIVE_FILE['path']
def __getattr__(self, name):
return getattr(bpy.data, name)
class Bpy:
data = Data()
def __getattr__(self, name):
return getattr(bpy, name)
writer.bpy = Bpy()
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)
NATIVE_FILE['path'] = ''
else:
bpy.ops.wm.open_mainfile(filepath=str(source))
NATIVE_FILE['path'] = spec['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()