"""Stamm-UCX fuer den spielfertigen Stand-Baum. Die Stand-Baeume brauchen Kollision. Gefordert ist EIN konvexer Stamm-Zylinder vom Fuss bis zum Kronenansatz - KEINE Kronen-Huelle: gegen die liefe der Spieler, bevor er den Baum ueberhaupt beruehrt. Die Namens- und Hierarchie-Konvention ist nicht geraten, sondern vom Fels-Export abgeschaut, der in der Spiel-Pipeline bereits abgenommen ist: `UCX_`, und zwar als GESCHWISTER der LodGroup, nicht als Kind - Unreal liest jedes Kind der Gruppe als LOD-Stufe. Gemessen wird im reimportierten FBX. Aufruf: blender --background --factory-startup --python tests/test_stamm_ucx.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( "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.apply_modifier = True s.leaf_card = card s.use_growth = False s.stand_ucx = True # Presets mit verschiedenen Stamm-Formen: gerade, krumm (Bend 1.5), ohne Aeste # am Setzling, und der Obstbaum als Produktionsfall. FAELLE = (("eiche", True), ("toter_baum", True), ("obstbaum", True), ("eiche", False)) # Erst ALLE Faelle exportieren, dann reimportieren. `read_factory_settings` # raeumt die Szene komplett aus - Blatt-Card und Einstellungen inklusive -, # ein Reimport mitten in der Schleife killt also den naechsten Durchlauf. ergebnisse = [] for preset, mit_lods in FAELLE: for o in list(bpy.context.scene.objects): if o is not card: bpy.data.objects.remove(o, do_unlink=True) ziel_dir = os.path.join(OUT, "%s_%d" % (preset, int(mit_lods))) os.makedirs(ziel_dir, exist_ok=True) s.preset = preset s.art_name = preset.capitalize() s.variant = 1 s.seed = 1 s.generate_lods = mit_lods s.export_dir = ziel_dir bpy.ops.object.treegen_create() baum = [o for o in bpy.context.scene.objects if o.type == 'MESH' and o is not card and not o.name.endswith(("_Leaf", "_Frucht"))][0] # BEWUSST nicht am Ursprung - eine Huelle, die die Weltmatrix mitschleppt, # faellt bei (0,0,0) nicht auf. baum.location = (9.0, -5.0, 0.0) for o in bpy.context.scene.objects: o.select_set(o is baum) bpy.context.view_layer.objects.active = baum bpy.ops.object.treegen_leaves() bl = bpy.data.objects.get(baum.name + "_Leaf") if bl is not None: bl.location = baum.location # Kronenansatz und Stammradius am Quellbaum, als Sollwerte. dg = bpy.context.evaluated_depsgraph_get() ev = baum.evaluated_get(dg) m = ev.to_mesh() attr = m.attributes.get("ast_id") vs = [v.co.copy() for v in m.vertices] aid = [attr.data[i].value for i in range(len(vs))] if attr else [0] * len(vs) ev.to_mesh_clear() z0 = min(v.z for v in vs) astz = [v.z for v, i in zip(vs, aid) if i > 0] ansatz = (min(astz) - z0) if astz else (max(v.z for v in vs) - z0) stamm_r = max((math.hypot(v.x, v.y) for v, i in zip(vs, aid) if i == 0 and v.z - z0 <= ansatz), default=0.0) for o in bpy.context.scene.objects: o.select_set(o is baum) bpy.context.view_layer.objects.active = baum bpy.ops.treegen.export_game() dateien = sorted(f for f in os.listdir(ziel_dir) if f.lower().endswith(".fbx")) if not dateien: fails.append("%s: kein FBX geschrieben" % preset) continue ergebnisse.append((preset, mit_lods, os.path.join(ziel_dir, dateien[0]), stamm_r, ansatz, max(v.z for v in vs) - z0, s.stand_ucx_verts)) print("%-12s %-5s %6s %8s %8s %8s %8s" % ("Preset", "LODs", "Verts", "UCX-R", "Stamm-R", "UCX-Z", "Ansatz-Z")) for preset, mit_lods, pfad, stamm_r, ansatz, blender_h, max_verts in ergebnisse: bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=pfad) ucx = [o for o in bpy.context.scene.objects if o.type == 'MESH' and o.name.startswith("UCX_")] rest = [o for o in bpy.context.scene.objects if o.type == 'MESH' and not o.name.startswith("UCX_")] gruppe = [o for o in bpy.context.scene.objects if o.type == 'EMPTY'] if len(ucx) != 1: fails.append("%s (LODs %s): %d UCX-Meshes im FBX, erwartet genau eines" % (preset, mit_lods, len(ucx))) continue u = ucx[0] # Name: exakt die Konvention des Fels-Exports. erwartet = ("UCX_" + gruppe[0].name) if gruppe else ("UCX_" + rest[0].name) if u.name != erwartet: fails.append("%s: Huelle heisst '%s', erwartet '%s' - Unreal ordnet " "die Kollision ueber den Namen zu" % (preset, u.name, erwartet)) # Hierarchie: NICHT unter der LodGroup, sonst waere sie eine LOD-Stufe. if u.parent is not None: fails.append("%s: Huelle haengt unter '%s' - Unreal liest jedes Kind " "der LodGroup als LOD-Stufe" % (preset, u.parent.name)) co = [u.matrix_world @ v.co for v in u.data.vertices] u_r = max(math.hypot(c.x, c.y) for c in co) u_z0 = min(c.z for c in co) u_z1 = max(c.z for c in co) n = len(u.data.vertices) # Skala aus dem FBX zurueckrechnen, damit die Vergleiche unabhaengig von # FBX_SCALE_ALL sind. NUR ueber die RINDE (Material-Slot 0): der erste # Anlauf nahm die Gesamthoehe inklusive Blatt-Cards, und die ragen ueber die # Rinde hinaus - die Skala kam dadurch zu gross heraus und die Huelle sah # zu klein aus. Ein Massstab, der etwas anderes misst als die Referenz, # verschiebt jede Aussage. lod0 = max(rest, key=lambda o: len(o.data.vertices)) rinde = [lod0.matrix_world @ lod0.data.vertices[vi].co for p in lod0.data.polygons if p.material_index == 0 for vi in p.vertices] fbx_h = (max(c.z for c in rinde) - min(c.z for c in rinde)) if rinde else 1.0 skala = fbx_h / blender_h if blender_h else 1.0 print("%-12s %-5s %6d %8.3f %8.3f %8.3f %8.3f" % (preset, mit_lods, n, u_r / skala, stamm_r, (u_z1 - u_z0) / skala, ansatz)) if not (8 <= n <= max_verts * 2): fails.append("%s: Huelle hat %d Verts, erwartet 8-%d" % (preset, n, max_verts * 2)) # DIE Bedingung: Stamm umschlossen, Krone NICHT. Der Stammradius liegt bei # wenigen Zentimetern, die Krone bei ein bis zwei Metern - dazwischen ist # so viel Luft, dass die Schranke nicht knapp sein muss. if u_r / skala < stamm_r * 0.95: fails.append("%s: Huellenradius %.3f m unter dem Stammradius %.3f - " "der Spieler wuerde in den Stamm laufen" % (preset, u_r / skala, stamm_r)) if u_r / skala > max(stamm_r * 2.5, stamm_r + 0.15): fails.append("%s: Huellenradius %.3f m gegen Stammradius %.3f - das " "ist keine Stammhuelle mehr" % (preset, u_r / skala, stamm_r)) # Fuss auf Z = 0 und nicht in die Krone hinaufreichend. if abs(u_z0 / skala) > 0.02: fails.append("%s: Huelle beginnt bei Z %.3f statt 0" % (preset, u_z0 / skala)) if (u_z1 - u_z0) / skala > ansatz * 1.35 + 0.05: fails.append("%s: Huelle ist %.2f m hoch, Kronenansatz liegt bei " "%.2f m - sie reicht in die Krone" % (preset, (u_z1 - u_z0) / skala, ansatz)) print("") if fails: print("ERGEBNIS: %d FEHLER" % len(fails)) for f in fails: print(" - " + f) sys.exit(1) print("ERGEBNIS: ALLE CHECKS OK")