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:
2026-08-03 08:02:14 +02:00
co-authored by Claude Opus 4.8
parent 26131f9346
commit 20a751edca
8 changed files with 619 additions and 13 deletions
+61
View File
@@ -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_<Name>` 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 ## Stylized Asset Utils
Fuer alles, was **von Hand** gebaut wird (Pilze, Baumstumpf, Erzfels, Props) und Fuer alles, was **von Hand** gebaut wird (Pilze, Baumstumpf, Erzfels, Props) und
@@ -391,6 +451,7 @@ gefiltert `.\run_tests.ps1 -Filter panels`.
| Datei | prueft | | Datei | prueft |
|---|---| |---|---|
| `tests/test_proportionen.py` | Baum-Proportionen: Palmenkrone, Strauch-Breite, Setzling-Schlankheit, Branch Count 0 | | `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_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_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 | | `tests/test_ecogame_rock.py` | Fels-Benennung, Bake liefert nur `_albedo`/`_normal`, AO im Albedo |
+1 -1
View File
@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "stylized_rock_generator" id = "stylized_rock_generator"
version = "2.13.0" version = "2.14.0"
name = "Stylized Rock Generator" name = "Stylized Rock Generator"
tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export"
maintainer = "D4rkst3r" maintainer = "D4rkst3r"
+3 -3
View File
@@ -40,14 +40,14 @@
<td>4.9KB</td> <td>4.9KB</td>
</tr> </tr>
<tr> <tr>
<td><tt><a href="./stylized_rock_generator-2.13.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_rock_generator-2.13.0</a></tt></td> <td><tt><a href="./stylized_rock_generator-2.14.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_rock_generator-2.14.0</a></tt></td>
<td>Stylized Rock Generator</td> <td>Stylized Rock Generator</td>
<td>Batch-Generator fuer stylized Rocks mit UE5-Export</td> <td>Batch-Generator fuer stylized Rocks mit UE5-Export</td>
<td><a href="https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator">link</a></td> <td><a href="https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator">link</a></td>
<td>4.2.0 - ~</td> <td>4.2.0 - ~</td>
<td>all</td> <td>all</td>
<td>all</td> <td>all</td>
<td>21.7KB</td> <td>26.1KB</td>
</tr> </tr>
<tr> <tr>
<td><tt><a href="./stylized_tree_generator-1.25.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.25.0</a></tt></td> <td><tt><a href="./stylized_tree_generator-1.25.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.25.0</a></tt></td>
@@ -61,6 +61,6 @@
</tr> </tr>
</table> </table>
<center><p>Built 2026-08-01, 20:46</p></center> <center><p>Built 2026-08-03, 06:02</p></center>
</body> </body>
</html> </html>
+4 -4
View File
@@ -57,7 +57,7 @@
"id": "stylized_rock_generator", "id": "stylized_rock_generator",
"name": "Stylized Rock Generator", "name": "Stylized Rock Generator",
"tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export",
"version": "2.13.0", "version": "2.14.0",
"type": "add-on", "type": "add-on",
"maintainer": "D4rkst3r", "maintainer": "D4rkst3r",
"license": [ "license": [
@@ -73,9 +73,9 @@
"Mesh", "Mesh",
"Modeling" "Modeling"
], ],
"archive_url": "./stylized_rock_generator-2.13.0.zip", "archive_url": "./stylized_rock_generator-2.14.0.zip",
"archive_size": 22263, "archive_size": 26683,
"archive_hash": "sha256:5ae39f5035435ae51803f63e1a0d06b67d8c68a5477e9fdb663a4a21c65d2492" "archive_hash": "sha256:f60aa4d5c8d73cabfccbccf19020ea30d31e77ad22dca5f8a7151e46c7ec1c42"
}, },
{ {
"schema_version": "1.0.0", "schema_version": "1.0.0",
Binary file not shown.
Binary file not shown.
+306 -5
View File
@@ -1,7 +1,7 @@
bl_info = { bl_info = {
"name": "Stylized Rock Generator", "name": "Stylized Rock Generator",
"author": "D4rkst3r", "author": "D4rkst3r",
"version": (2, 13, 0), "version": (2, 14, 0),
"blender": (4, 2, 0), "blender": (4, 2, 0),
"location": "View3D > Sidebar > Rock Gen", "location": "View3D > Sidebar > Rock Gen",
"description": ( "description": (
@@ -13,6 +13,7 @@ bl_info = {
} }
import bpy import bpy
import bmesh
import os import os
import json import json
import math import math
@@ -24,6 +25,7 @@ from bpy.props import (
StringProperty, PointerProperty, StringProperty, PointerProperty,
) )
from bpy.types import Operator, Panel, PropertyGroup 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) lod0 = _build_rock_mesh(context, settings, index, location, rng, name=base_name)
_apply_hardsurface(context, lod0, settings) _apply_hardsurface(context, lod0, settings)
_apply_random_transform(context, lod0, settings, rng) _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: if settings.assign_material:
_assign_material(lod0, index) _assign_material(lod0, index)
lod0.name = base_name + "_LOD0" lod0.name = base_name + "_LOD0"
@@ -1032,6 +1044,25 @@ class RockGenSettings(PropertyGroup):
name="Nur Auswahl exportieren", default=False, name="Nur Auswahl exportieren", default=False,
description="Exportiert nur die aktuell selektierten Rocks statt aller", 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_<Name>' 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 --- # --- Zufall ---
random_amount: FloatProperty( random_amount: FloatProperty(
@@ -1143,6 +1174,245 @@ class OBJECT_OT_generate_rocks(Operator):
# FBX-Export (UE-ready) # 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_<Name>` - 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_<Name>.__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_<Name>), 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): def _collect_export_targets(context, settings):
"""Top-Level-Rocks: LOD-Group-Empties + freistehende Rock-Meshes ohne Parent.""" """Top-Level-Rocks: LOD-Group-Empties + freistehende Rock-Meshes ohne Parent."""
tops = [] tops = []
@@ -1188,12 +1458,30 @@ class OBJECT_OT_export_rocks_fbx(Operator):
_ensure_object_mode(context) _ensure_object_mode(context)
exported, failed = 0, 0 exported, failed = 0, 0
for top in targets: 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) _deselect_all(context)
for o in objs: for o in objs:
o.select_set(True) o.select_set(True)
context.view_layer.objects.active = top context.view_layer.objects.active = objs[0]
filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx")
try: try:
bpy.ops.export_scene.fbx( bpy.ops.export_scene.fbx(
filepath=filepath, filepath=filepath,
@@ -1212,7 +1500,12 @@ class OBJECT_OT_export_rocks_fbx(Operator):
exported += 1 exported += 1
except RuntimeError as exc: except RuntimeError as exc:
failed += 1 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: if failed:
self.report({'WARNING'}, "%d FBX exportiert, %d fehlgeschlagen (Konsole)." % (exported, 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 s = context.scene.rock_gen_settings
layout.prop(s, "export_dir") layout.prop(s, "export_dir")
layout.prop(s, "export_selected_only") 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') layout.operator("object.export_rocks_fbx", icon='EXPORT')
+244
View File
@@ -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")