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>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user