Files
stylized-rock-generator/tests/test_baum_lod.py
T
D4rkst3randClaude Opus 4.8 ae7806e082 Tree Gen 1.42.0: UCX-Stammhuelle im Spielfertig-Export
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>
2026-08-07 06:18:29 +02:00

284 lines
11 KiB
Python

"""Punkt 5: LOD-Kette fuer den Stand-Baum.
Die Blatt-Reduktion laeuft ueber die DICHTE, nicht ueber Decimate: eine Card
besteht aus zwei Dreiecken und laesst sich nicht vereinfachen - man muss weniger
davon setzen und die verbleibenden groesser machen.
Gemessen wird am reimportierten FBX, und die Kugel-Normalen werden auf JEDER
Stufe geprueft: LOD1-3 haben eine eigene Card-Verteilung, die Normalen muessen
dazu passen. Nur LOD0 zu pruefen wuerde genau das verfehlen.
WICHTIG: Der Baum steht hier NICHT am Ursprung. Eine fruehere Fassung dieses
Tests baute ihn bei (0,0,0) - und ist damit an einem Fehler vorbeigelaufen, den
die Spielseite gefunden hat: LOD1-3 landeten bei MINUS der Standposition, weil
ihre Weltmatrix nie ins Mesh gebacken wurde. Bei x = 0 faellt das nicht auf,
weil minus null eben null ist.
Aufruf: blender --background --factory-startup --python tests/test_baum_lod.py
"""
import os
import sys
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)
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.sphere_normals = True
s.generate_lods = 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 wird BEWUSST weit vom Ursprung gestellt - siehe Kopf der Datei.
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()
# Die Blattebene muss mit an die Standposition - sie folgt dem Stamm.
_bl = bpy.data.objects.get(baum.name + "_Leaf")
if _bl is not None:
_bl.location = STANDORT
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()
if sorted(o.name for o in bpy.context.scene.objects) != namen_vorher:
fehlt = [n for n in namen_vorher
if n not in [o.name for o in bpy.context.scene.objects]]
neu = [o.name for o in bpy.context.scene.objects if o.name 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"))
lodfile = [f for f in dateien if "_LODGroup" in f]
print("Export: %s" % dateien)
if not lodfile:
print("ERGEBNIS: FEHLER: keine _LODGroup-Datei")
sys.exit(1)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, lodfile[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_")]
empties = [o for o in bpy.context.scene.objects if o.type == 'EMPTY']
stufen = sorted(meshes, key=lambda o: o.name)
print("")
print("Baum stand bei x=%.1f, y=%.1f - die Stufen muessen trotzdem um den "
"Ursprung liegen." % (STANDORT[0], STANDORT[1]))
print("")
print("%-24s %7s %7s %7s %9s %9s %9s"
% ("Stufe", "Rinde", "Blatt", "Tris", "Radial", "X-Mitte", "Y-Mitte"))
if len(stufen) != len(tg.LOD_STUFEN):
fails.append("%d LOD-Meshes, erwartet %d" % (len(stufen), len(tg.LOD_STUFEN)))
if not empties:
fails.append("Kein LodGroup-Empty im FBX - Unreal erkennt die Kette nicht")
blatt_zahlen = []
rinde_zahlen = []
spannen = []
for ob in stufen:
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)
tris = sum(len(p.vertices) - 2 for p in m.polygons)
blatt_zahlen.append(laub)
rinde_zahlen.append(rinde)
# Kugel-Normalen auf DIESER Stufe nachmessen.
quote = 0.0
blatt_polys = [p for p in m.polygons if p.material_index == 1]
if blatt_polys:
pts = [m.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(m, "calc_normals_split"):
m.calc_normals_split()
treffer = gesamt = 0
for p in blatt_polys:
for li in p.loop_indices:
n = Vector(m.loops[li].normal)
radial = m.vertices[m.loops[li].vertex_index].co - mitte
if n.length_squared < 1e-9 or radial.length_squared < 1e-9:
continue
gesamt += 1
if n.normalized().dot(radial.normalized()) > 0.8:
treffer += 1
quote = 100.0 * treffer / gesamt if gesamt else 0.0
# X/Y-Spanne dieser Stufe im FBX. Alle Stufen muessen deckungsgleich um
# den Ursprung liegen - eine verschobene Stufe waere in UE beim
# LOD-Wechsel schlagartig woanders.
co = [ob.matrix_world @ v.co for v in m.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)]
xm = (lo[0] + hi[0]) * 0.5
ym = (lo[1] + hi[1]) * 0.5
spannen.append((ob.name, xm, ym, lo, hi))
print("%-24s %7d %7d %7d %8.0f%% %9.3f %9.3f"
% (ob.name, rinde, laub, tris, quote, xm, ym))
if len(m.materials) != 2:
fails.append("%s: %d Slots, erwartet 2" % (ob.name, len(m.materials)))
if blatt_polys and quote < 90.0:
fails.append("%s: nur %.0f%% der Blatt-Normalen radial - die "
"Kugel-Normalen fehlen auf dieser Stufe" % (ob.name, quote))
if not blatt_polys:
fails.append("%s: keine Blatt-Faces" % ob.name)
# --- Die Ketten-Eigenschaft: es muss nach oben duenner werden -------------
print("")
print("Blatt-Faces je Stufe: %s" % blatt_zahlen)
print("Rinden-Faces je Stufe: %s" % rinde_zahlen)
for i in range(1, len(blatt_zahlen)):
if blatt_zahlen[i] >= blatt_zahlen[i - 1]:
fails.append("LOD%d hat %d Blatt-Faces, LOD%d hatte %d - die Kette wird "
"nicht duenner" % (i, blatt_zahlen[i], i - 1,
blatt_zahlen[i - 1]))
for i in range(1, len(rinde_zahlen)):
if rinde_zahlen[i] > rinde_zahlen[i - 1]:
fails.append("LOD%d hat mehr Rinden-Faces als LOD%d" % (i, i - 1))
# LOD3 muss deutlich guenstiger sein als LOD0, sonst lohnt die Kette nicht.
if blatt_zahlen and blatt_zahlen[0] > 0:
anteil = 100.0 * blatt_zahlen[-1] / blatt_zahlen[0]
print("LOD3 traegt %.0f%% der Cards von LOD0" % anteil)
if anteil > 30.0:
fails.append("LOD3 hat noch %.0f%% der Cards - als Impostor-Ersatz zu "
"teuer" % anteil)
# --- Die Cards muessen ein TEILSET von LOD0 sein -------------------------
# Das ist der Test gegen den Silhouetten-Sprung: wuerde je Stufe neu gestreut,
# saessen die Cards an anderen Stellen und die Krone spraenge beim LOD-Wechsel
# sichtbar um. Als Teilset liegt jede verbleibende Card dort, wo sie in LOD0
# schon war.
def card_mitten(ob):
"""Mittelpunkte der Blatt-Inseln (eine Insel = eine Card)."""
m = ob.data
faces = [p for p in m.polygons if p.material_index == 1]
if not faces:
return []
# Inseln ueber gemeinsame Vertices finden
von_vert = {}
for p in faces:
for vi in p.vertices:
von_vert.setdefault(vi, []).append(p.index)
gesehen = set()
mitten = []
for p in faces:
if p.index in gesehen:
continue
stapel = [p.index]
gesehen.add(p.index)
insel = []
while stapel:
fi = stapel.pop()
f = m.polygons[fi]
insel.append(f)
for vi in f.vertices:
for nb in von_vert.get(vi, ()):
if nb not in gesehen:
gesehen.add(nb)
stapel.append(nb)
mitte = Vector((0.0, 0.0, 0.0))
for f in insel:
mitte += f.center
mitten.append(mitte / len(insel))
return mitten
mitten0 = card_mitten(stufen[0])
print("")
print("LOD0 hat %d Cards" % len(mitten0))
for ob in stufen[1:]:
mitten = card_mitten(ob)
if not mitten0 or not mitten:
continue
treffer = 0
for m1 in mitten:
nah = min((m1 - m0).length for m0 in mitten0)
# Die Card wurde vergroessert, ihre Mitte bleibt aber - ein paar
# Zentimeter Toleranz reichen.
if nah < 0.15:
treffer += 1
quote = 100.0 * treffer / len(mitten)
print("%-24s %3d Cards, %.0f%% davon sitzen auf einer LOD0-Card"
% (ob.name, len(mitten), quote))
if quote < 90.0:
fails.append("%s: nur %.0f%% der Cards sitzen auf LOD0-Positionen - "
"die Stufe wurde neu gestreut statt ausgeduennt, die "
"Silhouette springt beim LOD-Wechsel" % (ob.name, quote))
# --- Alle Stufen deckungsgleich am Ursprung ------------------------------
# Das ist der Test fuer den aus UE gemeldeten Fehler: LOD1-3 sassen bei MINUS
# der Standposition, weil ihre Weltmatrix nie ins Mesh gebacken wurde.
print("")
for name, xm, ym, lo, hi in spannen:
if abs(xm) > 0.25 or abs(ym) > 0.25:
fails.append("%s: Mitte bei %.2f/%.2f statt am Ursprung - die Stufe "
"traegt die Standposition des Baums (%.1f/%.1f) mit"
% (name, xm, ym, STANDORT[0], STANDORT[1]))
if abs(lo[2]) > 0.15:
fails.append("%s: steht %.3f m ueber/unter dem Ursprung - der Baum "
"wuerde beim LOD-Wechsel springen" % (name, lo[2]))
# Und die Stufen muessen ZUEINANDER passen, nicht nur je einzeln zum Ursprung.
if len(spannen) > 1:
_, x0, y0, lo0, hi0 = spannen[0]
for name, xm, ym, lo, hi in spannen[1:]:
if abs(xm - x0) > 0.30 or abs(ym - y0) > 0.30:
fails.append("%s liegt %.2f/%.2f neben LOD0 - beim LOD-Wechsel "
"wuerde der Baum springen" % (name, xm - x0, ym - y0))
print("Versatz zu LOD0: %s"
% ", ".join("%s %.3f/%.3f" % (n.split("_")[-1], x - x0, y - y0)
for n, x, y, _, _ in spannen[1:]))
print("")
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for x in fails:
print(" - " + x)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")