Files
stylized-rock-generator/tests/test_stamm_ucx.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

201 lines
8.0 KiB
Python

"""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_<LODGroup-Name>`, 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")