Files
stylized-rock-generator/tests/test_export_robust.py
T
D4rkst3randClaude Opus 4.8 20a751edca Rock Gen 2.14.0: Export-Haertung (Wunschliste 1-3) + LOD/Vertex-Farben geklaert
Punkt 1 - Ursprung erzwingen (keine Option mehr)
  Der Export schrieb die Objekt-Transformation mit ins FBX. Dadurch trug das
  25er-Set seine Szenen-Rasterpositionen (bis 20 m) im Asset und die Steine
  schwebten im Spiel neben ihrem Platzierungspunkt. Jetzt wandert jedes Objekt
  an den Welt-Ursprung, Origin unten-mittig der Bbox. Am REIMPORTIERTEN FBX
  gemessen: Bbox-Mitte 0.000/0.000, min.Z 0.000.

Punkt 2 - flache Steine hinlegen
  Kuerzeste Bbox-Achse zeigt nach oben. Gedreht wird nur, wenn es etwas bringt:
  bei 2.53/1.40/1.54 wuerde Kippen die Form nur willkuerlich drehen.

Punkt 3 - UCX-Kollisionshuellen
  Konvexe Huelle als UCX_<Name> im selben FBX, Budget einstellbar (Default 32
  Verts). Gemessen 29-32 Verts, geschlossen, umschliesst den Stein.

Alles auf temporaeren KOPIEN - die Szene des Users bleibt unveraendert
(Test prueft Namen und Objektzahl vorher/nachher).

Offener Punkt der Spielseite beantwortet: LOD-Gruppen + Vertex-Farben gingen
NICHT. Der LOD-Zweig in make_one_rock sprang komplett am Cavity-Bake vorbei -
gemessen: kein Farbattribut auf einer der vier Stufen, dazu fehlten Origin und
Material. Jetzt wird auf LOD0 gebacken, BEVOR dupliziert wird; das Decimate
interpoliert mit, damit alle Stufen dieselbe Cavity zeigen und der LOD-Wechsel
nicht poppt. Nach Roundtrip: LOD0 40 V R 0.30..1.00, LOD1 21 V, LOD2 11 V,
LOD3 6 V - alle mit Farben.

Vier Fallen, die der Test aufgedeckt hat:
- Blender haengt an Kopien ".001" an; Unreal ordnet UCX ueber den EXAKTEN
  Mesh-Namen zu -> Original bekommt fuer die Exportdauer einen Hilfsnamen.
- bmesh.from_mesh bringt die Originalflaechen mit, convex_hull legt seine
  Huelle obendrauf -> 16-21 offene Kanten. Nur die PUNKTE einlesen.
- Jede Vereinfachung schneidet Ecken ab, die Huelle wird KLEINER als der Stein
  (der Spieler wuerde einsinken). Jetzt exakt so weit aufgeblasen, dass wieder
  alles hineinpasst - fuer Kollision ist minimal zu gross unkritisch.
- ob.matrix_world = Identity loescht die Transformation NICHT bei geparenteten
  Objekten; bei einer LOD-Gruppe auf (13/-7.5/2.2) landete das Mesh bei MINUS
  dieser Position. matrix_basis und matrix_parent_inverse direkt nullen.

Neu: tests/test_export_robust.py misst am reimportierten FBX statt an der
Szene - nur so faellt eine mitgeschriebene Transformation ueberhaupt auf.
15 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 08:02:14 +02:00

245 lines
9.5 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 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")