"""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 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] 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") # 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])) teile = [o for o in bpy.context.scene.objects if o.type == 'MESH'] 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, Hoehe %.2f m" % (len(teile), hoehe)) if len(teile) != len(einzeln): fails.append("Gesamtdatei hat %d Teile, einzeln waren es %d" % (len(teile), len(einzeln))) # 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) print("") if fails: print("ERGEBNIS: %d FEHLER" % len(fails)) for x in fails: print(" - " + x) sys.exit(1) print("ERGEBNIS: ALLE CHECKS OK")