Die Stand-Baeume bekommen eine konvexe Kollisionshuelle um den STAMM, vom Fuss bis zum Kronenansatz - keine Kronen-Huelle: gegen die liefe der Spieler, bevor er den Baum beruehrt. Die Trennung kommt aus ast_id (0 = Stamm), dasselbe Attribut wie beim Ursprung und bei den Faell-Teilen. Kronenansatz = tiefster Punkt mit ast_id > 0 plus ein Zehntel Stammhoehe Luft (der tiefste Astpunkt kann eine herabgebogene Spitze sein). Ohne Aeste ist die ganze Pflanze Stamm - beim Kaktus genau richtig. Name und Hierarchie sind NICHT geraten, sondern am Fels-Export nachgemessen, der in der Pipeline schon abgenommen ist: UCX_<LODGroup-Name>, als GESCHWISTER des LodGroup-Empty. Ein Kind der Gruppe wuerde Unreal als LOD-Stufe lesen. Zwei Befunde aus der Messung: - Die Huelle rutschte 4-6 cm UNTER den Boden. Das Vereinfachen auf 12 Verts schneidet Ecken ab, das anschliessende Aufblasen skaliert um die Huellenmitte und schiebt dabei die Unterseite nach unten. Im Spiel waere das eine Kollision, die aus dem Terrain ragt. Boden wird jetzt auf Z = 0 geklemmt. - Der erste Anlauf des Tests holte die FBX-Skala aus der Gesamthoehe - die Blatt-Cards ragen ueber die Rinde hinaus, die Skala kam zu gross heraus und die Huelle sah zu klein aus. Massstab kommt jetzt aus Material-Slot 0. Neu: tests/test_stamm_ucx.py, vier Faelle (gerader Stamm, krummer Stamm mit Bend 1.5, Obstbaum, einmal ohne LODs), gemessen im reimportierten FBX. Gegenprobe ohne Boden-Klemmung: vier Fehler. test_baum_export, test_baum_lod und test_ursprung zaehlten die Huelle als Render-Mesh mit und schlugen zu Recht an - sie filtern jetzt UCX_. Schalter im Export-Panel: "UCX-Stammhuelle" (an) + max. Verts (12). 27 Tests bestehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
7.3 KiB
Python
199 lines
7.3 KiB
Python
"""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)
|
|
|
|
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: Bbox-Unterseite Mitte -------------------------------------
|
|
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)
|
|
print("Ursprung: Mitte %.3f/%.3f, min.Z %.3f, Hoehe %.2f m"
|
|
% (mid[0], mid[1], lo[2], hi[2] - lo[2]))
|
|
if abs(mid[0]) > 0.10 or abs(mid[1]) > 0.10:
|
|
fails.append("Bbox nicht zentriert (%.3f/%.3f)" % mid)
|
|
if abs(lo[2]) > 0.01:
|
|
fails.append("min.Z = %.3f, erwartet 0" % lo[2])
|
|
|
|
# --- 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")
|