diff --git a/README.md b/README.md index f9e20c9..c55f037 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,14 @@ Gruppe. Blaetter/Krone macht man selbst — das Addon liefert die Struktur. **gleicher Seed = dieselbe Baum-Identitaet** (fuer Wachstums-Systeme im Spiel). - **Live bearbeiten:** Nach dem Erzeugen liegen alle Regler am Modifier `GN_Tree` — im Viewport ziehen, der Baum aktualisiert sich sofort. +- **Blattwerk:** Ein Blatt-/Billboard-Objekt als *Blatt-Card* waehlen (z. B. eine + Diamond-Plane mit Alpha-Textur) → der Button streut es auf die **Zweige** und legt + das Ergebnis als `_Leaf` mit identischem Ursprung ab. Gefiltert wird ueber + den Astradius (`Max Radius`), damit keine Blaetter am Stamm kleben. Ohne Card + bleibt `_Leaf` leer zum Selbermodellieren. +- **Tri-Budget:** Der Blattwerk-Button meldet `Stamm + Blatt = Summe` und **warnt** + bei Ueberschreitung. Default-Dichte 150 haelt auch das schwerste Preset (Eiche, + 1428 Tris) mit 2460 von 2500 Tris im Budget. - **Export:** „Modifier anwenden" friert das Ergebnis als normales Mesh ein. **v1.1.0:** Aeste folgen jetzt der Elternkurve (Tangent/Normal aus *Curve to @@ -188,6 +196,7 @@ Weitere Suiten (jeweils `blender --background --python `): | `tests/test_ecogame_rock.py` | Fels-Benennung, Bake liefert nur `_albedo`/`_normal`, AO im Albedo | | `tests/test_ecogame_tree.py` | Baum-Benennung `__S`, Ursprung, Lightmap-Default | | `tests/test_randomize.py` | Zufalls-Funktion beider Addons, `_Leaf`/`_Frucht` mit gleichem Ursprung | +| `tests/test_leaves.py` | Blattwerk: Streuung, Ursprung, Tri-Budget, leeres `_Leaf` ohne Card | Die Suiten erwarten die gebauten Zips in `dist/` bzw. lesen die `.py` direkt. diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 7e62cf4..9cb4d80 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.22.0" +version = "1.23.0" name = "Stylized Tree Generator" tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" maintainer = "D4rkst3r" diff --git a/dist/index.json b/dist/index.json index 74ed23f..82493f3 100644 --- a/dist/index.json +++ b/dist/index.json @@ -32,7 +32,7 @@ "id": "stylized_tree_generator", "name": "Stylized Tree Generator", "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", - "version": "1.22.0", + "version": "1.23.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -48,9 +48,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.22.0.zip", - "archive_size": 19331, - "archive_hash": "sha256:5e5e3b5e0b73aabe416f93c27bbef4772b561122762dd52daf836f10c0e45b35" + "archive_url": "./stylized_tree_generator-1.23.0.zip", + "archive_size": 22135, + "archive_hash": "sha256:a9b986aaf5e95f7a9600d529eb3eb305af3d1edc6b327862035cecde50fed6e9" } ] } \ No newline at end of file diff --git a/dist/stylized_tree_generator-1.22.0.zip b/dist/stylized_tree_generator-1.22.0.zip deleted file mode 100644 index 202497e..0000000 Binary files a/dist/stylized_tree_generator-1.22.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.23.0.zip b/dist/stylized_tree_generator-1.23.0.zip new file mode 100644 index 0000000..c3f0394 Binary files /dev/null and b/dist/stylized_tree_generator-1.23.0.zip differ diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index d9ab1eb..a8e1c9a 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, 22, 0), + "version": (1, 23, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", @@ -1051,6 +1051,159 @@ def make_object(ng, preset="baum", name=None, growth=None, seed=None): +# --------------------------------------------------------------------------- +# Blattwerk: Billboard-Cards auf die duennen Zweige streuen +# --------------------------------------------------------------------------- + +LEAF_GROUP_NAME = "GN_TreeLeaves" + +LEAF_DEFAULTS = { + "Density": 300.0, # Punkte pro m2 Zweigoberflaeche. Gemessen mit der + # 6-Tri-Diamond-Card: 300 -> ~180 Cards / 1092 Tris, + # zusammen mit dem Baum 2228 von 2500 Tris. 900 waere + # mit 4118 Tris deutlich ueber Budget. + "Max Radius": 0.02, # nur wo der Ast duenner ist als das (m) -> Zweige + "Size": 0.35, + "Size Var": 0.35, + "Tilt": 0.5, # wie stark die Cards aus der Senkrechten kippen (rad) + "Seed": 0, +} + + +def build_leaf_group(rebuild=False): + """Node-Gruppe, die eine Blatt-Card auf die ZWEIGE eines Baumes streut. + + Der Trick: der Generator legt den Astradius als Attribut "uv_r" am Mesh ab + (gemessen 0.8 mm bis 178 mm). Damit lassen sich Blaetter gezielt nur dort + setzen, wo der Ast duenn ist - sonst klebten sie auch am Stamm. + + WICHTIG: eine vorhandene Gruppe wird WIEDERVERWENDET. Sie bei jedem Aufruf + zu loeschen und neu zu bauen reisst sie allen bereits erzeugten _Leaf- + Objekten unter dem Modifier weg - die standen dann ohne Blattwerk da. + """ + existing = bpy.data.node_groups.get(LEAF_GROUP_NAME) + if existing is not None and not rebuild: + return existing + if existing is not None: + bpy.data.node_groups.remove(existing) + ng = bpy.data.node_groups.new(LEAF_GROUP_NAME, "GeometryNodeTree") + N, L = ng.nodes.new, ng.links.new + iface = ng.interface + iface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') + + def add_in(name, stype, default=None, mn=None, mx=None): + so = iface.new_socket(name, in_out='INPUT', socket_type=stype) + if default is not None: + so.default_value = default + if mn is not None: + so.min_value = mn + if mx is not None: + so.max_value = mx + + add_in("Tree", 'NodeSocketObject') + add_in("Leaf Card", 'NodeSocketObject') + 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) + add_in("Size Var", 'NodeSocketFloat', LEAF_DEFAULTS["Size Var"], 0.0, 1.0) + add_in("Tilt", 'NodeSocketFloat', LEAF_DEFAULTS["Tilt"], 0.0, 3.14159) + add_in("Seed", 'NodeSocketInt', LEAF_DEFAULTS["Seed"], 0, 9999) + + gin = N("NodeGroupInput"); gin.location = (-900, 0) + gout = N("NodeGroupOutput"); gout.location = (900, 0) + V = {so.name: gin.outputs[so.name] for so in iface.items_tree + if getattr(so, "in_out", "") == 'INPUT'} + + tree = N("GeometryNodeObjectInfo"); tree.location = (-700, 200) + L(V["Tree"], _sock(tree, "Object")) + card = N("GeometryNodeObjectInfo"); card.location = (-700, -200) + L(V["Leaf Card"], _sock(card, "Object")) + + # Auswahl: nur duenne Aeste (uv_r < Max Radius) + na = N("GeometryNodeInputNamedAttribute"); na.location = (-700, 20) + na.data_type = 'FLOAT' + _sock(na, "Name").default_value = "uv_r" + cmp = N("FunctionNodeCompare"); cmp.location = (-520, 20) + cmp.data_type = 'FLOAT'; cmp.operation = 'LESS_THAN' + L(_out(na, "Attribute"), cmp.inputs[0]) + L(V["Max Radius"], cmp.inputs[1]) + + dist = N("GeometryNodeDistributePointsOnFaces"); dist.location = (-320, 200) + L(_out(tree, "Geometry"), _sock(dist, "Mesh")) + L(cmp.outputs[0], _sock(dist, "Selection")) + L(V["Density"], _sock(dist, "Density")) + L(V["Seed"], _sock(dist, "Seed")) + + # Zufaellige Drehung: voll um Z, leicht gekippt (Tilt) + rrot = N("FunctionNodeRandomValue"); rrot.location = (-320, -120) + rrot.data_type = 'FLOAT_VECTOR' + L(V["Seed"], _sock(rrot, "Seed")) + tneg = N("ShaderNodeMath"); tneg.location = (-520, -220); tneg.operation = 'MULTIPLY' + tneg.inputs[1].default_value = -1.0 + L(V["Tilt"], tneg.inputs[0]) + vmin = N("ShaderNodeCombineXYZ"); vmin.location = (-520, -320) + L(tneg.outputs[0], vmin.inputs["X"]); L(tneg.outputs[0], vmin.inputs["Y"]) + vmax = N("ShaderNodeCombineXYZ"); vmax.location = (-520, -420) + L(V["Tilt"], vmax.inputs["X"]); L(V["Tilt"], vmax.inputs["Y"]) + vmax.inputs["Z"].default_value = 6.283185 + L(vmin.outputs[0], rrot.inputs[0]) + L(vmax.outputs[0], rrot.inputs[1]) + + # Zufaellige Groesse + rsc = N("FunctionNodeRandomValue"); rsc.location = (-320, -340) + rsc.data_type = 'FLOAT' + L(V["Seed"], _sock(rsc, "Seed")) + lo = N("ShaderNodeMath"); lo.location = (-520, -520); lo.operation = 'SUBTRACT' + lo.inputs[0].default_value = 1.0 + L(V["Size Var"], lo.inputs[1]) + slo = N("ShaderNodeMath"); slo.location = (-370, -520); slo.operation = 'MULTIPLY' + L(V["Size"], slo.inputs[0]); L(lo.outputs[0], slo.inputs[1]) + L(slo.outputs[0], rsc.inputs[2]) + L(V["Size"], rsc.inputs[3]) + + inst = N("GeometryNodeInstanceOnPoints"); inst.location = (100, 200) + L(_out(dist, "Points"), _sock(inst, "Points")) + L(_out(card, "Geometry"), _sock(inst, "Instance")) + L(rrot.outputs["Value"], _sock(inst, "Rotation")) + L(rsc.outputs[1], _sock(inst, "Scale")) + + real = N("GeometryNodeRealizeInstances"); real.location = (400, 200) + L(_out(inst, "Instances", "Geometry"), _sock(real, "Geometry")) + L(_out(real, "Geometry"), gout.inputs[0]) + return ng + + +def leaf_socket_ids(ng): + return {so.name: so.identifier for so in ng.interface.items_tree + if getattr(so, "in_out", "") == 'INPUT'} + + +def make_leaves(context, tree_obj, card_obj, name=None, **overrides): + """Erzeugt/aktualisiert ein _Leaf-Objekt mit dem Blattwerk-Modifier.""" + name = name or (tree_obj.name + "_Leaf") + ob = bpy.data.objects.get(name) + if ob is None: + me = bpy.data.meshes.new(name) + ob = bpy.data.objects.new(name, me) + context.collection.objects.link(ob) + # Gleicher Ursprung wie der Stamm - in UE muessen die Ebenen deckungsgleich sein + ob.location = tree_obj.location.copy() + ob.rotation_euler = tree_obj.rotation_euler.copy() + ob.scale = tree_obj.scale.copy() + + ng = build_leaf_group() + md = ob.modifiers.get("GN_Leaves") or ob.modifiers.new("GN_Leaves", 'NODES') + md.node_group = ng + ids = leaf_socket_ids(ng) + md[ids["Tree"]] = tree_obj + md[ids["Leaf Card"]] = card_obj + vals = dict(LEAF_DEFAULTS); vals.update(overrides) + for k, v in vals.items(): + if k in ids: + md[ids[k]] = v + return ob + + # --------------------------------------------------------------------------- # Addon-UI @@ -1091,11 +1244,34 @@ class TreeGenSettings(PropertyGroup): "1 = volle Bandbreite. Der Preset-Charakter bleibt erhalten"), ) make_leaf: BoolProperty( - name="_Leaf-Ebene anlegen", default=False, + name="_Leaf-Ebene anlegen", default=True, description=("Legt pro Baum ein leeres Objekt _Leaf mit GLEICHEM Ursprung " "an. Dort modellierst du die Krone - in UE sitzen beide Ebenen " "dadurch deckungsgleich"), ) + 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"), + ) + leaf_density: FloatProperty( + name="Dichte", default=150.0, min=0.0, max=20000.0, + description=("Cards pro m2 Zweigoberflaeche. Presets mit viel Geaest (Eiche) " + "bekommen dadurch automatisch mehr Blaetter als schlanke. 150 haelt " + "auch die Eiche im 2500-Tri-Budget; der Button meldet die Summe"), + ) + leaf_budget: IntProperty( + name="Tri-Budget", default=2500, min=0, max=100000, + description="Bei Ueberschreitung warnt der Blattwerk-Button (0 = keine Pruefung)", + ) + leaf_size: FloatProperty(name="Groesse", default=0.22, min=0.01, max=5.0) + leaf_size_var: FloatProperty(name="Groessen-Streuung", default=0.35, min=0.0, max=1.0) + leaf_max_radius: FloatProperty( + name="Nur Zweige duenner als", default=0.02, min=0.001, max=0.5, + description=("Blaetter nur dort, wo der Ast duenner als dieser Radius ist (m). " + "Verhindert Blaetter am Stamm"), + ) + leaf_tilt: FloatProperty(name="Kippung", default=0.5, min=0.0, max=3.14159) make_frucht: BoolProperty( name="_Frucht-Ebene anlegen", default=False, description="Zusaetzlich _Frucht (gleicher Ursprung) fuer die Frucht-Ebene", @@ -1185,6 +1361,58 @@ class TREEGEN_OT_randomize(Operator): return {'FINISHED'} +class TREEGEN_OT_leaves(Operator): + bl_idname = "object.treegen_leaves" + bl_label = "Blattwerk erzeugen" + bl_description = ("Streut die Blatt-Card auf die ZWEIGE der ausgewaehlten Baeume " + "und legt sie als _Leaf ab") + bl_options = {'REGISTER', 'UNDO'} + + 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).") + return {'CANCELLED'} + trees = [o for o in context.selected_objects + if o.type == 'MESH' and not o.name.endswith(("_Leaf", "_Frucht"))] + if not trees: + self.report({'ERROR'}, "Keinen Baum ausgewaehlt.") + return {'CANCELLED'} + dg = context.evaluated_depsgraph_get() + total_tree = total_leaf = 0 + for t in trees: + try: + lf = make_leaves(context, t, s.leaf_card, + Density=s.leaf_density, Size=s.leaf_size, + **{"Size Var": s.leaf_size_var, + "Max Radius": s.leaf_max_radius, + "Tilt": s.leaf_tilt, "Seed": s.seed}) + except Exception as exc: # noqa: BLE001 + self.report({'WARNING'}, "%s: %s" % (t.name, exc)) + continue + context.view_layer.update() + dg = context.evaluated_depsgraph_get() + for ob, acc in ((t, "tree"), (lf, "leaf")): + try: + n = sum(len(p.vertices) - 2 + for p in ob.evaluated_get(dg).data.polygons) + except Exception: + n = 0 + if acc == "tree": + total_tree += n + else: + total_leaf += n + total = total_tree + total_leaf + msg = ("Blattwerk fuer %d Baum/Baeume: %d Stamm + %d Blatt = %d Tris" + % (len(trees), total_tree, total_leaf, total)) + limit = s.leaf_budget * max(len(trees), 1) + if s.leaf_budget and total > limit: + self.report({'WARNING'}, msg + " - UEBER Budget (%d). Dichte senken." % limit) + else: + self.report({'INFO'}, msg) + return {'FINISHED'} + + class TREEGEN_OT_create(Operator): bl_idname = "object.treegen_create" bl_label = "Baum erzeugen" @@ -1262,7 +1490,17 @@ class TREEGEN_OT_create(Operator): extra = [] for ob in created: if s.make_leaf: - extra.append(_add_layer(context, ob, "_Leaf")) + if s.leaf_card is not None and s.leaf_card.type == 'MESH': + # Card gesetzt -> Blattwerk direkt streuen + extra.append(make_leaves( + context, ob, s.leaf_card, + Density=s.leaf_density, Size=s.leaf_size, + **{"Size Var": s.leaf_size_var, + "Max Radius": s.leaf_max_radius, + "Tilt": s.leaf_tilt, "Seed": s.seed})) + else: + # keine Card -> leeres _Leaf zum Selbermodellieren + extra.append(_add_layer(context, ob, "_Leaf")) if s.make_frucht: extra.append(_add_layer(context, ob, "_Frucht")) created.extend(extra) @@ -1314,6 +1552,21 @@ class VIEW3D_PT_tree_generator(Panel): box.prop(s, "random_amount") box.operator("object.treegen_randomize", icon='FILE_REFRESH') + box = layout.box() + box.label(text="Blattwerk", icon='OUTLINER_OB_POINTCLOUD') + box.prop(s, "leaf_card") + sub = box.column(align=True) + sub.enabled = s.leaf_card is not None + sub.prop(s, "leaf_density") + sub.prop(s, "leaf_size") + sub.prop(s, "leaf_size_var") + sub.prop(s, "leaf_max_radius") + sub.prop(s, "leaf_tilt") + sub.prop(s, "leaf_budget") + box.operator("object.treegen_leaves", icon='OUTLINER_OB_POINTCLOUD') + if s.leaf_card is None: + box.label(text="Ohne Card bleibt _Leaf leer", icon='INFO') + box = layout.box() box.label(text="Zusatz-Ebenen") box.prop(s, "make_leaf") @@ -1334,6 +1587,7 @@ class VIEW3D_PT_tree_generator(Panel): classes = ( TreeGenSettings, TREEGEN_OT_randomize, + TREEGEN_OT_leaves, TREEGEN_OT_create, VIEW3D_PT_tree_generator, ) diff --git a/tests/test_leaves.py b/tests/test_leaves.py new file mode 100644 index 0000000..1fff081 --- /dev/null +++ b/tests/test_leaves.py @@ -0,0 +1,61 @@ +import bpy, sys, zipfile, os, tempfile, importlib.util, math, bmesh +ZIP=r"A:\eco\stylized_rock_generator\dist\stylized_tree_generator-1.23.0.zip" +tmp=tempfile.mkdtemp() +with zipfile.ZipFile(ZIP) as z: z.extractall(tmp) +init=[os.path.join(r,"__init__.py") for r,_,f in os.walk(tmp) if "__init__.py" in f][0] +spec=importlib.util.spec_from_file_location("stg",init) +m=importlib.util.module_from_spec(spec); sys.modules["stg"]=m; spec.loader.exec_module(m) +try: m.unregister() +except Exception: pass +m.register() +fails=[] +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) + +# Card wie beim User +me=bpy.data.meshes.new("DiamondCard"); bm=bmesh.new() +for ang in (0.0, math.pi/3, 2*math.pi/3): + c,s_=math.cos(ang), math.sin(ang) + vs=[bm.verts.new(p) for p in [(-c,-s_,-1.41),(c,s_,-1.41),(c,s_,1.41),(-c,-s_,1.41)]] + bm.faces.new(vs) +bm.to_mesh(me); bm.free(); me.uv_layers.new(name="UVMap") +card=bpy.data.objects.new("Diamon Plane", me); bpy.context.collection.objects.link(card) + +s=bpy.context.scene.tree_gen_settings +s.preset='eiche'; s.art_name="Eiche"; s.variant=1 +s.leaf_card=card; s.leaf_size=0.22 # Dichte: Default verwenden +s.make_leaf=True; s.use_growth=False +r=bpy.ops.object.treegen_create() +names=sorted(o.name for o in bpy.context.scene.objects if o.type=='MESH') +print("OBJEKTE:", [n for n in names if n!="Diamon Plane"]) +if "Eiche_01" not in names or "Eiche_01_Leaf" not in names: fails.append("objekte") +bpy.context.view_layer.update() +dg=bpy.context.evaluated_depsgraph_get() +def tris(n): + ob=bpy.context.scene.objects[n] + return sum(len(p.vertices)-2 for p in ob.evaluated_get(dg).data.polygons) +tt,tl=tris("Eiche_01"), tris("Eiche_01_Leaf") +print("TRIS Stamm=%d Blatt=%d Summe=%d / 2500 %s (Dichte %.0f)" % (tt,tl,tt+tl,"OK" if tt+tl<=2500 else "UEBER", s.leaf_density)) +if tl==0: fails.append("blattwerk_leer") +if tt+tl>2500: fails.append("budget") +# Ursprung deckungsgleich +a=bpy.context.scene.objects["Eiche_01"]; b=bpy.context.scene.objects["Eiche_01_Leaf"] +d=(a.location-b.location).length +print("URSPRUNG-Abstand: %.6f" % d) +if d>1e-6: fails.append("ursprung") +# Operator auf Auswahl +for o in bpy.context.selected_objects: o.select_set(False) +a.select_set(True); bpy.context.view_layer.objects.active=a +r2=bpy.ops.object.treegen_leaves() +print("OPERATOR treegen_leaves:", list(r2)) +if 'FINISHED' not in r2: fails.append("operator") +# Ohne Card -> leeres _Leaf +for o in list(bpy.data.objects): + if o.name.startswith("Eiche"): bpy.data.objects.remove(o,do_unlink=True) +s.leaf_card=None; s.art_name="Birke"; s.preset='birke' +bpy.ops.object.treegen_create() +lf=bpy.context.scene.objects.get("Birke_01_Leaf") +print("ohne Card -> _Leaf vorhanden:", lf is not None, "| leer:", lf and len(lf.data.polygons)==0) +if lf is None: fails.append("leer_leaf") +m.unregister() +print("ERGEBNIS:", "ALLE CHECKS OK" if not fails else "FEHLER: "+", ".join(fails)) +sys.exit(1 if fails else 0)