Files
stylized-rock-generator/tests/test_export_robust.py
D4rkst3randClaude Opus 4.8 5e6b84369f UCX bei LOD-Gruppen nach dem MESH-Node benennen (Tree 1.45.0, Rock 2.20.0)
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>
2026-08-07 07:18:33 +02:00

263 lines
10 KiB
Python

"""Prueft die Export-Haertung: Ursprung, Hinlegen, UCX - am reimportierten FBX.
Gemessen wird NICHT an der Szene, sondern an dem, was tatsaechlich in der Datei
landet: exportieren, in eine leere Szene reimportieren, nachmessen. Nur so faellt
auf, wenn der Export die Objekt-Transformation mitschreibt - genau der Fehler,
durch den das 25er-Set seine Raster-Positionen (bis 20 m) im Asset trug und die
Steine im Spiel neben ihrem Platzierungspunkt schwebten.
Aufruf: blender --background --factory-startup --python tests/test_export_robust.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(
"rg", os.path.join(ROOT, "stylized_rock_generator.py"))
rg = importlib.util.module_from_spec(spec)
sys.modules["rg"] = rg
spec.loader.exec_module(rg)
rg.register()
fails = []
OUT = tempfile.mkdtemp()
# Startup-Szene leeren: --factory-startup liefert Wuerfel, Kamera und Licht mit,
# der Wuerfel wuerde als "Stein" mitgezaehlt.
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
s = bpy.context.scene.rock_gen_settings
s.batch_count = 3
s.subsurf_levels = 3
s.use_modal = False
s.generate_lods = False
s.assign_material = False
s.bake_vertex_cavity = True
s.export_dir = OUT
s.export_lay_flat = True
s.export_ucx = True
bpy.ops.object.generate_rocks_batch()
rocks = [o for o in bpy.context.scene.objects if o.type == 'MESH']
if len(rocks) != 3:
fails.append("Aufbau: %d Steine statt 3" % len(rocks))
# Szenen-Raster nachstellen: weit weg vom Ursprung, gedreht und skaliert.
# Genau diese Kombination hat das 25er-Set kaputtgemacht.
for i, ob in enumerate(rocks):
ob.location = (4.0 + i * 8.0, -6.0 - i * 3.0, 1.7)
ob.rotation_euler = (math.radians(23), math.radians(-41), math.radians(75))
ob.scale = (1.3, 0.8, 1.1)
# Einen Stein absichtlich plattig UND aufrecht stellen: duenn in X, hoch in Z.
# Achsparallel, damit der Fall eindeutig ist - er MUSS hingelegt werden.
platte = rocks[0]
platte.rotation_euler = (0.0, 0.0, 0.0)
platte.scale = (0.25, 1.4, 1.6)
namen_vorher = sorted(o.name for o in bpy.context.scene.objects)
anzahl_vorher = len(bpy.context.scene.objects)
bpy.ops.object.export_rocks_fbx()
# --- Szene darf sich NICHT veraendert haben --------------------------------
if sorted(o.name for o in bpy.context.scene.objects) != namen_vorher:
fails.append("Szene veraendert: Namen stimmen nach dem Export nicht mehr "
"(%d vorher, %d nachher)"
% (anzahl_vorher, len(bpy.context.scene.objects)))
if any(o.name.endswith(".__src") for o in bpy.context.scene.objects):
fails.append("Hilfsnamen '.__src' nicht zurueckgesetzt")
if any(o.name.startswith("UCX_") for o in bpy.context.scene.objects):
fails.append("UCX-Kopie in der Szene stehengeblieben")
fbx = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx"))
if len(fbx) != 3:
fails.append("%d FBX statt 3: %s" % (len(fbx), fbx))
print("")
print("%-22s %7s %7s %7s %8s %6s %5s" %
("Datei", "|X|mid", "|Y|mid", "min Z", "kuerzeste", "UCX", "VCol"))
for f in fbx:
# Leere Szene, damit nur das Importierte gemessen wird.
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, f))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
ucx = [o for o in meshes if o.name.startswith("UCX_")]
body = [o for o in meshes if not o.name.startswith("UCX_")]
if not body:
fails.append("%s: kein Mesh im FBX" % f)
continue
ob = body[0]
co = [ob.matrix_world @ v.co for v in ob.data.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)]
midx = (lo[0] + hi[0]) * 0.5
midy = (lo[1] + hi[1]) * 0.5
dims = [hi[i] - lo[i] for i in range(3)]
kurz = "XYZ"[dims.index(min(dims))]
has_vcol = bool(ob.data.color_attributes)
print("%-22s %7.3f %7.3f %7.3f %8s %6s %5s"
% (f, abs(midx), abs(midy), lo[2], kurz,
("%d V" % len(ucx[0].data.vertices)) if ucx else "-",
"ja" if has_vcol else "NEIN"))
# 1) Ursprung erzwungen: Bbox mittig, Unterkante auf 0
if abs(midx) > 0.10 or abs(midy) > 0.10:
fails.append("%s: Bbox nicht zentriert (|X| %.3f, |Y| %.3f, erlaubt 0.10)"
% (f, abs(midx), abs(midy)))
if abs(lo[2]) > 0.01:
fails.append("%s: min.Z = %.4f, erwartet 0" % (f, lo[2]))
# 2) Flach heisst liegend. Gefordert wird nicht "Z ist exakt am kuerzesten",
# sondern "Z ist nicht nennenswert laenger als die kuerzeste Achse" -
# bei einem 2.53/1.40/1.54-Stein braechte Kippen nichts und wuerde die
# Form nur willkuerlich drehen.
if min(dims) < dims[2] * 0.9:
fails.append("%s: steht hochkant - kuerzeste Achse ist %s (%.2f), Z ist "
"%.2f (%.2f/%.2f/%.2f)"
% (f, kurz, min(dims), dims[2], dims[0], dims[1], dims[2]))
# 3) UCX vorhanden, geschlossen, im Budget
if not ucx:
fails.append("%s: keine UCX-Huelle im FBX" % f)
else:
u = ucx[0]
erwartet = "UCX_" + os.path.splitext(f)[0]
if u.name != erwartet:
fails.append("%s: Huelle heisst '%s', Unreal erwartet '%s'"
% (f, u.name, erwartet))
nv = len(u.data.vertices)
if nv > s.ucx_max_verts:
fails.append("%s: UCX hat %d Verts, Budget %d"
% (f, nv, s.ucx_max_verts))
# Huelle muss den Koerper umschliessen
uco = [u.matrix_world @ v.co for v in u.data.vertices]
for i in range(3):
if min(c[i] for c in uco) > lo[i] + 0.02 or \
max(c[i] for c in uco) < hi[i] - 0.02:
fails.append("%s: UCX umschliesst den Stein nicht (Achse %d)"
% (f, i))
break
# geschlossen?
offen = [e for e in u.data.edges if True]
import bmesh as _bm
bm = _bm.new(); bm.from_mesh(u.data)
rand = [e for e in bm.edges if len(e.link_faces) != 2]
bm.free()
if rand:
fails.append("%s: UCX hat %d offene Kanten - keine geschlossene Huelle"
% (f, len(rand)))
# Vertex-Farben duerfen dabei nicht verlorengehen
if not has_vcol:
fails.append("%s: Vertex-Farben fehlen nach dem Roundtrip" % f)
# ---------------------------------------------------------------------------
# Offener Punkt der Spielseite: LOD-Gruppen UND Vertex-Farben zusammen.
# Relevant vor den Bruchstuecken - die sind klein und LODen frueh.
# ---------------------------------------------------------------------------
# read_factory_settings leert die SZENE, die Addon-Klassen bleiben registriert -
# ein zweites register() wirft "already registered as a subclass".
bpy.ops.wm.read_factory_settings(use_empty=True)
if not hasattr(bpy.context.scene, "rock_gen_settings"):
rg.register()
LOD_OUT = tempfile.mkdtemp()
s = bpy.context.scene.rock_gen_settings
s.batch_count = 1
s.subsurf_levels = 3
s.use_modal = False
s.assign_material = False
s.bake_vertex_cavity = True
s.generate_lods = True
s.lod_count = 4
s.export_dir = LOD_OUT
s.export_lay_flat = True
s.export_ucx = True
bpy.ops.object.generate_rocks_batch()
# Auch die LOD-Gruppe weit weg vom Ursprung stellen.
for o in bpy.context.scene.objects:
if o.parent is None:
o.location = (13.0, -7.5, 2.2)
bpy.ops.object.export_rocks_fbx()
lod_fbx = sorted(f for f in os.listdir(LOD_OUT) if f.lower().endswith(".fbx"))
if not lod_fbx:
fails.append("LOD: keine FBX exportiert")
else:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(LOD_OUT, lod_fbx[0]))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
stufen = sorted((o for o in meshes if not o.name.startswith("UCX_")),
key=lambda o: -len(o.data.vertices))
hull = [o for o in meshes if o.name.startswith("UCX_")]
print("")
print("LOD-Gruppe %s:" % lod_fbx[0])
for o in stufen:
vc = o.data.color_attributes
rng = "-"
if vc:
werte = [c.color[0] for c in vc[0].data]
rng = "R %.2f..%.2f" % (min(werte), max(werte))
print(" %-26s %5d Verts VCol %-4s %s"
% (o.name, len(o.data.vertices), "ja" if vc else "NEIN", rng))
if not vc:
fails.append("LOD %s: Vertex-Farben fehlen - LOD-Gruppe und "
"Vertex-Farben vertragen sich nicht" % o.name)
if len(stufen) != 4:
fails.append("LOD: %d Stufen im FBX, erwartet 4" % len(stufen))
if not hull:
fails.append("LOD: keine UCX-Huelle")
elif hull[0].parent is not None and hull[0].parent.type == 'EMPTY':
# Unter dem LodGroup-Empty wuerde Unreal die Huelle als LOD-Stufe lesen.
fails.append("LOD: UCX haengt unter dem LodGroup-Empty")
if hull and stufen:
# DER Punkt, der lange gefehlt hat: der Legacy-Importer matcht
# `UCX_<MESH-Node>`, nicht das LodGroup-Empty. Eine Huelle mit dem
# Gruppennamen liegt zwar in der Datei - dieser Test hat sie brav
# gefunden und fuer gut befunden -, wird von Unreal aber STILL
# verworfen. Gemeldet von der Spielseite: convex = 0 an 57 Fels-Meshes.
#
# Struktur pruefen reicht nicht. Geprueft wird deshalb die REGEL: hinter
# "UCX_" muss ein Mesh-Node aus derselben Datei stehen, und zwar LOD0.
ziel = hull[0].name[4:]
namen = {o.name for o in stufen}
if ziel not in namen:
fails.append("LOD: Huelle heisst '%s' - '%s' ist kein Mesh-Node "
"(vorhanden: %s). Unreal verwirft sie still."
% (hull[0].name, ziel, sorted(namen)))
elif ziel != stufen[0].name:
fails.append("LOD: Huelle zeigt auf '%s', LOD0 heisst '%s'"
% (ziel, stufen[0].name))
if stufen:
co = [stufen[0].matrix_world @ v.co for v in stufen[0].data.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)]
if abs((lo[0] + hi[0]) * 0.5) > 0.10 or abs((lo[1] + hi[1]) * 0.5) > 0.10 \
or abs(lo[2]) > 0.01:
fails.append("LOD: Ursprung nicht erzwungen (mid %.3f/%.3f, minZ %.3f)"
% ((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, lo[2]))
print("")
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for x in fails:
print(" - " + x)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")