diff --git a/README.md b/README.md index b69a781..7dde1c5 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,66 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: --- +## Export-Haertung (Rock Gen) + +Drei Dinge passieren beim FBX-Export jetzt automatisch. Alles laeuft auf +**temporaeren Kopien** - die Szene bleibt unveraendert. + +**1. Ursprung wird erzwungen** (keine Option). Jedes Objekt wandert an den +Welt-Ursprung, der Ursprung sitzt unten-mittig der Bounding-Box. Anlass: das +25er-Set trug seine Szenen-Rasterpositionen (bis 20 m) im Mesh, die Steine +schwebten im Spiel neben ihrem Platzierungspunkt. Nachgemessen am +**reimportierten** FBX: Bbox-Mitte 0.000/0.000, `min.Z` 0.000. + +**2. Flache Steine werden hingelegt** (`Flache Steine hinlegen`, Default an). +Die kuerzeste Bbox-Achse wird nach oben gedreht. Gedreht wird nur, wenn es etwas +bringt - bei einem 2.53/1.40/1.54-Stein wuerde Kippen die Form nur willkuerlich +drehen. Im Spiel gibt es nur zufaelligen Yaw, die gebackene Ausrichtung ist also +endgueltig. + +**3. UCX-Kollision** (`UCX-Kollision mitexportieren`, Default an). Konvexe Huelle +als `UCX_` im selben FBX, Unreal nimmt sie als Simple Collision. Budget +ueber `UCX max. Verts` (Default 32). + +Drei Fallen, die dabei aufgefallen sind: + +- Blender haengt an Kopien `.001` an. Unreal ordnet die Kollision aber ueber den + **exakten** Mesh-Namen zu - das Original bekommt fuer die Dauer des Exports + einen Hilfsnamen, die Kopie den echten. +- `bmesh.from_mesh` bringt die **Originalflaechen** mit, `convex_hull` legt seine + Huelle dann obendrauf: gemessen 16-21 offene Kanten. Es duerfen nur die + **Punkte** eingelesen werden. +- Jede Vereinfachung der Huelle schneidet Ecken ab, sie wird dadurch **kleiner** + als der Stein - eine Kollision kleiner als das Mesh laesst den Spieler + einsinken. Deshalb wird sie danach exakt so weit aufgeblasen, dass wieder alles + hineinpasst. + +### LOD-Gruppen + Vertex-Farben (war offen, jetzt geklaert) + +Ging **nicht**: der LOD-Zweig sprang komplett am Cavity-Bake vorbei, LOD-Gruppen +kamen ohne Farbattribut aus dem Export - auf keiner der vier Stufen. Jetzt wird +auf LOD0 gebacken, **bevor** dupliziert wird; das Decimate interpoliert die +Farben mit. Wuerde je Stufe neu gebacken, haette jede Stufe ihre eigene Cavity +und der LOD-Wechsel wuerde poppen. Nach dem FBX-Roundtrip gemessen: + +| Stufe | Verts | R-Bereich | +|---|---|---| +| LOD0 | 40 | 0.30..1.00 | +| LOD1 | 21 | 0.41..0.92 | +| LOD2 | 11 | 0.45..0.92 | +| LOD3 | 6 | 0.49..0.88 | + +Dazu die zweite Falle: `ob.matrix_world = Identity` loescht die Transformation +**nicht**, wenn das Objekt ein Eltern-Objekt hat - Blender rechnet eine lokale +Gegen-Matrix aus. Bei einer LOD-Gruppe auf (13/-7.5/2.2) landete das Mesh dadurch +bei genau MINUS dieser Position. `matrix_basis` und `matrix_parent_inverse` +muessen direkt genullt werden. + +Die UCX-Huelle haengt bewusst **neben** dem LodGroup-Empty, nicht darunter - +Unreal wuerde jedes Kind der Gruppe als weitere LOD-Stufe lesen. + +--- + ## Stylized Asset Utils Fuer alles, was **von Hand** gebaut wird (Pilze, Baumstumpf, Erzfels, Props) und @@ -391,6 +451,7 @@ gefiltert `.\run_tests.ps1 -Filter panels`. | Datei | prueft | |---|---| | `tests/test_proportionen.py` | Baum-Proportionen: Palmenkrone, Strauch-Breite, Setzling-Schlankheit, Branch Count 0 | +| `tests/test_export_robust.py` | Export am **reimportierten** FBX: Ursprung erzwungen, flach = liegend, UCX geschlossen/umschliessend/im Budget, LOD+Vertex-Farben | | `tests/test_panels.py` | Alle vier Addons: registrieren, jedes Panel zeichnet, Unter-Panels im richtigen Tab, **jede Property irgendwo erreichbar** | | `tests/test_tree_addon.py` | Tree-Addon: Presets, Wachstums-Stufen, UVs, Modifier-Apply | | `tests/test_ecogame_rock.py` | Fels-Benennung, Bake liefert nur `_albedo`/`_normal`, AO im Albedo | diff --git a/blender_manifest.toml b/blender_manifest.toml index 135c553..a811693 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.13.0" +version = "2.14.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/dist/index.html b/dist/index.html index 0999c73..bd963b4 100644 --- a/dist/index.html +++ b/dist/index.html @@ -40,14 +40,14 @@ 4.9KB - stylized_rock_generator-2.13.0 + stylized_rock_generator-2.14.0 Stylized Rock Generator Batch-Generator fuer stylized Rocks mit UE5-Export link 4.2.0 - ~ all all - 21.7KB + 26.1KB stylized_tree_generator-1.25.0 @@ -61,6 +61,6 @@ -

Built 2026-08-01, 20:46

+

Built 2026-08-03, 06:02

diff --git a/dist/index.json b/dist/index.json index 3a3577b..adc6c38 100644 --- a/dist/index.json +++ b/dist/index.json @@ -57,7 +57,7 @@ "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.13.0", + "version": "2.14.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -73,9 +73,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.13.0.zip", - "archive_size": 22263, - "archive_hash": "sha256:5ae39f5035435ae51803f63e1a0d06b67d8c68a5477e9fdb663a4a21c65d2492" + "archive_url": "./stylized_rock_generator-2.14.0.zip", + "archive_size": 26683, + "archive_hash": "sha256:f60aa4d5c8d73cabfccbccf19020ea30d31e77ad22dca5f8a7151e46c7ec1c42" }, { "schema_version": "1.0.0", diff --git a/dist/stylized_rock_generator-2.13.0.zip b/dist/stylized_rock_generator-2.13.0.zip deleted file mode 100644 index 193e963..0000000 Binary files a/dist/stylized_rock_generator-2.13.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.14.0.zip b/dist/stylized_rock_generator-2.14.0.zip new file mode 100644 index 0000000..6af1f84 Binary files /dev/null and b/dist/stylized_rock_generator-2.14.0.zip differ diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index be1b22c..b2f62df 100644 --- a/stylized_rock_generator.py +++ b/stylized_rock_generator.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", - "version": (2, 13, 0), + "version": (2, 14, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -13,6 +13,7 @@ bl_info = { } import bpy +import bmesh import os import json import math @@ -24,6 +25,7 @@ from bpy.props import ( StringProperty, PointerProperty, ) from bpy.types import Operator, Panel, PropertyGroup +from mathutils import Matrix, Vector # --------------------------------------------------------------------------- @@ -651,6 +653,16 @@ def _build_lod_group(context, settings, index, location, rng, base_name=None): lod0 = _build_rock_mesh(context, settings, index, location, rng, name=base_name) _apply_hardsurface(context, lod0, settings) _apply_random_transform(context, lod0, settings, rng) + + # Vertex-Farben JETZT backen, vor dem Duplizieren. Zwei Gruende: + # 1. Der LOD-Zweig sprang vorher komplett am Bake vorbei - LOD-Gruppen kamen + # OHNE Vertex-Farben aus dem Export (gemessen: kein Farbattribut auf einer + # der vier Stufen). Genau der offene Punkt der Spielseite. + # 2. Das Decimate interpoliert die Farben mit. Wuerde je Stufe neu gebacken, + # haette jede Stufe ihre eigene Cavity und der LOD-Wechsel wuerde poppen. + if settings.bake_vertex_cavity: + _bake_vertex_cavity(context, lod0, settings) + if settings.assign_material: _assign_material(lod0, index) lod0.name = base_name + "_LOD0" @@ -1032,6 +1044,25 @@ class RockGenSettings(PropertyGroup): name="Nur Auswahl exportieren", default=False, description="Exportiert nur die aktuell selektierten Rocks statt aller", ) + # Ursprung unten-mittig + Nullpunkt ist KEINE Option, sondern passiert immer - + # ein Stein, der seine Szenenposition mit ins Asset nimmt, ist schlicht kaputt. + export_lay_flat: BoolProperty( + name="Flache Steine hinlegen", default=True, + description=("Dreht vor dem Export die kuerzeste Bbox-Achse nach oben, " + "damit plattige Steine liegen statt zu stehen. Im Spiel gibt " + "es nur zufaelligen Yaw - die Ausrichtung ist endgueltig"), + ) + export_ucx: BoolProperty( + name="UCX-Kollision mitexportieren", default=True, + description=("Legt je Stein eine konvexe Huelle 'UCX_' ins FBX. " + "Unreal nimmt sie automatisch als Simple Collision"), + ) + ucx_max_verts: IntProperty( + name="UCX max. Verts", default=32, min=8, max=128, + description=("Obergrenze fuer die Kollisionshuelle. Simple Collision soll " + "wenige Ebenen haben - eine volle Huelle waere keine " + "Vereinfachung"), + ) # --- Zufall --- random_amount: FloatProperty( @@ -1143,6 +1174,245 @@ class OBJECT_OT_generate_rocks(Operator): # FBX-Export (UE-ready) # --------------------------------------------------------------------------- +def _lay_flat_matrix(dims, tolerance=0.9): + """Rotation, die die KUERZESTE Bbox-Achse nach oben dreht (flache Seite unten). + + Im Spiel gibt es nur zufaelligen Yaw - die gebackene Ausrichtung ist also + endgueltig. Plattige Steine muessen deshalb schon im FBX liegen. + + Gedreht wird nur, wenn die kuerzeste Achse deutlich kuerzer ist als die + aktuelle Hoehe (Toleranz). Sonst wuerde ein annaehernd wuerfeliger Stein + willkuerlich gekippt, obwohl er richtig steht. + """ + dx, dy, dz = dims + shortest = min(dx, dy, dz) + if shortest >= dz * tolerance: + return None # steht schon richtig (oder ist rundlich) + if shortest == dx: + return Matrix.Rotation(math.radians(90.0), 4, 'Y') + return Matrix.Rotation(math.radians(90.0), 4, 'X') + + +def _bake_transform(ob, world, extra=None): + """Backt die uebergebene Weltmatrix in die Mesh-Daten und nullt das Objekt. + + Noetig, weil der FBX-Export die Objekt-Transformation MITSCHREIBT: das + 25er-Set trug seine Szenen-Raster-Positionen (bis 20 m) im Asset und schwebte + im Spiel neben dem Platzierungspunkt. + + FALLE: `ob.matrix_world = Identity` loescht die Transformation NICHT, wenn das + Objekt ein Eltern-Objekt hat - Blender rechnet dann eine lokale Gegen-Matrix + aus, damit die Welt-Position stimmt. Bei einer LOD-Gruppe an Position + (13/-7.5/2.2) landete das Mesh dadurch bei MINUS derselben Position. Deshalb + werden matrix_basis UND matrix_parent_inverse direkt genullt. + """ + mat = extra @ world if extra is not None else world + ob.data.transform(mat) + # Bei gespiegelter Skalierung dreht sich die Flaechen-Orientierung um. + if mat.determinant() < 0.0: + ob.data.flip_normals() + ob.matrix_basis = Matrix.Identity(4) + ob.matrix_parent_inverse = Matrix.Identity(4) + + +def _bbox_of(objs): + """Gemeinsame Bounding-Box mehrerer Meshes in deren (bereits gebackenen) Koordinaten.""" + lo = [float("inf")] * 3 + hi = [float("-inf")] * 3 + for o in objs: + if o.type != 'MESH': + continue + for v in o.data.vertices: + for i in range(3): + lo[i] = min(lo[i], v.co[i]) + hi[i] = max(hi[i], v.co[i]) + if lo[0] == float("inf"): + return None, None + return Vector(lo), Vector(hi) + + +def _make_ucx(context, src_objs, mesh_name, max_verts=32): + """Konvexe Huelle als `UCX_` - Unreal nimmt sie als Simple Collision. + + Wichtig fuer die Physik-Bruchstuecke: "Complex as Simple" taugt dafuer nicht. + Die Huelle wird auf max_verts eingedampft (planares Aufloesen), weil UE fuer + Simple Collision wenige Ebenen will - eine 150-Vert-Huelle ist keine + Vereinfachung mehr. + """ + # FALLE: nur die PUNKTE einlesen, nicht das ganze Mesh. Mit from_mesh kommen + # die Originalflaechen mit, und convex_hull legt seine Huelle dann obendrauf - + # das Ergebnis hatte gemessen 16-21 offene Kanten und war keine geschlossene + # Huelle mehr. + bm = bmesh.new() + for o in src_objs: + if o.type == 'MESH': + for v in o.data.vertices: + bm.verts.new(v.co) + if not bm.verts: + bm.free() + return None + bm.verts.ensure_lookup_table() + res = bmesh.ops.convex_hull(bm, input=bm.verts[:], use_existing_faces=False) + # Punkte im Inneren und uebrig gebliebene Geometrie gehoeren nicht zur Huelle. + weg = list(set(res.get("geom_interior", []) + res.get("geom_unused", []))) + if weg: + bmesh.ops.delete(bm, geom=weg, context='VERTS') + bm.verts.ensure_lookup_table() + bm.normal_update() + + # Alle Quellpunkte merken - die Huelle muss sie danach noch umschliessen. + quelle = [v.co.copy() for v in bm.verts] + + # Aufs Budget eindampfen. Jede Vereinfachung schneidet zwangslaeufig Ecken ab, + # die Huelle wird dadurch KLEINER als der Stein - eine Kollision, die kleiner + # ist als das Mesh, laesst den Spieler einsinken. Deshalb wird sie danach + # exakt so weit aufgeblasen, dass wieder alles hineinpasst: fuer Kollision ist + # minimal zu gross unkritisch, zu klein nicht. + angle = math.radians(1.0) + while len(bm.verts) > max_verts and angle < math.radians(60.0): + bmesh.ops.dissolve_limit( + bm, angle_limit=angle, verts=bm.verts[:], edges=bm.edges[:]) + bmesh.ops.delete( + bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS') + bm.verts.ensure_lookup_table() + if not bm.faces: + break + angle += math.radians(3.0) + bm.normal_update() + + if bm.faces and quelle: + mitte = Vector((0.0, 0.0, 0.0)) + for v in bm.verts: + mitte += v.co + mitte /= max(len(bm.verts), 1) + # Kleinster Faktor, mit dem jede Ebene alle Quellpunkte einschliesst. + faktor = 1.0 + for f in bm.faces: + n = f.normal + if n.length_squared < 1e-12: + continue + d = n.dot(f.verts[0].co) + basis = d - n.dot(mitte) + if basis <= 1e-9: + continue + for co in quelle: + noetig = (n.dot(co) - n.dot(mitte)) / basis + if noetig > faktor: + faktor = noetig + if faktor > 1.0: + for v in bm.verts: + v.co = mitte + (v.co - mitte) * faktor + + bmesh.ops.triangulate(bm, faces=bm.faces[:]) + + me = bpy.data.meshes.new("UCX_" + mesh_name) + bm.to_mesh(me) + bm.free() + ob = bpy.data.objects.new("UCX_" + mesh_name, me) + context.scene.collection.objects.link(ob) + return ob + + +def _prepare_export_copies(context, top, lay_flat=True, want_ucx=True, + ucx_max_verts=32): + """Baut eine export-fertige Kopie der Hierarchie. Gibt (objekte, aufraeumen). + + Auf KOPIEN, damit die Szene des Users unangetastet bleibt - Punkt 1 und 2 + veraendern die Geometrie ja dauerhaft. + """ + made = [] + renamed = [] # (Original, urspruenglicher Name) zum Zuruecksetzen + # JETZT merken: gleich traegt das Original einen Hilfsnamen, und die + # Kollisionshuelle haette sonst "UCX_.__src" geheissen. + export_name = _safe_filename(top.name) + + def _copy(src): + # FALLE: Blender vergibt Namen eindeutig - eine Kopie von "Rock_A_S_01" + # heisst "Rock_A_S_01.001". Unreal ordnet die Kollision aber ueber den + # EXAKTEN Mesh-Namen zu (UCX_), und der Asset-Name haengt auch + # daran. Deshalb bekommt das Original fuer die Dauer des Exports einen + # Hilfsnamen und die Kopie den echten. + name = src.name + src.name = name + ".__src" + renamed.append((src, name)) + cp = src.copy() + if src.data is not None: + cp.data = src.data.copy() + cp.data.name = name + cp.name = name + context.scene.collection.objects.link(cp) + made.append(cp) + return cp + + src_children = [c for c in top.children_recursive if c.type == 'MESH'] + top_copy = _copy(top) + child_copies = [] + for c in src_children: + cc = _copy(c) + cc.parent = top_copy + cc.matrix_parent_inverse = c.matrix_parent_inverse.copy() + child_copies.append(cc) + + # FALLE: matrix_world wird faul berechnet. Direkt nach dem Setzen von parent + # und matrix_parent_inverse liefert es noch alte Werte - bei LOD-Gruppen wurde + # dadurch die Gruppenposition (13/-7.5/2.2) NICHT herausgerechnet und landete + # im Asset. Ein view_layer-Update erzwingt die Neuberechnung. + context.view_layer.update() + + meshes = [o for o in ([top_copy] + child_copies) if o.type == 'MESH'] + + # Weltmatrizen EINSAMMELN, bevor irgendetwas genullt wird - jedes Nullen + # veraendert die Matrizen der Kinder. + welt = {o: o.matrix_world.copy() for o in meshes} + + # 2) Hinlegen: EINE Drehung fuer alle LOD-Stufen desselben Steins, sonst + # liegen die Stufen unterschiedlich herum. + extra = None + if lay_flat and meshes: + ref = meshes[0] + co = [welt[ref] @ v.co for v in ref.data.vertices] + if co: + dims = [max(c[i] for c in co) - min(c[i] for c in co) for i in range(3)] + extra = _lay_flat_matrix(dims) + + # 1) Transform backen -> alle Objekte stehen auf Identitaet, das Mesh traegt alles. + for o in meshes: + _bake_transform(o, welt[o], extra) + if top_copy.type != 'MESH': + top_copy.matrix_basis = Matrix.Identity(4) + top_copy.matrix_parent_inverse = Matrix.Identity(4) + context.view_layer.update() + + # 1) Ursprung auf unten-mittig der gemeinsamen Bbox. + lo, hi = _bbox_of(meshes) + if lo is not None: + shift = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z)) + off = Matrix.Translation(shift) + for o in meshes: + o.data.transform(off) + + # 3) UCX. Bei einer LOD-Gruppe darf die Huelle NICHT unter das LodGroup-Empty: + # Unreal wuerde jedes Kind als LOD-Stufe lesen. Also als Geschwister. + if want_ucx and meshes: + ucx = _make_ucx(context, [meshes[0]], export_name, + max_verts=ucx_max_verts) + if ucx is not None: + made.append(ucx) + + def cleanup(): + for o in reversed(made): + data = o.data + bpy.data.objects.remove(o, do_unlink=True) + if data is not None and data.users == 0: + if isinstance(data, bpy.types.Mesh): + bpy.data.meshes.remove(data) + # Erst die Kopien weg, DANN die Namen zurueck - sonst kollidieren sie. + for ob, name in renamed: + ob.name = name + + return made, cleanup + + def _collect_export_targets(context, settings): """Top-Level-Rocks: LOD-Group-Empties + freistehende Rock-Meshes ohne Parent.""" tops = [] @@ -1188,12 +1458,30 @@ class OBJECT_OT_export_rocks_fbx(Operator): _ensure_object_mode(context) exported, failed = 0, 0 for top in targets: - objs = list(top.children_recursive) + [top] + # Dateiname VOR dem Kopieren merken: waehrend des Exports traegt das + # Original einen Hilfsnamen (siehe _prepare_export_copies). + filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx") + + # Punkt 1-3: Ursprung erzwingen, flache Steine hinlegen, UCX bauen. + # Laeuft auf Kopien, die Szene des Users bleibt unveraendert. + try: + objs, cleanup = _prepare_export_copies( + context, top, + lay_flat=settings.export_lay_flat, + want_ucx=settings.export_ucx, + ucx_max_verts=settings.ucx_max_verts, + ) + except Exception as exc: # ein Stein darf den Batch nicht killen + failed += 1 + print("[RockGen] Export-Vorbereitung '%s' fehlgeschlagen: %s" + % (top.name, exc)) + traceback.print_exc() + continue + _deselect_all(context) for o in objs: o.select_set(True) - context.view_layer.objects.active = top - filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx") + context.view_layer.objects.active = objs[0] try: bpy.ops.export_scene.fbx( filepath=filepath, @@ -1212,7 +1500,12 @@ class OBJECT_OT_export_rocks_fbx(Operator): exported += 1 except RuntimeError as exc: failed += 1 - print("[RockGen] FBX-Export '%s' fehlgeschlagen: %s" % (top.name, exc)) + print("[RockGen] FBX-Export '%s' fehlgeschlagen: %s" + % (os.path.basename(filepath), exc)) + finally: + # MUSS auch bei Fehlern laufen: sonst bleiben die Kopien in der + # Szene stehen und die Originale behalten ihren Hilfsnamen. + cleanup() if failed: self.report({'WARNING'}, "%d FBX exportiert, %d fehlgeschlagen (Konsole)." % (exported, failed)) @@ -1938,6 +2231,14 @@ class VIEW3D_PT_rock_export(_RockSub): s = context.scene.rock_gen_settings layout.prop(s, "export_dir") layout.prop(s, "export_selected_only") + layout.separator() + layout.prop(s, "export_lay_flat") + layout.prop(s, "export_ucx") + row = layout.row() + row.active = s.export_ucx + row.prop(s, "ucx_max_verts") + layout.label(text="Ursprung unten-mittig: immer", icon='CHECKMARK') + layout.separator() layout.operator("object.export_rocks_fbx", icon='EXPORT') diff --git a/tests/test_export_robust.py b/tests/test_export_robust.py new file mode 100644 index 0000000..6745731 --- /dev/null +++ b/tests/test_export_robust.py @@ -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")