"""Spielfertiger Stand-Baum: ein Mesh, zwei Slots, Kugel-Normalen. Der wichtigste Teil ist die NORMALEN-Messung am reimportierten FBX. Custom Normals sind die Sorte Daten, die der Exporter gerne stillschweigend plattbuegelt - dann sieht der Baum in Blender richtig aus und in UE flimmert die Krone. Am Objekt in der Szene zu messen wuerde das nicht zeigen. Aufruf: blender --background --factory-startup --python tests/test_baum_export.py """ import os import sys import math import tempfile import importlib.util import bpy from mathutils import Vector ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) spec = importlib.util.spec_from_file_location( "tg", os.path.join(ROOT, "stylized_tree_generator.py")) tg = importlib.util.module_from_spec(spec) sys.modules["tg"] = tg spec.loader.exec_module(tg) tg.register() fails = [] OUT = tempfile.mkdtemp() for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) def diamant(name="Leaf_Card"): """Diamond-Card wie im Projekt: vier Ecken, zwei Tris.""" me = bpy.data.meshes.new(name) me.from_pydata( [(-0.5, 0.0, 0.0), (0.0, 0.0, 0.7), (0.5, 0.0, 0.0), (0.0, 0.0, -0.1)], [], [(0, 1, 2), (0, 2, 3)]) me.update() ob = bpy.data.objects.new(name, me) bpy.context.scene.collection.objects.link(ob) return ob s = bpy.context.scene.tree_gen_settings s.preset = 'baum' s.use_growth = False s.apply_modifier = True s.art_name = "Eiche" s.variant = 1 s.export_dir = OUT s.sphere_normals = True card = diamant() bpy.ops.object.treegen_create() baum = [o for o in bpy.context.scene.objects if o.type == 'MESH' and o.name.startswith("Eiche_01") and not o.name.endswith(("_Leaf", "_Frucht"))] if not baum: print("ERGEBNIS: FEHLER: kein Baum erzeugt") sys.exit(1) baum = baum[0] # Der Blattwerk-Operator arbeitet auf der Auswahl. for o in bpy.context.scene.objects: o.select_set(o is baum) bpy.context.view_layer.objects.active = baum s.leaf_card = card bpy.ops.object.treegen_leaves() blatt = bpy.data.objects.get(baum.name + "_Leaf") if blatt is None: fails.append("Keine _Leaf-Ebene erzeugt") for o in bpy.context.scene.objects: o.select_set(o is baum) bpy.context.view_layer.objects.active = baum namen_vorher = sorted(o.name for o in bpy.context.scene.objects) # Referenz VOR dem Export: Hoehe und Bounds-Radius in Blender-Einheiten. _dg = bpy.context.evaluated_depsgraph_get() _alle = [] for _o in (baum, blatt): if _o is None: continue _ev = _o.evaluated_get(_dg) _m = _ev.to_mesh() _alle += [_o.matrix_world @ _v.co for _v in _m.vertices] _ev.to_mesh_clear() blender_h = (max(c.z for c in _alle) - min(c.z for c in _alle)) if _alle else 0.0 blender_r = max((math.hypot(c.x, c.y) for c in _alle), default=0.0) bpy.ops.treegen.export_game() # Die Arbeitsebene muss unangetastet bleiben. namen_nachher = sorted(o.name for o in bpy.context.scene.objects) if namen_nachher != namen_vorher: fehlt = [n for n in namen_vorher if n not in namen_nachher] neu = [n for n in namen_nachher if n not in namen_vorher] fails.append("Szene veraendert - fehlt: %s, neu: %s" % (fehlt, neu)) dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx")) if not dateien: print("ERGEBNIS: FEHLER: kein FBX geschrieben") sys.exit(1) bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, dateien[0])) # Die UCX-Stammhuelle ist KEIN Render-Mesh - sie gehoert nicht in diese Zaehlung. meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH' and not o.name.startswith("UCX_")] print("") print("FBX %s: %d Mesh(es)" % (dateien[0], len(meshes))) if len(meshes) != 1: fails.append("%d Meshes im FBX, erwartet genau eines (verschmolzen)" % len(meshes)) ob = meshes[0] me = ob.data slots = [m.name if m else "-" for m in me.materials] print("Slots: %s" % slots) if len(slots) != 2: fails.append("%d Material-Slots, erwartet 2 (Rinde + Blaetter)" % len(slots)) else: if tg.TREE_MATERIALS[0] not in slots[0]: fails.append("Slot 0 heisst '%s', erwartet '%s' (Rinde)" % (slots[0], tg.TREE_MATERIALS[0])) if tg.TREE_MATERIALS[1] not in slots[1]: fails.append("Slot 1 heisst '%s', erwartet '%s' (Blaetter)" % (slots[1], tg.TREE_MATERIALS[1])) # --- Ursprung und Bounds ------------------------------------------------- # # Die BBOX-MITTE wird bewusst NICHT mehr auf 0 geprueft. Das Kriterium stammt # aus der Zeit vor der Stammbasis-Konvention (1.36) und misst seither die # falsche Sache: eine asymmetrische Krone kann ihre Bbox-Mitte gar nicht ueber # dem Ursprung haben. Nachgemessen ueber drei Presets und drei Seeds: # # Stammachse am Fuss 0.0000 m in ALLEN neun Faellen # Bbox-Exzentrik 0.08-0.39 m, streut mit dem Seed # ...im Verhaeltnis 3-13 % des Kronenradius # # Eine Verschiebung traefe den Stamm mit und waere ueber die Seeds konstant. # Beides ist nicht der Fall - es ist Kronen-Schieflage. Der Nachfolger des # Kriteriums ist tests/test_ursprung.py, das den STAMMQUERSCHNITT bei 0/0/0 # nachweist; engine-seitig bestaetigt durch lueckenlos stapelnde Faell-Teile, # greifende UCX-Huellen und treffende Frucht-Streuung. # # Was stattdessen zaehlt, ist der BOUNDS-RADIUS: laeuft der aus dem Ruder, # greift das Culling daneben (die Spielseite hatte den Fall mit Radius 3.5 # statt 350 - ein Skalenfehler). Geprueft wird er nicht gegen eine Konstante, # sondern gegen die Blender-Messung: Hoehe und Radius muessen DENSELBEN # Skalenfaktor tragen. co = [ob.matrix_world @ v.co for v in me.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)] mid = ((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5) fbx_h = hi[2] - lo[2] fbx_r = max(math.hypot(c[0], c[1]) for c in co) print("Ursprung: min.Z %.3f, Hoehe %.2f, Bounds-Radius %.2f, Bbox-Mitte " "%.3f/%.3f (nur zur Info)" % (lo[2], fbx_h, fbx_r, mid[0], mid[1])) if abs(lo[2]) > 0.01: fails.append("min.Z = %.3f, erwartet 0" % lo[2]) if blender_h > 0 and blender_r > 0: sk_h = fbx_h / blender_h sk_r = fbx_r / blender_r print("Skala aus Hoehe %.3f, aus Radius %.3f" % (sk_h, sk_r)) if abs(sk_r - sk_h) > sk_h * 0.05: fails.append("Bounds-Radius traegt eine andere Skala als die Hoehe " "(%.3f gegen %.3f) - so greift das Culling daneben" % (sk_r, sk_h)) # --- Der eigentliche Punkt: Kugel-Normalen im FBX ------------------------ blatt_polys = [p for p in me.polygons if p.material_index == 1] rinde_polys = [p for p in me.polygons if p.material_index == 0] print("Faces: %d Rinde, %d Blaetter" % (len(rinde_polys), len(blatt_polys))) if not blatt_polys: fails.append("Keine Blatt-Faces im FBX (Slot 1 leer)") else: pts = [me.vertices[vi].co for p in blatt_polys for vi in p.vertices] mitte = Vector((sum(p.x for p in pts) / len(pts), sum(p.y for p in pts) / len(pts), sum(p.z for p in pts) / len(pts))) if hasattr(me, "calc_normals_split"): me.calc_normals_split() treffer, gesamt, summe = 0, 0, 0.0 for p in blatt_polys: for li in p.loop_indices: n = Vector(me.loops[li].normal) if n.length_squared < 1e-9: continue radial = me.vertices[me.loops[li].vertex_index].co - mitte if radial.length_squared < 1e-9: continue d = n.normalized().dot(radial.normalized()) summe += d gesamt += 1 if d > 0.8: treffer += 1 quote = 100.0 * treffer / gesamt if gesamt else 0.0 print("Blatt-Normalen: %d Loops, mittlere Radialitaet %.3f, " "%.0f%% ueber 0.8" % (gesamt, summe / gesamt if gesamt else 0, quote)) # Bei echten Kugel-Normalen liegt die Radialitaet nahe 1. Flache # Card-Normalen streuen um 0 - das waere der Zustand, den wir vermeiden. if gesamt == 0: fails.append("Keine auswertbaren Blatt-Normalen im FBX") elif quote < 90.0: fails.append("Nur %.0f%% der Blatt-Normalen zeigen radial nach aussen " "(mittlere Radialitaet %.3f) - der FBX-Export hat die " "Custom Normals plattgebuegelt" % (quote, summe / gesamt)) # Gegenprobe: die Rinde darf NICHT radial sein, sonst haben wir die # Normalen des ganzen Baums ueberschrieben. if rinde_polys: rs, rn = 0.0, 0 for p in rinde_polys: for li in p.loop_indices: n = Vector(me.loops[li].normal) radial = me.vertices[me.loops[li].vertex_index].co - mitte if n.length_squared < 1e-9 or radial.length_squared < 1e-9: continue rs += n.normalized().dot(radial.normalized()) rn += 1 mittel = rs / rn if rn else 0.0 print("Rinden-Normalen: mittlere Radialitaet %.3f (soll deutlich " "niedriger sein)" % mittel) if mittel > 0.75: fails.append("Auch die Rinde ist radial (%.3f) - die Kugel-Normalen " "wurden auf den ganzen Baum gelegt" % mittel) print("") if fails: print("ERGEBNIS: %d FEHLER" % len(fails)) for x in fails: print(" - " + x) sys.exit(1) print("ERGEBNIS: ALLE CHECKS OK")