"""Prueft die Export-Haertung: Ursprung, Hinlegen, UCX - am reimportierten FBX. Gemessen wird NICHT an der Szene, sondern an dem, was tatsaechlich in der Datei landet: exportieren, in eine leere Szene reimportieren, nachmessen. Nur so faellt auf, wenn der Export die Objekt-Transformation mitschreibt - genau der Fehler, durch den das 25er-Set seine Raster-Positionen (bis 20 m) im Asset trug und die Steine im Spiel neben ihrem Platzierungspunkt schwebten. Aufruf: blender --background --factory-startup --python tests/test_export_robust.py """ import os import sys import math import tempfile import importlib.util import bpy ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) spec = importlib.util.spec_from_file_location( "rg", os.path.join(ROOT, "stylized_rock_generator.py")) rg = importlib.util.module_from_spec(spec) sys.modules["rg"] = rg spec.loader.exec_module(rg) rg.register() fails = [] OUT = tempfile.mkdtemp() # Startup-Szene leeren: --factory-startup liefert Wuerfel, Kamera und Licht mit, # der Wuerfel wuerde als "Stein" mitgezaehlt. for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) s = bpy.context.scene.rock_gen_settings s.batch_count = 3 s.subsurf_levels = 3 s.use_modal = False s.generate_lods = False s.assign_material = False s.bake_vertex_cavity = True s.export_dir = OUT s.export_lay_flat = True s.export_ucx = True bpy.ops.object.generate_rocks_batch() rocks = [o for o in bpy.context.scene.objects if o.type == 'MESH'] if len(rocks) != 3: fails.append("Aufbau: %d Steine statt 3" % len(rocks)) # Szenen-Raster nachstellen: weit weg vom Ursprung, gedreht und skaliert. # Genau diese Kombination hat das 25er-Set kaputtgemacht. for i, ob in enumerate(rocks): ob.location = (4.0 + i * 8.0, -6.0 - i * 3.0, 1.7) ob.rotation_euler = (math.radians(23), math.radians(-41), math.radians(75)) ob.scale = (1.3, 0.8, 1.1) # Einen Stein absichtlich plattig UND aufrecht stellen: duenn in X, hoch in Z. # Achsparallel, damit der Fall eindeutig ist - er MUSS hingelegt werden. platte = rocks[0] platte.rotation_euler = (0.0, 0.0, 0.0) platte.scale = (0.25, 1.4, 1.6) namen_vorher = sorted(o.name for o in bpy.context.scene.objects) anzahl_vorher = len(bpy.context.scene.objects) bpy.ops.object.export_rocks_fbx() # --- Szene darf sich NICHT veraendert haben -------------------------------- if sorted(o.name for o in bpy.context.scene.objects) != namen_vorher: fails.append("Szene veraendert: Namen stimmen nach dem Export nicht mehr " "(%d vorher, %d nachher)" % (anzahl_vorher, len(bpy.context.scene.objects))) if any(o.name.endswith(".__src") for o in bpy.context.scene.objects): fails.append("Hilfsnamen '.__src' nicht zurueckgesetzt") if any(o.name.startswith("UCX_") for o in bpy.context.scene.objects): fails.append("UCX-Kopie in der Szene stehengeblieben") fbx = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx")) if len(fbx) != 3: fails.append("%d FBX statt 3: %s" % (len(fbx), fbx)) print("") print("%-22s %7s %7s %7s %8s %6s %5s" % ("Datei", "|X|mid", "|Y|mid", "min Z", "kuerzeste", "UCX", "VCol")) for f in fbx: # Leere Szene, damit nur das Importierte gemessen wird. bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, f)) meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] ucx = [o for o in meshes if o.name.startswith("UCX_")] body = [o for o in meshes if not o.name.startswith("UCX_")] if not body: fails.append("%s: kein Mesh im FBX" % f) continue ob = body[0] co = [ob.matrix_world @ v.co for v in ob.data.vertices] lo = [min(c[i] for c in co) for i in range(3)] hi = [max(c[i] for c in co) for i in range(3)] midx = (lo[0] + hi[0]) * 0.5 midy = (lo[1] + hi[1]) * 0.5 dims = [hi[i] - lo[i] for i in range(3)] kurz = "XYZ"[dims.index(min(dims))] has_vcol = bool(ob.data.color_attributes) print("%-22s %7.3f %7.3f %7.3f %8s %6s %5s" % (f, abs(midx), abs(midy), lo[2], kurz, ("%d V" % len(ucx[0].data.vertices)) if ucx else "-", "ja" if has_vcol else "NEIN")) # 1) Ursprung erzwungen: Bbox mittig, Unterkante auf 0 if abs(midx) > 0.10 or abs(midy) > 0.10: fails.append("%s: Bbox nicht zentriert (|X| %.3f, |Y| %.3f, erlaubt 0.10)" % (f, abs(midx), abs(midy))) if abs(lo[2]) > 0.01: fails.append("%s: min.Z = %.4f, erwartet 0" % (f, lo[2])) # 2) Flach heisst liegend. Gefordert wird nicht "Z ist exakt am kuerzesten", # sondern "Z ist nicht nennenswert laenger als die kuerzeste Achse" - # bei einem 2.53/1.40/1.54-Stein braechte Kippen nichts und wuerde die # Form nur willkuerlich drehen. if min(dims) < dims[2] * 0.9: fails.append("%s: steht hochkant - kuerzeste Achse ist %s (%.2f), Z ist " "%.2f (%.2f/%.2f/%.2f)" % (f, kurz, min(dims), dims[2], dims[0], dims[1], dims[2])) # 3) UCX vorhanden, geschlossen, im Budget if not ucx: fails.append("%s: keine UCX-Huelle im FBX" % f) else: u = ucx[0] erwartet = "UCX_" + os.path.splitext(f)[0] if u.name != erwartet: fails.append("%s: Huelle heisst '%s', Unreal erwartet '%s'" % (f, u.name, erwartet)) nv = len(u.data.vertices) if nv > s.ucx_max_verts: fails.append("%s: UCX hat %d Verts, Budget %d" % (f, nv, s.ucx_max_verts)) # Huelle muss den Koerper umschliessen uco = [u.matrix_world @ v.co for v in u.data.vertices] for i in range(3): if min(c[i] for c in uco) > lo[i] + 0.02 or \ max(c[i] for c in uco) < hi[i] - 0.02: fails.append("%s: UCX umschliesst den Stein nicht (Achse %d)" % (f, i)) break # geschlossen? offen = [e for e in u.data.edges if True] import bmesh as _bm bm = _bm.new(); bm.from_mesh(u.data) rand = [e for e in bm.edges if len(e.link_faces) != 2] bm.free() if rand: fails.append("%s: UCX hat %d offene Kanten - keine geschlossene Huelle" % (f, len(rand))) # Vertex-Farben duerfen dabei nicht verlorengehen if not has_vcol: fails.append("%s: Vertex-Farben fehlen nach dem Roundtrip" % f) # --------------------------------------------------------------------------- # Offener Punkt der Spielseite: LOD-Gruppen UND Vertex-Farben zusammen. # Relevant vor den Bruchstuecken - die sind klein und LODen frueh. # --------------------------------------------------------------------------- # read_factory_settings leert die SZENE, die Addon-Klassen bleiben registriert - # ein zweites register() wirft "already registered as a subclass". bpy.ops.wm.read_factory_settings(use_empty=True) if not hasattr(bpy.context.scene, "rock_gen_settings"): rg.register() LOD_OUT = tempfile.mkdtemp() s = bpy.context.scene.rock_gen_settings s.batch_count = 1 s.subsurf_levels = 3 s.use_modal = False s.assign_material = False s.bake_vertex_cavity = True s.generate_lods = True s.lod_count = 4 s.export_dir = LOD_OUT s.export_lay_flat = True s.export_ucx = True bpy.ops.object.generate_rocks_batch() # Auch die LOD-Gruppe weit weg vom Ursprung stellen. for o in bpy.context.scene.objects: if o.parent is None: o.location = (13.0, -7.5, 2.2) bpy.ops.object.export_rocks_fbx() lod_fbx = sorted(f for f in os.listdir(LOD_OUT) if f.lower().endswith(".fbx")) if not lod_fbx: fails.append("LOD: keine FBX exportiert") else: bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=os.path.join(LOD_OUT, lod_fbx[0])) meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] stufen = sorted((o for o in meshes if not o.name.startswith("UCX_")), key=lambda o: -len(o.data.vertices)) hull = [o for o in meshes if o.name.startswith("UCX_")] print("") print("LOD-Gruppe %s:" % lod_fbx[0]) for o in stufen: vc = o.data.color_attributes rng = "-" if vc: werte = [c.color[0] for c in vc[0].data] rng = "R %.2f..%.2f" % (min(werte), max(werte)) print(" %-26s %5d Verts VCol %-4s %s" % (o.name, len(o.data.vertices), "ja" if vc else "NEIN", rng)) if not vc: fails.append("LOD %s: Vertex-Farben fehlen - LOD-Gruppe und " "Vertex-Farben vertragen sich nicht" % o.name) if len(stufen) != 4: fails.append("LOD: %d Stufen im FBX, erwartet 4" % len(stufen)) if not hull: fails.append("LOD: keine UCX-Huelle") elif hull[0].parent is not None and hull[0].parent.type == 'EMPTY': # Unter dem LodGroup-Empty wuerde Unreal die Huelle als LOD-Stufe lesen. fails.append("LOD: UCX haengt unter dem LodGroup-Empty") if stufen: co = [stufen[0].matrix_world @ v.co for v in stufen[0].data.vertices] lo = [min(c[i] for c in co) for i in range(3)] hi = [max(c[i] for c in co) for i in range(3)] if abs((lo[0] + hi[0]) * 0.5) > 0.10 or abs((lo[1] + hi[1]) * 0.5) > 0.10 \ or abs(lo[2]) > 0.01: fails.append("LOD: Ursprung nicht erzwungen (mid %.3f/%.3f, minZ %.3f)" % ((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, lo[2])) print("") if fails: print("ERGEBNIS: %d FEHLER" % len(fails)) for x in fails: print(" - " + x) sys.exit(1) print("ERGEBNIS: ALLE CHECKS OK")