diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 0b45f60..76ac871 100644 --- a/blender_manifest_tree.toml +++ b/blender_manifest_tree.toml @@ -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" diff --git a/dist/index.html b/dist/index.html index a6ae283..24a80ba 100644 --- a/dist/index.html +++ b/dist/index.html @@ -50,17 +50,17 @@ 34.1KB - stylized_tree_generator-1.25.0 + stylized_tree_generator-1.26.0 Stylized Tree Generator Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen link 4.2.0 - ~ all all - 24.1KB + 27.6KB -

Built 2026-08-03, 12:20

+

Built 2026-08-06, 04:42

diff --git a/dist/index.json b/dist/index.json index dea8d7e..857183e 100644 --- a/dist/index.json +++ b/dist/index.json @@ -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" } ] } \ No newline at end of file diff --git a/dist/stylized_tree_generator-1.25.0.zip b/dist/stylized_tree_generator-1.25.0.zip deleted file mode 100644 index c09c9ee..0000000 Binary files a/dist/stylized_tree_generator-1.25.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.26.0.zip b/dist/stylized_tree_generator-1.26.0.zip new file mode 100644 index 0000000..a87412e Binary files /dev/null and b/dist/stylized_tree_generator-1.26.0.zip differ diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index 5460413..761f040 100644 --- a/stylized_tree_generator.py +++ b/stylized_tree_generator.py @@ -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 _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. diff --git a/tests/test_baum_export.py b/tests/test_baum_export.py new file mode 100644 index 0000000..cabf858 --- /dev/null +++ b/tests/test_baum_export.py @@ -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")