bl_info = { "name": "Stylized Tree Generator", "author": "D4rkst3r", "version": (1, 28, 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 bmesh import math import os from mathutils import Matrix, Vector import sys import os GROUP_NAME = "GN_StylizedTree" OBJ_NAME = "StylizedTree" # ============================ REGLER (Defaults) ============================ DEFAULTS = { "Seed": 0, "Height": 5.0, "Trunk Radius": 0.07, # TreeIt-Verhaeltnis: Radius/Hoehe ~0.013 # (0.079 bei 6.04 m). Bei 5 m also ~0.065-0.07. "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) "Droop Curve": 2.0, # 2 = weicher Bogen, 4+ = scharfer Ellbogen "Attraction Up": 0.45, # Phototropismus in Radiant: Ast dreht sich zur # Spitze hin zur Senkrechten. Echte Rotation um # den Ansatzpunkt -> Astlaenge bleibt erhalten "Trunk Tip": 0.3, # Anteil von Tip Blunt fuer die Stammspitze; # klein = laeuft duenn aus, 1.0 = stumpfe Kuppe "Branch Thickness": 0.17, # Ast-Dicke relativ zum Stamm. Am TreeIt-Baum # gemessen: 0.008-0.013 bei Stamm 0.079 = 11-16 %. # 0.32 war doppelt zu dick -> grobe Ansaetze. "Branch Taper": 0.55, # schwach verjuengen -> Roehre statt Kegel/Dorn "Sub Count": 2, # Sub-Aeste je Hauptast (0 = aus) "Sub Start": 0.45, # Ansatzbereich der Zweige am Ast ... "Sub End": 0.95, # ... nahe 1.0 + hohes Sub Up = Gabelung "Sub Length": 0.55, "Sub Up": 2.2, # hoch = Zweig folgt dem Ast (Gabel statt Nadel) "Sides": 12, # Stamm rund genug fuer die Wurzelbretter # (+104 Tris ggue. 8 - gemessen, lohnt sich) "Branch Sides": 4, # Aeste sparsamer "Sub Sides": 3, # Zweige nur Dreiecke - sieht man nicht "UV Scale": 1.0, # Rinden-Dichte; UVs sind world-space (m) "Tip Blunt": 0.18, # >0 verhindert Nadelspitzen (0 = spitz, 0.45 = Kaktus) "Crown Taper": 0.35, # >0 kegelfoermig, 0 saeulig, <0 besenfoermig "Crown Bulge": 0.0, # >0 = Bauch in der Mitte (runde Krone) "Root Flare": 1.55, # Wurzelanlauf. Ziel ist das TreeIt-Verhaeltnis # Fuss/Stamm ~2.2 (dort 0.179 zu 0.082). "Root Lobes": 6.0, # Wurzelbretter; sollte in "Sides" aufgehen, # sonst sampelt das Profil die Lappen weg "Root Lobe Depth": 0.65, # wie weit die Lappen rausstehen. Am TreeIt-Baum # gemessen: Unrundheit am Basisring 1.60. # Messfalle: Ringe nicht ueber z-Bereiche mitteln, # sonst mischt man Nachbarringe rein (ergab faelschlich 5.1). "Root Height": 1.1, # Hoehe (m) des Anlaufs; zu klein -> zu wenige # Ringe liegen drin und man sieht nichts "Detail": 0.55, # skaliert alle Resample-Counts = Tri-Budget-Regler "Branch Collar": 0.55, # Ansatz-Verdickung: Uebergang Ast->Stamm (0 = aus) "Merge": 0.0, # 1 = Aeste per Voxel-Remesh mit dem Stamm verschmelzen "Merge Voxel": 0.03, # Voxelgroesse (m): klein = feiner, aber teuer "Ribs": 0.0, # senkrechte Kanneluren (0 = glatt); Kaktus ~9 "Rib Depth": 0.0, } # Presets: nur die Abweichungen von DEFAULTS. PRESETS = { "baum": {}, # Palme - am TreeIt-Referenzbaum ausgemessen (palm07_0 + frond stem_1): # Stamm 4.33 m, Radius 0.133 unten -> ~0.086 Mitte -> 0.106 OBEN # (Kronenschaft!), 11 Seiten, nur 150 Tris. # Wedel-STIELE sind kurze Stummel von 0.49-0.80 m - die langen Wedel machen # dort die BLATT-Cards (Spannweite 3.3 m, eigenes Objekt). Mein Generator # liefert also nur die Stiele; die Fiederung kommt als _Leaf-Ebene dazu. "palme": { "Height": 4.4, "Trunk Radius": 0.133, "Bend": 0.7, "Taper": 0.35, # sanft, nicht kegelig "Tip Blunt": 0.7, # Krone bleibt dick (Kronenschaft) UND die # Stiele laufen nicht drahtig aus "Trunk Tip": 1.0, # Wedel-Geometrie nach zwei unabhaengigen Quellen, die sich decken: # tree-gen palm.py (Weber&Penn) sagt length[1] = 0.3 x Stammlaenge, # branches[1] = 25, down_angle 60 Grad, curve 40 Grad, tropism -3. # Die TreeIt-Referenzpalme kommt auf Spannweite 3.3 m bei 4.33 m Stamm # = Kronenradius/Hoehe 0.38. Ziel also 0.30-0.38. # Vorher: Length 0.78 + Up 0.9 ergab GEMESSEN 0.11 - die Krone war ein # Mop-Kopf, der am Stamm klebte, statt einer ausladenden Krone. # Jetzt gemessen: 0.34, Wedel steigen 1.1 m ueber den Ansatz und kippen # dann ab (echter Bogen statt Antennenspeichen oder Haengemop). "Branch Count": 18, "Branch Length": 2.0, "Branch Up": 0.35, "Branch Start": 0.9, "Branch End": 1.0, "Branch Bend": 0.25, "Branch Droop": 1.2, "Droop Curve": 2.2, "Attraction Up": 0.2, # Wedel schwingen leicht hoch, dann Bogen "Branch Thickness": 0.16, "Branch Taper": 0.45, "Root Flare": 0.5, # leichte Verdickung ... "Root Lobes": 0.0, "Root Lobe Depth": 0.0, # ... ohne Wurzelbretter "Crown Taper": 0.0, "Crown Bulge": 0.0, "Sub Count": 0, # Fiederung = _Leaf-Ebene "Detail": 0.55, "Sides": 11, "Branch Sides": 4, }, # Strauch statt Kleinbaum. Botanisch ist der Unterschied NICHT die Groesse, # sondern die Architektur: ein Strauch hat mehrere Stiele, die dicht ueber # dem Boden entspringen, und keinen durchgehenden Leittrieb; gemessen wird # er nach Hoehe UND Breite. Vorher: ein durchgehender Stamm ueber die volle # Hoehe, Aeste erst ab 15 %, Wurzelanlauf wie beim Baum - gemessen 1.81 m # hoch bei 0.84 m Breite (B/H 0.46). Das IST ein kleiner Baum. # Jetzt: kurzer Stumpf (Height 0.7), Aeste ab dem Boden (Branch Start 0.0) # und laenger als der Stumpf - damit uebernehmen die Aeste die Rolle der # Stiele. Gemessen: 1.40 m hoch, 2.16 m breit (B/H 1.54), 640 Tris. "busch": { "Height": 0.7, "Trunk Radius": 0.07, "Bend": 0.4, "Taper": 0.8, "Branch Count": 14, "Branch Length": 1.55, "Branch Up": 0.35, "Branch Start": 0.0, "Branch End": 0.95, "Branch Bend": 0.6, "Branch Droop": 0.0, "Root Flare": 0.2, # Straeucher haben keinen Wurzelanlauf "Sub Count": 1, "Sub Length": 0.35, "Sub Up": 1.6, "Sides": 4, "Detail": 0.3, }, # --- Varianten ueber die Kronenform (Idee: modular_tree "envelope shapes") --- "tanne": { # kegelfoermig: unten lange Aeste, oben kurz, leicht haengend "Height": 7.0, "Trunk Radius": 0.15, "Bend": 0.15, "Taper": 0.95, "Branch Count": 22, "Branch Length": 1.9, "Branch Up": -0.25, "Branch Start": 0.12, "Branch End": 0.97, "Crown Taper": 0.9, "Crown Bulge": 0.0, "Attraction Up": 0.0, # Tannenaeste haengen, streben nicht hoch "Branch Bend": 0.3, "Branch Thickness": 0.12, "Sub Count": 1, "Sub Length": 0.4, "Root Flare": 0.7, }, "eiche": { # runde, breite Krone: Bauch in der Mitte, dicker Stamm "Height": 5.5, "Trunk Radius": 0.24, "Bend": 0.5, "Taper": 0.75, "Branch Count": 10, "Branch Length": 2.2, "Branch Up": 0.9, "Branch Start": 0.3, "Branch End": 0.95, "Crown Taper": -0.15, "Crown Bulge": 0.5, "Attraction Up": 0.5, # Eiche: Aeste schwingen deutlich hoch "Branch Bend": 0.9, "Branch Thickness": 0.24, "Sub Count": 3, "Sub Length": 0.8, "Root Flare": 1.5, "Root Lobe Depth": 0.4, }, "birke": { # schlank, hoher Ansatz, feine Zweige "Height": 7.5, "Trunk Radius": 0.09, "Bend": 0.7, "Taper": 0.92, "Branch Count": 12, "Branch Length": 1.4, "Branch Up": 1.1, "Branch Start": 0.45, "Branch End": 0.98, "Crown Taper": 0.1, "Crown Bulge": 0.25, "Branch Bend": 0.8, "Branch Thickness": 0.11, "Sub Count": 3, "Sub Length": 0.5, "Root Flare": 0.6, }, "weide": { # haengende Zweige (Droop positiv), breit ausladend "Height": 5.0, "Trunk Radius": 0.18, "Bend": 0.8, "Taper": 0.8, "Branch Count": 12, "Branch Length": 2.0, "Branch Up": 0.5, "Branch Start": 0.35, "Branch End": 0.95, "Crown Taper": -0.2, "Crown Bulge": 0.4, "Attraction Up": -0.25, # Weide: Zweige haengen "Branch Droop": 1.2, "Branch Bend": 0.7, "Branch Thickness": 0.16, "Sub Count": 3, "Sub Length": 0.9, "Root Flare": 1.2, }, "toter_baum": { # knorrig, kaum Zweige, starke Kruemmung "Height": 4.5, "Trunk Radius": 0.17, "Bend": 1.5, "Taper": 0.88, "Branch Count": 6, "Branch Length": 1.5, "Branch Up": 0.9, "Branch Start": 0.3, "Branch End": 0.9, "Crown Taper": 0.3, "Crown Bulge": 0.0, "Attraction Up": 0.35, "Branch Bend": 1.4, "Branch Thickness": 0.18, "Sub Count": 1, "Sub Length": 0.5, "Root Flare": 1.4, "Tip Blunt": 0.08, }, # Kaktus: dicker, kaum verjuengter Stamm, wenige Arme, die per NEGATIVEM # Droop nach oben kruemmen (Saguaro-Silhouette). Keine Sub-Aeste. "kaktus": { "Height": 3.4, "Trunk Radius": 0.28, "Bend": 0.08, "Taper": 0.10, "Branch Count": 2, "Branch Length": 1.9, "Branch Up": 0.12, "Branch Start": 0.30, "Branch End": 0.46, "Branch Bend": 0.0, "Attraction Up": 0.0, # Kaktus nutzt den Droop-Ellbogen "Branch Droop": -1.35, # negativ = Arme kruemmen nach OBEN (Saguaro); # zu gross und die Arme ueberragen den Stamm "Droop Curve": 4.5, # spaeter, dafuer scharfer Ellbogen "Branch Thickness": 0.72, # Arme fast so dick wie der Stamm "Branch Taper": 0.12, # Arme bleiben dick statt spitz zuzulaufen "Sub Count": 0, "Crown Taper": 0.0, "Root Flare": 0.25, "Trunk Tip": 1.0, "Root Lobe Depth": 0.0, "Tip Blunt": 0.45, "Sides": 16, "Branch Sides": 12, "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 _count(base, x, y): """Resample-Count = base * Detail, mindestens 3. Der zentrale Tri-Budget-Regler: die Segmentzahl entlang der Kurven dominiert die Dreiecke (Tris ~ Segmente * Sides * 2).""" mul = N("ShaderNodeMath"); mul.location = (x, y); mul.operation = 'MULTIPLY' mul.inputs[0].default_value = float(base) L(V["Detail"], mul.inputs[1]) mx = N("ShaderNodeMath"); mx.location = (x + 150, y); mx.operation = 'MAXIMUM' mx.inputs[1].default_value = 3.0 L(mul.outputs[0], mx.inputs[0]) return mx 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("Droop Curve", 'NodeSocketFloat', DEFAULTS["Droop Curve"], 1.0, 8.0) add_in("Attraction Up", 'NodeSocketFloat', DEFAULTS["Attraction Up"], -1.5, 1.5) add_in("Trunk Tip", 'NodeSocketFloat', DEFAULTS["Trunk Tip"], 0.05, 1.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 Start", 'NodeSocketFloat', DEFAULTS["Sub Start"], 0.0, 1.0) add_in("Sub End", 'NodeSocketFloat', DEFAULTS["Sub End"], 0.0, 1.0) 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("Branch Sides", 'NodeSocketInt', DEFAULTS["Branch Sides"], 3, 16) add_in("Sub Sides", 'NodeSocketInt', DEFAULTS["Sub Sides"], 3, 12) 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("Detail", 'NodeSocketFloat', DEFAULTS["Detail"], 0.2, 2.0) add_in("Crown Taper", 'NodeSocketFloat', DEFAULTS["Crown Taper"], -1.5, 1.5) add_in("Crown Bulge", 'NodeSocketFloat', DEFAULTS["Crown Bulge"], -1.0, 2.0) add_in("Root Flare", 'NodeSocketFloat', DEFAULTS["Root Flare"], 0.0, 4.0) add_in("Root Lobes", 'NodeSocketFloat', DEFAULTS["Root Lobes"], 0.0, 12.0) add_in("Root Lobe Depth", 'NodeSocketFloat', DEFAULTS["Root Lobe Depth"], 0.0, 3.0) add_in("Root Height", 'NodeSocketFloat', DEFAULTS["Root Height"], 0.05, 3.0) add_in("Branch Collar", 'NodeSocketFloat', DEFAULTS["Branch Collar"], 0.0, 3.0) add_in("Merge", 'NodeSocketFloat', DEFAULTS["Merge"], 0.0, 1.0) add_in("Merge Voxel", 'NodeSocketFloat', DEFAULTS["Merge Voxel"], 0.005, 0.2) 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) L(_count(24, -1150, 380).outputs[0], _sock(res, "Count")) 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, spar_node=None, collar=False): 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]) src = mx if collar and spar_node is not None: # Ansatz-Verdickung ("Collar"): der Ast wird an seiner Basis breiter # und laeuft schnell aus -> der Uebergang zum Stamm sieht gewachsen # aus statt wie eine durchgesteckte Roehre. Kostet KEINE Verts. ci = N("ShaderNodeMath"); ci.location = (x, y - 160); ci.operation = 'SUBTRACT' ci.inputs[0].default_value = 1.0 L(spar_node.outputs["Factor"], ci.inputs[1]) cp = N("ShaderNodeMath"); cp.location = (x + 150, y - 160); cp.operation = 'POWER' # Exponent NIEDRIG halten: bei ~7 Segmenten pro Ast ist ein hoher # Exponent nur EINEN Punkt breit -> sichtbarer Widerhaken statt # Uebergang. 2.5 verteilt die Verdickung auf 2-3 Segmente. cp.inputs[1].default_value = 2.5 L(ci.outputs[0], cp.inputs[0]) cm = N("ShaderNodeMath"); cm.location = (x + 300, y - 160); cm.operation = 'MULTIPLY' L(V["Branch Collar"], cm.inputs[0]); L(cp.outputs[0], cm.inputs[1]) ca = N("ShaderNodeMath"); ca.location = (x + 450, y - 160); ca.operation = 'ADD' ca.inputs[0].default_value = 1.0 L(cm.outputs[0], ca.inputs[1]) cf = N("ShaderNodeMath"); cf.location = (x + 450, y - 60); cf.operation = 'MULTIPLY' L(mx.outputs[0], cf.inputs[0]); L(ca.outputs[0], cf.inputs[1]) src = cf r = N("ShaderNodeMath"); r.location = (x + 620, y); r.operation = 'MULTIPLY' L(base_socket, r.inputs[0]); L(src.outputs[0], r.inputs[1]) return r tdome = _dome(spar, -900, 220) # Stammspitze duenner auslaufen lassen als die Aeste: sonst endet der Stamm # mit einem sichtbaren flachen Deckel. Ein eigener, kleinerer Mindestwert. ttip = N("ShaderNodeMath"); ttip.location = (-420, -80); ttip.operation = 'MULTIPLY' L(V["Tip Blunt"], ttip.inputs[0]); L(V["Trunk Tip"], ttip.inputs[1]) tmulr = N("ShaderNodeMath"); tmulr.location = (-260, 60); tmulr.operation = 'MULTIPLY' L(inv.outputs[0], tmulr.inputs[0]); L(tdome.outputs[0], tmulr.inputs[1]) tmaxr = N("ShaderNodeMath"); tmaxr.location = (-110, 60); tmaxr.operation = 'MAXIMUM' L(tmulr.outputs[0], tmaxr.inputs[0]); L(ttip.outputs[0], tmaxr.inputs[1]) # Wurzelanlauf: der Stamm verbreitert sich am Fuss. Hoher Exponent -> nur # das unterste Stueck ist betroffen. Kostet keine Tris, nur Radius. rfi = N("ShaderNodeMath"); rfi.location = (-420, -220); rfi.operation = 'SUBTRACT' rfi.inputs[0].default_value = 1.0 L(spar.outputs["Factor"], rfi.inputs[1]) rfp = N("ShaderNodeMath"); rfp.location = (-270, -220); rfp.operation = 'POWER' rfp.inputs[1].default_value = 9.0 L(rfi.outputs[0], rfp.inputs[0]) rfm = N("ShaderNodeMath"); rfm.location = (-120, -220); rfm.operation = 'MULTIPLY' L(V["Root Flare"], rfm.inputs[0]); L(rfp.outputs[0], rfm.inputs[1]) rfa = N("ShaderNodeMath"); rfa.location = (30, -220); rfa.operation = 'ADD' rfa.inputs[0].default_value = 1.0 L(rfm.outputs[0], rfa.inputs[1]) tflare = N("ShaderNodeMath"); tflare.location = (30, -60); tflare.operation = 'MULTIPLY' L(tmaxr.outputs[0], tflare.inputs[0]); L(rfa.outputs[0], tflare.inputs[1]) trad = N("ShaderNodeMath"); trad.location = (190, 60); trad.operation = 'MULTIPLY' L(V["Trunk Radius"], trad.inputs[0]); L(tflare.outputs[0], trad.inputs[1]) 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) L(_count(14, 100, 200).outputs[0], _sock(bres, "Count")) 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, spar_node=bspar, collar=True) 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")) # Aeste an die ELTERNDICKE anpassen: "Instance on Points" wertet Felder auf # der Punkt-Domain aus, dort liefert "Input Radius" den Stammradius genau am # Ansatzpunkt. Ohne das sind Aeste oben (duenner Stamm) genauso dick wie # unten - und damit dicker als der Stamm selbst. prad = N("GeometryNodeInputRadius"); prad.location = (900, 120) pratio = N("ShaderNodeMath"); pratio.location = (1050, 120); pratio.operation = 'DIVIDE' L(prad.outputs[0], pratio.inputs[0]); L(V["Trunk Radius"], pratio.inputs[1]) pscale = N("ShaderNodeMapRange"); pscale.location = (1200, 120) L(pratio.outputs[0], pscale.inputs["Value"]) pscale.inputs["To Min"].default_value = 0.35 # nicht ganz kollabieren lassen pscale.inputs["To Max"].default_value = 1.0 pscale.clamp = True # ---------- Kronenform (botanische Huellkurve) ---------- # Idee aus modular_tree / Weber-Penn: die Astlaenge haengt von der relativen # Hoehe am Stamm ab. Das entscheidet die Silhouette: # Crown Taper > 0 -> unten lang, oben kurz = kegelfoermig (Tanne) # Crown Taper = 0 -> ueberall gleich = saeulenfoermig (Pappel) # Crown Taper < 0 -> unten kurz, oben lang = besenfoermig # Crown Bulge > 0 -> Maximum in der Mitte = rund (Eiche) czp = N("GeometryNodeInputPosition"); czp.location = (750, -20) czs = N("ShaderNodeSeparateXYZ"); czs.location = (900, -20) L(czp.outputs[0], czs.inputs[0]) czn = N("ShaderNodeMath"); czn.location = (1050, -20); czn.operation = 'DIVIDE' L(czs.outputs["Z"], czn.inputs[0]); L(V["Height"], czn.inputs[1]) czc = N("ShaderNodeMath"); czc.location = (1200, -20); czc.operation = 'MINIMUM' czc.inputs[1].default_value = 1.0 L(czn.outputs[0], czc.inputs[0]) ctm = N("ShaderNodeMath"); ctm.location = (1350, -20); ctm.operation = 'MULTIPLY' L(V["Crown Taper"], ctm.inputs[0]); L(czc.outputs[0], ctm.inputs[1]) cts = N("ShaderNodeMath"); cts.location = (1500, -20); cts.operation = 'SUBTRACT' cts.inputs[0].default_value = 1.0 L(ctm.outputs[0], cts.inputs[1]) # Bauch in der Mitte: sin(pi * t) cbs = N("ShaderNodeMath"); cbs.location = (1350, -180); cbs.operation = 'MULTIPLY' cbs.inputs[1].default_value = 3.14159 L(czc.outputs[0], cbs.inputs[0]) cbi = N("ShaderNodeMath"); cbi.location = (1500, -180); cbi.operation = 'SINE' L(cbs.outputs[0], cbi.inputs[0]) cbm = N("ShaderNodeMath"); cbm.location = (1650, -180); cbm.operation = 'MULTIPLY' L(V["Crown Bulge"], cbm.inputs[0]); L(cbi.outputs[0], cbm.inputs[1]) cba = N("ShaderNodeMath"); cba.location = (1800, -180); cba.operation = 'ADD' cba.inputs[0].default_value = 1.0 L(cbm.outputs[0], cba.inputs[1]) ccm = N("ShaderNodeMath"); ccm.location = (1800, -20); ccm.operation = 'MULTIPLY' L(cts.outputs[0], ccm.inputs[0]); L(cba.outputs[0], ccm.inputs[1]) ccl = N("ShaderNodeMath"); ccl.location = (1950, -20); ccl.operation = 'MAXIMUM' ccl.inputs[1].default_value = 0.05 L(ccm.outputs[0], ccl.inputs[0]) # mit der Elterndicken-Skalierung kombinieren pfin = N("ShaderNodeMath"); pfin.location = (2100, 120); pfin.operation = 'MULTIPLY' L(pscale.outputs[0], pfin.inputs[0]); L(ccl.outputs[0], pfin.inputs[1]) # Ansatzpunkt als Attribut mitgeben. Punkt-Attribute ueberleben # "Instance on Points" + "Realize" (geprueft) - dadurch kennt spaeter JEDER # Punkt eines Astes seinen eigenen Ursprung. Das ist die Voraussetzung fuer # echtes Biegen (Rotation) statt blossem Verschieben. apos = N("GeometryNodeInputPosition"); apos.location = (900, 480) anch = N("GeometryNodeStoreNamedAttribute"); anch.location = (1050, 320) anch.domain = 'POINT'; anch.data_type = 'FLOAT_VECTOR' L(_out(c2p, "Points"), _sock(anch, "Geometry")) _sock(anch, "Name").default_value = "anchor" L(apos.outputs[0], _sock(anch, "Value")) # AST-INDEX: jeder Punkt auf dem Stamm ist der Ansatz genau EINES Astes. # Sein Index wird als Attribut mitgeschleift, damit spaeter feststeht, welche # Geometrie - und vor allem welche Blatt-Card - zu welchem Ast gehoert. # Ohne das muesste man die Zuordnung raeumlich raten (Nearest Neighbor); # so ist sie eine Nachschlage-Frage und exakt nachzaehlbar. # 0 bleibt fuer den Stamm reserviert, Aeste zaehlen ab 1. aidx = N("GeometryNodeInputIndex"); aidx.location = (900, 560) aplus = N("ShaderNodeMath"); aplus.location = (1050, 560) aplus.operation = 'ADD'; aplus.inputs[1].default_value = 1.0 L(aidx.outputs[0], aplus.inputs[0]) astid = N("GeometryNodeStoreNamedAttribute"); astid.location = (1200, 400) astid.domain = 'POINT'; astid.data_type = 'INT' L(_out(anch, "Geometry"), _sock(astid, "Geometry")) _sock(astid, "Name").default_value = "ast_id" L(aplus.outputs[0], _sock(astid, "Value")) inst = N("GeometryNodeInstanceOnPoints"); inst.location = (1400, 320) L(_out(astid, "Geometry"), _sock(inst, "Points")) L(_out(bsetr, "Curve"), _sock(inst, "Instance")) L(pfin.outputs[0], _sock(inst, "Scale")) 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). # ---------- Phototropismus (Lichtsuche) ---------- # Idee aus UE 5.8 Procedural Vegetation ("light detection") bzw. Weber-Penn # ("attraction up"): Aeste kruemmen sich zur Spitze hin zur Senkrechten. # Umgesetzt als echte ROTATION des Punktes um seinen Astansatz - im # Gegensatz zum Z-Versatz (Droop) bleibt die Astlaenge dabei erhalten. ph_spar = N("GeometryNodeSplineParameter"); ph_spar.location = (1200, 780) ph_pos = N("GeometryNodeInputPosition"); ph_pos.location = (1200, 640) ph_anch = N("GeometryNodeInputNamedAttribute"); ph_anch.location = (1200, 500) ph_anch.data_type = 'FLOAT_VECTOR' _sock(ph_anch, "Name").default_value = "anchor" ph_rel = N("ShaderNodeVectorMath"); ph_rel.location = (1400, 640) ph_rel.operation = 'SUBTRACT' L(ph_pos.outputs[0], ph_rel.inputs[0]) L(_out(ph_anch, "Attribute"), ph_rel.inputs[1]) ph_up = N("ShaderNodeCombineXYZ"); ph_up.location = (1400, 500) ph_up.inputs["Z"].default_value = 1.0 ph_ax = N("ShaderNodeVectorMath"); ph_ax.location = (1550, 570) ph_ax.operation = 'CROSS_PRODUCT' L(ph_rel.outputs[0], ph_ax.inputs[0]); L(ph_up.outputs[0], ph_ax.inputs[1]) ph_ang = N("ShaderNodeMath"); ph_ang.location = (1400, 780) ph_ang.operation = 'MULTIPLY' L(V["Attraction Up"], ph_ang.inputs[0]) L(ph_spar.outputs["Factor"], ph_ang.inputs[1]) ph_rot = N("ShaderNodeVectorRotate"); ph_rot.location = (1750, 640) ph_rot.rotation_type = 'AXIS_ANGLE' L(ph_rel.outputs[0], ph_rot.inputs["Vector"]) L(ph_ax.outputs[0], ph_rot.inputs["Axis"]) L(ph_ang.outputs[0], ph_rot.inputs["Angle"]) ph_new = N("ShaderNodeVectorMath"); ph_new.location = (1900, 640) ph_new.operation = 'ADD' L(_out(ph_anch, "Attribute"), ph_new.inputs[0]) L(ph_rot.outputs[0], ph_new.inputs[1]) ph_set = N("GeometryNodeSetPosition"); ph_set.location = (2050, 780) L(_out(real, "Geometry"), _sock(ph_set, "Geometry")) L(ph_new.outputs[0], _sock(ph_set, "Position")) 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 # Droop-Kurve: Exponent 2 = weicher Bogen (Palmwedel), hoeher = der Arm # bleibt erst flach und knickt dann scharf ab (Saguaro-Ellbogen). dsq = N("ShaderNodeMath"); dsq.location = (1350, -320); dsq.operation = 'POWER' L(rspar.outputs["Factor"], dsq.inputs[0]); L(V["Droop Curve"], 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 = (2200, 320) L(_out(ph_set, "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")) # Wo am Elternast die Zweige sitzen. Nahe 1.0 zusammengedraengt + hohes # "Sub Up" ergibt eine GABELUNG (Weber-Penn "split") statt seitlicher # Kinder - das ist der Unterschied zwischen "Stab mit Nadeln" und Geaest. try: L(V["Sub Start"], _sock(strim, "Start")) L(V["Sub End"], _sock(strim, "End")) 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) L(_count(9, 2100, 700).outputs[0], _sock(sres, "Count")) 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]) # Zweige muessen DEUTLICH duenner sein als die Hauptaeste. Bei Branch # Thickness 0.17 waren 0.12 rund 70 % davon -> Zweige fast so dick wie # der Ast, an dem sie sitzen. 0.06 = ~35 %, passt zum TreeIt-Verhaeltnis. sbase.inputs[1].default_value = 0.06 sdome = _dome(sspar, 2550, 1200) srad2 = _shape(sinv.outputs[0], sdome, sbase.outputs[0], 2750, 900, spar_node=sspar, collar=True) 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")) # Zweige ebenso an die Dicke ihres Elternastes koppeln. sprad = N("GeometryNodeInputRadius"); sprad.location = (2700, 300) sbaseref = N("ShaderNodeMath"); sbaseref.location = (2700, 180); sbaseref.operation = 'MULTIPLY' L(V["Trunk Radius"], sbaseref.inputs[0]); L(V["Branch Thickness"], sbaseref.inputs[1]) sratio = N("ShaderNodeMath"); sratio.location = (2850, 240); sratio.operation = 'DIVIDE' L(sprad.outputs[0], sratio.inputs[0]); L(sbaseref.outputs[0], sratio.inputs[1]) sscale = N("ShaderNodeMapRange"); sscale.location = (3000, 240) L(sratio.outputs[0], sscale.inputs["Value"]) sscale.inputs["To Min"].default_value = 0.4 sscale.inputs["To Max"].default_value = 1.0 sscale.clamp = True sinst = N("GeometryNodeInstanceOnPoints"); sinst.location = (3150, 520) L(_out(sp, "Points"), _sock(sinst, "Points")) L(_out(ssetr, "Curve"), _sock(sinst, "Instance")) L(sscale.outputs[0], _sock(sinst, "Scale")) 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")) # ---------- Curve -> Mesh, PRO EBENE mit eigener Profilaufloesung ---------- # Referenz (TreeIt): Stamm ~9 Segmente, duenne Zweige nur Dreiecke. Ein # "Curve to Mesh" kennt aber nur EIN Profil -> deshalb wird jede Ebene # einzeln gewandelt und erst danach zusammengefuegt. Spart massiv Tris, # weil die vielen duennen Zweige nicht die Rundung des Stammes brauchen. def _profile(sides_socket, x, y): c = N("GeometryNodeCurvePrimitiveCircle"); c.location = (x, y) L(sides_socket, _sock(c, "Resolution")) _sock(c, "Radius").default_value = 1.0 # Rippen (Kaktus-Kanneluren): radiale Welle auf dem Profil sp = N("GeometryNodeSplineParameter"); sp.location = (x, y - 200) ang = N("ShaderNodeMath"); ang.location = (x + 150, y - 200); ang.operation = 'MULTIPLY' ang.inputs[1].default_value = 6.283185 L(sp.outputs["Factor"], ang.inputs[0]) rb = N("ShaderNodeMath"); rb.location = (x + 300, y - 200); rb.operation = 'MULTIPLY' L(ang.outputs[0], rb.inputs[0]); L(V["Ribs"], rb.inputs[1]) co = N("ShaderNodeMath"); co.location = (x + 450, y - 200); co.operation = 'COSINE' L(rb.outputs[0], co.inputs[0]) am = N("ShaderNodeMath"); am.location = (x + 600, y - 200); am.operation = 'MULTIPLY' L(co.outputs[0], am.inputs[0]); L(V["Rib Depth"], am.inputs[1]) po = N("GeometryNodeInputPosition"); po.location = (x + 300, y - 340) dn = N("ShaderNodeVectorMath"); dn.location = (x + 450, y - 340); dn.operation = 'NORMALIZE' L(po.outputs[0], dn.inputs[0]) of = N("ShaderNodeVectorMath"); of.location = (x + 750, y - 300); of.operation = 'SCALE' L(dn.outputs[0], of.inputs[0]); L(am.outputs[0], _sock(of, "Scale")) st = N("GeometryNodeSetPosition"); st.location = (x + 900, y) L(_out(c, "Curve"), _sock(st, "Geometry")); L(of.outputs[0], _sock(st, "Offset")) # uv_u = Bogenlaenge um das Profil sp2 = N("GeometryNodeSplineParameter"); sp2.location = (x + 900, y - 200) su = N("GeometryNodeStoreNamedAttribute"); su.location = (x + 1050, y) su.domain = 'POINT'; su.data_type = 'FLOAT' L(_out(st, "Geometry"), _sock(su, "Geometry")) _sock(su, "Name").default_value = "uv_u" L(sp2.outputs["Length"], _sock(su, "Value")) return su def _to_mesh(curve_node, curve_out_name, sides_socket, x, y): """Kurven -> Mesh inkl. world-space UVs. Pro Ast-Ebene einmal.""" cs = N("GeometryNodeSplineParameter"); cs.location = (x, y - 140) sv = N("GeometryNodeStoreNamedAttribute"); sv.location = (x + 150, y) sv.domain = 'POINT'; sv.data_type = 'FLOAT' L(_out(curve_node, curve_out_name), _sock(sv, "Geometry")) _sock(sv, "Name").default_value = "uv_v" L(cs.outputs["Length"], _sock(sv, "Value")) rd = N("GeometryNodeInputRadius"); rd.location = (x, y - 280) sw = N("GeometryNodeStoreNamedAttribute"); sw.location = (x + 300, y) sw.domain = 'POINT'; sw.data_type = 'FLOAT' L(_out(sv, "Geometry"), _sock(sw, "Geometry")) _sock(sw, "Name").default_value = "uv_r" L(rd.outputs[0], _sock(sw, "Value")) prof = _profile(sides_socket, x - 1100, y - 500) cm = N("GeometryNodeCurveToMesh"); cm.location = (x + 500, y) L(_out(sw, "Geometry"), _sock(cm, "Curve")) L(_out(prof, "Geometry"), _sock(cm, "Profile Curve")) # Falle 7 (Blender 5.x): Radius-Attribut wird NICHT mehr implizit # ausgewertet -> muss an den "Scale"-Eingang. if "Scale" in cm.inputs: ra = N("GeometryNodeInputRadius"); ra.location = (x + 350, y - 420) L(ra.outputs[0], _sock(cm, "Scale")) try: _sock(cm, "Fill Caps").default_value = True except KeyError: pass nu = N("GeometryNodeInputNamedAttribute"); nu.location = (x + 500, y - 200) nu.data_type = 'FLOAT'; _sock(nu, "Name").default_value = "uv_u" nv = N("GeometryNodeInputNamedAttribute"); nv.location = (x + 500, y - 320) nv.data_type = 'FLOAT'; _sock(nv, "Name").default_value = "uv_v" nr = N("GeometryNodeInputNamedAttribute"); nr.location = (x + 500, y - 440) nr.data_type = 'FLOAT'; _sock(nr, "Name").default_value = "uv_r" um = N("ShaderNodeMath"); um.location = (x + 650, y - 200); um.operation = 'MULTIPLY' L(_out(nu, "Attribute"), um.inputs[0]); L(_out(nr, "Attribute"), um.inputs[1]) us_ = N("ShaderNodeMath"); us_.location = (x + 800, y - 200); us_.operation = 'MULTIPLY' L(um.outputs[0], us_.inputs[0]); L(V["UV Scale"], us_.inputs[1]) vs_ = N("ShaderNodeMath"); vs_.location = (x + 800, y - 320); vs_.operation = 'MULTIPLY' L(_out(nv, "Attribute"), vs_.inputs[0]); L(V["UV Scale"], vs_.inputs[1]) uv = N("ShaderNodeCombineXYZ"); uv.location = (x + 950, y - 260) L(us_.outputs[0], uv.inputs["X"]); L(vs_.outputs[0], uv.inputs["Y"]) sm = N("GeometryNodeStoreNamedAttribute"); sm.location = (x + 1100, y) sm.domain = 'CORNER'; sm.data_type = 'FLOAT2' L(_out(cm, "Mesh", "Geometry"), _sock(sm, "Geometry")) _sock(sm, "Name").default_value = "UVMap" L(uv.outputs[0], _sock(sm, "Value")) return sm mesh_trunk0 = _to_mesh(setrad, "Curve", V["Sides"], 3400, 900) # ---------- Wurzelanlauf ausformen (Wurzelbretter) ---------- # Der reine Radius-Flare ergibt einen glatten Trichter. Echte Wurzelanlaeufe # sind GELAPPT. Das laesst sich nicht ueber den Kurvenradius machen (der ist # rundum gleich), sondern erst am fertigen Mesh: Vertices nahe dem Boden # radial nach aussen schieben, moduliert ueber den Winkel. Kostet 0 Tris. fpos = N("GeometryNodeInputPosition"); fpos.location = (4750, 1150) fsep = N("ShaderNodeSeparateXYZ"); fsep.location = (4900, 1150) L(fpos.outputs[0], fsep.inputs[0]) # Hoehen-Abfall: nur das unterste Stueck ist betroffen fh = N("ShaderNodeMath"); fh.location = (5050, 1300); fh.operation = 'DIVIDE' L(fsep.outputs["Z"], fh.inputs[0]); L(V["Root Height"], fh.inputs[1]) fc = N("ShaderNodeMath"); fc.location = (5200, 1300); fc.operation = 'MINIMUM' fc.inputs[1].default_value = 1.0 L(fh.outputs[0], fc.inputs[0]) fi = N("ShaderNodeMath"); fi.location = (5350, 1300); fi.operation = 'SUBTRACT' fi.inputs[0].default_value = 1.0 L(fc.outputs[0], fi.inputs[1]) fp = N("ShaderNodeMath"); fp.location = (5500, 1300); fp.operation = 'POWER' fp.inputs[1].default_value = 2.5 L(fi.outputs[0], fp.inputs[0]) fpc = N("ShaderNodeMath"); fpc.location = (5650, 1300); fpc.operation = 'MAXIMUM' fpc.inputs[1].default_value = 0.0 L(fp.outputs[0], fpc.inputs[0]) # Lappen ueber den Winkel: cos(Lobes * atan2(y,x)), auf 0..1 gehoben fat = N("ShaderNodeMath"); fat.location = (5050, 1000); fat.operation = 'ARCTAN2' L(fsep.outputs["Y"], fat.inputs[0]); L(fsep.outputs["X"], fat.inputs[1]) fml = N("ShaderNodeMath"); fml.location = (5200, 1000); fml.operation = 'MULTIPLY' L(fat.outputs[0], fml.inputs[0]); L(V["Root Lobes"], fml.inputs[1]) fco = N("ShaderNodeMath"); fco.location = (5350, 1000); fco.operation = 'COSINE' L(fml.outputs[0], fco.inputs[0]) # cos direkt verwenden (-1..1), NICHT auf 0..1 heben: sonst druecken die # Lappen nur nach aussen und der MITTLERE Radius waechst mit der Lappentiefe # (gemessen: 0.184 -> 0.224). Zentriert bleibt der Mittelwert konstant und # es entstehen echte Taeler zwischen den Wurzelbrettern. fno = fco # Betrag = RootLobeDepth * Hoehenabfall * Lappen * Stammradius fa1 = N("ShaderNodeMath"); fa1.location = (5800, 1150); fa1.operation = 'MULTIPLY' L(fpc.outputs[0], fa1.inputs[0]); L(fno.outputs[0], fa1.inputs[1]) fa2 = N("ShaderNodeMath"); fa2.location = (5950, 1150); fa2.operation = 'MULTIPLY' L(fa1.outputs[0], fa2.inputs[0]); L(V["Root Lobe Depth"], fa2.inputs[1]) fa3 = N("ShaderNodeMath"); fa3.location = (6100, 1150); fa3.operation = 'MULTIPLY' L(fa2.outputs[0], fa3.inputs[0]); L(V["Trunk Radius"], fa3.inputs[1]) # Richtung: radial nach aussen (XY), Z bleibt unangetastet fxy = N("ShaderNodeCombineXYZ"); fxy.location = (5200, 850) L(fsep.outputs["X"], fxy.inputs["X"]); L(fsep.outputs["Y"], fxy.inputs["Y"]) fnrm = N("ShaderNodeVectorMath"); fnrm.location = (5350, 850); fnrm.operation = 'NORMALIZE' L(fxy.outputs[0], fnrm.inputs[0]) foff = N("ShaderNodeVectorMath"); foff.location = (6250, 1000); foff.operation = 'SCALE' L(fnrm.outputs[0], foff.inputs[0]); L(fa3.outputs[0], _sock(foff, "Scale")) mesh_trunk = N("GeometryNodeSetPosition"); mesh_trunk.location = (6400, 900) L(_out(mesh_trunk0, "Geometry"), _sock(mesh_trunk, "Geometry")) L(foff.outputs[0], _sock(mesh_trunk, "Offset")) mesh_branch = _to_mesh(rsetp, "Geometry", V["Branch Sides"], 3400, 200) mesh_sub = _to_mesh(sbset, "Geometry", V["Sub Sides"], 3400, -500) # FALLE: "Curve to Points" klemmt Count auf mindestens 1 -> Sub Count = 0 # schaltet die Zweige NICHT ab, es entsteht trotzdem einer pro Ast (gemessen: # Sub Count 0 und 1 ergeben identische 583 Verts). Bei Palme und Kaktus waren # das duenne, gerade "Nadeln" neben den richtigen Aesten. Deshalb hier ein # echter Schalter, der die Sub-Ebene bei 0 komplett verwirft. sub_on = N("FunctionNodeCompare"); sub_on.location = (4400, -350) try: sub_on.data_type = 'INT'; sub_on.operation = 'GREATER_THAN' L(V["Sub Count"], sub_on.inputs[2]) sub_on.inputs[3].default_value = 0 except Exception: sub_on = N("ShaderNodeMath"); sub_on.location = (4400, -350) sub_on.operation = 'GREATER_THAN'; sub_on.inputs[1].default_value = 0.5 L(V["Sub Count"], sub_on.inputs[0]) sub_sw = N("GeometryNodeSwitch"); sub_sw.location = (4550, -250) sub_sw.input_type = 'GEOMETRY' L(sub_on.outputs[0], _sock(sub_sw, "Switch")) L(_out(mesh_sub, "Geometry"), _sock(sub_sw, "True")) # Dieselbe Falle gilt fuer die HAUPTAESTE: "Branch Count" = 0 lieferte # trotzdem einen Ast. Aufgefallen beim Kaktus-Setzling, der laut NPS noch # gar keine Arme haben darf - er behielt einen. Weil die Sub-Aeste auf den # Hauptaesten sitzen, wandern beide Ebenen hinter denselben Schalter. br_join = N("GeometryNodeJoinGeometry"); br_join.location = (4620, 100) L(_out(sub_sw, "Output"), br_join.inputs[0]) L(_out(mesh_branch, "Geometry"), br_join.inputs[0]) br_on = N("FunctionNodeCompare"); br_on.location = (4400, 60) try: br_on.data_type = 'INT'; br_on.operation = 'GREATER_THAN' L(V["Branch Count"], br_on.inputs[2]) br_on.inputs[3].default_value = 0 except Exception: br_on = N("ShaderNodeMath"); br_on.location = (4400, 60) br_on.operation = 'GREATER_THAN'; br_on.inputs[1].default_value = 0.5 L(V["Branch Count"], br_on.inputs[0]) br_sw = N("GeometryNodeSwitch"); br_sw.location = (4660, 200) br_sw.input_type = 'GEOMETRY' L(br_on.outputs[0], _sock(br_sw, "Switch")) L(_out(br_join, "Geometry"), _sock(br_sw, "True")) join = N("GeometryNodeJoinGeometry"); join.location = (4700, 300) L(_out(br_sw, "Output"), join.inputs[0]) L(_out(mesh_trunk, "Geometry"), join.inputs[0]) stuv = join # Name beibehalten fuer den nachfolgenden Merge-Zweig # ---------- optional: Aeste mit dem Stamm VERSCHMELZEN ---------- # Voxel-Remesh (Mesh to Volume -> Volume to Mesh) macht aus den sich # durchdringenden Roehren EINE organische Oberflaeche mit weichen Uebergaengen. # ACHTUNG, ehrlicher Preis: der Remesh erzeugt komplett neue Topologie -> # die UVs sind danach WEG und der Vert-Count steigt deutlich. Deshalb als # Schalter (Merge = 0 laesst den schnellen, UV-tauglichen Pfad unberuehrt). m2v = N("GeometryNodeMeshToVolume"); m2v.location = (1650, -200) L(_out(stuv, "Geometry"), _sock(m2v, "Mesh")) try: _sock(m2v, "Resolution Mode").default_value = 'Size' except Exception: pass L(V["Merge Voxel"], _sock(m2v, "Voxel Size")) v2m = N("GeometryNodeVolumeToMesh"); v2m.location = (1820, -200) L(_out(m2v, "Volume", "Geometry"), _sock(v2m, "Volume")) try: _sock(v2m, "Resolution Mode").default_value = 'Size' except Exception: pass L(V["Merge Voxel"], _sock(v2m, "Voxel Size")) pick = N("GeometryNodeSwitch"); pick.location = (2000, 100) pick.input_type = 'GEOMETRY' mgt = N("ShaderNodeMath"); mgt.location = (1820, 60); mgt.operation = 'GREATER_THAN' mgt.inputs[1].default_value = 0.5 L(V["Merge"], mgt.inputs[0]) L(mgt.outputs[0], _sock(pick, "Switch")) L(_out(stuv, "Geometry"), _sock(pick, "False")) L(_out(v2m, "Mesh", "Geometry"), _sock(pick, "True")) shade = N("GeometryNodeSetShadeSmooth"); shade.location = (2200, 200) L(_out(pick, "Output"), _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_HEIGHT = 0.16 # Setzling ist 16 % der Endhoehe GROWTH_YOUNG = { "Height": GROWTH_HEIGHT, # Faktoren (werden mit dem Zielwert multipliziert) # Der Stamm darf NICHT einfach mitskalieren. Vorher stand hier 0.30 bei einem # Hoehenfaktor von 0.16 - dadurch war der Setzling relativ zu seiner Hoehe # 2.4x DICKER als der Altbaum (gemessen: r/H 0.025 gegen 0.0104) und sah # aus wie ein Stummel. Botanisch gilt die elastische Aehnlichkeit nach # McMahon: Hoehe ~ Durchmesser^(2/3), also Durchmesser ~ Hoehe^(3/2). # Bei Hoehenfaktor 0.16 sind das 0.16^1.5 = 0.064. Junge Baeume sind sogar # noch schlanker als die Regel vorhersagt - 0.064 ist die obere Grenze. "Trunk Radius": GROWTH_HEIGHT ** 1.5, # Wurzelanlauf und Wurzelbretter entstehen erst mit dem Alter. Blieben sie # auf dem Altbaum-Wert, bekam jeder Setzling einen Trichterfuss - das war # der auffaelligste Grund, warum die kleinste Stufe "komisch" aussah. "Root Flare": 0.15, "Root Lobe Depth": 0.0, # Aeste muessen MIT der Hoehe schrumpfen, nicht langsamer. Mit 0.30 gegen # Hoehe 0.16 waren die Aeste am Setzling relativ 1.9x zu lang - bei der # Weide reichten sie dadurch unter den Boden. "Branch Length": GROWTH_HEIGHT, "Branch Count": 0.35, # Junge Baeume tragen ihre Aeste TIEFER: das Aufasten (Selbstbeschneidung # der unteren Aeste) passiert erst spaeter. Ohne das sass die Krone beim # Setzling genauso hoch wie beim Altbaum - typisches Merkmal eines bloss # runterskalierten Modells. "Branch Start": 0.35, # Haengen und Ueberbiegen kommt mit Laenge und Gewicht. Am Setzling stehen # Aeste und Wedel noch aufrecht. Ohne das reichten Palmwedel (Droop 1.2) und # Kaktusarme (Droop -1.35) beim Setzling GEMESSEN bis zu 0.58 m bzw. 0.18 m # unter den Wurzelansatz - das Asset haette in UE ueber dem Boden geschwebt. "Branch Droop": 0.30, "Sub Count": 0.0, # Setzling hat noch keine Sub-Aeste } # Nicht jede Pflanze waechst wie ein Laubbaum. # # Palmen sind Einkeimblaettrige und haben KEIN Dickenwachstum: der Stamm baut # erst seinen Durchmesser auf und streckt sich danach in die Hoehe - "once a # palm stem emerges from the apical meristem, its diameter is fixed for life" # (UF/IFAS ENH1212, Palm Morphology and Anatomy). Eine junge Palme ist also ein # kurzer Stumpf in fast voller Dicke, kein duenner Schoessling. Mit dem # Laubbaum-Faktor 0.064 wurde sie zum Draht. # # Kakteen sind Sukkulenten und folgen derselben Logik: der Saeulenkaktus bildet # zuerst seinen Koerper und waechst dann nach oben. GROWTH_OVERRIDES = { # Die Trauerweide haengt erst, wenn die Zweige lang und schwer genug sind - # junge Weiden wachsen aufrecht. Mit dem allgemeinen Setzling-Droop reichten # ihre Zweige GEMESSEN 0.27 m unter den Stammfuss; das Asset haette in UE # ueber dem Boden geschwebt. "weide": {"Branch Droop": 0.0, "Attraction Up": 0.0}, "palme": {"Trunk Radius": 0.85, "Branch Length": 0.28}, # Saeulenkakteen bilden ihre Arme erst spaet: "branches normally begin to # appear when a saguaro reaches 50 to 70 years of age" - da ist er schon # rund 2 m hoch (NPS, How Saguaros Grow). Der Setzling ist eine reine Saeule. "kaktus": {"Trunk Radius": 0.75, "Branch Length": 0.20, "Branch Count": 0.0}, } 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)) factors = dict(GROWTH_YOUNG) factors.update(GROWTH_OVERRIDES.get(preset, {})) for key, young_factor in factors.items(): if key not in full: continue target = full[key] young = target * young_factor v = young + (target - young) * t if isinstance(target, int): # Ein Faktor von genau 0.0 heisst "gibt es beim Setzling noch gar # nicht" und darf auf 0 fallen. Alles andere bekommt mindestens 1, # damit aus einer Rundung kein kahler Stock wird. Vorher war die # Ausnahme fest auf "Sub Count" verdrahtet - dadurch konnte der # Kaktus-Setzling seine Arme nicht loswerden. out[key] = int(round(v)) if young_factor == 0.0 else max(1, int(round(v))) else: out[key] = 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 # --------------------------------------------------------------------------- # 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, "Use Collection": False, } 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') # 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) 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")) # 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' _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]) # AST-INDEX auf die Blattpunkte uebernehmen. Der Baum traegt "ast_id" je # Vertex; beim Streuen wird er am Punkt abgegriffen und mitgespeichert. # Damit ist "welche Card gehoert zu welchem Ast" spaeter eine # Nachschlage-Frage und keine Nearest-Neighbor-Schaetzung - und der Test # kann die Zuordnung exakt nachzaehlen. lna = N("GeometryNodeInputNamedAttribute"); lna.location = (-320, 60) lna.data_type = 'INT' _sock(lna, "Name").default_value = "ast_id" lstore = N("GeometryNodeStoreNamedAttribute"); lstore.location = (-100, 200) lstore.domain = 'POINT'; lstore.data_type = 'INT' L(_out(dist, "Points"), _sock(lstore, "Geometry")) _sock(lstore, "Name").default_value = "ast_id" L(_out(lna, "Attribute"), _sock(lstore, "Value")) inst = N("GeometryNodeInstanceOnPoints"); inst.location = (100, 200) L(_out(lstore, "Geometry"), _sock(inst, "Points")) 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")) 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, 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) 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 # 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: md[ids[k]] = v return ob # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # Faell-Teile: Stammsegmente und Aeste als eigene Physik-Assets # --------------------------------------------------------------------------- def _tree_ucx(quell_objs, name, max_verts=32): """Konvexe Huelle als `UCX_` - gleiches Muster wie bei den Felsen. Nur die PUNKTE einlesen: mit `from_mesh` kaemen die Originalflaechen mit und die Huelle laege obendrauf statt darum herum. """ bm = bmesh.new() for o in quell_objs: if o.type == 'MESH': for v in o.data.vertices: bm.verts.new(o.matrix_world @ v.co) if len(bm.verts) < 4: bm.free() return None bm.verts.ensure_lookup_table() res = bmesh.ops.convex_hull(bm, input=bm.verts[:], use_existing_faces=False) weg = list(set(res.get("geom_interior", []) + res.get("geom_unused", []))) if weg: bmesh.ops.delete(bm, geom=weg, context='VERTS') bm.verts.ensure_lookup_table() bm.normal_update() if not bm.faces: bm.free() return None quelle = [v.co.copy() for v in bm.verts] winkel = math.radians(1.0) while len(bm.verts) > max_verts and winkel < math.radians(60.0): bmesh.ops.dissolve_limit(bm, angle_limit=winkel, verts=bm.verts[:], edges=bm.edges[:]) bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS') bm.verts.ensure_lookup_table() if not bm.faces: break winkel += math.radians(3.0) bm.normal_update() # Das Vereinfachen schneidet Ecken ab - die Huelle waere danach KLEINER als # das Teil und der Spieler wuerde einsinken. Exakt so weit aufblasen, dass # wieder alles hineinpasst. if bm.faces and quelle: mitte = Vector((0.0, 0.0, 0.0)) for v in bm.verts: mitte += v.co mitte /= max(len(bm.verts), 1) faktor = 1.0 for f in bm.faces: n = f.normal if n.length_squared < 1e-12: continue d = n.dot(f.verts[0].co) basis = d - n.dot(mitte) if basis <= 1e-9: continue for co in quelle: noetig = (n.dot(co) - n.dot(mitte)) / basis if noetig > faktor: faktor = noetig if faktor > 1.0: for v in bm.verts: v.co = mitte + (v.co - mitte) * faktor bmesh.ops.triangulate(bm, faces=bm.faces[:]) me = bpy.data.meshes.new("UCX_" + name) bm.to_mesh(me) bm.free() ob = bpy.data.objects.new("UCX_" + name, me) bpy.context.scene.collection.objects.link(ob) return ob def _mass_center(me): """Massenmittelpunkt als flaechengewichteter Schwerpunkt der Faces. Fuer einen Physik-Actor ist der Pivot der Drehpunkt - liegt er in einer Ecke, torkelt das Teil beim Fallen. Der reine Vertex-Mittelwert taugt nicht: dort, wo das Mesh dicht tesselliert ist (Astansatz), zieht er den Punkt hin. Flaechengewichtet trifft es deutlich besser und funktioniert auch bei offener Geometrie - die Blatt-Cards sind offen. """ gesamt = 0.0 acc = Vector((0.0, 0.0, 0.0)) for p in me.polygons: a = p.area if a <= 0.0: continue acc += p.center * a gesamt += a if gesamt <= 0.0: if not me.vertices: return Vector((0.0, 0.0, 0.0)) m = Vector((0.0, 0.0, 0.0)) for v in me.vertices: m += v.co return m / len(me.vertices) return acc / gesamt def _teil_aus_ids(context, quellen, ids, name, z_von=None, z_bis=None): """Baut ein Teil-Objekt aus allen Faces, deren ast_id in `ids` liegt. `quellen` ist eine Liste (Objekt, Material-Slot). Optional wird zusaetzlich nach Hoehe geschnitten - so entstehen die Stammsegmente. """ 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)) bm = bmesh.new() lay = None for quelle, slot in quellen: me = quelle.data attr = me.attributes.get("ast_id") if attr is None: continue werte = [d.value for d in attr.data] tmp = bmesh.new() tmp.from_mesh(me) tmp.transform(quelle.matrix_world) tmp.verts.ensure_lookup_table() tmp.faces.ensure_lookup_table() # Faces behalten, deren Vertices die passende ID tragen. weg = [] for f in tmp.faces: fid = werte[f.verts[0].index] if f.verts[0].index < len(werte) else 0 if fid not in ids: weg.append(f) if weg: bmesh.ops.delete(tmp, geom=weg, context='FACES') if z_von is not None or z_bis is not None: if slot == 1: # Blatt-Cards NICHT schneiden. Eine Card ist ein unteilbares # Billboard - halbiert man sie, entstehen zwei Faces mit # kaputten Atlas-UVs, und die Bilanz stimmt nicht mehr # (gemessen 178 Cards in den Teilen gegen 170 im Stand-Baum). # Stattdessen wandert jede Card als GANZES in das Segment, in # dem ihre Inselmitte liegt. inseln = [] offen = set(tmp.faces) while offen: start = offen.pop() insel = [start] stapel = [start] while stapel: f = stapel.pop() for e in f.edges: for nf in e.link_faces: if nf in offen: offen.discard(nf) insel.append(nf) stapel.append(nf) inseln.append(insel) weg2 = [] for insel in inseln: mz = sum(f.calc_center_median().z for f in insel) / len(insel) drin = ((z_von is None or mz >= z_von) and (z_bis is None or mz < z_bis)) if not drin: weg2.extend(insel) if weg2: bmesh.ops.delete(tmp, geom=weg2, context='FACES') else: if z_von is not None: bmesh.ops.bisect_plane( tmp, geom=tmp.verts[:] + tmp.edges[:] + tmp.faces[:], plane_co=Vector((0, 0, z_von)), plane_no=Vector((0, 0, -1)), clear_outer=True) if z_bis is not None and tmp.faces: bmesh.ops.bisect_plane( tmp, geom=tmp.verts[:] + tmp.edges[:] + tmp.faces[:], plane_co=Vector((0, 0, z_bis)), plane_no=Vector((0, 0, 1)), clear_outer=True) # Schlagflaechen schliessen - offene Segmente taugen nicht als # Physik-Actor und die UCX-Huelle waere sinnlos. rand = [e for e in tmp.edges if len(e.link_faces) == 1] if rand: bmesh.ops.holes_fill(tmp, edges=rand, sides=0) if not tmp.faces: tmp.free() continue # Material-Index setzen, bevor es in den Sammel-bmesh geht. for f in tmp.faces: f.material_index = slot hilf = bpy.data.meshes.new("__t") tmp.to_mesh(hilf) tmp.free() bm.from_mesh(hilf) bpy.data.meshes.remove(hilf) if not bm.faces: bm.free() return None me_neu = bpy.data.meshes.new(name) bm.to_mesh(me_neu) bm.free() me_neu.materials.append(rinde) me_neu.materials.append(laub) ob = bpy.data.objects.new(name, me_neu) context.scene.collection.objects.link(ob) return ob # --------------------------------------------------------------------------- # Addon-UI # --------------------------------------------------------------------------- from bpy.props import ( IntProperty, FloatProperty, EnumProperty, BoolProperty, StringProperty, 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) art_name: StringProperty( name="Art", default="", description=("Art-Name fuer den Export, z.B. 'Birke'. Leer = Preset-Name. " "Schema: __S"), ) variant: IntProperty( name="Variante", default=1, min=1, max=99, description="Laufende Variante derselben Art (Birke_01, Birke_02 ...)", ) spacing: FloatProperty(name="Abstand", default=4.0, min=0.0, max=20.0) random_amount: FloatProperty( name="Streuung", default=0.3, min=0.0, max=1.0, description=("Wie stark 'Zufaellig' die Werte veraendert. 0 = nur neuer Seed, " "1 = volle Bandbreite. Der Preset-Charakter bleibt erhalten"), ) make_leaf: BoolProperty( 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=("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, 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", ) piece_trunk_segments: IntProperty( name="Stamm-Segmente", default=3, min=2, max=3, description="In wie viele Stuecke der Stamm beim Faellen zerfaellt", ) piece_ucx: BoolProperty( name="UCX je Teil", default=True, description="Legt je Teil eine konvexe Kollisionshuelle ins FBX", ) piece_ucx_verts: IntProperty( name="UCX max. Verts", default=32, min=8, max=128, description="Obergrenze fuer die Kollisionshuelle je Teil", ) piece_export_combined: BoolProperty( name="Zusatzdatei mit gemeinsamem Ursprung", default=True, description=("Schreibt zusaetzlich _Pieces_All.fbx, in der alle " "Teile an ihrer Originalposition stehen - damit Unreal den " "zusammengesetzten Baum deckungsgleich aufbauen kann"), ) piece_keep: BoolProperty( name="Teile in der Szene behalten", default=False, description="Laesst die erzeugten Teile zum Nachschauen stehen", ) 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)", ) generate_lightmap_uv: BoolProperty( name="UV1 Lightmap-UV", default=False, description=("Zweiter, nicht ueberlappender UV-Kanal fuer Lightmaps. Braucht " "'Modifier anwenden'. EcoGame beleuchtet dynamisch -> nicht noetig"), ) def _art(s): """Art-Name fuer das EcoGame-Schema __S.""" return (s.art_name.strip() or s.preset).capitalize() 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 def _add_layer(context, ob, suffix): """Leeres Mesh-Objekt mit IDENTISCHEM Ursprung neben dem Baum anlegen. Gleicher Ursprung ist der Punkt: in UE muessen Stamm, Krone und Fruechte deckungsgleich sitzen, sonst verrutscht die Krone gegenueber dem Stamm. """ name = ob.name + suffix old = bpy.data.objects.get(name) if old: bpy.data.objects.remove(old, do_unlink=True) me = bpy.data.meshes.new(name) new = bpy.data.objects.new(name, me) context.collection.objects.link(new) new.location = ob.location.copy() new.rotation_euler = ob.rotation_euler.copy() new.scale = ob.scale.copy() 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): # Die Objektliste kann nach dem Entfernen von Objekten kurzzeitig None- # Eintraege und tote Referenzen enthalten - beim Piece-Export passiert das # zwischen den Teilen staendig. for o in list(context.view_layer.objects): if o is None: continue try: o.select_set(False) except (ReferenceError, RuntimeError, AttributeError): 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_export_pieces(Operator): """Exportiert den Baum als Faell-Teile fuer das Zerlegen im Spiel. Kein Laufzeit-Zerlegen: beim Faellen tauscht das Spiel die ISM-Instanz gegen einen Actor aus diesen vordefinierten Teilen. Sie muessen deshalb vollwertige Assets sein - eigener Massenmittelpunkt als Pivot, zwei Material-Slots, konvexe UCX-Huelle. """ bl_idname = "treegen.export_pieces" bl_label = "Faell-Teile exportieren" bl_description = ("Schneidet den Stamm in Segmente, loest die Aeste samt " "ihren Blatt-Cards heraus und schreibt je Teil eine FBX " "mit UCX-Huelle") 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 = [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.") return {'CANCELLED'} gesamt = 0 for baum in baeume: gesamt += self._ein_baum(context, s, baum, verzeichnis) self.report({'INFO'}, "%d Faell-Teil(e) nach %s." % (gesamt, verzeichnis)) return {'FINISHED'} def _ein_baum(self, context, s, baum, verzeichnis): blatt = bpy.data.objects.get(baum.name + "_Leaf") basis = _safe_name(baum.name) # Ausgewertete Kopien: die GN-Modifier muessen aufgeloest sein, sonst # gibt es keine Faces zum Schneiden. dg = context.evaluated_depsgraph_get() temp = [] def _ausgewertet(src): 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 + "__src", me) ob.matrix_world = src.matrix_world.copy() context.scene.collection.objects.link(ob) temp.append(ob.name) return ob stamm_src = _ausgewertet(baum) blatt_src = _ausgewertet(blatt) if blatt is not None else None quellen = [(stamm_src, 0)] if blatt_src is not None: quellen.append((blatt_src, 1)) # Welche Ast-IDs gibt es? attr = stamm_src.data.attributes.get("ast_id") if attr is None: self.report({'WARNING'}, "%s traegt kein ast_id - bitte den Baum neu " "erzeugen." % baum.name) for n in temp: o = bpy.data.objects.get(n) if o: bpy.data.objects.remove(o, do_unlink=True) return 0 ast_ids = sorted({d.value for d in attr.data if d.value > 0}) zs = [(stamm_src.matrix_world @ v.co).z for v in stamm_src.data.vertices] z_unten, z_oben = min(zs), max(zs) teile = [] # --- Stamm in Segmente ------------------------------------------ n_seg = s.piece_trunk_segments # Nur bis zum untersten Astansatz schneiden waere schoener, ist aber # unnoetig: die Aeste sind eigene Teile, der Stamm-Anteil bleibt uebrig. grenzen = [z_unten + (z_oben - z_unten) * i / n_seg for i in range(n_seg + 1)] for i in range(n_seg): name = "%s_Piece_Stamm%02d" % (basis, i + 1) ob = _teil_aus_ids(context, quellen, {0}, name, z_von=grenzen[i], z_bis=grenzen[i + 1]) if ob is not None: teile.append(ob) # --- Aeste, jeder mit SEINEN Blatt-Cards ------------------------ for k, aid in enumerate(ast_ids): name = "%s_Piece_Ast%02d" % (basis, k + 1) ob = _teil_aus_ids(context, quellen, {aid}, name) if ob is not None: teile.append(ob) # --- Gesamtdatei mit GEMEINSAMEM Ursprung ----------------------- # Damit Unreal den zusammengesetzten Baum deckungsgleich rekonstruieren # kann: hier stehen alle Teile noch an ihrer Originalposition. if s.piece_export_combined and teile: _deselect(context) for t in teile: t.select_set(True) context.view_layer.objects.active = teile[0] bpy.ops.export_scene.fbx( filepath=os.path.join(verzeichnis, basis + "_Pieces_All.fbx"), use_selection=True, object_types={'MESH'}, use_mesh_modifiers=False, mesh_smooth_type='OFF', use_triangles=True, use_custom_props=True, colors_type='SRGB', add_leaf_bones=False, bake_anim=False, path_mode='AUTO', apply_scale_options='FBX_SCALE_ALL') # --- Je Teil eine Datei, Pivot = Massenmittelpunkt -------------- geschrieben = 0 for t in teile: schwer = _mass_center(t.data) t.data.transform(Matrix.Translation(-schwer)) t.matrix_basis = Matrix.Identity(4) hull = _tree_ucx([t], t.name, max_verts=s.piece_ucx_verts) \ if s.piece_ucx else None _deselect(context) t.select_set(True) if hull is not None: hull.select_set(True) context.view_layer.objects.active = t bpy.ops.export_scene.fbx( filepath=os.path.join(verzeichnis, t.name + ".fbx"), use_selection=True, object_types={'MESH'}, use_mesh_modifiers=False, mesh_smooth_type='OFF', use_triangles=True, 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 hull is not None: hm = hull.data bpy.data.objects.remove(hull, do_unlink=True) if hm.users == 0: bpy.data.meshes.remove(hm) if not s.piece_keep: for t in teile: me = t.data bpy.data.objects.remove(t, do_unlink=True) if me.users == 0: bpy.data.meshes.remove(me) for n in temp: 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) return geschrieben class TREEGEN_OT_randomize(Operator): bl_idname = "object.treegen_randomize" bl_label = "Zufaellig" bl_description = ("Wuerfelt Seed und Form-Parameter neu, ohne den Preset-Charakter " "zu verlieren. 'Streuung' bestimmt die Abweichung") bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.tree_gen_settings import random as _r rng = _r.Random() s.seed = rng.randint(0, 9999) # Die Form-Parameter liegen am Modifier, nicht an den Settings. Deshalb # merken wir uns die Streuung und wenden sie beim Erzeugen an. s["_rand_jitter"] = s.random_amount self.report({'INFO'}, "Neuer Seed %d, Streuung %.0f%%." % (s.seed, s.random_amount * 100)) 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 # 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"))] 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, 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, "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" 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_%02d_S%d" % (_art(s), s.variant, i + 1)) _place(ob, x); x += s.spacing created.append(ob) else: ob = make_object(ng, preset=s.preset, seed=s.seed, name="%s_%02d" % (_art(s), s.variant)) created.append(ob) except Exception as exc: # noqa: BLE001 self.report({'ERROR'}, "Erzeugen fehlgeschlagen: %s" % exc) return {'CANCELLED'} # Streuung auf die Form-Regler anwenden (Preset-Charakter bleibt) amt = float(s.get("_rand_jitter", 0.0)) if amt > 0.0: import random as _r rng = _r.Random(s.seed) ids = socket_ids(ng) JITTER = {"Height": 0.35, "Trunk Radius": 0.25, "Bend": 0.5, "Branch Count": 0.3, "Branch Length": 0.35, "Branch Up": 0.3, "Branch Bend": 0.4, "Crown Taper": 0.3, "Crown Bulge": 0.3, "Root Flare": 0.4, "Root Lobes": 0.3} for ob in created: md = ob.modifiers.get("GN_Tree") if md is None: continue for key, rel in JITTER.items(): sid = ids.get(key) if sid is None: continue cur = md[sid] f = 1.0 + rng.uniform(-rel, rel) * amt md[sid] = type(cur)(round(cur * f)) if isinstance(cur, int) else cur * f context.view_layer.update() 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) if s.make_leaf or s.make_frucht: extra = [] for ob in created: if s.make_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) 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): """Haupt-Panel: Vorlage, Name, Erzeugen. Der Rest sitzt in Unter-Panels.""" 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 layout.prop(s, "preset", text="") row = layout.row(align=True) row.prop(s, "art_name", text="") row.prop(s, "variant") layout.separator() col = layout.column(align=True) col.scale_y = 1.4 col.operator("object.treegen_create", text="Baum erzeugen", icon='OUTLINER_OB_MESH') layout.label(text="%s_%02d%s" % (_art(s), s.variant, "_S1..S%d" % s.stages if s.use_growth else ""), icon='INFO') row = layout.row(align=True) row.prop(s, "seed") row.prop(s, "random_amount", text="") row.operator("object.treegen_randomize", text="", icon='FILE_REFRESH') layout.label(text="Feinjustierung: Modifier 'GN_Tree'", icon='MODIFIER') class _TreeSub(Panel): """Basis aller Unter-Panels.""" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "Tree Gen" bl_parent_id = "VIEW3D_PT_tree_generator" bl_options = {'DEFAULT_CLOSED'} class VIEW3D_PT_tree_growth(_TreeSub): bl_label = "1 - Wachstum" def draw_header(self, context): self.layout.prop(context.scene.tree_gen_settings, "use_growth", text="") def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings col = layout.column(align=True) col.active = s.use_growth col.prop(s, "stages") col.prop(s, "spacing") if s.use_growth: layout.label(text="Setzling bis Baum in %d Stufen" % s.stages, icon='INFO') class VIEW3D_PT_tree_leaves(_TreeSub): bl_label = "2 - Blattwerk" def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings 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 or s.leaf_cards is not None col.prop(s, "leaf_density") col.prop(s, "leaf_size") col.prop(s, "leaf_size_var") col.prop(s, "leaf_max_radius") col.prop(s, "leaf_tilt") col.prop(s, "leaf_budget") layout.separator() layout.operator("object.treegen_leaves", icon='OUTLINER_OB_POINTCLOUD') 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') class VIEW3D_PT_tree_layers(_TreeSub): bl_label = "3 - Zusatz-Ebenen" def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings layout.prop(s, "make_leaf") layout.prop(s, "make_frucht") class VIEW3D_PT_tree_export(_TreeSub): bl_label = "4 - Export" def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings layout.prop(s, "apply_modifier") 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.separator() box = layout.box() box.label(text="Faell-Teile", icon='MOD_BUILD') box.prop(s, "piece_trunk_segments") box.prop(s, "piece_ucx") row = box.row() row.active = s.piece_ucx row.prop(s, "piece_ucx_verts") box.prop(s, "piece_export_combined") box.prop(s, "piece_keep") box.operator("treegen.export_pieces", icon='MOD_BUILD') 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_export_pieces, TREEGEN_OT_leaves, TREEGEN_OT_create, # Eltern-Panel zuerst, sonst findet Blender die bl_parent_id nicht. VIEW3D_PT_tree_generator, VIEW3D_PT_tree_growth, VIEW3D_PT_tree_leaves, VIEW3D_PT_tree_layers, VIEW3D_PT_tree_export, ) 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()