Tree Gen 1.42.0: UCX-Stammhuelle im Spielfertig-Export

Die Stand-Baeume bekommen eine konvexe Kollisionshuelle um den STAMM, vom Fuss
bis zum Kronenansatz - keine Kronen-Huelle: gegen die liefe der Spieler, bevor
er den Baum beruehrt.

Die Trennung kommt aus ast_id (0 = Stamm), dasselbe Attribut wie beim Ursprung
und bei den Faell-Teilen. Kronenansatz = tiefster Punkt mit ast_id > 0 plus ein
Zehntel Stammhoehe Luft (der tiefste Astpunkt kann eine herabgebogene Spitze
sein). Ohne Aeste ist die ganze Pflanze Stamm - beim Kaktus genau richtig.

Name und Hierarchie sind NICHT geraten, sondern am Fels-Export nachgemessen,
der in der Pipeline schon abgenommen ist: UCX_<LODGroup-Name>, als GESCHWISTER
des LodGroup-Empty. Ein Kind der Gruppe wuerde Unreal als LOD-Stufe lesen.

Zwei Befunde aus der Messung:
- Die Huelle rutschte 4-6 cm UNTER den Boden. Das Vereinfachen auf 12 Verts
  schneidet Ecken ab, das anschliessende Aufblasen skaliert um die Huellenmitte
  und schiebt dabei die Unterseite nach unten. Im Spiel waere das eine
  Kollision, die aus dem Terrain ragt. Boden wird jetzt auf Z = 0 geklemmt.
- Der erste Anlauf des Tests holte die FBX-Skala aus der Gesamthoehe - die
  Blatt-Cards ragen ueber die Rinde hinaus, die Skala kam zu gross heraus und
  die Huelle sah zu klein aus. Massstab kommt jetzt aus Material-Slot 0.

Neu: tests/test_stamm_ucx.py, vier Faelle (gerader Stamm, krummer Stamm mit
Bend 1.5, Obstbaum, einmal ohne LODs), gemessen im reimportierten FBX.
Gegenprobe ohne Boden-Klemmung: vier Fehler.

test_baum_export, test_baum_lod und test_ursprung zaehlten die Huelle als
Render-Mesh mit und schlugen zu Recht an - sie filtern jetzt UCX_.

Schalter im Export-Panel: "UCX-Stammhuelle" (an) + max. Verts (12).

27 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 06:18:29 +02:00
co-authored by Claude Opus 4.8
parent 263b8507d8
commit ae7806e082
11 changed files with 369 additions and 17 deletions
+48
View File
@@ -563,6 +563,54 @@ Der Kaktus erzeugt regulaer **0 Cards** — kein Ast ist duenner als `Max Radius
und das ist botanisch richtig. Vorher lief das still durch und man stand vor und das ist botanisch richtig. Vorher lief das still durch und man stand vor
einer leeren `_Leaf`-Ebene; jetzt sagt der Operator es und nennt den Regler. einer leeren `_Leaf`-Ebene; jetzt sagt der Operator es und nennt den Regler.
### Stamm-UCX fuer den Stand-Baum
Die Stand-Baeume bekommen eine konvexe Kollisionshuelle um den **Stamm**, vom
Fuss bis zum Kronenansatz. Bewusst keine Kronen-Huelle: gegen die liefe der
Spieler, bevor er den Baum ueberhaupt beruehrt.
Die Trennung kommt aus `ast_id` (0 = Stamm) - dasselbe Attribut wie beim
Ursprung und bei den Faell-Teilen. Kronenansatz ist der tiefste Punkt mit
ast_id > 0, plus ein Zehntel Stammhoehe Luft, weil der tiefste Astpunkt eine
herabgebogene Spitze sein kann. Ohne Aeste (Kaktus-Setzling) ist die ganze
Pflanze Stamm - und genau die soll dann die Kollision sein.
**Name und Hierarchie sind nicht geraten, sondern vom Fels-Export abgeschaut**,
der in der Spiel-Pipeline schon abgenommen ist: dort exportiert eine LOD-Gruppe
als `UCX_<LODGroup-Name>`, und zwar als GESCHWISTER des LodGroup-Empty, nicht
als Kind - Unreal liest jedes Kind der Gruppe als LOD-Stufe. Nachgemessen im
Fels-FBX, bevor eine Zeile Baum-Code entstand.
Ergebnis im reimportierten Baum-FBX:
```
Eiche_01_LOD0..3 MESH parent = Eiche_01_LODGroup
Eiche_01_LODGroup EMPTY parent = -
UCX_Eiche_01_LODGroup MESH parent = - 12 Verts
```
Zwei Dinge, die erst die Messung gezeigt hat:
* Die Huelle rutschte **4-6 cm unter den Boden**. Das Vereinfachen auf 12 Verts
schneidet Ecken ab, und das anschliessende Aufblasen (damit wieder alles
hineinpasst) skaliert um die Huellenmitte - dabei wandert die Unterseite nach
unten. Im Spiel waere das eine Kollision, die aus dem Terrain herausragt.
Jetzt wird der Boden auf Z = 0 geklemmt.
* Der erste Anlauf des Tests rechnete die FBX-Skala aus der **Gesamthoehe**
zurueck - die Blatt-Cards ragen aber ueber die Rinde hinaus, die Skala kam zu
gross heraus und die Huelle sah zu klein aus. Jetzt kommt der Massstab aus
Material-Slot 0. Ein Massstab, der etwas anderes misst als die Referenz,
verschiebt jede Aussage.
`tests/test_stamm_ucx.py` prueft an vier Faellen (gerader Stamm, krummer Stamm
mit Bend 1.5, Obstbaum, und einmal ohne LODs): genau eine Huelle, richtiger
Name, kein Elternteil, 8-24 Verts, Radius zwischen Stammradius und dem
2.5-fachen davon, Fuss auf Z = 0, Hoehe nicht ueber den Kronenansatz hinaus.
Gegenprobe ohne die Boden-Klemmung: vier Fehler.
Drei bestehende Tests zaehlten die Huelle als Render-Mesh mit und schlugen zu
Recht an - sie filtern jetzt `UCX_`.
### Vom Trichter ueber die Haube zur Kugel ### Vom Trichter ueber die Haube zur Kugel
Zielbild ist der **Lollipop** der stilisierten Vektor-Illustration: kurzer Zielbild ist der **Lollipop** der stilisierten Vektor-Illustration: kurzer
+1 -1
View File
@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "stylized_tree_generator" id = "stylized_tree_generator"
version = "1.41.0" version = "1.42.0"
name = "Stylized Tree Generator" name = "Stylized Tree Generator"
tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen"
maintainer = "D4rkst3r" maintainer = "D4rkst3r"
+3 -3
View File
@@ -50,17 +50,17 @@
<td>34.1KB</td> <td>34.1KB</td>
</tr> </tr>
<tr> <tr>
<td><tt><a href="./stylized_tree_generator-1.41.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.41.0</a></tt></td> <td><tt><a href="./stylized_tree_generator-1.42.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.42.0</a></tt></td>
<td>Stylized Tree Generator</td> <td>Stylized Tree Generator</td>
<td>Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen</td> <td>Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen</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>46.3KB</td> <td>47.6KB</td>
</tr> </tr>
</table> </table>
<center><p>Built 2026-08-06, 13:04</p></center> <center><p>Built 2026-08-07, 04:18</p></center>
</body> </body>
</html> </html>
+4 -4
View File
@@ -82,7 +82,7 @@
"id": "stylized_tree_generator", "id": "stylized_tree_generator",
"name": "Stylized Tree Generator", "name": "Stylized Tree Generator",
"tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen",
"version": "1.41.0", "version": "1.42.0",
"type": "add-on", "type": "add-on",
"maintainer": "D4rkst3r", "maintainer": "D4rkst3r",
"license": [ "license": [
@@ -98,9 +98,9 @@
"Mesh", "Mesh",
"Modeling" "Modeling"
], ],
"archive_url": "./stylized_tree_generator-1.41.0.zip", "archive_url": "./stylized_tree_generator-1.42.0.zip",
"archive_size": 47402, "archive_size": 48711,
"archive_hash": "sha256:c5d5c612fd723af6c1e226ce0ca42cf6d28298641ec5af77bb12223ebd6935c6" "archive_hash": "sha256:e1b5eb8cb83ecabb60d24ce9c6be75be4c3d168aee86b9a3baa2856bdf440e80"
} }
] ]
} }
Binary file not shown.
Binary file not shown.
+104 -6
View File
@@ -1,7 +1,7 @@
bl_info = { bl_info = {
"name": "Stylized Tree Generator", "name": "Stylized Tree Generator",
"author": "D4rkst3r", "author": "D4rkst3r",
"version": (1, 41, 0), "version": (1, 42, 0),
"blender": (4, 2, 0), "blender": (4, 2, 0),
"location": "View3D > Sidebar > Tree Gen", "location": "View3D > Sidebar > Tree Gen",
"description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen",
@@ -2177,17 +2177,25 @@ def _merge_for_export(context, tree_ob, leaf_ob, name, bark_decimate=1.0):
# Faell-Teile: Stammsegmente und Aeste als eigene Physik-Assets # Faell-Teile: Stammsegmente und Aeste als eigene Physik-Assets
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _tree_ucx(quell_objs, name, max_verts=32): def _tree_ucx(quell_objs, name, max_verts=32, punkte=None):
"""Konvexe Huelle als `UCX_<Name>` - gleiches Muster wie bei den Felsen. """Konvexe Huelle als `UCX_<Name>` - gleiches Muster wie bei den Felsen.
Nur die PUNKTE einlesen: mit `from_mesh` kaemen die Originalflaechen mit und Nur die PUNKTE einlesen: mit `from_mesh` kaemen die Originalflaechen mit und
die Huelle laege obendrauf statt darum herum. die Huelle laege obendrauf statt darum herum.
`punkte` uebergeht `quell_objs` und huellt genau die gegebenen Koordinaten -
dafuer, dass die Stand-Baum-Huelle nur den STAMM umschliesst und nicht die
Krone.
""" """
bm = bmesh.new() bm = bmesh.new()
for o in quell_objs: if punkte is not None:
if o.type == 'MESH': for co in punkte:
for v in o.data.vertices: bm.verts.new(co)
bm.verts.new(o.matrix_world @ v.co) else:
for o in quell_objs:
if o.type == 'MESH':
for v in o.data.vertices:
bm.verts.new(o.matrix_world @ v.co)
if len(bm.verts) < 4: if len(bm.verts) < 4:
bm.free() bm.free()
return None return None
@@ -2249,6 +2257,49 @@ def _tree_ucx(quell_objs, name, max_verts=32):
return ob return ob
def _stamm_punkte(tree_obj, matrix):
"""Stamm-Koordinaten vom Fuss bis zum KRONENANSATZ, in Weltkoordinaten.
Fuer die Kollision des Stand-Baums. Bewusst nur der Stamm: eine Huelle um
die ganze Krone waere ein Zylinder von mehreren Metern Durchmesser, gegen
den der Spieler laeuft, bevor er den Baum ueberhaupt beruehrt.
Die Trennung kommt aus `ast_id` (0 = Stamm) - dasselbe Attribut wie beim
Ursprung und bei den Faell-Teilen. Der Kronenansatz ist der tiefste Punkt
mit ast_id > 0; oberhalb davon steckt der Stamm ohnehin in der Krone.
Ohne Aeste (Kaktus-Setzling: `Branch Count 0` schaltet den Ast-Zweig des
Graphen ab, dann gibt es gar kein ast_id) ist die ganze Pflanze Stamm - und
genau die soll dann auch die Kollision sein.
"""
dg = bpy.context.evaluated_depsgraph_get()
ev = tree_obj.evaluated_get(dg)
me = ev.to_mesh()
attr = me.attributes.get("ast_id")
hat_id = (attr is not None and attr.domain == 'POINT'
and len(attr.data) == len(me.vertices))
stamm, aeste = [], []
for i, v in enumerate(me.vertices):
co = matrix @ v.co
if hat_id and attr.data[i].value > 0:
aeste.append(co)
else:
stamm.append(co)
ev.to_mesh_clear()
if not stamm:
return []
if aeste:
# Etwas Luft ueber den Ansatz: der tiefste Astpunkt kann eine
# herabgebogene Spitze sein, dann waere die Huelle zu kurz. Ein Zehntel
# der Stammhoehe reicht und bleibt weit unter der Krone.
zs = [c.z for c in stamm]
grenze = min(aeste, key=lambda c: c.z).z + (max(zs) - min(zs)) * 0.1
gekuerzt = [c for c in stamm if c.z <= grenze]
if len(gekuerzt) >= 8:
return gekuerzt
return stamm
def _mass_center(me): def _mass_center(me):
"""Massenmittelpunkt als flaechengewichteter Schwerpunkt der Faces. """Massenmittelpunkt als flaechengewichteter Schwerpunkt der Faces.
@@ -2485,6 +2536,17 @@ class TreeGenSettings(PropertyGroup):
name="_Frucht-Ebene anlegen", default=False, name="_Frucht-Ebene anlegen", default=False,
description="Zusaetzlich <Name>_Frucht (gleicher Ursprung) fuer die Frucht-Ebene", description="Zusaetzlich <Name>_Frucht (gleicher Ursprung) fuer die Frucht-Ebene",
) )
stand_ucx: BoolProperty(
name="UCX-Stammhuelle", default=True,
description=("Legt dem spielfertigen Baum eine konvexe Huelle um den "
"STAMM (Fuss bis Kronenansatz) ins FBX. Bewusst keine "
"Kronen-Huelle: gegen die liefe der Spieler, bevor er den "
"Baum beruehrt"),
)
stand_ucx_verts: IntProperty(
name="Stammhuelle max. Verts", default=12, min=8, max=32,
description="Ein Stamm braucht keine feine Huelle - 8-12 reichen",
)
piece_trunk_segments: IntProperty( piece_trunk_segments: IntProperty(
name="Stamm-Segmente", default=3, min=2, max=3, name="Stamm-Segmente", default=3, min=2, max=3,
description="In wie viele Stuecke der Stamm beim Faellen zerfaellt", description="In wie viele Stuecke der Stamm beim Faellen zerfaellt",
@@ -2790,6 +2852,33 @@ class TREEGEN_OT_export_game(Operator):
weitere.matrix_parent_inverse = Matrix.Identity(4) weitere.matrix_parent_inverse = Matrix.Identity(4)
weitere.data.transform(versatz) weitere.data.transform(versatz)
# UCX-Stammhuelle. Die Punkte kommen aus dem QUELLBAUM, weil nur
# der noch `ast_id` traegt - die Export-Kopie ist verschmolzen
# und bei LOD1-3 zusaetzlich dezimiert. Sie durchlaufen dieselbe
# Kette wie die Stufen: eigene Weltmatrix, dann die gemeinsame
# Zentrierung `versatz`.
stamm_ucx_name = (basis_name + "_LODGroup") if s.generate_lods \
else basis_name
hull = None
if s.stand_ucx:
pts = _stamm_punkte(baum, baum.matrix_world)
pts = [versatz @ c for c in pts]
hull = _tree_ucx(None, stamm_ucx_name,
max_verts=s.stand_ucx_verts, punkte=pts)
if hull is not None:
# Der Boden der Huelle MUSS auf Z = 0 liegen. Das
# Vereinfachen schneidet Ecken ab, und das anschliessende
# Aufblasen (damit wieder alles hineinpasst) skaliert um
# die Huellenmitte - dabei rutschte die Unterseite
# gemessen 4-6 cm unter den Boden. Im Spiel waere das
# eine Kollision, die aus dem Terrain herausragt.
for v in hull.data.vertices:
if v.co.z < 0.0:
v.co.z = 0.0
aufraeumen.append(hull.name)
print("[TreeGen] %s: Stamm-UCX mit %d Verts"
% (hull.name, len(hull.data.vertices)))
_deselect(context) _deselect(context)
if s.generate_lods: if s.generate_lods:
gruppe = bpy.data.objects.new(basis_name + "_LODGroup", None) gruppe = bpy.data.objects.new(basis_name + "_LODGroup", None)
@@ -2807,6 +2896,11 @@ class TREEGEN_OT_export_game(Operator):
else: else:
lods[0].select_set(True) lods[0].select_set(True)
context.view_layer.objects.active = lods[0] context.view_layer.objects.active = lods[0]
# Die Huelle wird MITEXPORTIERT, aber NICHT unter das
# LodGroup-Empty gehaengt: Unreal liest jedes Kind der Gruppe
# als LOD-Stufe. Geschwister, so wie es der Fels-Export macht.
if hull is not None:
hull.select_set(True)
bpy.ops.export_scene.fbx( bpy.ops.export_scene.fbx(
filepath=ziel, filepath=ziel,
@@ -3339,6 +3433,10 @@ class VIEW3D_PT_tree_export(_TreeSub):
layout.separator() layout.separator()
layout.prop(s, "sphere_normals") layout.prop(s, "sphere_normals")
layout.prop(s, "generate_lods") layout.prop(s, "generate_lods")
layout.prop(s, "stand_ucx")
row = layout.row()
row.active = s.stand_ucx
row.prop(s, "stand_ucx_verts")
layout.prop(s, "export_dir") layout.prop(s, "export_dir")
layout.operator("treegen.export_game", icon='EXPORT') layout.operator("treegen.export_game", icon='EXPORT')
+3 -1
View File
@@ -94,7 +94,9 @@ if not dateien:
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, dateien[0])) bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, dateien[0]))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] # Die UCX-Stammhuelle ist KEIN Render-Mesh - sie gehoert nicht in diese Zaehlung.
meshes = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and not o.name.startswith("UCX_")]
print("") print("")
print("FBX %s: %d Mesh(es)" % (dateien[0], len(meshes))) print("FBX %s: %d Mesh(es)" % (dateien[0], len(meshes)))
if len(meshes) != 1: if len(meshes) != 1:
+3 -1
View File
@@ -96,7 +96,9 @@ if not lodfile:
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, lodfile[0])) bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, lodfile[0]))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] # Die UCX-Stammhuelle ist KEIN Render-Mesh - sie gehoert nicht in diese Zaehlung.
meshes = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and not o.name.startswith("UCX_")]
empties = [o for o in bpy.context.scene.objects if o.type == 'EMPTY'] empties = [o for o in bpy.context.scene.objects if o.type == 'EMPTY']
stufen = sorted(meshes, key=lambda o: o.name) stufen = sorted(meshes, key=lambda o: o.name)
print("") print("")
+200
View File
@@ -0,0 +1,200 @@
"""Stamm-UCX fuer den spielfertigen Stand-Baum.
Die Stand-Baeume brauchen Kollision. Gefordert ist EIN konvexer Stamm-Zylinder
vom Fuss bis zum Kronenansatz - KEINE Kronen-Huelle: gegen die liefe der
Spieler, bevor er den Baum ueberhaupt beruehrt.
Die Namens- und Hierarchie-Konvention ist nicht geraten, sondern vom
Fels-Export abgeschaut, der in der Spiel-Pipeline bereits abgenommen ist:
`UCX_<LODGroup-Name>`, und zwar als GESCHWISTER der LodGroup, nicht als Kind -
Unreal liest jedes Kind der Gruppe als LOD-Stufe.
Gemessen wird im reimportierten FBX.
Aufruf: blender --background --factory-startup --python tests/test_stamm_ucx.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(
"tg", os.path.join(ROOT, "stylized_tree_generator.py"))
tg = importlib.util.module_from_spec(spec)
sys.modules["tg"] = tg
spec.loader.exec_module(tg)
tg.register()
fails = []
OUT = tempfile.mkdtemp()
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
me = bpy.data.meshes.new("Leaf_Card")
me.from_pydata([(-0.5, 0, 0), (0, 0, 0.7), (0.5, 0, 0), (0, 0, -0.1)],
[], [(0, 1, 2), (0, 2, 3)])
me.update()
card = bpy.data.objects.new("Leaf_Card", me)
bpy.context.scene.collection.objects.link(card)
s = bpy.context.scene.tree_gen_settings
s.apply_modifier = True
s.leaf_card = card
s.use_growth = False
s.stand_ucx = True
# Presets mit verschiedenen Stamm-Formen: gerade, krumm (Bend 1.5), ohne Aeste
# am Setzling, und der Obstbaum als Produktionsfall.
FAELLE = (("eiche", True), ("toter_baum", True), ("obstbaum", True),
("eiche", False))
# Erst ALLE Faelle exportieren, dann reimportieren. `read_factory_settings`
# raeumt die Szene komplett aus - Blatt-Card und Einstellungen inklusive -,
# ein Reimport mitten in der Schleife killt also den naechsten Durchlauf.
ergebnisse = []
for preset, mit_lods in FAELLE:
for o in list(bpy.context.scene.objects):
if o is not card:
bpy.data.objects.remove(o, do_unlink=True)
ziel_dir = os.path.join(OUT, "%s_%d" % (preset, int(mit_lods)))
os.makedirs(ziel_dir, exist_ok=True)
s.preset = preset
s.art_name = preset.capitalize()
s.variant = 1
s.seed = 1
s.generate_lods = mit_lods
s.export_dir = ziel_dir
bpy.ops.object.treegen_create()
baum = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and o is not card
and not o.name.endswith(("_Leaf", "_Frucht"))][0]
# BEWUSST nicht am Ursprung - eine Huelle, die die Weltmatrix mitschleppt,
# faellt bei (0,0,0) nicht auf.
baum.location = (9.0, -5.0, 0.0)
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
bpy.ops.object.treegen_leaves()
bl = bpy.data.objects.get(baum.name + "_Leaf")
if bl is not None:
bl.location = baum.location
# Kronenansatz und Stammradius am Quellbaum, als Sollwerte.
dg = bpy.context.evaluated_depsgraph_get()
ev = baum.evaluated_get(dg)
m = ev.to_mesh()
attr = m.attributes.get("ast_id")
vs = [v.co.copy() for v in m.vertices]
aid = [attr.data[i].value for i in range(len(vs))] if attr else [0] * len(vs)
ev.to_mesh_clear()
z0 = min(v.z for v in vs)
astz = [v.z for v, i in zip(vs, aid) if i > 0]
ansatz = (min(astz) - z0) if astz else (max(v.z for v in vs) - z0)
stamm_r = max((math.hypot(v.x, v.y) for v, i in zip(vs, aid)
if i == 0 and v.z - z0 <= ansatz), default=0.0)
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
bpy.ops.treegen.export_game()
dateien = sorted(f for f in os.listdir(ziel_dir)
if f.lower().endswith(".fbx"))
if not dateien:
fails.append("%s: kein FBX geschrieben" % preset)
continue
ergebnisse.append((preset, mit_lods, os.path.join(ziel_dir, dateien[0]),
stamm_r, ansatz, max(v.z for v in vs) - z0,
s.stand_ucx_verts))
print("%-12s %-5s %6s %8s %8s %8s %8s"
% ("Preset", "LODs", "Verts", "UCX-R", "Stamm-R", "UCX-Z", "Ansatz-Z"))
for preset, mit_lods, pfad, stamm_r, ansatz, blender_h, max_verts in ergebnisse:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=pfad)
ucx = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and o.name.startswith("UCX_")]
rest = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and not o.name.startswith("UCX_")]
gruppe = [o for o in bpy.context.scene.objects if o.type == 'EMPTY']
if len(ucx) != 1:
fails.append("%s (LODs %s): %d UCX-Meshes im FBX, erwartet genau eines"
% (preset, mit_lods, len(ucx)))
continue
u = ucx[0]
# Name: exakt die Konvention des Fels-Exports.
erwartet = ("UCX_" + gruppe[0].name) if gruppe else ("UCX_" + rest[0].name)
if u.name != erwartet:
fails.append("%s: Huelle heisst '%s', erwartet '%s' - Unreal ordnet "
"die Kollision ueber den Namen zu"
% (preset, u.name, erwartet))
# Hierarchie: NICHT unter der LodGroup, sonst waere sie eine LOD-Stufe.
if u.parent is not None:
fails.append("%s: Huelle haengt unter '%s' - Unreal liest jedes Kind "
"der LodGroup als LOD-Stufe" % (preset, u.parent.name))
co = [u.matrix_world @ v.co for v in u.data.vertices]
u_r = max(math.hypot(c.x, c.y) for c in co)
u_z0 = min(c.z for c in co)
u_z1 = max(c.z for c in co)
n = len(u.data.vertices)
# Skala aus dem FBX zurueckrechnen, damit die Vergleiche unabhaengig von
# FBX_SCALE_ALL sind. NUR ueber die RINDE (Material-Slot 0): der erste
# Anlauf nahm die Gesamthoehe inklusive Blatt-Cards, und die ragen ueber die
# Rinde hinaus - die Skala kam dadurch zu gross heraus und die Huelle sah
# zu klein aus. Ein Massstab, der etwas anderes misst als die Referenz,
# verschiebt jede Aussage.
lod0 = max(rest, key=lambda o: len(o.data.vertices))
rinde = [lod0.matrix_world @ lod0.data.vertices[vi].co
for p in lod0.data.polygons if p.material_index == 0
for vi in p.vertices]
fbx_h = (max(c.z for c in rinde) - min(c.z for c in rinde)) if rinde else 1.0
skala = fbx_h / blender_h if blender_h else 1.0
print("%-12s %-5s %6d %8.3f %8.3f %8.3f %8.3f"
% (preset, mit_lods, n, u_r / skala, stamm_r,
(u_z1 - u_z0) / skala, ansatz))
if not (8 <= n <= max_verts * 2):
fails.append("%s: Huelle hat %d Verts, erwartet 8-%d"
% (preset, n, max_verts * 2))
# DIE Bedingung: Stamm umschlossen, Krone NICHT. Der Stammradius liegt bei
# wenigen Zentimetern, die Krone bei ein bis zwei Metern - dazwischen ist
# so viel Luft, dass die Schranke nicht knapp sein muss.
if u_r / skala < stamm_r * 0.95:
fails.append("%s: Huellenradius %.3f m unter dem Stammradius %.3f - "
"der Spieler wuerde in den Stamm laufen"
% (preset, u_r / skala, stamm_r))
if u_r / skala > max(stamm_r * 2.5, stamm_r + 0.15):
fails.append("%s: Huellenradius %.3f m gegen Stammradius %.3f - das "
"ist keine Stammhuelle mehr"
% (preset, u_r / skala, stamm_r))
# Fuss auf Z = 0 und nicht in die Krone hinaufreichend.
if abs(u_z0 / skala) > 0.02:
fails.append("%s: Huelle beginnt bei Z %.3f statt 0" % (preset, u_z0 / skala))
if (u_z1 - u_z0) / skala > ansatz * 1.35 + 0.05:
fails.append("%s: Huelle ist %.2f m hoch, Kronenansatz liegt bei "
"%.2f m - sie reicht in die Krone"
% (preset, (u_z1 - u_z0) / skala, ansatz))
print("")
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for f in fails:
print(" - " + f)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")
+3 -1
View File
@@ -146,7 +146,9 @@ print("%-12s %3s %9s %11s %11s %10s"
for preset, stufe, pfad, r_rel in ergebnisse: for preset, stufe, pfad, r_rel in ergebnisse:
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=pfad) bpy.ops.import_scene.fbx(filepath=pfad)
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] # Die UCX-Stammhuelle ist KEIN Render-Mesh - sie gehoert nicht in diese Zaehlung.
meshes = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and not o.name.startswith("UCX_")]
if not meshes: if not meshes:
fails.append("%s S%d: kein Mesh im FBX" % (preset, stufe)) fails.append("%s S%d: kein Mesh im FBX" % (preset, stufe))
continue continue