"""Punkt 4: Faell-Teile (Stammsegmente + Aeste mit ihren Blatt-Cards). Gemessen wird am REIMPORTIERTEN FBX, nicht in der Szene - gleiche Messlatte wie bei den Punkten 1-3. Die Kernaussage ist die Card-Bilanz: die Summe der Blatt-Faces ueber alle Teile muss der Blatt-Zahl des Stand-Baums entsprechen. Das ist nur pruefbar, weil die Ast-Zugehoerigkeit als Attribut `ast_id` durch die Instanzierung geschleift wird - mit einer raeumlichen Nearest-Neighbor-Schaetzung waere "stimmt die Zuordnung" keine beantwortbare Frage, sondern Ansichtssache. Aufruf: blender --background --factory-startup --python tests/test_faell_teile.py """ import os import sys import math import tempfile import importlib.util from collections import Counter import bpy import bmesh 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) me = bpy.data.meshes.new("Leaf_Card") me.from_pydata([(-0.5, 0, 0), (0, 0, 0.7), (0.5, 0, 0), (0, 0, -0.1)], [], [(0, 1, 2), (0, 2, 3)]) me.update() card = bpy.data.objects.new("Leaf_Card", me) bpy.context.scene.collection.objects.link(card) 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.piece_trunk_segments = 3 s.piece_ucx = True s.piece_export_combined = True 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"))][0] # Der Baum steht BEWUSST nicht am Ursprung. Die Gesamtdatei trug den # Szenen-Nullpunkt mit (gemessen X 16.5..19.3 bei einem Baum auf x = 18), und # bei (0,0,0) faellt genau das nicht auf. STANDORT = (18.0, -7.5, 0.0) baum.location = STANDORT 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 not None: blatt.location = STANDORT # Referenz: wie viele Blatt-Faces hat der Stand-Baum? dg = bpy.context.evaluated_depsgraph_get() ev = blatt.evaluated_get(dg) mb = ev.to_mesh() cards_gesamt = len(mb.polygons) attr = mb.attributes.get("ast_id") ids_stand = Counter(d.value for d in attr.data) if attr else Counter() ev.to_mesh_clear() print("Stand-Baum: %d Blatt-Faces, ast_id auf %d Gruppen" % (cards_gesamt, len(ids_stand))) if not ids_stand: fails.append("Die Blatt-Cards tragen kein ast_id - die Zuordnung waere " "nur zu raten") 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) bpy.ops.treegen.export_pieces() if sorted(o.name for o in bpy.context.scene.objects) != namen_vorher: fails.append("Szene veraendert - die Arbeitsebene soll bleiben") dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx")) einzeln = [f for f in dateien if "_Piece_" in f and not f.endswith("_All.fbx")] gesamt = [f for f in dateien if f.endswith("_Pieces_All.fbx")] stamm = [f for f in einzeln if "_Stamm" in f] aeste = [f for f in einzeln if "_Ast" in f] print("Export: %d Teile (%d Stamm, %d Ast), Gesamtdatei: %s" % (len(einzeln), len(stamm), len(aeste), "ja" if gesamt else "NEIN")) if len(stamm) != s.piece_trunk_segments: fails.append("%d Stammsegmente, erwartet %d" % (len(stamm), s.piece_trunk_segments)) if not aeste: fails.append("Keine Ast-Teile exportiert") if not gesamt: fails.append("Keine Gesamtdatei mit gemeinsamem Ursprung") print("") print("%-30s %6s %6s %6s %7s %s" % ("Teil", "Rinde", "Blatt", "UCX", "|Pivot|", "Topologie")) blatt_summe = 0 for f in sorted(einzeln): 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'] hull = [o for o in meshes if o.name.startswith("UCX_")] koerper = [o for o in meshes if not o.name.startswith("UCX_")] if not koerper: fails.append("%s: kein Mesh" % f) continue ob = koerper[0] m = ob.data rinde = sum(1 for p in m.polygons if p.material_index == 0) laub = sum(1 for p in m.polygons if p.material_index == 1) blatt_summe += laub # Pivot = Massenmittelpunkt: der Schwerpunkt muss NAHE am Ursprung liegen, # sonst torkelt das Teil als Physik-Actor um eine Ecke. schwer = tg._mass_center(m) abstand = (ob.matrix_world @ schwer).length diag = max((max(v.co[i] for v in m.vertices) - min(v.co[i] for v in m.vertices)) for i in range(3)) if m.vertices else 1.0 bm = bmesh.new(); bm.from_mesh(m) nm = sum(1 for e in bm.edges if len(e.link_faces) > 2) bm.free() nv = len(hull[0].data.vertices) if hull else 0 print("%-30s %6d %6d %6s %7.3f %s" % (f[:-4], rinde, laub, nv or "-", abstand, "ok" if nm == 0 else "%d non-manifold" % nm)) if len(m.materials) != 2: fails.append("%s: %d Slots, erwartet 2" % (f, len(m.materials))) if not hull: fails.append("%s: keine UCX-Huelle" % f) else: if hull[0].name != "UCX_" + f[:-4]: fails.append("%s: Huelle heisst '%s', Unreal erwartet 'UCX_%s'" % (f, hull[0].name, f[:-4])) if nv > s.piece_ucx_verts: fails.append("%s: UCX hat %d Verts, Budget %d" % (f, nv, s.piece_ucx_verts)) # Der Schwerpunkt darf nur einen Bruchteil der groessten Ausdehnung # vom Ursprung entfernt liegen. if abstand > diag * 0.15: fails.append("%s: Schwerpunkt %.3f m vom Pivot entfernt (Ausdehnung " "%.2f m) - als Physik-Actor wuerde das Teil torkeln" % (f, abstand, diag)) # --- Die Bilanz: Summe der Cards ueber alle Teile == Stand-Baum ---------- print("") print("Blatt-Bilanz: %d Cards in den Teilen, %d im Stand-Baum" % (blatt_summe, cards_gesamt)) if blatt_summe != cards_gesamt: fails.append("Blatt-Bilanz stimmt nicht: %d in den Teilen, %d im Stand-Baum " "- es gehen Cards verloren oder werden doppelt zugeordnet" % (blatt_summe, cards_gesamt)) # --- Gesamtdatei: Teile stehen an ihrer Originalposition ---------------- if gesamt: bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, gesamt[0])) meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] teile = [o for o in meshes if not o.name.startswith("UCX_")] hullen = [o for o in meshes if o.name.startswith("UCX_")] alle = [o.matrix_world @ v.co for o in teile for v in o.data.vertices] hoehe = max(c.z for c in alle) - min(c.z for c in alle) print("Gesamtdatei: %d Teile, %d Huellen, Hoehe %.2f m" % (len(teile), len(hullen), hoehe)) if len(teile) != len(einzeln): fails.append("Gesamtdatei hat %d Teile, einzeln waren es %d" % (len(teile), len(einzeln))) # Die Gesamtdatei kam frueher OHNE Huellen an, waehrend die Einzeldateien # welche hatten - gemeldet von der Spielseite, die interimsweise eigene # Boxen setzen musste. Je Teil GENAU eine Huelle, gleiche Konvention wie # beim Stand-Baum: `UCX_` als Geschwister, kein Elternteil. namen = {o.name for o in teile} for h in hullen: if h.parent is not None: fails.append("Gesamtdatei: Huelle '%s' haengt unter '%s'" % (h.name, h.parent.name)) if h.name[4:] not in namen: fails.append("Gesamtdatei: Huelle '%s' gehoert zu keinem Teil" % h.name) fehlend = sorted(n for n in namen if ("UCX_" + n) not in {h.name for h in hullen}) if fehlend: fails.append("Gesamtdatei: %d Teile ohne Huelle (%s)" % (len(fehlend), ", ".join(fehlend[:3]))) # Und die Huelle umschliesst das HOLZ, nicht das Laub. Beim obersten # Stammsegment, das die Krone traegt, war der Unterschied gemessen 67 % # (Diagonale 1.75 m statt 1.05 m) - eine Kollision, die weit ins Laub ragt. nach_name = {o.name: o for o in teile} for h in hullen: koerper = nach_name.get(h.name[4:]) if koerper is None: continue hc = [h.matrix_world @ v.co for v in h.data.vertices] rinde_idx = {vi for p in koerper.data.polygons if p.material_index == 0 for vi in p.vertices} if not rinde_idx: continue rc = [koerper.matrix_world @ koerper.data.vertices[i].co for i in rinde_idx] def spanne(pts): lo = [min(c[i] for c in pts) for i in range(3)] hi = [max(c[i] for c in pts) for i in range(3)] return math.dist(lo, hi) h_d, r_d = spanne(hc), spanne(rc) if h_d < r_d * 0.95: fails.append("%s: Huelle %.2f kleiner als die Rinde %.2f" % (h.name, h_d, r_d)) if h_d > r_d * 1.35: fails.append("%s: Huelle %.2f gegen Rinde %.2f (%.2fx) - sie " "umschliesst das Laub mit" % (h.name, h_d, r_d, h_d / r_d)) # Zusammengesetzt muss der Baum wieder seine volle Hoehe haben - das ist # der Beleg, dass die Teile deckungsgleich rekonstruierbar sind. if hoehe < 3.0: fails.append("Gesamtdatei nur %.2f m hoch - die Teile stehen nicht an " "ihrer Originalposition" % hoehe) # URSPRUNG auf der Stammbasis, wie beim Stand-Baum. Geprueft ueber den # STAMMQUERSCHNITT, nicht ueber die Bbox-Mitte: die Krone haengt einseitig, # und eine Bbox-Mitte wuerde den Fehler verwischen. Gesucht wird auf # Fusshoehe ein Ring von Vertices, der die Achse umschliesst. stamm_teile = [o for o in teile if "_Piece_Stamm" in o.name] if stamm_teile: sco = [o.matrix_world @ v.co for o in stamm_teile for v in o.data.vertices] zmin = min(c.z for c in sco) band = [c for c in sco if c.z <= zmin + hoehe * 0.03] mx = sum(c.x for c in band) / len(band) my = sum(c.y for c in band) / len(band) print("Gesamtdatei: Stammfuss bei %.3f / %.3f / %.3f" % (mx, my, zmin)) if math.hypot(mx, my) > hoehe * 0.02: fails.append("Gesamtdatei: Stammfuss liegt bei %.2f/%.2f statt " "ueber dem Ursprung - die Datei traegt den " "Szenen-Nullpunkt mit" % (mx, my)) if abs(zmin) > hoehe * 0.02: fails.append("Gesamtdatei: Stammfuss bei Z %.3f statt 0" % zmin) print("") if fails: print("ERGEBNIS: %d FEHLER" % len(fails)) for x in fails: print(" - " + x) sys.exit(1) print("ERGEBNIS: ALLE CHECKS OK")