Tree Gen 1.26.0: spielfertiger Stand-Baum + Kugel-Normalen (Punkt 1 + 2)
Punkt 1 - "Spielfertig exportieren" Stamm und _Leaf-Ebene werden zu EINEM Mesh mit zwei Material-Slots verschmolzen (Slot 0 = M_Bark, Slot 1 = M_Foliage). Die getrennten Objekte im Blend bleiben die Arbeitsebene - verschmolzen wird nur eine Export-Kopie. Ursprung Bbox-Unterseite Mitte und FBX-Einstellungen wie in der Fels-Pipeline (use_selection, FBX_SCALE_ALL). Punkt 2 - Kugel-Normalen auf die Blatt-Cards Alle Blatt-Normalen zeigen radial vom Kronen-Schwerpunkt nach aussen, damit die Krone als EIN weiches Volumen liest statt als Haufen flacher Bleche. Der Schwerpunkt wird nur aus den BLATT-Faces gebildet - der Stamm wuerde die Mitte nach unten ziehen und die Kugel verkippen. Bewusst direkt ueber normals_split_custom_set statt ueber den Normal-Edit-Modifier: der Modifier muesste vor dem Export angewendet werden, und angewendete Modifier plus Custom Normals ist genau die Kombination, bei der der FBX-Export gerne flachbuegelt. Am REIMPORTIERTEN FBX gemessen: mittlere Radialitaet 1.000, 100 % der 510 Blatt-Loops ueber 0.8. Gegenprobe Rinde 0.043 - die bleibt unangetastet. Drei Fallen unterwegs: - Ueber bmesh.from_mesh zusammenfuegen haengt die Geometrie zwar an, wirft aber die MATERIAL-INDIZES weg: danach lagen alle 748 Faces auf Index 0, Blaetter und Rinde ununterscheidbar. Blenders join fuehrt die Slot-Listen zusammen und rechnet die Indizes korrekt um. - join loescht das zweite Objekt; jede gehaltene Referenz darauf ist danach tot. Die Aufraeumliste fuehrt deshalb NAMEN statt Objekte. - Die Export-Kopie muss exakt so heissen wie der Baum (Unreal leitet den Asset-Namen davon ab). Solange das Original den Namen haelt, haengt Blender ".001" an - Original wird fuer die Exportdauer beiseite benannt, wie bei den Felsen. Neu: tests/test_baum_export.py misst die Normalen im EXPORTIERTEN FBX, nicht am Szenenobjekt - nur so faellt ein plattgebuegelter Custom-Normal auf. 19 Tests bestehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "stylized_tree_generator"
|
||||
version = "1.25.0"
|
||||
version = "1.26.0"
|
||||
name = "Stylized Tree Generator"
|
||||
tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen"
|
||||
maintainer = "D4rkst3r"
|
||||
|
||||
Vendored
+3
-3
@@ -50,17 +50,17 @@
|
||||
<td>34.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>
|
||||
<td><tt><a href="./stylized_tree_generator-1.26.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.26.0</a></tt></td>
|
||||
<td>Stylized Tree Generator</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>4.2.0 - ~</td>
|
||||
<td>all</td>
|
||||
<td>all</td>
|
||||
<td>24.1KB</td>
|
||||
<td>27.6KB</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<center><p>Built 2026-08-03, 12:20</p></center>
|
||||
<center><p>Built 2026-08-06, 04:42</p></center>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+4
-4
@@ -82,7 +82,7 @@
|
||||
"id": "stylized_tree_generator",
|
||||
"name": "Stylized Tree Generator",
|
||||
"tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "D4rkst3r",
|
||||
"license": [
|
||||
@@ -98,9 +98,9 @@
|
||||
"Mesh",
|
||||
"Modeling"
|
||||
],
|
||||
"archive_url": "./stylized_tree_generator-1.25.0.zip",
|
||||
"archive_size": 24729,
|
||||
"archive_hash": "sha256:51006e9a2968cfce6dcb7ce13888a5e410b25119916c300013a7105ee83f78e5"
|
||||
"archive_url": "./stylized_tree_generator-1.26.0.zip",
|
||||
"archive_size": 28299,
|
||||
"archive_hash": "sha256:8e6cf3131a36efe840d0f59802003a3cb198b611a405c53e6428f2a99029b0c9"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+287
-1
@@ -1,7 +1,7 @@
|
||||
bl_info = {
|
||||
"name": "Stylized Tree Generator",
|
||||
"author": "D4rkst3r",
|
||||
"version": (1, 25, 0),
|
||||
"version": (1, 26, 0),
|
||||
"blender": (4, 2, 0),
|
||||
"location": "View3D > Sidebar > Tree Gen",
|
||||
"description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen",
|
||||
@@ -18,6 +18,9 @@ bl_info = {
|
||||
# im Viewport ziehen, der Baum aktualisiert sich sofort.
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import os
|
||||
from mathutils import Matrix, Vector
|
||||
import sys
|
||||
import os
|
||||
|
||||
@@ -1311,6 +1314,130 @@ def make_leaves(context, tree_obj, card_obj, name=None, **overrides):
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spielfertiger Export: verschmolzener Stand-Baum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Slot 0 = Rinde, Slot 1 = Blaetter. Die Reihenfolge ist Teil des Vertrags -
|
||||
# die UE-Skripte mappen ueber den Slot-Namen, aber der Index entscheidet, welche
|
||||
# Faces welches Material bekommen.
|
||||
TREE_MATERIALS = ("M_Bark", "M_Foliage")
|
||||
|
||||
|
||||
def _get_or_create_mat(name, color):
|
||||
mat = bpy.data.materials.get(name)
|
||||
if mat is None:
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.diffuse_color = color
|
||||
return mat
|
||||
|
||||
|
||||
def _sphere_normals(ob, slot_index, center=None):
|
||||
"""Setzt die Normalen der Faces in `slot_index` RADIAL von einem Mittelpunkt.
|
||||
|
||||
Der wichtigste Stylized-Trick fuer Blatt-Cards: statt der flachen
|
||||
Card-Normalen (jede Card wirkt wie ein eigenes flaches Blech, die Krone
|
||||
flimmert im Licht) zeigen alle Blatt-Normalen nach aussen wie bei einer
|
||||
Kugel. Die Krone liest sich dadurch als EIN weiches Volumen.
|
||||
|
||||
Bewusst direkt ueber `normals_split_custom_set` statt ueber den
|
||||
Normal-Edit-Modifier: der Modifier muesste vor dem Export angewendet werden,
|
||||
und angewendete Modifier plus Custom Normals ist genau die Kombination, bei
|
||||
der der FBX-Export gerne flachbuegelt. Direkt gesetzte Loop-Normalen gehen
|
||||
unveraendert in die Datei.
|
||||
"""
|
||||
me = ob.data
|
||||
if not me.polygons:
|
||||
return None
|
||||
if center is None:
|
||||
# Schwerpunkt NUR der Blatt-Faces - der Stamm wuerde die Mitte nach
|
||||
# unten ziehen und die Kugel verkippen.
|
||||
pts = [me.vertices[vi].co for p in me.polygons
|
||||
if p.material_index == slot_index for vi in p.vertices]
|
||||
if not pts:
|
||||
return None
|
||||
center = Vector((sum(p.x for p in pts) / len(pts),
|
||||
sum(p.y for p in pts) / len(pts),
|
||||
sum(p.z for p in pts) / len(pts)))
|
||||
|
||||
# Vorhandene Loop-Normalen als Ausgangslage, dann nur die Blatt-Loops ersetzen.
|
||||
me.calc_normals_split() if hasattr(me, "calc_normals_split") else None
|
||||
normalen = [Vector(l.normal) for l in me.loops]
|
||||
for poly in me.polygons:
|
||||
if poly.material_index != slot_index:
|
||||
continue
|
||||
for li in poly.loop_indices:
|
||||
v = me.vertices[me.loops[li].vertex_index].co
|
||||
richtung = v - center
|
||||
if richtung.length_squared < 1e-12:
|
||||
richtung = Vector((0.0, 0.0, 1.0))
|
||||
normalen[li] = richtung.normalized()
|
||||
|
||||
me.normals_split_custom_set([tuple(n) for n in normalen])
|
||||
return center
|
||||
|
||||
|
||||
def _merge_for_export(context, tree_ob, leaf_ob, name):
|
||||
"""Baut EINE Export-Kopie aus Stamm + Blattebene mit zwei Material-Slots.
|
||||
|
||||
Die getrennten Objekte im Blend bleiben unangetastet - das ist die
|
||||
Arbeits-Ebene. Verschmolzen wird nur diese Kopie.
|
||||
"""
|
||||
# Die Aufraeumliste fuehrt NAMEN, keine Objekte: `join` loescht das zweite
|
||||
# Objekt, und danach wirft schon der Zugriff auf `.name` einer gehaltenen
|
||||
# Referenz ("StructRNA of type Object has been removed").
|
||||
made = []
|
||||
|
||||
def _kopie(src):
|
||||
dg = context.evaluated_depsgraph_get()
|
||||
me = bpy.data.meshes.new_from_object(
|
||||
src.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg)
|
||||
ob = bpy.data.objects.new(src.name + "__exp", me)
|
||||
ob.matrix_world = src.matrix_world.copy()
|
||||
context.scene.collection.objects.link(ob)
|
||||
made.append(ob.name)
|
||||
return ob
|
||||
|
||||
stamm = _kopie(tree_ob)
|
||||
blatt = _kopie(leaf_ob) if leaf_ob is not None else None
|
||||
|
||||
rinde = _get_or_create_mat(TREE_MATERIALS[0], (0.29, 0.21, 0.14, 1.0))
|
||||
laub = _get_or_create_mat(TREE_MATERIALS[1], (0.18, 0.34, 0.14, 1.0))
|
||||
|
||||
stamm.data.materials.clear()
|
||||
stamm.data.materials.append(rinde)
|
||||
for p in stamm.data.polygons:
|
||||
p.material_index = 0
|
||||
|
||||
if blatt is not None and blatt.data.polygons:
|
||||
# FALLE: Ueber `bmesh.from_mesh` zusammenzufuegen haengt die Geometrie
|
||||
# zwar an, wirft dabei aber die MATERIAL-INDIZES weg - gemessen lagen
|
||||
# danach alle 748 Faces auf Index 0, Blaetter und Rinde ununterscheidbar.
|
||||
# Blenders `join` macht genau das richtig: es fuehrt die Slot-Listen
|
||||
# zusammen und rechnet die Indizes um. Das Blatt-Mesh traegt nur seinen
|
||||
# eigenen Slot und landet nach dem Join automatisch auf Index 1.
|
||||
blatt.data.materials.clear()
|
||||
blatt.data.materials.append(laub)
|
||||
for p in blatt.data.polygons:
|
||||
p.material_index = 0
|
||||
|
||||
# join LOESCHT das zweite Objekt. Danach ist jede Referenz darauf tot
|
||||
# ("StructRNA of type Object has been removed") - also vorher merken.
|
||||
blatt_name = blatt.name
|
||||
with context.temp_override(active_object=stamm,
|
||||
selected_editable_objects=[stamm, blatt]):
|
||||
bpy.ops.object.join()
|
||||
if blatt_name not in bpy.data.objects:
|
||||
made = [n for n in made if n != blatt_name]
|
||||
|
||||
# Der Stamm wird umbenannt - die Aufraeumliste muss mitziehen.
|
||||
made = [name if n == stamm.name else n for n in made]
|
||||
stamm.name = name
|
||||
stamm.data.name = name
|
||||
return stamm, made
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Addon-UI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1382,6 +1509,17 @@ class TreeGenSettings(PropertyGroup):
|
||||
name="_Frucht-Ebene anlegen", default=False,
|
||||
description="Zusaetzlich <Name>_Frucht (gleicher Ursprung) fuer die Frucht-Ebene",
|
||||
)
|
||||
export_dir: StringProperty(
|
||||
name="Export-Ordner", default="", subtype='DIR_PATH',
|
||||
description="Zielordner fuer die spielfertigen FBX",
|
||||
)
|
||||
sphere_normals: BoolProperty(
|
||||
name="Kugel-Normalen auf Blaetter", default=True,
|
||||
description=("Setzt die Blatt-Normalen radial vom Kronen-Schwerpunkt nach "
|
||||
"aussen. Ohne das wirkt jede Card wie ein eigenes flaches "
|
||||
"Blech und die Krone flimmert im Licht"),
|
||||
)
|
||||
|
||||
apply_modifier: BoolProperty(
|
||||
name="Modifier anwenden", default=False,
|
||||
description="Ergebnis als normales Mesh einfrieren (fuer den Export)",
|
||||
@@ -1447,6 +1585,147 @@ def _add_layer(context, ob, suffix):
|
||||
return new
|
||||
|
||||
|
||||
|
||||
def _safe_name(name):
|
||||
keep = "-_.() "
|
||||
return "".join(c for c in name if c.isalnum() or c in keep).strip() or "Tree"
|
||||
|
||||
|
||||
def _deselect(context):
|
||||
for o in list(context.view_layer.objects):
|
||||
try:
|
||||
o.select_set(False)
|
||||
except (ReferenceError, RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _origin_bottom_center(ob):
|
||||
"""Ursprung auf Bbox-Unterseite Mitte - identisch zur Fels-Pipeline.
|
||||
|
||||
Die Transformation wandert dabei in die Mesh-Daten und das Objekt steht
|
||||
danach auf Identitaet: der FBX-Export schreibt die Objekt-Transformation
|
||||
sonst MIT, und das Asset traegt seine Szenenposition mit ins Spiel.
|
||||
"""
|
||||
me = ob.data
|
||||
me.transform(ob.matrix_world)
|
||||
ob.matrix_basis = Matrix.Identity(4)
|
||||
ob.matrix_parent_inverse = Matrix.Identity(4)
|
||||
if not me.vertices:
|
||||
return
|
||||
xs = [v.co.x for v in me.vertices]
|
||||
ys = [v.co.y for v in me.vertices]
|
||||
zs = [v.co.z for v in me.vertices]
|
||||
versatz = Matrix.Translation((
|
||||
-(min(xs) + max(xs)) * 0.5,
|
||||
-(min(ys) + max(ys)) * 0.5,
|
||||
-min(zs),
|
||||
))
|
||||
me.transform(versatz)
|
||||
|
||||
|
||||
|
||||
class TREEGEN_OT_export_game(Operator):
|
||||
"""Exportiert den ausgewaehlten Baum spielfertig: EIN Mesh, zwei Slots.
|
||||
|
||||
Stamm und _Leaf-Ebene bleiben im Blend getrennt - das ist die Arbeitsebene.
|
||||
Verschmolzen wird nur die Export-Kopie.
|
||||
"""
|
||||
bl_idname = "treegen.export_game"
|
||||
bl_label = "Spielfertig exportieren"
|
||||
bl_description = ("Verschmilzt Stamm + Blattebene zu einem Mesh mit zwei "
|
||||
"Material-Slots, setzt Kugel-Normalen auf die Blaetter "
|
||||
"und schreibt eine FBX")
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
s = context.scene.tree_gen_settings
|
||||
verzeichnis = bpy.path.abspath(s.export_dir) if s.export_dir else ""
|
||||
if not verzeichnis or not os.path.isdir(verzeichnis):
|
||||
self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Baeume = ausgewaehlte Meshes, die KEINE Zusatzebene sind.
|
||||
baeume = [o for o in context.selected_objects
|
||||
if o.type == 'MESH'
|
||||
and not o.name.endswith(("_Leaf", "_Frucht"))]
|
||||
if not baeume:
|
||||
self.report({'ERROR'}, "Keinen Baum ausgewaehlt (Stamm-Objekt waehlen).")
|
||||
return {'CANCELLED'}
|
||||
|
||||
geschrieben, fehler = 0, 0
|
||||
for baum in baeume:
|
||||
blatt = bpy.data.objects.get(baum.name + "_Leaf")
|
||||
ziel = os.path.join(verzeichnis, _safe_name(baum.name) + ".fbx")
|
||||
aufraeumen = []
|
||||
umbenannt = []
|
||||
try:
|
||||
# Die Export-Kopie muss EXAKT so heissen wie der Baum - Unreal
|
||||
# leitet den Asset-Namen davon ab. Solange das Original den
|
||||
# Namen haelt, haengt Blender ".001" an die Kopie. Also das
|
||||
# Original fuer die Dauer des Exports beiseite benennen.
|
||||
for quelle in (baum, blatt):
|
||||
if quelle is not None:
|
||||
umbenannt.append((quelle, quelle.name))
|
||||
quelle.name = quelle.name + ".__src"
|
||||
|
||||
merge, aufraeumen = _merge_for_export(
|
||||
context, baum, blatt, umbenannt[0][1])
|
||||
|
||||
# Kugel-Normalen auf die Blatt-Faces (Slot 1).
|
||||
mitte = None
|
||||
if s.sphere_normals and len(merge.data.materials) > 1:
|
||||
mitte = _sphere_normals(merge, 1)
|
||||
|
||||
# Ursprung: Bbox-Unterseite Mitte, wie in der Fels-Pipeline.
|
||||
_origin_bottom_center(merge)
|
||||
|
||||
_deselect(context)
|
||||
merge.select_set(True)
|
||||
context.view_layer.objects.active = merge
|
||||
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=ziel,
|
||||
use_selection=True,
|
||||
object_types={'MESH'},
|
||||
use_mesh_modifiers=False, # ist bereits ausgewertet
|
||||
mesh_smooth_type='OFF', # OFF = Custom Normals bleiben
|
||||
use_custom_props=True,
|
||||
colors_type='SRGB',
|
||||
add_leaf_bones=False,
|
||||
bake_anim=False,
|
||||
path_mode='AUTO',
|
||||
apply_scale_options='FBX_SCALE_ALL',
|
||||
)
|
||||
geschrieben += 1
|
||||
if mitte is not None:
|
||||
print("[TreeGen] %s: Kugel-Normalen um (%.2f, %.2f, %.2f)"
|
||||
% (merge.name, mitte.x, mitte.y, mitte.z))
|
||||
except Exception as exc: # ein Baum darf den Rest nicht killen
|
||||
fehler += 1
|
||||
print("[TreeGen] Export '%s' fehlgeschlagen: %s" % (baum.name, exc))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
for n in aufraeumen:
|
||||
o = bpy.data.objects.get(n)
|
||||
if o is not None:
|
||||
me = o.data
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
if me is not None and me.users == 0:
|
||||
bpy.data.meshes.remove(me)
|
||||
# Erst die Kopien weg, DANN die Namen zurueck - sonst kollidieren sie.
|
||||
for quelle, alt_name in umbenannt:
|
||||
quelle.name = alt_name
|
||||
|
||||
if fehler:
|
||||
self.report({'WARNING'}, "%d FBX geschrieben, %d fehlgeschlagen "
|
||||
"(Konsole)." % (geschrieben, fehler))
|
||||
else:
|
||||
self.report({'INFO'}, "%d spielfertige FBX nach %s."
|
||||
% (geschrieben, verzeichnis))
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class TREEGEN_OT_randomize(Operator):
|
||||
bl_idname = "object.treegen_randomize"
|
||||
bl_label = "Zufaellig"
|
||||
@@ -1725,11 +2004,18 @@ class VIEW3D_PT_tree_export(_TreeSub):
|
||||
row = layout.row()
|
||||
row.active = s.apply_modifier
|
||||
row.prop(s, "generate_lightmap_uv")
|
||||
layout.separator()
|
||||
layout.prop(s, "sphere_normals")
|
||||
layout.prop(s, "export_dir")
|
||||
layout.operator("treegen.export_game", icon='EXPORT')
|
||||
layout.label(text="Slot 0 = %s, Slot 1 = %s"
|
||||
% (TREE_MATERIALS[0], TREE_MATERIALS[1]), icon='MATERIAL')
|
||||
|
||||
|
||||
classes = (
|
||||
TreeGenSettings,
|
||||
TREEGEN_OT_randomize,
|
||||
TREEGEN_OT_export_game,
|
||||
TREEGEN_OT_leaves,
|
||||
TREEGEN_OT_create,
|
||||
# Eltern-Panel zuerst, sonst findet Blender die bl_parent_id nicht.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Spielfertiger Stand-Baum: ein Mesh, zwei Slots, Kugel-Normalen.
|
||||
|
||||
Der wichtigste Teil ist die NORMALEN-Messung am reimportierten FBX. Custom
|
||||
Normals sind die Sorte Daten, die der Exporter gerne stillschweigend
|
||||
plattbuegelt - dann sieht der Baum in Blender richtig aus und in UE flimmert
|
||||
die Krone. Am Objekt in der Szene zu messen wuerde das nicht zeigen.
|
||||
|
||||
Aufruf: blender --background --factory-startup --python tests/test_baum_export.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
import tempfile
|
||||
import importlib.util
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def diamant(name="Leaf_Card"):
|
||||
"""Diamond-Card wie im Projekt: vier Ecken, zwei Tris."""
|
||||
me = bpy.data.meshes.new(name)
|
||||
me.from_pydata(
|
||||
[(-0.5, 0.0, 0.0), (0.0, 0.0, 0.7), (0.5, 0.0, 0.0), (0.0, 0.0, -0.1)],
|
||||
[], [(0, 1, 2), (0, 2, 3)])
|
||||
me.update()
|
||||
ob = bpy.data.objects.new(name, me)
|
||||
bpy.context.scene.collection.objects.link(ob)
|
||||
return ob
|
||||
|
||||
|
||||
s = bpy.context.scene.tree_gen_settings
|
||||
s.preset = 'baum'
|
||||
s.use_growth = False
|
||||
s.apply_modifier = True
|
||||
s.art_name = "Eiche"
|
||||
s.variant = 1
|
||||
s.export_dir = OUT
|
||||
s.sphere_normals = True
|
||||
|
||||
card = diamant()
|
||||
bpy.ops.object.treegen_create()
|
||||
|
||||
baum = [o for o in bpy.context.scene.objects
|
||||
if o.type == 'MESH' and o.name.startswith("Eiche_01")
|
||||
and not o.name.endswith(("_Leaf", "_Frucht"))]
|
||||
if not baum:
|
||||
print("ERGEBNIS: FEHLER: kein Baum erzeugt")
|
||||
sys.exit(1)
|
||||
baum = baum[0]
|
||||
|
||||
# Der Blattwerk-Operator arbeitet auf der Auswahl.
|
||||
for o in bpy.context.scene.objects:
|
||||
o.select_set(o is baum)
|
||||
bpy.context.view_layer.objects.active = baum
|
||||
s.leaf_card = card
|
||||
bpy.ops.object.treegen_leaves()
|
||||
blatt = bpy.data.objects.get(baum.name + "_Leaf")
|
||||
if blatt is None:
|
||||
fails.append("Keine _Leaf-Ebene erzeugt")
|
||||
|
||||
for o in bpy.context.scene.objects:
|
||||
o.select_set(o is baum)
|
||||
bpy.context.view_layer.objects.active = baum
|
||||
namen_vorher = sorted(o.name for o in bpy.context.scene.objects)
|
||||
|
||||
bpy.ops.treegen.export_game()
|
||||
|
||||
# Die Arbeitsebene muss unangetastet bleiben.
|
||||
namen_nachher = sorted(o.name for o in bpy.context.scene.objects)
|
||||
if namen_nachher != namen_vorher:
|
||||
fehlt = [n for n in namen_vorher if n not in namen_nachher]
|
||||
neu = [n for n in namen_nachher if n not in namen_vorher]
|
||||
fails.append("Szene veraendert - fehlt: %s, neu: %s" % (fehlt, neu))
|
||||
|
||||
dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx"))
|
||||
if not dateien:
|
||||
print("ERGEBNIS: FEHLER: kein FBX geschrieben")
|
||||
sys.exit(1)
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
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']
|
||||
print("")
|
||||
print("FBX %s: %d Mesh(es)" % (dateien[0], len(meshes)))
|
||||
if len(meshes) != 1:
|
||||
fails.append("%d Meshes im FBX, erwartet genau eines (verschmolzen)"
|
||||
% len(meshes))
|
||||
|
||||
ob = meshes[0]
|
||||
me = ob.data
|
||||
slots = [m.name if m else "-" for m in me.materials]
|
||||
print("Slots: %s" % slots)
|
||||
if len(slots) != 2:
|
||||
fails.append("%d Material-Slots, erwartet 2 (Rinde + Blaetter)" % len(slots))
|
||||
else:
|
||||
if tg.TREE_MATERIALS[0] not in slots[0]:
|
||||
fails.append("Slot 0 heisst '%s', erwartet '%s' (Rinde)"
|
||||
% (slots[0], tg.TREE_MATERIALS[0]))
|
||||
if tg.TREE_MATERIALS[1] not in slots[1]:
|
||||
fails.append("Slot 1 heisst '%s', erwartet '%s' (Blaetter)"
|
||||
% (slots[1], tg.TREE_MATERIALS[1]))
|
||||
|
||||
# --- Ursprung: Bbox-Unterseite Mitte -------------------------------------
|
||||
co = [ob.matrix_world @ v.co for v in me.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)]
|
||||
mid = ((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5)
|
||||
print("Ursprung: Mitte %.3f/%.3f, min.Z %.3f, Hoehe %.2f m"
|
||||
% (mid[0], mid[1], lo[2], hi[2] - lo[2]))
|
||||
if abs(mid[0]) > 0.10 or abs(mid[1]) > 0.10:
|
||||
fails.append("Bbox nicht zentriert (%.3f/%.3f)" % mid)
|
||||
if abs(lo[2]) > 0.01:
|
||||
fails.append("min.Z = %.3f, erwartet 0" % lo[2])
|
||||
|
||||
# --- Der eigentliche Punkt: Kugel-Normalen im FBX ------------------------
|
||||
blatt_polys = [p for p in me.polygons if p.material_index == 1]
|
||||
rinde_polys = [p for p in me.polygons if p.material_index == 0]
|
||||
print("Faces: %d Rinde, %d Blaetter" % (len(rinde_polys), len(blatt_polys)))
|
||||
if not blatt_polys:
|
||||
fails.append("Keine Blatt-Faces im FBX (Slot 1 leer)")
|
||||
else:
|
||||
pts = [me.vertices[vi].co for p in blatt_polys for vi in p.vertices]
|
||||
mitte = Vector((sum(p.x for p in pts) / len(pts),
|
||||
sum(p.y for p in pts) / len(pts),
|
||||
sum(p.z for p in pts) / len(pts)))
|
||||
|
||||
if hasattr(me, "calc_normals_split"):
|
||||
me.calc_normals_split()
|
||||
treffer, gesamt, summe = 0, 0, 0.0
|
||||
for p in blatt_polys:
|
||||
for li in p.loop_indices:
|
||||
n = Vector(me.loops[li].normal)
|
||||
if n.length_squared < 1e-9:
|
||||
continue
|
||||
radial = me.vertices[me.loops[li].vertex_index].co - mitte
|
||||
if radial.length_squared < 1e-9:
|
||||
continue
|
||||
d = n.normalized().dot(radial.normalized())
|
||||
summe += d
|
||||
gesamt += 1
|
||||
if d > 0.8:
|
||||
treffer += 1
|
||||
quote = 100.0 * treffer / gesamt if gesamt else 0.0
|
||||
print("Blatt-Normalen: %d Loops, mittlere Radialitaet %.3f, "
|
||||
"%.0f%% ueber 0.8" % (gesamt, summe / gesamt if gesamt else 0, quote))
|
||||
# Bei echten Kugel-Normalen liegt die Radialitaet nahe 1. Flache
|
||||
# Card-Normalen streuen um 0 - das waere der Zustand, den wir vermeiden.
|
||||
if gesamt == 0:
|
||||
fails.append("Keine auswertbaren Blatt-Normalen im FBX")
|
||||
elif quote < 90.0:
|
||||
fails.append("Nur %.0f%% der Blatt-Normalen zeigen radial nach aussen "
|
||||
"(mittlere Radialitaet %.3f) - der FBX-Export hat die "
|
||||
"Custom Normals plattgebuegelt"
|
||||
% (quote, summe / gesamt))
|
||||
|
||||
# Gegenprobe: die Rinde darf NICHT radial sein, sonst haben wir die
|
||||
# Normalen des ganzen Baums ueberschrieben.
|
||||
if rinde_polys:
|
||||
rs, rn = 0.0, 0
|
||||
for p in rinde_polys:
|
||||
for li in p.loop_indices:
|
||||
n = Vector(me.loops[li].normal)
|
||||
radial = me.vertices[me.loops[li].vertex_index].co - mitte
|
||||
if n.length_squared < 1e-9 or radial.length_squared < 1e-9:
|
||||
continue
|
||||
rs += n.normalized().dot(radial.normalized())
|
||||
rn += 1
|
||||
mittel = rs / rn if rn else 0.0
|
||||
print("Rinden-Normalen: mittlere Radialitaet %.3f (soll deutlich "
|
||||
"niedriger sein)" % mittel)
|
||||
if mittel > 0.75:
|
||||
fails.append("Auch die Rinde ist radial (%.3f) - die Kugel-Normalen "
|
||||
"wurden auf den ganzen Baum gelegt" % mittel)
|
||||
|
||||
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