Der Legacy-Importer matcht UCX_<Mesh-Node>, nicht das LodGroup-Empty. Eine Huelle mit dem Gruppennamen liegt in der Datei und ein Reimport nach Blender zeigt sie an - Unreal verwirft sie aber STILL. Gemeldet von der Spielseite: convex = 0 an 108 Stand-Baeumen und 57 Fels-Meshes; dieselbe Datei mit der Huelle auf dem LOD0-Node liefert convex = 1. UCX_<Basisname> stimmt nur OHNE LOD-Kette, weil der Mesh-Node dann so heisst - deshalb waren Faell-Teile und _Pieces_All die ganze Zeit in Ordnung. BEIDE Addons betroffen: der Fels-Export hatte den Fehler zuerst, der Baum-Export hat ihn 1.42 von dort uebernommen. Wie es durchrutschen konnte, ist die eigentliche Lehre: fuer 1.42 habe ich die Konvention am Fels-Export NACHGEMESSEN statt sie zu raten - exportieren, reimportieren, Name und Hierarchie pruefen. Das war richtig und hat trotzdem nichts bewiesen, weil die Quelle denselben Fehler hatte. Gemessen wurde die STRUKTUR, nicht die WIRKUNG. Eine abgeschaute Konvention ist nur so gut wie die Quelle, von der sie stammt. Die Tests pruefen jetzt die REGEL statt des Namens: hinter UCX_ muss ein Mesh-Node aus derselben Datei stehen, und zwar LOD0 (hoechste Vertexzahl). Das haette den Fehler in beiden Addons gefunden, ohne die Antwort vorher zu kennen. Gegenproben: Baum 9 Fehler, Fels 1 Fehler. 27 Tests bestehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
9.4 KiB
Python
227 lines
9.4 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_<LOD0-Mesh-Node>`, und zwar als GESCHWISTER der LodGroup, nicht als Kind -
|
|
Unreal liest jedes Kind der Gruppe als LOD-Stufe.
|
|
|
|
WICHTIG, weil es genau hier schiefging: der Legacy-Importer matcht gegen den
|
|
MESH-Node, NICHT gegen das LodGroup-Empty. Eine Huelle mit dem LodGroup-Namen
|
|
liegt zwar in der Datei und ein Reimport nach Blender zeigt sie brav an - Unreal
|
|
verwirft sie aber still (convex = 0). Struktur pruefen reicht nicht.
|
|
|
|
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: er muss auf einen MESH-Node zeigen, und zwar auf LOD0.
|
|
#
|
|
# Die erste Fassung dieses Tests prueft genau das NICHT scharf genug: sie
|
|
# verglich gegen den LodGroup-Namen, weil der Fels-Export es so machte.
|
|
# Struktur und Name waren damit stimmig - Unreal hat die Huelle trotzdem
|
|
# still verworfen (convex = 0 an 108 Baeumen), weil der Legacy-Importer
|
|
# gegen den MESH-Node matcht. Ein Reimport nach Blender beweist nur, dass
|
|
# die Huelle in der Datei liegt, nicht dass sie angenommen wird.
|
|
#
|
|
# Deshalb steht hier jetzt die Regel und nicht der Name: der Rest hinter
|
|
# "UCX_" muss ein Mesh im FBX sein. Und der Vollstaendigkeit halber: er darf
|
|
# NICHT das Empty sein.
|
|
ziel = u.name[4:]
|
|
mesh_namen = {o.name for o in rest}
|
|
if ziel not in mesh_namen:
|
|
fails.append("%s: Huelle heisst '%s' - '%s' ist kein Mesh-Node im FBX "
|
|
"(vorhanden: %s). Unreal verwirft sie dann still."
|
|
% (preset, u.name, ziel, sorted(mesh_namen)))
|
|
if gruppe and ziel == gruppe[0].name:
|
|
fails.append("%s: Huelle zeigt auf das LodGroup-Empty '%s' statt auf "
|
|
"einen Mesh-Node" % (preset, ziel))
|
|
# Und zwar auf LOD0, nicht auf irgendeine Stufe: Unreal haengt die
|
|
# Kollision an das Render-Mesh der obersten Stufe.
|
|
lod0 = max(rest, key=lambda o: len(o.data.vertices))
|
|
if ziel != lod0.name:
|
|
fails.append("%s: Huelle zeigt auf '%s', LOD0 heisst aber '%s'"
|
|
% (preset, ziel, lod0.name))
|
|
|
|
# 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")
|