diff --git a/README.md b/README.md index 2b6a793..b541d15 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,39 @@ Gras-, Blumen- und Farn-Bueschel. Der Kern ist die **Wind-UV**: ## Stylized Tree Generator +### Spielfertiger Stand-Baum (Faellsystem) + +Knopf **Spielfertig exportieren** im Export-Panel: 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 die Export-Kopie. Ursprung +Bbox-Unterseite Mitte, FBX-Einstellungen wie in der Fels-Pipeline. + +**Kugel-Normalen** (Haken `Kugel-Normalen auf Blaetter`, Standard an): alle +Blatt-Normalen zeigen radial vom Kronen-Schwerpunkt nach aussen. Ohne das wirkt +jede Card wie ein eigenes flaches Blech und die Krone flimmert im Licht. Der +Schwerpunkt kommt **nur aus den Blatt-Faces** — 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 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. + +**Card-Varianten:** `Card-Sammlung` nimmt eine Collection mit 4–6 Diamond-Cards +unterschiedlicher Atlas-UVs; je Blattpunkt wird eine zufaellig gezogen +(seed-stabil). Eine einzelne Card geht weiter — die Sammlung hat Vorrang. + +Zwei Fallen beim Bauen: + +- Ueber `bmesh.from_mesh` zusammenzufuegen haengt die Geometrie zwar an, wirft + aber die **Material-Indizes** weg: danach lagen alle 748 Faces auf Index 0. + Blenders `join` fuehrt die Slot-Listen zusammen und rechnet die Indizes um. +- `join` loescht das zweite Objekt; jede gehaltene Referenz darauf ist danach + tot. Die Aufraeumliste fuehrt deshalb **Namen** statt Objekte. + ### Proportionen: gemessen, nicht geschaetzt Drei Presets sahen falsch aus. Die Ursachen wurden gemessen bzw. belegt, nicht @@ -572,6 +605,8 @@ gefiltert `.\run_tests.ps1 -Filter panels`. | Datei | prueft | |---|---| +| `tests/test_baum_export.py` | Stand-Baum: ein Mesh, zwei Slots, Ursprung, **Kugel-Normalen im exportierten FBX** | +| `tests/test_blatt_varianten.py` | Card-Sammlung: alle Varianten werden gezogen, seed-stabil, Einzelobjekt weiter moeglich | | `tests/test_proportionen.py` | Baum-Proportionen: Palmenkrone, Strauch-Breite, Setzling-Schlankheit, Branch Count 0 | | `tests/test_bruchstuecke.py` | Bruchstuecke: Volumen 100 % des Muttersteins, dichte Topologie, genau ein Erz-Stueck, Material-Slots, Ursprung, UCX je Teil | | `tests/test_schnellstart.py` | Schnellstart: jede Sorte liefert ohne Nacharbeit Geometrie + Slot, Erz-Sorte ueber der Dichte-Schwelle, Dissolve-Bereich streut je Stein | diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 76ac871..1375eff 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.26.0" +version = "1.27.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 24a80ba..4e434ed 100644 --- a/dist/index.html +++ b/dist/index.html @@ -50,17 +50,17 @@ 34.1KB - stylized_tree_generator-1.26.0 + stylized_tree_generator-1.27.0 Stylized Tree Generator Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen link 4.2.0 - ~ all all - 27.6KB + 28.6KB -

Built 2026-08-06, 04:42

+

Built 2026-08-06, 04:48

diff --git a/dist/index.json b/dist/index.json index 857183e..c42e433 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.26.0", + "version": "1.27.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -98,9 +98,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.26.0.zip", - "archive_size": 28299, - "archive_hash": "sha256:8e6cf3131a36efe840d0f59802003a3cb198b611a405c53e6428f2a99029b0c9" + "archive_url": "./stylized_tree_generator-1.27.0.zip", + "archive_size": 29267, + "archive_hash": "sha256:daeb815cde034e527cdffb0e76a980e964f228b7a0f39070ba78477be9fc3e57" } ] } \ No newline at end of file diff --git a/dist/stylized_tree_generator-1.26.0.zip b/dist/stylized_tree_generator-1.26.0.zip deleted file mode 100644 index a87412e..0000000 Binary files a/dist/stylized_tree_generator-1.26.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.27.0.zip b/dist/stylized_tree_generator-1.27.0.zip new file mode 100644 index 0000000..457c728 Binary files /dev/null and b/dist/stylized_tree_generator-1.27.0.zip differ diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index 761f040..a4e33ca 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, 26, 0), + "version": (1, 27, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", @@ -1176,6 +1176,7 @@ LEAF_DEFAULTS = { "Size Var": 0.35, "Tilt": 0.5, # wie stark die Cards aus der Senkrechten kippen (rad) "Seed": 0, + "Use Collection": False, } @@ -1211,6 +1212,11 @@ def build_leaf_group(rebuild=False): add_in("Tree", 'NodeSocketObject') add_in("Leaf Card", 'NodeSocketObject') + # Mehrere Atlas-Varianten: die Collection liefert je Punkt eine zufaellig + # gewaehlte Card. "Use Collection" wird von make_leaves gesetzt - Geometry + # Nodes kann einen leeren Collection-Zeiger nicht selbst pruefen. + add_in("Leaf Cards", 'NodeSocketCollection') + add_in("Use Collection", 'NodeSocketBool', False) add_in("Density", 'NodeSocketFloat', LEAF_DEFAULTS["Density"], 0.0, 20000.0) add_in("Max Radius", 'NodeSocketFloat', LEAF_DEFAULTS["Max Radius"], 0.001, 0.5) add_in("Size", 'NodeSocketFloat', LEAF_DEFAULTS["Size"], 0.01, 5.0) @@ -1228,6 +1234,23 @@ def build_leaf_group(rebuild=False): card = N("GeometryNodeObjectInfo"); card.location = (-700, -200) L(V["Leaf Card"], _sock(card, "Object")) + # Separate Children = jedes Objekt der Collection wird eine EIGENE Instanz. + # Ohne das kaeme die Collection als ein einziger Klumpen an und "Pick + # Instance" haette nichts zum Auswaehlen. + coll = N("GeometryNodeCollectionInfo"); coll.location = (-700, -420) + L(V["Leaf Cards"], _sock(coll, "Collection")) + try: + _sock(coll, "Separate Children").default_value = True + _sock(coll, "Reset Children").default_value = True + except (AttributeError, KeyError): + pass + + quelle = N("GeometryNodeSwitch"); quelle.location = (-500, -300) + quelle.input_type = 'GEOMETRY' + L(V["Use Collection"], _sock(quelle, "Switch")) + L(_out(card, "Geometry"), _sock(quelle, "False")) + L(_out(coll, "Instances", "Geometry"), _sock(quelle, "True")) + # Auswahl: nur duenne Aeste (uv_r < Max Radius) na = N("GeometryNodeInputNamedAttribute"); na.location = (-700, 20) na.data_type = 'FLOAT' @@ -1272,7 +1295,35 @@ def build_leaf_group(rebuild=False): inst = N("GeometryNodeInstanceOnPoints"); inst.location = (100, 200) L(_out(dist, "Points"), _sock(inst, "Points")) - L(_out(card, "Geometry"), _sock(inst, "Instance")) + L(_out(quelle, "Output"), _sock(inst, "Instance")) + try: + L(V["Use Collection"], _sock(inst, "Pick Instance")) + except (KeyError, RuntimeError): + pass + + # Zufaelliger Index je Punkt, aus der tatsaechlichen Anzahl abgeleitet - + # eine feste Obergrenze wuerde bei weniger Cards Luecken erzeugen. + dsize = N("GeometryNodeAttributeDomainSize"); dsize.location = (-320, -560) + try: + dsize.component = 'INSTANCES' + except (AttributeError, TypeError): + pass + L(_out(coll, "Instances", "Geometry"), _sock(dsize, "Geometry")) + dmax = N("ShaderNodeMath"); dmax.location = (-150, -560) + dmax.operation = 'SUBTRACT'; dmax.inputs[1].default_value = 1.0 + try: + L(_out(dsize, "Instance Count", "Point Count"), dmax.inputs[0]) + except (KeyError, RuntimeError): + dmax.inputs[0].default_value = 1.0 + ridx = N("FunctionNodeRandomValue"); ridx.location = (100, -560) + ridx.data_type = 'INT' + L(V["Seed"], _sock(ridx, "Seed")) + ridx.inputs[4].default_value = 0 + L(dmax.outputs[0], ridx.inputs[5]) + try: + L(ridx.outputs["Value"], _sock(inst, "Instance Index")) + except (KeyError, RuntimeError): + pass L(rrot.outputs["Value"], _sock(inst, "Rotation")) L(rsc.outputs[1], _sock(inst, "Scale")) @@ -1287,7 +1338,7 @@ def leaf_socket_ids(ng): if getattr(so, "in_out", "") == 'INPUT'} -def make_leaves(context, tree_obj, card_obj, name=None, **overrides): +def make_leaves(context, tree_obj, card_obj, name=None, card_coll=None, **overrides): """Erzeugt/aktualisiert ein _Leaf-Objekt mit dem Blattwerk-Modifier.""" name = name or (tree_obj.name + "_Leaf") ob = bpy.data.objects.get(name) @@ -1306,6 +1357,10 @@ def make_leaves(context, tree_obj, card_obj, name=None, **overrides): ids = leaf_socket_ids(ng) md[ids["Tree"]] = tree_obj md[ids["Leaf Card"]] = card_obj + # Rueckwaertskompatibel: ohne Collection bleibt alles wie bisher. + if "Leaf Cards" in ids: + md[ids["Leaf Cards"]] = card_coll + overrides.setdefault("Use Collection", card_coll is not None) vals = dict(LEAF_DEFAULTS); vals.update(overrides) for k, v in vals.items(): if k in ids: @@ -1484,8 +1539,15 @@ class TreeGenSettings(PropertyGroup): ) leaf_card: PointerProperty( name="Blatt-Card", type=bpy.types.Object, - description=("Objekt, das als Blatt/Billboard gestreut wird (z.B. dein " - "'Diamon Plane'). Leer = _Leaf bleibt leer zum Selbermodellieren"), + description=("Einzelne Card, die gestreut wird (z.B. dein 'Diamon " + "Plane'). Leer = _Leaf bleibt leer zum Selbermodellieren. " + "Wird ignoriert, sobald eine Card-Sammlung gesetzt ist"), + ) + leaf_cards: PointerProperty( + name="Card-Sammlung", type=bpy.types.Collection, + description=("Sammlung mehrerer Cards mit unterschiedlichen Atlas-UVs. " + "Je Blattpunkt wird zufaellig eine gewaehlt (Seed-stabil) - " + "damit mischt die Krone 4-6 Varianten statt einer"), ) leaf_density: FloatProperty( name="Dichte", default=150.0, min=0.0, max=20000.0, @@ -1755,8 +1817,13 @@ class TREEGEN_OT_leaves(Operator): def execute(self, context): s = context.scene.tree_gen_settings - if s.leaf_card is None or s.leaf_card.type != 'MESH': - self.report({'ERROR'}, "Bitte zuerst eine Blatt-Card waehlen (Mesh-Objekt).") + # Card-Sammlung hat Vorrang; ohne sie muss die Einzel-Card ein Mesh sein. + if s.leaf_cards is not None: + if not [o for o in s.leaf_cards.objects if o.type == 'MESH']: + self.report({'ERROR'}, "Die Card-Sammlung enthaelt keine Meshes.") + return {'CANCELLED'} + elif s.leaf_card is None or s.leaf_card.type != 'MESH': + self.report({'ERROR'}, "Bitte eine Blatt-Card oder eine Card-Sammlung waehlen.") return {'CANCELLED'} trees = [o for o in context.selected_objects if o.type == 'MESH' and not o.name.endswith(("_Leaf", "_Frucht"))] @@ -1768,6 +1835,7 @@ class TREEGEN_OT_leaves(Operator): for t in trees: try: lf = make_leaves(context, t, s.leaf_card, + card_coll=s.leaf_cards, Density=s.leaf_density, Size=s.leaf_size, **{"Size Var": s.leaf_size_var, "Max Radius": s.leaf_max_radius, @@ -1969,9 +2037,12 @@ class VIEW3D_PT_tree_leaves(_TreeSub): def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings - layout.prop(s, "leaf_card") + layout.prop(s, "leaf_cards") + row = layout.row() + row.active = s.leaf_cards is None + row.prop(s, "leaf_card") col = layout.column(align=True) - col.active = s.leaf_card is not None + col.active = s.leaf_card is not None or s.leaf_cards is not None col.prop(s, "leaf_density") col.prop(s, "leaf_size") col.prop(s, "leaf_size_var") @@ -1980,7 +2051,11 @@ class VIEW3D_PT_tree_leaves(_TreeSub): col.prop(s, "leaf_budget") layout.separator() layout.operator("object.treegen_leaves", icon='OUTLINER_OB_POINTCLOUD') - if s.leaf_card is None: + if s.leaf_cards is not None: + n = len([o for o in s.leaf_cards.objects if o.type == 'MESH']) + layout.label(text="%d Card-Variante(n), zufaellig je Blatt" % n, + icon='INFO') + elif s.leaf_card is None: layout.label(text="Ohne Card bleibt _Leaf leer", icon='INFO') diff --git a/tests/test_blatt_varianten.py b/tests/test_blatt_varianten.py new file mode 100644 index 0000000..79091c9 --- /dev/null +++ b/tests/test_blatt_varianten.py @@ -0,0 +1,135 @@ +"""Punkt 3: Blatt-Cards aus einer COLLECTION statt aus einem Einzelobjekt. + +Damit kann der Nutzer 4-6 Diamond-Cards mit unterschiedlichen Atlas-UVs anlegen +und die Krone mischt sie. Rueckwaertskompatibel: ein einzelnes Objekt geht +weiter. + +Beweisfuehrung: die Zufalls-Groesse wird ABGESCHALTET (Size Var 0). Dann kommt +jede Groessenabweichung ausschliesslich von der Card-Variante, und die Anzahl +verschiedener Face-Flaechen ist ein exakter Zaehler fuer die benutzten Varianten. +Mit eingeschalteter Groessenstreuung waere das Mass unbrauchbar - alles streut. + +Aufruf: blender --background --factory-startup --python tests/test_blatt_varianten.py +""" +import os +import sys +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 = [] +for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + + +def card(name, groesse): + """Diamond-Card in definierter Groesse - die Groesse ist hier der Marker.""" + me = bpy.data.meshes.new(name) + g = groesse + me.from_pydata([(-g, 0, 0), (0, 0, g * 1.4), (g, 0, 0), (0, 0, -g * 0.2)], + [], [(0, 1, 2), (0, 2, 3)]) + me.update() + ob = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(ob) + return ob + + +coll = bpy.data.collections.new("Leaf_Cards") +bpy.context.scene.collection.children.link(coll) +einzel = card("Card_Single", 0.5) +GROESSEN = (0.30, 0.45, 0.60, 0.75) +for i, g in enumerate(GROESSEN): + c = card("Card_%02d" % (i + 1), g) + bpy.context.scene.collection.objects.unlink(c) + coll.objects.link(c) + +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.leaf_size_var = 0.0 # entscheidend: Groesse kommt NUR von der Variante + +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"))][0] + + +def streuen(collection, einzelcard, seed): + s.leaf_cards = collection + s.leaf_card = einzelcard + s.seed = seed + 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") + dg = bpy.context.evaluated_depsgraph_get() + ev = bl.evaluated_get(dg) + me = ev.to_mesh() + flaechen = sorted({round(p.area, 5) for p in me.polygons}) + n = len(me.polygons) + ev.to_mesh_clear() + return n, flaechen + + +print("%-26s %7s %s" % ("Variante", "Faces", "verschiedene Flaechen")) + +# --- 1) Rueckwaertskompatibel: Einzelobjekt ------------------------------ +n1, f1 = streuen(None, einzel, 0) +print("%-26s %7d %d" % ("Einzel-Card", n1, len(f1))) +if n1 == 0: + fails.append("Einzel-Card erzeugt keine Blaetter - Rueckwaertskompatibilitaet weg") +# Eine Card = zwei unterschiedlich grosse Tris = genau zwei Flaechen. +if len(f1) > 3: + fails.append("Einzel-Card liefert %d verschiedene Flaechen, erwartet <= 3 - " + "die Groessenstreuung ist nicht wirklich aus" % len(f1)) + +# --- 2) Collection: alle Varianten kommen vor ---------------------------- +n2, f2 = streuen(coll, None, 0) +print("%-26s %7d %d" % ("Collection (%d Cards)" % len(GROESSEN), n2, len(f2))) +if n2 == 0: + fails.append("Collection erzeugt keine Blaetter") +# Vier Cards mit je zwei Tris = acht verschiedene Flaechen, wenn wirklich alle +# vier gezogen werden. Weniger heisst: es kommen nicht alle Varianten vor. +erwartet = 2 * len(GROESSEN) +if len(f2) < erwartet: + fails.append("Nur %d verschiedene Flaechen, erwartet %d - es werden nicht " + "alle %d Card-Varianten gezogen" + % (len(f2), erwartet, len(GROESSEN))) + +# --- 3) Seed-stabil ------------------------------------------------------ +n3, f3 = streuen(coll, None, 0) +print("%-26s %7d %d" % ("gleicher Seed", n3, len(f3))) +if (n3, f3) != (n2, f2): + fails.append("Gleicher Seed liefert ein anderes Ergebnis - nicht stabil") + +n4, f4 = streuen(coll, None, 7) +print("%-26s %7d %d" % ("anderer Seed", n4, len(f4))) +if (n4, f4) == (n2, f2): + fails.append("Anderer Seed liefert dasselbe Ergebnis - der Seed wirkt nicht") + +# --- 4) Collection hat Vorrang vor der Einzel-Card ----------------------- +n5, f5 = streuen(coll, einzel, 0) +print("%-26s %7d %d" % ("beides gesetzt", n5, len(f5))) +if len(f5) < erwartet: + fails.append("Mit gesetzter Einzel-Card faellt die Collection zurueck - " + "die Sammlung muss Vorrang haben") + +print("") +if fails: + print("ERGEBNIS: %d FEHLER" % len(fails)) + for x in fails: + print(" - " + x) + sys.exit(1) +print("ERGEBNIS: ALLE CHECKS OK")