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