Files
stylized-rock-generator/tests/test_baum_export.py
T
D4rkst3randClaude Opus 4.8 46b4e9ee34 Tree Gen 1.26.0: spielfertiger Stand-Baum + Kugel-Normalen (Punkt 1 + 2)
Punkt 1 - "Spielfertig exportieren"
  Stamm und _Leaf-Ebene werden zu EINEM Mesh mit zwei Material-Slots
  verschmolzen (Slot 0 = M_Bark, Slot 1 = M_Foliage). Die getrennten Objekte
  im Blend bleiben die Arbeitsebene - verschmolzen wird nur eine Export-Kopie.
  Ursprung Bbox-Unterseite Mitte und FBX-Einstellungen wie in der
  Fels-Pipeline (use_selection, FBX_SCALE_ALL).

Punkt 2 - Kugel-Normalen auf die Blatt-Cards
  Alle Blatt-Normalen zeigen radial vom Kronen-Schwerpunkt nach aussen, damit
  die Krone als EIN weiches Volumen liest statt als Haufen flacher Bleche.
  Der Schwerpunkt wird nur aus den BLATT-Faces gebildet - der Stamm wuerde die
  Mitte nach unten ziehen und die Kugel verkippen.

  Bewusst direkt ueber normals_split_custom_set statt ueber den
  Normal-Edit-Modifier: der Modifier muesste vor dem Export angewendet werden,
  und angewendete Modifier plus Custom Normals ist genau die Kombination, bei
  der der FBX-Export gerne flachbuegelt. Am REIMPORTIERTEN FBX gemessen:
  mittlere Radialitaet 1.000, 100 % der 510 Blatt-Loops ueber 0.8. Gegenprobe
  Rinde 0.043 - die bleibt unangetastet.

Drei Fallen unterwegs:
- Ueber bmesh.from_mesh zusammenfuegen haengt die Geometrie zwar an, wirft
  aber die MATERIAL-INDIZES weg: danach lagen alle 748 Faces auf Index 0,
  Blaetter und Rinde ununterscheidbar. Blenders join fuehrt die Slot-Listen
  zusammen und rechnet die Indizes korrekt um.
- join loescht das zweite Objekt; jede gehaltene Referenz darauf ist danach
  tot. Die Aufraeumliste fuehrt deshalb NAMEN statt Objekte.
- Die Export-Kopie muss exakt so heissen wie der Baum (Unreal leitet den
  Asset-Namen davon ab). Solange das Original den Namen haelt, haengt Blender
  ".001" an - Original wird fuer die Exportdauer beiseite benannt, wie bei den
  Felsen.

Neu: tests/test_baum_export.py misst die Normalen im EXPORTIERTEN FBX, nicht
am Szenenobjekt - nur so faellt ein plattgebuegelter Custom-Normal auf.
19 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 06:42:59 +02:00

197 lines
7.1 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]))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
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")