bl_info = { "name": "Stylized Tree Generator", "author": "D4rkst3r", "version": (1, 4, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", "category": "Add Mesh", } # Parametrischer Stylized-Baum als Geometry-Nodes-Gruppe (Stamm + Aeste). # Blaetter/Krone macht der Nutzer selbst - dieses Addon liefert das Geaest. # # HINWEIS: Der Node-Aufbau unten ist identisch mit EcoGame tools/blender_tree_gen.py # (dort als CLI-Variante). Aenderungen bitte in beiden pflegen. # # LIVE BEARBEITEN: Nach dem Erzeugen liegen alle Regler am Modifier "GN_Tree" - # im Viewport ziehen, der Baum aktualisiert sich sofort. import bpy import sys import os GROUP_NAME = "GN_StylizedTree" OBJ_NAME = "StylizedTree" # ============================ REGLER (Defaults) ============================ DEFAULTS = { "Seed": 0, "Height": 5.0, "Trunk Radius": 0.13, # schlanker Stamm; zu dick wirkt sofort "stumpf" "Bend": 0.5, "Taper": 0.9, "Branch Count": 9, "Branch Length": 1.3, "Branch Up": 1.3, # >1 = Aeste zeigen nach oben; negativ = haengend "Branch Start": 0.35, # ab wo am Stamm Aeste sitzen (0..1) "Branch End": 0.95, "Branch Bend": 0.75, # Krummheit je Ast/Zweig (0 = gerade Staebe) "Branch Droop": 0.0, # >0 haengt nach unten (Palme), <0 kruemmt nach oben (Kaktus) "Branch Thickness": 0.32, # Ast-Dicke relativ zum Stamm "Branch Taper": 0.55, # schwach verjuengen -> Roehre statt Kegel/Dorn "Sub Count": 3, # Sub-Aeste je Hauptast (0 = aus) "Sub Length": 0.55, "Sub Up": 1.4, "Sides": 6, "UV Scale": 1.0, # Rinden-Dichte; UVs sind world-space (m) "Tip Blunt": 0.18, # >0 verhindert Nadelspitzen (0 = spitz, 0.45 = Kaktus) "Ribs": 0.0, # senkrechte Kanneluren (0 = glatt); Kaktus ~9 "Rib Depth": 0.0, } # Presets: nur die Abweichungen von DEFAULTS. PRESETS = { "baum": {}, "palme": { "Height": 7.0, "Trunk Radius": 0.16, "Bend": 1.1, "Taper": 0.55, "Branch Count": 11, "Branch Length": 2.6, "Branch Up": 0.55, "Branch Start": 0.93, "Branch End": 1.0, "Branch Bend": 0.25, "Branch Droop": 1.5, "Sub Count": 0, # Wedel-Fiederung macht der User als Blattwerk "Sides": 6, }, "busch": { "Height": 1.6, "Trunk Radius": 0.07, "Bend": 0.4, "Taper": 0.8, "Branch Count": 14, "Branch Length": 0.9, "Branch Up": 1.0, "Branch Start": 0.15, "Branch End": 0.95, "Branch Bend": 0.6, "Branch Droop": 0.0, "Sub Count": 2, "Sub Length": 0.35, "Sub Up": 1.6, "Sides": 5, }, # Kaktus: dicker, kaum verjuengter Stamm, wenige Arme, die per NEGATIVEM # Droop nach oben kruemmen (Saguaro-Silhouette). Keine Sub-Aeste. "kaktus": { "Height": 3.0, "Trunk Radius": 0.28, "Bend": 0.08, "Taper": 0.10, "Branch Count": 2, "Branch Length": 1.6, "Branch Up": 0.05, "Branch Start": 0.28, "Branch End": 0.5, "Branch Bend": 0.0, "Branch Droop": -2.2, # negativ = Arme kruemmen nach OBEN (Saguaro) "Branch Thickness": 0.72, # Arme fast so dick wie der Stamm "Branch Taper": 0.12, # Arme bleiben dick statt spitz zuzulaufen "Sub Count": 0, "Tip Blunt": 0.45, "Sides": 18, "Ribs": 9.0, "Rib Depth": 0.09, }, } # =========================================================================== def _sock(node, *names): """Falle 1: Socket nach Namen holen, mit Alternativen.""" for n in names: if n in node.inputs: return node.inputs[n] raise KeyError("Socket %s nicht in %s" % (names, node.bl_idname)) def _out(node, *names): for n in names: if n in node.outputs: return node.outputs[n] return node.outputs[0] def build_group(): old = bpy.data.node_groups.get(GROUP_NAME) if old: bpy.data.node_groups.remove(old) ng = bpy.data.node_groups.new(GROUP_NAME, "GeometryNodeTree") N, L = ng.nodes.new, ng.links.new iface = ng.interface def _new_any(*bl_idnames): """Falle 2: erster Node-Typ, den diese Blender-Version kennt.""" for bid in bl_idnames: try: return N(bid) except Exception: continue return None iface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') def add_in(name, stype, default, mn=None, mx=None): s = iface.new_socket(name, in_out='INPUT', socket_type=stype) s.default_value = default if mn is not None: s.min_value = mn if mx is not None: s.max_value = mx add_in("Seed", 'NodeSocketInt', DEFAULTS["Seed"], 0, 9999) add_in("Height", 'NodeSocketFloat', DEFAULTS["Height"], 0.5, 30.0) add_in("Trunk Radius", 'NodeSocketFloat', DEFAULTS["Trunk Radius"], 0.01, 3.0) add_in("Bend", 'NodeSocketFloat', DEFAULTS["Bend"], 0.0, 3.0) add_in("Taper", 'NodeSocketFloat', DEFAULTS["Taper"], 0.0, 1.0) add_in("Branch Count", 'NodeSocketInt', DEFAULTS["Branch Count"], 0, 60) add_in("Branch Length", 'NodeSocketFloat', DEFAULTS["Branch Length"], 0.1, 10.0) add_in("Branch Up", 'NodeSocketFloat', DEFAULTS["Branch Up"], -3.0, 3.0) add_in("Branch Start", 'NodeSocketFloat', DEFAULTS["Branch Start"], 0.0, 1.0) add_in("Branch End", 'NodeSocketFloat', DEFAULTS["Branch End"], 0.0, 1.0) add_in("Branch Bend", 'NodeSocketFloat', DEFAULTS["Branch Bend"], 0.0, 2.0) # Droop darf NEGATIV sein: dann kruemmen sich die Aeste nach OBEN # (= Kaktus-Arme statt Palmwedel). add_in("Branch Droop", 'NodeSocketFloat', DEFAULTS["Branch Droop"], -3.0, 3.0) add_in("Branch Thickness", 'NodeSocketFloat', DEFAULTS["Branch Thickness"], 0.05, 1.0) add_in("Branch Taper", 'NodeSocketFloat', DEFAULTS["Branch Taper"], 0.0, 1.0) add_in("Sub Count", 'NodeSocketInt', DEFAULTS["Sub Count"], 0, 12) add_in("Sub Length", 'NodeSocketFloat', DEFAULTS["Sub Length"], 0.05, 5.0) add_in("Sub Up", 'NodeSocketFloat', DEFAULTS["Sub Up"], -3.0, 3.0) add_in("Sides", 'NodeSocketInt', DEFAULTS["Sides"], 3, 32) add_in("Tip Blunt", 'NodeSocketFloat', DEFAULTS["Tip Blunt"], 0.0, 0.9) add_in("UV Scale", 'NodeSocketFloat', DEFAULTS["UV Scale"], 0.01, 20.0) add_in("Ribs", 'NodeSocketFloat', DEFAULTS["Ribs"], 0.0, 20.0) add_in("Rib Depth", 'NodeSocketFloat', DEFAULTS["Rib Depth"], 0.0, 0.5) gin = N("NodeGroupInput"); gin.location = (-1400, 0) gout = N("NodeGroupOutput"); gout.location = (1400, 0) V = gin.outputs # ---------- Stamm ---------- line = N("GeometryNodeCurvePrimitiveLine"); line.location = (-1150, 200) top = N("ShaderNodeCombineXYZ"); top.location = (-1300, 120) L(V["Height"], top.inputs["Z"]) L(top.outputs[0], _sock(line, "End")) res = N("GeometryNodeResampleCurve"); res.location = (-950, 200) _sock(res, "Count").default_value = 24 # genug Punkte, damit die Kuppel rund wird L(_out(line, "Curve"), _sock(res, "Curve")) spar = N("GeometryNodeSplineParameter"); spar.location = (-950, -60) pos = N("GeometryNodeInputPosition"); pos.location = (-1150, -220) noise = N("ShaderNodeTexNoise"); noise.location = (-950, -260) noise.noise_dimensions = '4D' _sock(noise, "Scale").default_value = 0.55 L(pos.outputs[0], _sock(noise, "Vector")) L(V["Seed"], _sock(noise, "W")) nsub = N("ShaderNodeVectorMath"); nsub.location = (-750, -260) nsub.operation = 'SUBTRACT'; nsub.inputs[1].default_value = (0.5, 0.5, 0.5) L(noise.outputs["Color"], nsub.inputs[0]) # Falle 5: Z des Offsets platt machen flat = N("ShaderNodeVectorMath"); flat.location = (-580, -260) flat.operation = 'MULTIPLY'; flat.inputs[1].default_value = (1.0, 1.0, 0.0) L(nsub.outputs[0], flat.inputs[0]) # Falle 4: Offset mit Spline-Faktor skalieren -> Fuss bleibt stehen bendf = N("ShaderNodeMath"); bendf.location = (-750, -60) bendf.operation = 'MULTIPLY' L(V["Bend"], bendf.inputs[0]) L(spar.outputs["Factor"], bendf.inputs[1]) offs = N("ShaderNodeVectorMath"); offs.location = (-400, -200) offs.operation = 'SCALE' L(flat.outputs[0], offs.inputs[0]) L(bendf.outputs[0], _sock(offs, "Scale")) setpos = N("GeometryNodeSetPosition"); setpos.location = (-250, 200) L(_out(res, "Curve"), _sock(setpos, "Geometry")) L(offs.outputs[0], _sock(setpos, "Offset")) # Radius: TrunkRadius * (1 - Taper * factor) tf = N("ShaderNodeMath"); tf.location = (-580, 60); tf.operation = 'MULTIPLY' L(V["Taper"], tf.inputs[0]); L(spar.outputs["Factor"], tf.inputs[1]) inv = N("ShaderNodeMath"); inv.location = (-420, 60); inv.operation = 'SUBTRACT' inv.inputs[0].default_value = 1.0 L(tf.outputs[0], inv.inputs[1]) # Kuppel-Profil: (1 - f^3)^0.5 -> breite, gleichmaessige Kuppe. Mit einem # zu spaeten/steilen Profil (f^8) trifft die Rundung nur 1-2 Resample- # Punkte und wird zum KEGEL. Breit + genug Punkte = runde Kuppe. # Bei starkem Taper (Baum) dominiert ohnehin die lineare Verjuengung. def _dome(spar_node, x, y): pw = N("ShaderNodeMath"); pw.location = (x, y); pw.operation = 'POWER' pw.inputs[1].default_value = 3.0 L(spar_node.outputs["Factor"], pw.inputs[0]) s = N("ShaderNodeMath"); s.location = (x + 150, y); s.operation = 'SUBTRACT' s.inputs[0].default_value = 1.0 L(pw.outputs[0], s.inputs[1]) rt = N("ShaderNodeMath"); rt.location = (x + 300, y); rt.operation = 'POWER' rt.inputs[1].default_value = 0.5 L(s.outputs[0], rt.inputs[0]) return rt # Verjuengung UND Kuppel GEMEINSAM begrenzen, dann erst mit dem Basisradius # multiplizieren. Wirkt der Mindestwert nur auf die Kuppel, bleibt der bereits # verjuengte Radius trotzdem winzig — gemessen: Sub-Ast-Spitze 0.8 mm bei # 31 mm Basis, also weiterhin eine Nadel. def _shape(inv_taper_socket, dome_node, base_socket, x, y): mul = N("ShaderNodeMath"); mul.location = (x, y); mul.operation = 'MULTIPLY' L(inv_taper_socket, mul.inputs[0]); L(dome_node.outputs[0], mul.inputs[1]) mx = N("ShaderNodeMath"); mx.location = (x + 150, y); mx.operation = 'MAXIMUM' L(mul.outputs[0], mx.inputs[0]); L(V["Tip Blunt"], mx.inputs[1]) r = N("ShaderNodeMath"); r.location = (x + 300, y); r.operation = 'MULTIPLY' L(base_socket, r.inputs[0]); L(mx.outputs[0], r.inputs[1]) return r tdome = _dome(spar, -900, 220) trad = _shape(inv.outputs[0], tdome, V["Trunk Radius"], -260, 60) setrad = N("GeometryNodeSetCurveRadius"); setrad.location = (-80, 200) L(_out(setpos, "Geometry"), _sock(setrad, "Curve")) L(trad.outputs[0], _sock(setrad, "Radius")) # ---------- Ast-Ursprünge auf dem oberen Stamm ---------- trim = N("GeometryNodeTrimCurve"); trim.location = (100, 320) L(_out(setrad, "Curve"), _sock(trim, "Curve")) # Ansatzbereich der Aeste: Baum = breit gestreut, Palme = alles ganz oben. try: L(V["Branch Start"], _sock(trim, "Start")) L(V["Branch End"], _sock(trim, "End")) except KeyError: pass c2p = N("GeometryNodeCurveToPoints"); c2p.location = (280, 320) try: c2p.mode = 'COUNT' # Falle 3 except Exception: pass L(_out(trim, "Curve"), _sock(c2p, "Curve")) L(V["Branch Count"], _sock(c2p, "Count")) # ---------- Ast-Geometrie (verjüngte Linie) ---------- bline = N("GeometryNodeCurvePrimitiveLine"); bline.location = (100, 40) btop = N("ShaderNodeCombineXYZ"); btop.location = (-60, -20) L(V["Branch Length"], btop.inputs["Z"]) L(btop.outputs[0], _sock(bline, "End")) bres = N("GeometryNodeResampleCurve"); bres.location = (280, 40) _sock(bres, "Count").default_value = 14 # zu wenig -> Kuppel wird zum Kegel L(_out(bline, "Curve"), _sock(bres, "Curve")) # Ast-Verjuengung: 1 - BranchTaper*factor. Bei Kakteen klein halten, # sonst laufen die Arme spitz zu wie Dornen. bspar = N("GeometryNodeSplineParameter"); bspar.location = (280, -140) btap = N("ShaderNodeMath"); btap.location = (360, -240); btap.operation = 'MULTIPLY' L(V["Branch Taper"], btap.inputs[0]) L(bspar.outputs["Factor"], btap.inputs[1]) binv = N("ShaderNodeMath"); binv.location = (440, -140) binv.operation = 'SUBTRACT'; binv.inputs[0].default_value = 1.0 L(btap.outputs[0], binv.inputs[1]) # Ast-Dicke relativ zum Stamm. Beim Kaktus muessen die Arme fast so dick # sein wie der Stamm (0.7+), beim Baum deutlich duenner (~0.3). bbase = N("ShaderNodeMath"); bbase.location = (600, -140); bbase.operation = 'MULTIPLY' L(V["Trunk Radius"], bbase.inputs[0]); L(V["Branch Thickness"], bbase.inputs[1]) bdome = _dome(bspar, 600, -600) bscl = _shape(binv.outputs[0], bdome, bbase.outputs[0], 780, -140) bsetr = N("GeometryNodeSetCurveRadius"); bsetr.location = (600, 40) L(_out(bres, "Curve"), _sock(bsetr, "Curve")) L(bscl.outputs[0], _sock(bsetr, "Radius")) # ---------- Ausrichtung relativ zur ELTERNKURVE ---------- # Curve to Points liefert Tangent + Normal des Ansatzpunktes gratis mit. # Die Normale um die Tangente zu drehen (Index * Goldener Winkel) verteilt # die Aeste spiralig um den Elternast (Phyllotaxis) UND folgt automatisch # dessen Biegung. Vorher wurde die Richtung aus Weltkoordinaten gerechnet -> # Aeste standen wie angeklebte Nadeln quer zum Ast. def _child_dir(points_node, up_socket, x, y): i = N("GeometryNodeInputIndex"); i.location = (x, y + 220) a = N("ShaderNodeMath"); a.location = (x + 150, y + 220); a.operation = 'MULTIPLY' a.inputs[1].default_value = 2.399963 # Goldener Winkel L(i.outputs[0], a.inputs[0]) vr = N("ShaderNodeVectorRotate"); vr.location = (x + 320, y + 120) vr.rotation_type = 'AXIS_ANGLE' L(_out(points_node, "Normal"), vr.inputs["Vector"]) L(_out(points_node, "Tangent"), vr.inputs["Axis"]) L(a.outputs[0], vr.inputs["Angle"]) lean = N("ShaderNodeVectorMath"); lean.location = (x + 320, y - 60) lean.operation = 'SCALE' L(_out(points_node, "Tangent"), lean.inputs[0]) L(up_socket, _sock(lean, "Scale")) add = N("ShaderNodeVectorMath"); add.location = (x + 500, y + 40) add.operation = 'ADD' L(vr.outputs[0], add.inputs[0]); L(lean.outputs[0], add.inputs[1]) return add # ---------- radiale Ausrichtung je Ast ---------- dirv = _child_dir(c2p, V["Branch Up"], 100, 560) align = _new_any("FunctionNodeAlignRotationToVector", "FunctionNodeAlignEulerToVector") if align is not None: align.location = (900, 560) try: align.axis = 'Z' except Exception: pass L(dirv.outputs[0], _sock(align, "Vector")) inst = N("GeometryNodeInstanceOnPoints"); inst.location = (1050, 320) L(_out(c2p, "Points"), _sock(inst, "Points")) L(_out(bsetr, "Curve"), _sock(inst, "Instance")) if align is not None: L(align.outputs[0], _sock(inst, "Rotation")) real = N("GeometryNodeRealizeInstances"); real.location = (1200, 320) L(_out(inst, "Instances", "Geometry"), _sock(real, "Geometry")) # ---------- Ast-Biegung NACH dem Realize ---------- # Trick fuer Variation je Ast: hier ist die Position bereits die WELT-Position # des jeweiligen Astes. Dieselbe Noise liefert damit pro Ast einen anderen # Wert — vor dem Realize haetten alle Instanzen identische lokale Coords # (und wuerden exakt gleich gebogen). rspar = N("GeometryNodeSplineParameter"); rspar.location = (1200, 60) rpos = N("GeometryNodeInputPosition"); rpos.location = (1200, -100) rnoise = N("ShaderNodeTexNoise"); rnoise.location = (1350, -140) rnoise.noise_dimensions = '4D' _sock(rnoise, "Scale").default_value = 0.9 L(rpos.outputs[0], _sock(rnoise, "Vector")) L(V["Seed"], _sock(rnoise, "W")) rsub = N("ShaderNodeVectorMath"); rsub.location = (1500, -140) rsub.operation = 'SUBTRACT'; rsub.inputs[1].default_value = (0.5, 0.5, 0.5) L(rnoise.outputs["Color"], rsub.inputs[0]) # Staerke waechst zur Astspitze -> Ansatz bleibt am Stamm rfac = N("ShaderNodeMath"); rfac.location = (1350, 60); rfac.operation = 'MULTIPLY' L(V["Branch Bend"], rfac.inputs[0]) L(rspar.outputs["Factor"], rfac.inputs[1]) rscale = N("ShaderNodeVectorMath"); rscale.location = (1650, -140) rscale.operation = 'SCALE' L(rsub.outputs[0], rscale.inputs[0]) L(rfac.outputs[0], _sock(rscale, "Scale")) # Droop: Spitzen haengen nach unten (Palmwedel) -- quadratisch = schoene Kurve dsq = N("ShaderNodeMath"); dsq.location = (1350, -320); dsq.operation = 'MULTIPLY' L(rspar.outputs["Factor"], dsq.inputs[0]); L(rspar.outputs["Factor"], dsq.inputs[1]) dmul = N("ShaderNodeMath"); dmul.location = (1500, -320); dmul.operation = 'MULTIPLY' L(V["Branch Droop"], dmul.inputs[0]); L(dsq.outputs[0], dmul.inputs[1]) dneg = N("ShaderNodeMath"); dneg.location = (1650, -320); dneg.operation = 'MULTIPLY' dneg.inputs[1].default_value = -1.0 L(dmul.outputs[0], dneg.inputs[0]) dvec = N("ShaderNodeCombineXYZ"); dvec.location = (1800, -320) L(dneg.outputs[0], dvec.inputs["Z"]) roffs = N("ShaderNodeVectorMath"); roffs.location = (1800, -140) roffs.operation = 'ADD' L(rscale.outputs[0], roffs.inputs[0]) L(dvec.outputs[0], roffs.inputs[1]) rsetp = N("GeometryNodeSetPosition"); rsetp.location = (1950, 320) L(_out(real, "Geometry"), _sock(rsetp, "Geometry")) L(roffs.outputs[0], _sock(rsetp, "Offset")) # ---------- Sub-Aeste (zweite Verzweigungsebene) ---------- # Sitzen auf den bereits gebogenen Hauptaesten. Bei "Sub Count" = 0 liefert # Curve to Points keine Punkte -> es entsteht schlicht nichts (kein Fehler). strim = N("GeometryNodeTrimCurve"); strim.location = (2100, 520) L(_out(rsetp, "Geometry"), _sock(strim, "Curve")) try: _sock(strim, "Start").default_value = 0.3 _sock(strim, "End").default_value = 0.9 except KeyError: pass sp = N("GeometryNodeCurveToPoints"); sp.location = (2250, 520) try: sp.mode = 'COUNT' except Exception: pass L(_out(strim, "Curve"), _sock(sp, "Curve")) L(V["Sub Count"], _sock(sp, "Count")) sline = N("GeometryNodeCurvePrimitiveLine"); sline.location = (2100, 760) stop = N("ShaderNodeCombineXYZ"); stop.location = (1950, 700) L(V["Sub Length"], stop.inputs["Z"]) L(stop.outputs[0], _sock(sline, "End")) sres = N("GeometryNodeResampleCurve"); sres.location = (2250, 760) _sock(sres, "Count").default_value = 9 # genug Punkte, damit die Biegung sichtbar wird L(_out(sline, "Curve"), _sock(sres, "Curve")) # Sub-Aeste: gleiche Verjuengungs-Logik wie Hauptaeste (Branch Taper + Kuppel). # Vorher lief der Radius hier hart auf 0 -> die Sub-Aeste wurden zu Nadeln. sspar = N("GeometryNodeSplineParameter"); sspar.location = (2250, 900) # ... und verjuengen sich nur SCHWACH. Ein dicker Ansatz, der spitz zulaeuft, # ist genau die Dorn-Form - ein echter Zweig ist duenn und gleichmaessig. shalf = N("ShaderNodeMath"); shalf.location = (2180, 1000); shalf.operation = 'MULTIPLY' L(V["Branch Taper"], shalf.inputs[0]); shalf.inputs[1].default_value = 0.35 stap = N("ShaderNodeMath"); stap.location = (2330, 1000); stap.operation = 'MULTIPLY' L(shalf.outputs[0], stap.inputs[0]); L(sspar.outputs["Factor"], stap.inputs[1]) sinv = N("ShaderNodeMath"); sinv.location = (2400, 900) sinv.operation = 'SUBTRACT'; sinv.inputs[0].default_value = 1.0 L(stap.outputs[0], sinv.inputs[1]) sbase = N("ShaderNodeMath"); sbase.location = (2550, 900); sbase.operation = 'MULTIPLY' L(V["Trunk Radius"], sbase.inputs[0]) sbase.inputs[1].default_value = 0.12 # Zweige sind DUENN ... sdome = _dome(sspar, 2550, 1200) srad2 = _shape(sinv.outputs[0], sdome, sbase.outputs[0], 2750, 900) ssetr = N("GeometryNodeSetCurveRadius"); ssetr.location = (2550, 760) L(_out(sres, "Curve"), _sock(ssetr, "Curve")) L(srad2.outputs[0], _sock(ssetr, "Radius")) # Richtung: radial gestreut (Index) + "Sub Up" sdir = _child_dir(sp, V["Sub Up"], 2100, 1060) salign = _new_any("FunctionNodeAlignRotationToVector", "FunctionNodeAlignEulerToVector") if salign is not None: salign.location = (2700, 1060) try: salign.axis = 'Z' except Exception: pass L(sdir.outputs[0], _sock(salign, "Vector")) sinst = N("GeometryNodeInstanceOnPoints"); sinst.location = (2850, 520) L(_out(sp, "Points"), _sock(sinst, "Points")) L(_out(ssetr, "Curve"), _sock(sinst, "Instance")) if salign is not None: L(salign.outputs[0], _sock(sinst, "Rotation")) sreal = N("GeometryNodeRealizeInstances"); sreal.location = (3000, 520) L(_out(sinst, "Instances", "Geometry"), _sock(sreal, "Geometry")) # Sub-Aeste genauso biegen wie Stamm und Hauptaeste. Ohne diesen Schritt # blieben sie schnurgerade - das ist der Grund, warum sie wie angeklebte # Staebe wirkten. Auch hier NACH dem Realize, damit die Weltposition jedem # Zweig ein eigenes Noise gibt (sonst biegen alle identisch). sbspar = N("GeometryNodeSplineParameter"); sbspar.location = (3000, 300) sbpos = N("GeometryNodeInputPosition"); sbpos.location = (3000, 160) sbnoise = N("ShaderNodeTexNoise"); sbnoise.location = (3150, 160) sbnoise.noise_dimensions = '4D' _sock(sbnoise, "Scale").default_value = 1.6 L(sbpos.outputs[0], _sock(sbnoise, "Vector")) L(V["Seed"], _sock(sbnoise, "W")) sbsub = N("ShaderNodeVectorMath"); sbsub.location = (3320, 160) sbsub.operation = 'SUBTRACT'; sbsub.inputs[1].default_value = (0.5, 0.5, 0.5) L(sbnoise.outputs["Color"], sbsub.inputs[0]) sbfac = N("ShaderNodeMath"); sbfac.location = (3150, 300); sbfac.operation = 'MULTIPLY' L(V["Branch Bend"], sbfac.inputs[0]) L(sbspar.outputs["Factor"], sbfac.inputs[1]) sbamt = N("ShaderNodeMath"); sbamt.location = (3320, 300); sbamt.operation = 'MULTIPLY' sbamt.inputs[1].default_value = 0.5 # Zweige biegen etwas weniger als Aeste L(sbfac.outputs[0], sbamt.inputs[0]) sboff = N("ShaderNodeVectorMath"); sboff.location = (3480, 220) sboff.operation = 'SCALE' L(sbsub.outputs[0], sboff.inputs[0]) L(sbamt.outputs[0], _sock(sboff, "Scale")) sbset = N("GeometryNodeSetPosition"); sbset.location = (3640, 520) L(_out(sreal, "Geometry"), _sock(sbset, "Geometry")) L(sboff.outputs[0], _sock(sbset, "Offset")) join = N("GeometryNodeJoinGeometry"); join.location = (3150, 200) L(_out(sbset, "Geometry"), join.inputs[0]) L(_out(rsetp, "Geometry"), join.inputs[0]) L(_out(setrad, "Curve"), join.inputs[0]) # ---------- Curve -> Mesh ---------- # Falle 6: Der Profil-Radius wird mit dem Curve-Radius MULTIPLIZIERT. # Profil deshalb auf 1.0 lassen waere richtig -- aber nur, wenn der # Curve-Radius bereits die echte Staerke ist. Hier ist er das, also 1.0. circ = N("GeometryNodeCurvePrimitiveCircle"); circ.location = (1350, -100) L(V["Sides"], _sock(circ, "Resolution")) _sock(circ, "Radius").default_value = 1.0 # ---------- Rippen (senkrechte Kanneluren, Kaktus-Signatur) ---------- # Das Profil bekommt eine radiale Welle: Offset entlang der Punktrichtung, # moduliert mit cos(Rippen * Winkel). Rib Depth = 0 -> glatter Kreis. pspar = N("GeometryNodeSplineParameter"); pspar.location = (1350, -420) pang = N("ShaderNodeMath"); pang.location = (1500, -420); pang.operation = 'MULTIPLY' pang.inputs[1].default_value = 6.283185 L(pspar.outputs["Factor"], pang.inputs[0]) pribs = N("ShaderNodeMath"); pribs.location = (1650, -420); pribs.operation = 'MULTIPLY' L(pang.outputs[0], pribs.inputs[0]) L(V["Ribs"], pribs.inputs[1]) pcos = N("ShaderNodeMath"); pcos.location = (1800, -420); pcos.operation = 'COSINE' L(pribs.outputs[0], pcos.inputs[0]) pamp = N("ShaderNodeMath"); pamp.location = (1950, -420); pamp.operation = 'MULTIPLY' L(pcos.outputs[0], pamp.inputs[0]) L(V["Rib Depth"], pamp.inputs[1]) ppos = N("GeometryNodeInputPosition"); ppos.location = (1650, -560) pdir = N("ShaderNodeVectorMath"); pdir.location = (1800, -560) pdir.operation = 'NORMALIZE' L(ppos.outputs[0], pdir.inputs[0]) poff = N("ShaderNodeVectorMath"); poff.location = (2100, -520) poff.operation = 'SCALE' L(pdir.outputs[0], poff.inputs[0]) L(pamp.outputs[0], _sock(poff, "Scale")) pset = N("GeometryNodeSetPosition"); pset.location = (2250, -100) L(_out(circ, "Curve"), _sock(pset, "Geometry")) L(poff.outputs[0], _sock(pset, "Offset")) # ---------- UVs (Curve to Mesh erzeugt KEINE - gemessen!) ---------- # V = echte Bogenlaenge entlang Stamm/Ast, U = Bogenlaenge um das Profil mal # Radius => world-space UVs: Rinde sitzt auf dickem Stamm und duennem Zweig # gleich dicht. Attribute VOR Curve to Mesh ablegen, danach kombinieren. cspar = N("GeometryNodeSplineParameter"); cspar.location = (2250, 60) stv = N("GeometryNodeStoreNamedAttribute"); stv.location = (2400, 200) stv.domain = 'POINT'; stv.data_type = 'FLOAT' L(_out(join, "Geometry"), _sock(stv, "Geometry")) _sock(stv, "Name").default_value = "uv_v" L(cspar.outputs["Length"], _sock(stv, "Value")) crad = N("GeometryNodeInputRadius"); crad.location = (2250, -80) stw = N("GeometryNodeStoreNamedAttribute"); stw.location = (2550, 200) stw.domain = 'POINT'; stw.data_type = 'FLOAT' L(_out(stv, "Geometry"), _sock(stw, "Geometry")) _sock(stw, "Name").default_value = "uv_r" L(crad.outputs[0], _sock(stw, "Value")) pspar2 = N("GeometryNodeSplineParameter"); pspar2.location = (2250, -700) stu = N("GeometryNodeStoreNamedAttribute"); stu.location = (2400, -100) stu.domain = 'POINT'; stu.data_type = 'FLOAT' L(_out(pset, "Geometry"), _sock(stu, "Geometry")) _sock(stu, "Name").default_value = "uv_u" L(pspar2.outputs["Length"], _sock(stu, "Value")) c2m = N("GeometryNodeCurveToMesh"); c2m.location = (1500, 200) L(_out(stw, "Geometry"), _sock(c2m, "Curve")) L(_out(stu, "Geometry"), _sock(c2m, "Profile Curve")) # Falle 7 (Blender 5.x!): "Curve to Mesh" wertet das Radius-Attribut NICHT # mehr implizit aus, sondern hat einen eigenen "Scale"-Eingang. Ohne diese # Verbindung bleibt der Stamm immer bei Profil-Radius 1.0 (= 2 m dick), # egal was "Trunk Radius" sagt. Gemessen: Stammbreite konstant 1.995. if "Scale" in c2m.inputs: radattr = N("GeometryNodeInputRadius"); radattr.location = (1350, -260) L(radattr.outputs[0], _sock(c2m, "Scale")) try: _sock(c2m, "Fill Caps").default_value = True except KeyError: pass # UVMap aus den drei Attributen zusammensetzen (FACE_CORNER, sonst kein UV) na_u = N("GeometryNodeInputNamedAttribute"); na_u.location = (2700, -300) na_u.data_type = 'FLOAT'; _sock(na_u, "Name").default_value = "uv_u" na_v = N("GeometryNodeInputNamedAttribute"); na_v.location = (2700, -420) na_v.data_type = 'FLOAT'; _sock(na_v, "Name").default_value = "uv_v" na_r = N("GeometryNodeInputNamedAttribute"); na_r.location = (2700, -540) na_r.data_type = 'FLOAT'; _sock(na_r, "Name").default_value = "uv_r" umul = N("ShaderNodeMath"); umul.location = (2880, -300); umul.operation = 'MULTIPLY' L(_out(na_u, "Attribute"), umul.inputs[0]); L(_out(na_r, "Attribute"), umul.inputs[1]) us = N("ShaderNodeMath"); us.location = (3030, -300); us.operation = 'MULTIPLY' L(umul.outputs[0], us.inputs[0]); L(V["UV Scale"], us.inputs[1]) vs = N("ShaderNodeMath"); vs.location = (3030, -420); vs.operation = 'MULTIPLY' L(_out(na_v, "Attribute"), vs.inputs[0]); L(V["UV Scale"], vs.inputs[1]) uvvec = N("ShaderNodeCombineXYZ"); uvvec.location = (3180, -360) L(us.outputs[0], uvvec.inputs["X"]); L(vs.outputs[0], uvvec.inputs["Y"]) stuv = N("GeometryNodeStoreNamedAttribute"); stuv.location = (1650, 60) stuv.domain = 'CORNER'; stuv.data_type = 'FLOAT2' L(_out(c2m, "Mesh", "Geometry"), _sock(stuv, "Geometry")) _sock(stuv, "Name").default_value = "UVMap" L(uvvec.outputs[0], _sock(stuv, "Value")) shade = N("GeometryNodeSetShadeSmooth"); shade.location = (1650, 200) L(_out(stuv, "Geometry"), _sock(shade, "Geometry")) L(_out(shade, "Geometry"), gout.inputs[0]) return ng # Wachstums-Stufen: Faktoren/Werte fuer t = 0 (Setzling) .. 1 (ausgewachsen). # Gleicher Seed -> dieselbe Baum-"Identitaet", nur juenger. Genau das braucht # ein Wachstums-System, damit Stufe 1 und Stufe 4 wie DERSELBE Baum wirken. GROWTH_YOUNG = { "Height": 0.16, # Faktoren (werden mit dem Zielwert multipliziert) "Trunk Radius": 0.30, "Branch Length": 0.30, "Branch Count": 0.35, "Sub Count": 0.0, # Setzling hat noch keine Sub-Aeste } def growth_values(preset, t): """Parameter fuer Wachstums-Fortschritt t (0 = Setzling, 1 = ausgewachsen).""" full = dict(DEFAULTS) full.update(PRESETS.get(preset, {})) out = dict(full) t = max(0.0, min(1.0, t)) for key, young_factor in GROWTH_YOUNG.items(): if key not in full: continue target = full[key] young = target * young_factor v = young + (target - young) * t out[key] = max(1, int(round(v))) if isinstance(target, int) and key != "Sub Count" \ else (int(round(v)) if isinstance(target, int) else v) return out def socket_ids(ng): """Name -> Modifier-Key ('Socket_3'). Fuer Presets/Skripting.""" out = {} for s in ng.interface.items_tree: if getattr(s, "in_out", "") == 'INPUT': out[s.name] = s.identifier return out def make_object(ng, preset="baum", name=None, growth=None, seed=None): name = name or OBJ_NAME old = bpy.data.objects.get(name) if old: bpy.data.objects.remove(old, do_unlink=True) me = bpy.data.meshes.new(name + "Mesh") ob = bpy.data.objects.new(name, me) bpy.context.scene.collection.objects.link(ob) md = ob.modifiers.new("GN_Tree", 'NODES') md.node_group = ng ids = socket_ids(ng) if growth is None: values = dict(DEFAULTS) values.update(PRESETS.get(preset, {})) else: values = growth_values(preset, growth) if seed is not None: values["Seed"] = seed for k, v in values.items(): if k in ids: md[ids[k]] = v bpy.context.view_layer.objects.active = ob return ob # --------------------------------------------------------------------------- # Addon-UI # --------------------------------------------------------------------------- from bpy.props import ( IntProperty, FloatProperty, EnumProperty, BoolProperty, PointerProperty, ) from bpy.types import Operator, Panel, PropertyGroup def _preset_enum(self, context): return [(k, k.capitalize(), "") for k in PRESETS] class TreeGenSettings(PropertyGroup): preset: EnumProperty(name="Preset", items=_preset_enum) seed: IntProperty(name="Seed", default=0, min=0, max=9999) use_growth: BoolProperty( name="Wachstums-Stufen", default=False, description="Erzeugt mehrere Stufen (Setzling .. ausgewachsen) mit gleichem Seed", ) stages: IntProperty(name="Anzahl Stufen", default=4, min=2, max=8) spacing: FloatProperty(name="Abstand", default=4.0, min=0.0, max=20.0) apply_modifier: BoolProperty( name="Modifier anwenden", default=False, description="Ergebnis als normales Mesh einfrieren (fuer den Export)", ) generate_lightmap_uv: BoolProperty( name="UV1 Lightmap-UV", default=True, description=("Zweiter, nicht ueberlappender UV-Kanal fuer Unreal-Lightmaps. " "Braucht 'Modifier anwenden' (nur echte Meshes lassen sich unwrappen)"), ) def _place(ob, x): ob.location.x = x def _add_lightmap_uv(context, ob, op): """UV1 fuer Unreal-Lightmaps. Nur auf echten Meshes moeglich (nach Apply).""" if not ob.data.polygons: return lm = ob.data.uv_layers.get("Lightmap") or ob.data.uv_layers.new(name="Lightmap") # WICHTIG: aktiven Kanal setzen, sonst ueberschreibt lightmap_pack UV0. ob.data.uv_layers.active = lm for o in context.view_layer.objects: try: o.select_set(False) except (ReferenceError, RuntimeError): pass ob.select_set(True) context.view_layer.objects.active = ob try: bpy.ops.uv.lightmap_pack(PREF_CONTEXT='ALL_FACES', PREF_PACK_IN_ONE=True, PREF_NEW_UVLAYER=False, PREF_BOX_DIV=12, PREF_MARGIN_DIV=0.2) except RuntimeError as exc: op.report({'WARNING'}, "Lightmap-UV: %s" % exc) # UV0 wieder als Kanal 0 / Render-UV (Unreal nutzt die Reihenfolge) if ob.data.uv_layers: ob.data.uv_layers.active_index = 0 ob.data.uv_layers[0].active_render = True class TREEGEN_OT_create(Operator): bl_idname = "object.treegen_create" bl_label = "Baum erzeugen" bl_description = "Erzeugt einen parametrischen Baum (Regler danach am Modifier)" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.tree_gen_settings try: ng = build_group() except Exception as exc: # noqa: BLE001 self.report({'ERROR'}, "Node-Gruppe fehlgeschlagen: %s" % exc) return {'CANCELLED'} try: ng.asset_mark() ng.asset_data.description = "Parametrischer Stylized-Baum (Stamm + Aeste)" except Exception: pass created = [] x = 0.0 try: if s.use_growth: for i in range(s.stages): t = i / float(s.stages - 1) if s.stages > 1 else 1.0 ob = make_object(ng, preset=s.preset, growth=t, seed=s.seed, name="%s_%s_stage%d" % (OBJ_NAME, s.preset, i)) _place(ob, x); x += s.spacing created.append(ob) else: ob = make_object(ng, preset=s.preset, seed=s.seed, name="%s_%s" % (OBJ_NAME, s.preset)) created.append(ob) except Exception as exc: # noqa: BLE001 self.report({'ERROR'}, "Erzeugen fehlgeschlagen: %s" % exc) return {'CANCELLED'} if s.apply_modifier: for ob in created: context.view_layer.objects.active = ob try: bpy.ops.object.modifier_apply(modifier="GN_Tree") except RuntimeError as exc: self.report({'WARNING'}, "Modifier-Apply: %s" % exc) continue # UV0 heisst nach dem Apply "UVMap" (aus den Geometry Nodes). if s.generate_lightmap_uv: _add_lightmap_uv(context, ob, self) context.view_layer.update() total = 0 dg = context.evaluated_depsgraph_get() for ob in created: try: total += len(ob.evaluated_get(dg).data.vertices) except Exception: pass self.report({'INFO'}, "%d Objekt(e) erzeugt, %d Verts gesamt." % (len(created), total)) return {'FINISHED'} class VIEW3D_PT_tree_generator(Panel): bl_label = "Tree Generator" bl_idname = "VIEW3D_PT_tree_generator" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "Tree Gen" def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings box = layout.box() box.label(text="Vorlage", icon='PRESET') box.prop(s, "preset", text="") box.prop(s, "seed") box = layout.box() box.label(text="Wachstum") box.prop(s, "use_growth") sub = box.column(align=True) sub.enabled = s.use_growth sub.prop(s, "stages") sub.prop(s, "spacing") box = layout.box() box.label(text="Export") box.prop(s, "apply_modifier") row = box.row() row.enabled = s.apply_modifier row.prop(s, "generate_lightmap_uv") layout.separator() layout.operator("object.treegen_create", icon='OUTLINER_OB_MESH') layout.label(text="Feinjustierung: am Modifier 'GN_Tree'", icon='INFO') classes = ( TreeGenSettings, TREEGEN_OT_create, VIEW3D_PT_tree_generator, ) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.tree_gen_settings = PointerProperty(type=TreeGenSettings) def unregister(): del bpy.types.Scene.tree_gen_settings for cls in reversed(classes): bpy.utils.unregister_class(cls) if __name__ == "__main__": register()