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:
@@ -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
|
||||
|
||||
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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+3
-3
@@ -40,14 +40,14 @@
|
||||
<td>4.9KB</td>
|
||||
</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>Batch-Generator fuer stylized Rocks mit UE5-Export</td>
|
||||
<td><a href="https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator">link</a></td>
|
||||
<td>4.2.0 - ~</td>
|
||||
<td>all</td>
|
||||
<td>all</td>
|
||||
<td>21.7KB</td>
|
||||
<td>26.1KB</td>
|
||||
</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>
|
||||
@@ -61,6 +61,6 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<center><p>Built 2026-08-01, 20:46</p></center>
|
||||
<center><p>Built 2026-08-03, 06:02</p></center>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+4
-4
@@ -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",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+306
-5
@@ -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_<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 ---
|
||||
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_<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):
|
||||
"""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')
|
||||
|
||||
|
||||
|
||||
@@ -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