bl_info = { "name": "Stylized Tree Generator", "author": "D4rkst3r", "version": (1, 47, 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 = { # Der generische Laubbaum war eine SAEULE: gemessen Breite/Hoehe 0.18 ueber # fuenf Seeds, schmaler als jedes andere Laubgehoelz hier. Ursache war # dieselbe wie bei der Birke - "Branch Up" 1.3 aus den DEFAULTS liess die # Aeste fast senkrecht nach oben zeigen und am Stamm kleben. # # Zielband der Spielseite: 0.45-0.60, "zwischen Birke (0.51) und einem # klassischen Parkbaum". # # Die DEFAULTS bleiben unangetastet - sie sind die Socket-Vorgaben der # Node-Gruppe, an denen jeder haendische Regler haengt. Nur "baum" bekommt # eigene Werte; alle anderen Presets ueberschreiben Branch Up ohnehin. "baum": { "Branch Up": 0.5, "Branch Length": 2.1, "Attraction Up": 0.15, # spreizen statt hochschwingen }, # 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 # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. "Height": 13.0, "Trunk Radius": 0.28, "Bend": 0.15, "Taper": 0.95, "Branch Count": 22, "Branch Length": 3.55, "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 # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. "Height": 7.9, "Trunk Radius": 0.34, "Bend": 0.5, "Taper": 0.75, "Branch Count": 10, "Branch Length": 3.15, "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, }, # Hochstamm-Obstbaum fuer Apfel, Kirsche, Birne (Seeds 1/15/22, 2/16/23, # 3/17/24). Eigenes Preset statt Umbau des generischen "baum": der wird # anderswo verwendet, und ein Obstbaum ist keine Standard-Silhouette. # # Der Zweck ist nicht nur die Form: die Frucht-Streuung der Spielseite setzt # Fruechte auf die AUSSENHAUT der Krone. Dafuer muss der Kronenradius # groesser sein als eine Blatt-Card, sonst gibt es keine Haut, auf die sich # etwas setzen laesst. Am generischen Baum war der Kronenradius 0.78 m bei # 1.28 m Card-Diagonale - die Cards waren groesser als die Krone. # # Proportion belegt: beim Hochstamm liegt der Kronenansatz bei 180-220 cm, # die Krone selbst ist 3-4 m hoch. 1.8 m Ansatz plus 3 m Krone sind 4.8 m # Gesamthoehe und ein Ansatz bei knapp 40 % - genau das Band der Spielseite. # # Zielbild ist der LOLLIPOP der stilisierten Vektor-Illustration: kurzer # kraeftiger Stamm, darauf eine Kugelkrone. Der Weg dorthin ist NICHT mehr # Baumhoehe (4.9 m sind die Ernte-Reichweite), sondern ein tieferer # Kronenansatz - tiefere Fruechte sind fuers Ernten ohnehin besser. # # Gemessen ueber alle neun Produktions-Seeds: H 4.90 m, Kronenansatz 29 %, # Kronenhoehe/-breite 0.97 (Kugel), Breitenprofil unten/mitte 0.95 und # oben/mitte 0.63, Deckung wie die Eiche. # # Fuer dieses Preset gilt B/H NICHT (Entscheidung der Spielseite, # 2026-08-06): Kronenradius >= 1.80 schlaegt B/H <= 0.75, weil an der # Kronen-Aussenhaut die Frucht-Streuung haengt. Gemessen 0.77-0.82. Die Form # sichert das BREITENPROFIL ab, das schaerfer trennt - B/H haette sowohl den # Trichter als auch die Pilzform durchgelassen. Siehe tests/test_obstbaum.py. # # FALLE beim Einregeln: "Height" ist die STAMMlaenge, nicht die Baumhoehe. # Die Aeste ragen darueber hinaus - mit Height 4.8 kam der Baum auf 5.4-6.2 m. # Und der Kronenansatz zaehlt gegen die GESAMThoehe: Branch Start 0.37 ergab # gemessen 29 %, nicht 37 %. "obstbaum": { "Height": 4.9, "Trunk Radius": 0.13, "Bend": 0.3, "Taper": 0.7, "Branch Count": 9, # offene Krone: wenige Leitaeste, Licht durch # Kurze Aeste an einem HOHEN Stamm. Vorher genau umgekehrt (Height 3.8, # Branch Length 2.55) - daraus wurde ein Trichter, siehe unten. "Branch Length": 1.55, "Branch Up": 0.7, # Kronenansatz bei 30 % statt 40 %: das ist der Weg zur KUGEL, ohne den # Baum hoeher zu machen (4.9 m haengen an der Ernte-Reichweite). Die # Krone wird dadurch 4.3 m hoch bei 4.5 m Breite - Kugelverhaeltnis # 0.97 statt 0.83 der Haube. "Branch Start": 0.30, "Branch End": 0.90, "Crown Taper": 0.35, # oben kuerzere Aeste "Crown Bulge": 1.5, # Bauch in der Mitte "Attraction Up": -0.05, # KEIN Aufwaertsschwung ... "Branch Droop": 0.25, "Droop Curve": 3.0, # ... Spitzen kippen ab "Branch Bend": 0.9, "Branch Thickness": 0.22, "Sub Count": 3, "Sub Length": 0.7, "Root Flare": 1.0, }, # Birke: schlanker Stamm, aber KEINE Saeule. Der vorherige Satz war eine - # gemessen Breite/Hoehe 0.15, weniger als der Standardbaum (0.22) und nicht # einmal die Haelfte der Eiche (0.36). Ursache war "Branch Up" 1.1: die # Aeste zeigten fast senkrecht nach oben und klebten am Stamm. # # Zielband aus zwei Baumschul-/Fachquellen, die sich ueberlappen: # Van den Berk nennt fuer Betula pendula 15-20 m Hoehe bei 8-12 m # Kronenbreite (B/H 0.40-0.80), der Woodland Trust 15-25 m bei 6-10 m # (0.24-0.67). Schnittmenge also rund 0.35-0.55; gemessen jetzt 0.45. # # Der Habitus steht bei Van den Berk woertlich: "spreading branches have # clearly pendulous and thin twigs", Krone "irregularly oval to oblong, # half-open". Das ist der Palmen-Bogen im Kleinen - der Ast steigt erst und # kippt dann ab -, deshalb Branch Droop mit spaeter Droop Curve statt # negativem "Sub Up". Sub Up wurde durchgemessen und bewegt hier nichts: # von +2.2 bis -2.0 blieb der Anteil haengender Vertices bei 1-6 %, weil # die Sub-Aeste zu kurz sind, um die Silhouette zu tragen. Mit Droop 0.8 # sind es 26 % bei einem Fall von 0.033 x Hoehe - vergleichbar der Tanne # (0.030), weit weg von der Weide (0.213). Zweige haengen, der Baum nicht. "birke": { # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. "Height": 11.0, "Trunk Radius": 0.13, "Bend": 0.7, "Taper": 0.92, "Branch Count": 16, "Branch Length": 3.05, "Branch Up": 0.55, "Branch Start": 0.38, "Branch End": 0.98, "Crown Taper": 0.1, "Crown Bulge": 0.25, "Attraction Up": 0.15, # spreizen, nicht hochschwingen "Branch Droop": 0.8, # Zweigenden kippen ab ... "Droop Curve": 3.5, # ... erst im aeusseren Drittel "Branch Bend": 0.8, "Branch Thickness": 0.11, "Sub Count": 3, "Sub Length": 0.7, "Root Flare": 0.6, }, "weide": { # haengende Zweige (Droop positiv), breit ausladend # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. "Height": 7.0, "Trunk Radius": 0.25, "Bend": 0.8, "Taper": 0.8, "Branch Count": 12, "Branch Length": 2.8, "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] # Schema-Version der Baum-Gruppe. HOCHZAEHLEN, sobald sich Sockets aendern - # daran erkennt build_group, ob eine vorhandene Gruppe noch passt. TREE_SCHEMA = 3 def _gruppe_passt(ng): """Ist eine vorhandene GN_Tree-Gruppe noch die aktuelle? Zwei Kriterien: die abgelegte Schema-Version UND die tatsaechlich vorhandenen Eingaenge. Die Version allein reichte nicht - wer waehrend der Entwicklung Sockets aendert, ohne die Zahl anzufassen, bekaeme eine veraltete Gruppe untergeschoben. """ if ng.get("schema") != TREE_SCHEMA: return False vorhanden = {so.name for so in ng.interface.items_tree if getattr(so, "in_out", "") == 'INPUT'} return set(DEFAULTS).issubset(vorhanden) def build_group(rebuild=False): """Baut die Baum-Node-Gruppe - aber nur, wenn noetig. FALLE, die das hier noetig macht: die Gruppe bedingungslos zu loeschen und neu anzulegen reisst sie ALLEN bereits erzeugten Baeumen unter dem Modifier weg. Gemessen: nach vier Arten-Erzeugungen lebte nur noch die letzte, alle frueheren evaluierten leer. Dass der Export direkt nach dem Erzeugen lief, hat das lange verdeckt. Wenn doch neu gebaut werden muss (Schema-Aenderung), werden bestehende Modifier auf die neue Gruppe umgehaengt - sonst zeigen sie auf ein totes Datablock. """ alt = bpy.data.node_groups.get(GROUP_NAME) if alt is not None and not rebuild and _gruppe_passt(alt): return alt nutzer = [] if alt is not None: for ob in bpy.data.objects: for md in ob.modifiers: if getattr(md, "node_group", None) is alt: nutzer.append(md) bpy.data.node_groups.remove(alt) ng = bpy.data.node_groups.new(GROUP_NAME, "GeometryNodeTree") ng["schema"] = TREE_SCHEMA 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]) # Bestehende Modifier auf die NEUE Gruppe umhaengen. Ohne das zeigen sie # nach einem Rebuild auf ein geloeschtes Datablock und der Baum evaluiert # leer - der Fehler, der ueberhaupt zu diesem Waechter gefuehrt hat. for md in nutzer: try: md.node_group = ng except (ReferenceError, AttributeError): pass 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 # Mindest-Stammradius und Mindest-Wurzelanlauf fuer den Saemling. # # Die McMahon-Skalierung (Durchmesser ~ Hoehe^1.5) macht duennstaemmige Arten am # Setzling zu Draehten: gemessen kam die Birke auf 0.6 cm Fussradius gegen 1.9 # der Eiche - dreimal duenner, waehrend der Unterschied beim Altbaum nur 1.2x # betraegt. Die Regel gilt fuer die STATIK ausgewachsener Baeume; ein Saemling # ist eine Pflanze, kein Balken. Der Boden hebt die duennen Arten an, ohne die # dicken (Tanne, Palme, Kaktus) anzufassen. SEEDLING_MIN_RADIUS = 0.018 # m, Stammradius am Fuss vor dem Wurzelanlauf SEEDLING_MIN_FLARE = 0.55 # Wurzelanlauf-Faktor, sonst steht der Stiel stumpf 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}, # Ein HOCHSTAMM wird gezogen, nicht gewachsen. Der allgemeine # Setzlings-Faktor 0.35 auf "Branch Start" bildet die Selbstbeschneidung # eines WILDEN Baums ab - junge Baeume tragen ihre Aeste tiefer und asten # sich erst spaeter auf. Beim Obstbaum stimmt das nicht: die Baumschule # veredelt hoch und nimmt die unteren Triebe weg, der freie Stamm ist von # Anfang an da. Genau daran haengt der Hochstamm-Charakter. # # Gemessen ohne diese Ausnahme (Kronenansatz je Stufe): 10 / 16 / 22 / 29 %. # Nur S4 war kalibriert; im Spiel las sich ein S3 als duenne Astgabel mit # Laubklumpen statt als Obstbaeumchen - gemeldet aus dem Welt-Test. "obstbaum": {"Branch Start": 1.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 # Boden fuer die Saemlings-Stufen anwenden, bevor gerundet wird. if key == "Trunk Radius": v = max(v, min(target, SEEDLING_MIN_RADIUS)) elif key == "Root Flare": v = max(v, min(target, SEEDLING_MIN_FLARE)) 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 _modifier_anstossen(md) # Wachstums-Fortschritt am Objekt merken. Der Blattwerk-Operator laeuft # spaeter und getrennt - er kann sonst nicht wissen, ob er einen Setzling # oder einen Altbaum vor sich hat. Aus dem Namen zu lesen waere bruechig. ob["growth_t"] = 1.0 if growth is None else max(0.0, min(1.0, float(growth))) # Das Preset ebenso: einzelne Arten brauchen eine eigene Ziel-Cardzahl # (siehe LEAF_STUFEN_PRESET), und der Blattwerk-Operator sieht spaeter nur # noch das fertige Objekt. ob["preset"] = preset bpy.context.view_layer.objects.active = ob return ob # --------------------------------------------------------------------------- # Blattwerk: Billboard-Cards auf die duennen Zweige streuen # --------------------------------------------------------------------------- LEAF_GROUP_NAME = "GN_TreeLeaves" # Eine Card ist ein ganzer ZWEIGBUESCHEL, kein einzelnes Blaettchen. Die alten # Werte (Size 0.22, Density 300-500) stammen aus der Blaettchen-Aera: damit # streute der Generator hunderte winziger Cards, wo ein paar Dutzend grosse # hingehoeren. Mit Bueschel-Atlanten liegen die Groessenordnungen bei # Size ~0.7 und Density ~90. LEAF_DEFAULTS = { # Punkte pro m2 Zweigoberflaeche. Seit der Bueschel-Schale sind das # CLUSTER-Zentren, nicht mehr einzelne Cards - jedes Zentrum traegt SECHS # Cards auf seiner Halbkugel. Die Dichte liegt entsprechend niedriger; die # Bisektion regelt sie ohnehin auf die Ziel-Cardzahl ein. # Cards je m2 KRONEN-HUELLE. Die Bisektion regelt sie auf die Ziel-Cardzahl # ein; der Startwert muss nur die Groessenordnung treffen. "Density": 12.0, "Cluster Radius": 0.45, # Kugeln je m2 Zweigflaeche - bestimmt, wie klumpig die Huelle wird. # # Der Wert sieht hoch aus, ist es aber nicht: die zulaessige ZWEIGflaeche # eines Baums ist winzig (duenne Zweige oberhalb des Kronenansatzes), und # die Spitzen-Gewichtung nimmt nochmal davon weg. Mit 30 entstanden # gemessen VIER Kugeln auf drei Aesten - die Huelle war ein Klumpen, und # sieben Aeste bekamen beim Faellen kein Laub. In der alten Bauform regelte # die Bisektion diese Dichte selbst auf rund 550 hoch; seit sie stattdessen # die Cards auf der Huelle regelt, muss der Startwert hier stimmen. "Cluster Density": 550.0, # Aufloesung der Verschmelzung, in Metern. "Voxel Size": 0.12, # DER Volumen-Hebel. Bei 0.02 trugen nur die aeussersten Zweigspitzen Laub - # die Krone hatte nur Kontur und war innen nackt. Bei 0.04 wachsen Bueschel # auch am mittleren Geaest und die Krone bekommt Tiefe. Am fertigen Baum in # UE gegengeprueft, nicht geschaetzt. "Max Radius": 0.04, "Size": 0.85, "Min Height": 0.25, # Kronenansatz als Anteil der Baumhoehe "Size Var": 0.35, "Tilt": 0.9, # mehr Rotations-Chaos schliesst Sichtluecken "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) # Kronenansatz als ANTEIL der Baumhoehe. Der Radius-Filter allein reicht # nicht: er trennt duenn von dick, aber ein junger Stamm ist ueberall duenn - # gemessen sassen beim Setzling 44 % der Cards unter 40 % der Hoehe, die # tiefste bei 6 %. Das ergibt eine belaubte Saeule statt einer Krone. add_in("Min Height", 'NodeSocketFloat', LEAF_DEFAULTS["Min Height"], 0.0, 0.95) 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) # Radius der Kugeln, aus denen die Kronen-Huelle verschmilzt. Siehe den # Block "KRONEN-HUELLE" weiter unten. add_in("Cluster Radius", 'NodeSocketFloat', LEAF_DEFAULTS["Cluster Radius"], 0.02, 5.0) # Wie dicht die Kugeln auf den Zweigen sitzen. Getrennt von "Density": # die regelt jetzt die CARDS auf der fertigen Huelle, diese hier die Zahl # der Kugeln, aus denen die Huelle ueberhaupt entsteht. add_in("Cluster Density", 'NodeSocketFloat', LEAF_DEFAULTS["Cluster Density"], 0.0, 2000.0) # Aufloesung der Verschmelzung. Gross = klumpig und billig, klein = feine # Kontur und teuer. Der eigentliche Kostenregler dieser Bauform. add_in("Voxel Size", 'NodeSocketFloat', LEAF_DEFAULTS["Voxel Size"], 0.02, 2.0) 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]) # HOEHEN-FILTER: erst ab dem Kronenansatz. Die Schwelle wird aus der # Bounding Box des Baums abgeleitet, damit sie fuer jede Groesse stimmt - # ein fester Meterwert waere beim Setzling die halbe Pflanze. bb = N("GeometryNodeBoundBox"); bb.location = (-700, -60) L(_out(tree, "Geometry"), _sock(bb, "Geometry")) bmin = N("ShaderNodeSeparateXYZ"); bmin.location = (-560, -60) L(_out(bb, "Min"), bmin.inputs[0]) bmax = N("ShaderNodeSeparateXYZ"); bmax.location = (-560, -140) L(_out(bb, "Max"), bmax.inputs[0]) bh = N("ShaderNodeMath"); bh.location = (-420, -100); bh.operation = 'SUBTRACT' L(bmax.outputs["Z"], bh.inputs[0]); L(bmin.outputs["Z"], bh.inputs[1]) bmul = N("ShaderNodeMath"); bmul.location = (-280, -100); bmul.operation = 'MULTIPLY' L(bh.outputs[0], bmul.inputs[0]); L(V["Min Height"], bmul.inputs[1]) bschwelle = N("ShaderNodeMath"); bschwelle.location = (-140, -100) bschwelle.operation = 'ADD' L(bmin.outputs["Z"], bschwelle.inputs[0]); L(bmul.outputs[0], bschwelle.inputs[1]) hpos = N("GeometryNodeInputPosition"); hpos.location = (-560, -220) hsep = N("ShaderNodeSeparateXYZ"); hsep.location = (-420, -220) L(hpos.outputs[0], hsep.inputs[0]) # Der Filter darf HAENGENDE Astspitzen nicht wegschneiden. Ein Ast der Tanne # setzt bei 12 % der Hoehe an und haengt; seine Spitze faellt unter den # Kronenansatz und flog damit aus der Auswahl - gemessen blieben 4 von 22 # Aesten ohne Card an der Spitze, bei der Weide 3 von 12. Genau die # nackten Ast-Enden, die die Spielseite gemeldet hat. # # Der Baum traegt den ANSATZPUNKT jedes Astpunkts als Attribut "anchor". # Ein Punkt zaehlt, wenn ENTWEDER er selbst oder der Ansatz seines Astes # ueber der Schwelle liegt. Beides braucht es: nur ueber den Ansatz zu # gehen wuerde beim Setzling das Stamm-Laub ausschliessen (der Stamm hat # keinen Ansatz und laege bei 0). hank = N("GeometryNodeInputNamedAttribute"); hank.location = (-560, -300) hank.data_type = 'FLOAT_VECTOR' _sock(hank, "Name").default_value = "anchor" hasep = N("ShaderNodeSeparateXYZ"); hasep.location = (-420, -300) L(_out(hank, "Attribute"), hasep.inputs[0]) hmax = N("ShaderNodeMath"); hmax.location = (-280, -260) hmax.operation = 'MAXIMUM' L(hsep.outputs["Z"], hmax.inputs[0]) L(hasep.outputs["Z"], hmax.inputs[1]) hcmp = N("FunctionNodeCompare"); hcmp.location = (-140, -220) hcmp.data_type = 'FLOAT'; hcmp.operation = 'GREATER_THAN' L(hmax.outputs[0], hcmp.inputs[0]) L(bschwelle.outputs[0], hcmp.inputs[1]) beide = N("FunctionNodeBooleanMath"); beide.location = (0, 20) beide.operation = 'AND' L(cmp.outputs[0], beide.inputs[0]) L(hcmp.outputs[0], beide.inputs[1]) # DICHTE ZUR SPITZE HIN GEWICHTEN. # # "Distribute Points on Faces" verteilt nach FLAECHE. Eine Astspitze ist # duenn und hat entsprechend wenig davon - sie bekommt also am seltensten # ein Cluster, und genau das hat die Spielseite gemeldet: nackte Ast-Enden # mit einem Mini-Bueschel daran. Gemessen als Abstand Astspitze zur # naechsten Card, relativ zur Astlaenge: Tanne 48 % im Mittel, im # schlimmsten Fall 123 % - dort sass gar nichts mehr am Ende. # # Der Astradius liegt als "uv_r" am Mesh. Duenn bekommt volle Dichte, dick # nur einen Bruchteil - damit wandern die Cluster nach aussen, wo das Laub # hingehoert. wmap = N("ShaderNodeMapRange"); wmap.location = (-460, 300) wmap.clamp = True L(_out(na, "Attribute"), wmap.inputs["Value"]) # Und der Bezug ist ein BRUCHTEIL von Max Radius, nicht der ganze Bereich: # oberhalb eines Viertels der Schwelle ist der Zweig fuer ein Polster schon # zu dick. wmap.inputs["From Min"].default_value = 0.0 wviertel = N("ShaderNodeMath"); wviertel.location = (-600, 300) wviertel.operation = 'MULTIPLY'; wviertel.inputs[1].default_value = 0.25 L(V["Max Radius"], wviertel.inputs[0]) L(wviertel.outputs[0], wmap.inputs["From Max"]) # Der Kontrast muss KRAEFTIG sein. Mit 1.0 -> 0.2 war die Gewichtung # wirkungslos: die Zweigradien liegen bei 0.001-0.003, Max Radius bei 0.04, # also bekamen praktisch alle waehlbaren Punkte fast dasselbe Gewicht - # und die Flaeche entschied weiter. Mit 1.0 -> 0.02 zieht es die Cluster # wirklich nach aussen. wmap.inputs["To Min"].default_value = 1.0 wmap.inputs["To Max"].default_value = 0.02 wmul = N("ShaderNodeMath"); wmul.location = (-320, 300) wmul.operation = 'MULTIPLY' L(V["Cluster Density"], wmul.inputs[0]) L(wmap.outputs["Result"], wmul.inputs[1]) dist = N("GeometryNodeDistributePointsOnFaces"); dist.location = (-320, 200) L(_out(tree, "Geometry"), _sock(dist, "Mesh")) L(beide.outputs[0], _sock(dist, "Selection")) L(wmul.outputs[0], _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")) # ------------------------------------------------------------------ # KRONEN-HUELLE: die Kugeln VERSCHMELZEN erst, dann wird auf der # gemeinsamen Oberflaeche gestreut. # # Vorher bekam jeder Cluster seine eigene Halbkugel und wurde einzeln # bestueckt. Die Polster blieben dadurch als getrennte Koerper sichtbar - # ein Laubhandschuh je Ast, im Spiel als "behaarte Finger" gemeldet. Mehr # oder groessere Cards heilen das nicht: das Problem ist die STREUFLAECHE, # nicht ihre Belegung. # # Der Weg ist der Standard aus den Produktions-Breakdowns (80.lv "Meadows", # "The Witness"-Baeume): Kugeln auf die Aeste setzen, zu EINER Kronenmasse # verschmelzen, Cards ueber deren Oberflaeche streuen. Die Aeste # verschwinden dabei im Inneren der Krone. # # Verschmolzen wird ueber Volumen (Mesh to Volume -> Volume to Mesh) - das # ist die Metaball-Wirkung: sich beruehrende Kugeln werden zu einem Koerper # mit weichem Uebergang, statt sich zu durchdringen. kugel = N("GeometryNodeMeshUVSphere"); kugel.location = (-320, 460) _sock(kugel, "Segments").default_value = 8 _sock(kugel, "Rings").default_value = 6 _sock(kugel, "Radius").default_value = 1.0 # Radius mit Streuung, damit die Krone keine Perlenkette wird. rrad = N("FunctionNodeRandomValue"); rrad.location = (-180, 320) rrad.data_type = 'FLOAT' L(V["Seed"], _sock(rrad, "Seed")) rlo = N("ShaderNodeMath"); rlo.location = (-320, 260); rlo.operation = 'MULTIPLY' rlo.inputs[1].default_value = 0.7 L(V["Cluster Radius"], rlo.inputs[0]) L(rlo.outputs[0], rrad.inputs[2]) L(V["Cluster Radius"], rrad.inputs[3]) ballen = N("GeometryNodeInstanceOnPoints"); ballen.location = (120, 460) L(_out(lstore, "Geometry"), _sock(ballen, "Points")) L(_out(kugel, "Mesh"), _sock(ballen, "Instance")) L(rrad.outputs[1], _sock(ballen, "Scale")) breal = N("GeometryNodeRealizeInstances"); breal.location = (280, 460) L(_out(ballen, "Instances", "Geometry"), _sock(breal, "Geometry")) m2v = N("GeometryNodeMeshToVolume"); m2v.location = (420, 460) L(_out(breal, "Geometry"), _sock(m2v, "Mesh")) try: m2v.resolution_mode = 'VOXEL_SIZE' except (AttributeError, TypeError): pass L(V["Voxel Size"], _sock(m2v, "Voxel Size")) v2m = N("GeometryNodeVolumeToMesh"); v2m.location = (560, 460) L(_out(m2v, "Volume"), _sock(v2m, "Volume")) try: v2m.resolution_mode = 'VOXEL_SIZE' except (AttributeError, TypeError): pass L(V["Voxel Size"], _sock(v2m, "Voxel Size")) # Cards auf der HUELLE streuen. "Density" regelt jetzt hier - die Bisektion # trifft die Ziel-Cardzahl damit direkt, ohne den Umweg ueber die # Cluster-Zahl und ihre feste Kartenzahl je Cluster. # Der KRONENANSATZ-Filter muss auch auf der Huelle greifen, nicht nur auf # den Zentren. Die Verschmelzung weitet die Form um bis zu einen # Kugelradius nach unten - gemessen lagen dadurch 31 % der Cards unter dem # Ansatz und die tiefste bei 18 % der Hoehe statt bei 40 %. Das Laub klebte # wieder am Stamm, der Fehler, gegen den der Filter ueberhaupt gebaut wurde. hpos3 = N("GeometryNodeInputPosition"); hpos3.location = (560, 620) hsep3 = N("ShaderNodeSeparateXYZ"); hsep3.location = (700, 620) L(hpos3.outputs[0], hsep3.inputs[0]) hcmp3 = N("FunctionNodeCompare"); hcmp3.location = (840, 620) hcmp3.data_type = 'FLOAT'; hcmp3.operation = 'GREATER_THAN' L(hsep3.outputs["Z"], hcmp3.inputs[0]) L(bschwelle.outputs[0], hcmp3.inputs[1]) hdist = N("GeometryNodeDistributePointsOnFaces"); hdist.location = (700, 460) L(_out(v2m, "Mesh"), _sock(hdist, "Mesh")) L(hcmp3.outputs[0], _sock(hdist, "Selection")) L(V["Density"], _sock(hdist, "Density")) L(V["Seed"], _sock(hdist, "Seed")) # AST-ID zurueckholen. Die Verschmelzung erzeugt frische Geometrie und # verliert JEDES Attribut - ohne diesen Schritt wuesste keine Card mehr, # zu welchem Ast sie gehoert, und die Faell-Teile zerfielen falsch. # # Die Zuordnung ist hier zwangslaeufig raeumlich: ein Punkt auf der # verschmolzenen Huelle gehoert nicht mehr per Konstruktion zu einem Ast. # Gefragt wird deshalb das naechste CLUSTER-ZENTRUM - das ist die Kugel, # aus der dieses Stueck Huelle entstanden ist, also die bestmoegliche # Antwort. Die Blatt-Bilanz im Test prueft, ob die Summe stimmt. snear = N("GeometryNodeSampleNearest"); snear.location = (700, 300) # DOMAIN setzen. Ohne das sucht der Knoten in der FACE-Domaene; die # Cluster-Zentren sind aber eine reine Punktwolke ohne Faces, und die Suche # lief ins Leere - gemessen kamen nur 3 von 10 Ast-IDs an den Cards an, # sieben Aeste waeren beim Faellen ohne Laub abgefallen. snear.domain = 'POINT' L(_out(lstore, "Geometry"), _sock(snear, "Geometry")) hpos2 = N("GeometryNodeInputPosition"); hpos2.location = (560, 300) L(hpos2.outputs[0], _sock(snear, "Sample Position")) sidx = N("GeometryNodeSampleIndex"); sidx.location = (840, 300) sidx.data_type = 'INT' sidx.domain = 'POINT' L(_out(lstore, "Geometry"), _sock(sidx, "Geometry")) lna2 = N("GeometryNodeInputNamedAttribute"); lna2.location = (700, 220) lna2.data_type = 'INT' _sock(lna2, "Name").default_value = "ast_id" L(_out(lna2, "Attribute"), _sock(sidx, "Value")) L(_out(snear, "Index"), _sock(sidx, "Index")) hstore = N("GeometryNodeStoreNamedAttribute"); hstore.location = (1000, 460) hstore.domain = 'POINT'; hstore.data_type = 'INT' L(_out(hdist, "Points"), _sock(hstore, "Geometry")) _sock(hstore, "Name").default_value = "ast_id" L(_out(sidx, "Value"), _sock(hstore, "Value")) inst = N("GeometryNodeInstanceOnPoints"); inst.location = (1160, 460) L(_out(hstore, "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'} # Deckung PRO STUFE statt einer Formel. Die fruehere Rechnung (Dichte durch # faktor^2) war zu grob: sie hielt den Setzling zwar vor der Kahlheit, gab ihm # aber eine Deckung wie einem Altbaum. # # Der Dichte-Anteil bei S1/S2 sieht hoch aus - das taeuscht. "Dichte" ist # Punkte pro m2 ZWEIGFLAECHE, und die waechst ueber die Stufen dramatisch: # gemessen hat der S1-Setzling 0.013 m2 zulaessige Flaeche. Ohne Anhebung # bekaeme er null Cards. Was zaehlt, ist die ANZAHL, und die steigt sauber # von ~8 auf ~65. # # S4 traegt die volle Dichte (170), S2 liegt 15 % darunter, S1 deutlich. # Dichte-Anteil Groessen-Anteil # Boden fuer den Radius-Filter beim Setzling. Nachgemessen: 0.064 (der reine # Stammradius-Faktor) laesst ihn kahl, 0.20 gibt 12 Cards bei 12 % am Stamm. LEAF_RADIUS_YOUNG = 0.20 # Dichte-Anteil Groessen-Anteil # ZIEL-ANZAHL statt Dichte-Anteil. # # Dichte-Faktoren waren preset-abhaengig und mussten fuer jeden Baum neu # geeicht werden: "Dichte" ist Punkte pro m2 Zweigflaeche, und die verteilt sich # je Preset voellig anders. Ein Faktor, der am Standardbaum 11 Cards ergab, # lieferte auf der Eiche mehr als das Doppelte - die Kompensation ueberschoss. # # Jetzt steht die gewuenschte Card-ANZAHL da, und der Generator regelt die # Dichte per Bisektion dagegen ein. Das ist preset-unabhaengig, und der Test # kann die Anzahl DIREKT pruefen statt ueber Dichte x Anteil x Flaeche zu # rechnen. None = nicht regeln, volle Dichte (der Altbaum ist die Referenz, # an der die Dichte ueberhaupt eingestellt wurde). # # Der GROESSEN-Anteil misst sich an der BAUMHOEHE, nicht an sich selbst. # # Mit 0.45/0.63/0.82/1.00 schrumpfte die Card ueber die Stufen um Faktor 2.2 - # der Baum aber um 6.2. Gemessen an der Birke, Card-Diagonale im Verhaeltnis zur # Baumhoehe: S1 27 %, S2 14 %, S3 11 %, S4 9 %. Der Setzling trug also relativ # dreimal so grosses Laub wie der Altbaum und sah aus wie ein Laubpaket auf # einem Stiel; an der Eiche waren es sogar 33 % gegen 11 %. # # Jetzt 0.24/0.46/0.75/1.00, gemessen 14 % / 10 % / 10 % / 9 %. NICHT ganz # flach: ein junger Baum traegt seine Bueschel relativ groeber als ein Altbaum, # und wer den Setzling auf Altbaum-Proportion zwingt (0.18 durchgemessen: 11 %), # macht ihn kahl. Faktor 1.5 statt 3 ist der Kompromiss. # # Ziel-Cards Groesse Kronenansatz # Seit der Bueschel-Schale ist die kleinste Einheit ein CLUSTER (rund 26 Cards # auf einer Halbkugel), nicht mehr die einzelne Card. Ziele unter 26 sind damit # unerreichbar - S1 stand vorher auf 10 und die Regelung haette nie konvergiert. # Die Zahlen sind deshalb auf Cluster-Vielfache gelegt: 1 / 3 / 8 / 15. # # S4 ist jetzt GEREGELT (vorher None = volle Dichte). Ohne Regelung lieferte die # Schale das 26-fache der frueheren Cardzahl - gemessen 2706 statt 444 an der # Eiche. "Volle Dichte" war eine sinnvolle Vorgabe, solange ein Punkt eine Card # war; jetzt ist sie es nicht mehr. # # Ziel-Cards Groesse Kronenansatz LEAF_STUFEN = ( (26, 0.24, 0.40), # S1 - EIN Bueschel, Fuss bleibt frei (78, 0.46, 0.35), # S2 - drei (208, 0.75, 0.30), # S3 - acht (390, 1.00, 0.25), # S4 - rund fuenfzehn ) # AUSNAHMEN je Preset: {preset: {stufen_index: ziel_cards}}. # # "Volle Dichte" auf S4 heisst: das Ergebnis haengt an der Zweigflaeche, und die # faellt je Art verschieden aus. Die WEIDE traegt die meiste - haengende Zweige # mit Sub Length 0.9 und Sub Count 3 - und landete deshalb gemessen bei # 5456/5558/5594 Tris ueber drei Seeds, gegen ein Stufenbudget von 4300. # Deckelung auf 350 Cards: gemessen 361 Cards / 3818 Tris, damit im Budget. # # Das macht die Tabelle preset-abhaengig - bewusst so entschieden, statt das # Budget fuer alle anzuheben: die Weide ist der Ausreisser, nicht die Regel. LEAF_STUFEN_PRESET = { "weide": {3: 350}, # Die Birke hat mit 2100 Tris die dickste Rinde im Satz; bei 390 Cards # gemessen 4572 Tris. (4300 - 2100) / 6 = 366 Cards passen, 340 mit Luft # fuer die 15-%-Toleranz der Regelung. "birke": {3: 340}, # Obstbaum: breite offene Krone auf kurzem Stamm gibt viel Zweigflaeche auf # wenig Hoehe - ungeregelt 4304-4400 Tris ueber drei Seeds, knapp ueber dem # Budget. Die Regelung haelt eine Toleranz von 15 %, deshalb liegt das Ziel # unter dem rechnerischen Maximum (497 Cards): 420 x 1.15 x 6 + 1316 = 4214. "obstbaum": {3: 420}, } # Card-GROESSE je Preset, als Faktor auf die eingestellte Groesse. # # Warum das ueberhaupt eine eigene Tabelle braucht: die Kronenfuellung haengt an # der Card-FLAECHE, nicht an der Card-Zahl - und wie viel Flaeche eine Krone # braucht, haengt an ihrem Volumen. Gemessen (S4, Cards je m3 Kronenzylinder / # Anteil belegter 0.4-m-Voxel): # # Eiche 444 Cards, Krone r 1.26 x 3.78 = 18.9 m3 -> 23.7/m3, 88 % belegt # Obstbaum 399 Cards, Krone r 2.00 x 2.88 = 36.2 m3 -> 11.1/m3, 60 % belegt # # Die Obstbaum-Krone hat fast das doppelte Volumen bei gleicher Card-Zahl - # daher las sie sich als einzelne belaubte Ast-Schlaeuche mit Himmel dazwischen # ("behaarte Finger", Abnahme der Spielseite). # # Durchgemessen und VERWORFEN: mehr und laengere Sub-Aeste als Fuellwerk. Sub # Count 5/7, Sub Length 1.0/1.3, Sub Start 0.20/0.30, Branch Count 12/14 - der # Fuellgrad bewegte sich um hoechstens 7 Punkte, die Dreiecke stiegen auf # 4310-5162. Der Grund ist strukturell: neue Zweige wachsen aus denselben wenigen # Hauptaesten und vergroessern dabei die Krone, waehrend die Card-Zahl gedeckelt # bleibt. Das Volumen waechst also mit. # # Ebenfalls verworfen: mehr Cards. Bei Ziel 740 (was rechnerisch fuer 25/m3 # noetig waere) kamen 5426 Tris heraus - 26 % ueber Budget - und der Fuellgrad # stieg nur auf 75 %. Cards zaehlen ist nicht dasselbe wie Flaeche decken. # # Was wirkt, ist die Card-Groesse: sie kostet KEIN einziges Dreieck, weil die # Zahl gedeckelt ist. Sie kostet aber OVERDRAW - und der ist bei Bueschel-Cards # der eigentliche Engpass, nicht der Dreieckszaehler. Deshalb der maessige # Faktor: 1.47 hebt den Fuellgrad von 60 auf 74 % und verdoppelt die Card- # Flaeche (414 -> 832 m2). 1.71 brachte 79 % bei 2.7-facher Flaeche - das waere # eine Overdraw-Entscheidung der Spielseite, keine Generator-Frage. LEAF_SIZE_PRESET = { "obstbaum": 1.47, # Die vier vergroesserten Arten: die Card muss mit dem Baum wachsen, sonst # wird das Laub relativ zum Baum feiner und die Krone duenn - dieselbe # Falle wie beim Setzling, nur andersherum. Faktoren = Skalierung des Baums. "eiche": 1.43, "birke": 1.46, "tanne": 1.86, "weide": 1.40, } # Radius der Bueschel-Kappe je Preset, als Faktor. # # EIGENE Tabelle, nicht an LEAF_SIZE_PRESET gehaengt. Der erste Anlauf tat # genau das - und hat die abgenommene Obstbaum-Kugel wieder plattgedrueckt: mit # Faktor 1.47 wuchs auch die Kappe um 47 %, und weil sie in ALLE Richtungen # waechst, legt sie mehr Breite als Hoehe zu. Gemessen fiel der Kugel-Kennwert # von 1.35-1.47 auf 0.93-1.14 und die Bbox lag 14 cm ausser Mitte. # # Card-Groesse und Kappen-Radius sind zwei verschiedene Fragen: die erste # entscheidet, wie grob das Laub ist, die zweite, wie weit das Polster vom Ast # absteht. LEAF_CLUSTER_PRESET = {} def _stufen_anteile(growth_t, preset=None): """Ziel-Cardzahl, Groessen- und Kronenansatz-Wert fuer einen Wachstums-Fortschritt. Die Stufen liegen bei t = 0 / 1/3 / 2/3 / 1 (vier Stufen). Zwischen den Stuetzstellen wird linear gemischt, damit auch drei oder fuenf Stufen sinnvolle Werte bekommen. `preset` zieht die Ausnahmen aus LEAF_STUFEN_PRESET; ohne Angabe gilt die allgemeine Tabelle. """ aus = LEAF_STUFEN_PRESET.get(preset or "", {}) def _ziel(i): return aus.get(i, LEAF_STUFEN[i][0]) n = len(LEAF_STUFEN) x = max(0.0, min(1.0, growth_t)) * (n - 1) i = min(int(x), n - 2) f = x - i z0, g0, h0 = _ziel(i), LEAF_STUFEN[i][1], LEAF_STUFEN[i][2] z1, g1, h1 = _ziel(i + 1), LEAF_STUFEN[i + 1][1], LEAF_STUFEN[i + 1][2] # Groesse und Kronenansatz werden gemischt, die Ziel-ANZAHL nicht: sie ist # eine Stueckzahl, kein Verlauf. Es gilt die naeher liegende Stuetzstelle. ziel = z0 if f < 0.5 else z1 return (ziel, g0 + (g1 - g0) * f, h0 + (h1 - h0) * f) def _stufen_budget(s, growth_t): """Tri-Budget fuer einen Baum nach seiner Wachstums-Stufe.""" if growth_t < 0.15: return s.leaf_budget_s1 if growth_t <= 0.55: return s.leaf_budget_s2 return s.leaf_budget def _modifier_anstossen(md): """Erzwingt die Neuauswertung nach direktem Schreiben von Socket-Werten. FALLE, die von der UE-Seite gemeldet und hier nachgestellt wurde: `md[socket] = wert` loest KEINE Neuauswertung aus. Gemessen blieb die Blattebene bei 118 Faces stehen, obwohl die Dichte von 90 auf 400 geschrieben war - auch `view_layer.update()` half nicht. Erst ein show_viewport-Toggle brachte das Ergebnis. Der Knopf-Weg funktionierte bisher nur ZUFAELLIG: make_leaves schreibt jedes Mal auch die Objekt-Zeiger neu, und das Aendern eines ID-Zeigers baut die Depsgraph-Beziehungen um, was den Cache verwirft. Wer das spaeter optimiert ("nur geaenderte Werte schreiben"), haette den Fehler still zurueck. """ sichtbar = md.show_viewport md.show_viewport = not sichtbar md.show_viewport = sichtbar def _cards_pro_stueck(card_obj, card_coll): """Wie viele Faces hat EINE Card? Fuer die Umrechnung Faces -> Cardzahl.""" quellen = [] if card_coll is not None: quellen = [o for o in card_coll.objects if o.type == 'MESH'] elif card_obj is not None and card_obj.type == 'MESH': quellen = [card_obj] zahlen = [len(o.data.polygons) for o in quellen if o.data.polygons] if not zahlen: return 1 return max(1, int(round(sum(zahlen) / float(len(zahlen))))) def _card_zahl(ob, pro_stueck, dg=None): """Anzahl gestreuter Cards auf einer ausgewerteten Blatt-Ebene.""" dg = dg or bpy.context.evaluated_depsgraph_get() ev = ob.evaluated_get(dg) me = ev.to_mesh() n = len(me.polygons) ev.to_mesh_clear() return int(round(n / float(max(pro_stueck, 1)))) def _dichte_einregeln(context, ob, md, ids, ziel, start_dichte, pro_stueck, schritte=14, toleranz=0.15): """Regelt die Dichte per Bisektion auf eine Ziel-Cardzahl ein. Warum ueberhaupt regeln: "Dichte" ist Punkte pro m2 Zweigflaeche, und die faellt je Preset voellig anders aus. Ein Faktor, der am Standardbaum passte, ueberschoss auf der Eiche um mehr als das Doppelte. Eine Stueckzahl ist dagegen preset-unabhaengig - und direkt pruefbar. Seed-stabil: der Seed wird nicht angefasst, nur die Dichte. Gleicher Baum und gleicher Seed ergeben deshalb dieselbe Verteilung. """ if not ziel or ziel <= 0: return start_dichte, _card_zahl(ob, pro_stueck) def _messen(dichte): md[ids["Density"]] = float(dichte) _modifier_anstossen(md) context.view_layer.update() return _card_zahl(ob, pro_stueck) lo, hi = start_dichte, start_dichte n = _messen(start_dichte) if n == ziel: return start_dichte, n # Klammer suchen: verdoppeln bzw. halbieren, bis das Ziel eingeschlossen ist. if n < ziel: for _ in range(12): hi = hi * 2.0 if _messen(hi) >= ziel: break lo = start_dichte else: for _ in range(12): lo = lo * 0.5 if lo < 1e-3 or _messen(lo) <= ziel: break hi = start_dichte bestes = (abs(n - ziel), start_dichte, n) for _ in range(schritte): mitte = (lo + hi) * 0.5 n = _messen(mitte) if abs(n - ziel) < bestes[0]: bestes = (abs(n - ziel), mitte, n) if abs(n - ziel) <= max(1, ziel * toleranz): return mitte, n if n < ziel: lo = mitte else: hi = mitte # Beste gefundene Naeherung wiederherstellen. _messen(bestes[1]) return bestes[1], bestes[2] def make_leaves(context, tree_obj, card_obj, name=None, card_coll=None, stufen=True, **overrides): """Erzeugt/aktualisiert ein _Leaf-Objekt mit dem Blattwerk-Modifier. Die WACHSTUMS-STAFFEL sitzt hier drin, nicht beim Aufrufer. Uebergeben werden die Grundwerte (Size, Max Radius); welche Stufe daraus wird, zieht diese Funktion selbst aus `growth_t` und `preset` am Baum-Objekt. Warum hier und nicht beim Aufrufer: es gab zwei Wege zum Blattwerk - den Knopf "Blattwerk streuen" und das Haekchen "_Leaf anlegen" beim Erzeugen - und nur der erste rechnete die Staffel. Gemessen am S1-Modifier des Create-Pfads: Size 0.85, Max Radius 0.04, Min Height 0.25, also exakt die Altbaum-Werte. Das ist dieselbe Fehlerklasse wie bei der Stufen-Deckung und bei build_group: zwei Quellen fuer dieselbe Frage. Solange die Staffel hier liegt, KANN ein Aufrufer sie nicht vergessen. `stufen=False` schaltet sie ab - fuer Aufrufer, die bewusst rohe Werte setzen. """ if stufen: # Stufe aus dem OBJEKT lesen, nicht aus dem Kontext: der Blattwerk-Lauf # kommt spaeter und getrennt vom Erzeugen. gt = float(tree_obj.get("growth_t", 1.0)) _ziel, g_anteil, h_anteil = _stufen_anteile(gt, tree_obj.get("preset")) # Der RADIUS-FILTER muss mit dem Baum schrumpfen. Er trennt duenne # Zweige (Laub) von dickem Geaest (kein Laub) - aber ein Setzling ist # KOMPLETT duenner als die Schwelle. Gemessen mit festem 0.04 m: bei S1 # sassen 92 % der Cards am Stamm, bei S2 noch 81 %, das Laub klebte also # am Stiel statt in der Krone. Boden bei LEAF_RADIUS_YOUNG, weil der # reine Stammradius-Faktor (0.064 bei t = 0) unter die duennsten Zweige # fiel und der Setzling wieder kahl stand (gemessen 0 Cards). r_faktor = LEAF_RADIUS_YOUNG + (1.0 - LEAF_RADIUS_YOUNG) * gt basis = dict(LEAF_DEFAULTS) basis.update(overrides) # Preset-Faktor auf die Card-Groesse: eine weite Krone braucht mehr # Card-FLAECHE, um geschlossen zu wirken (siehe LEAF_SIZE_PRESET). p_faktor = LEAF_SIZE_PRESET.get(tree_obj.get("preset") or "", 1.0) overrides["Size"] = basis["Size"] * g_anteil * p_faktor overrides["Max Radius"] = basis["Max Radius"] * r_faktor # Der Kronenansatz-Filter darf NIE ueber dem echten Kronenansatz der Art # liegen. Die Stufen-Tabelle nennt 0.25 der Baumhoehe - die Tanne setzt # ihre Aeste aber ab 12 % an und laesst sie haengen. Gemessen lag bei # 5 von 22 Tannenaesten das aeussere Fuenftel KOMPLETT unter der # Schwelle; sie bekamen gar kein Laub und standen als nackte Aeste da. # # "Branch Start" ist ein Anteil der STAMMlaenge, die Schwelle einer der # Gesamthoehe. Die Stammlaenge ist kleiner, der Wert liegt also ohnehin # etwas zu hoch - die 0.8 geben zusaetzlich Luft. ast_start = PRESETS.get(tree_obj.get("preset") or "", {}).get( "Branch Start", DEFAULTS["Branch Start"]) overrides["Min Height"] = min(h_anteil, ast_start * 0.8) # Die Bueschel-Kappe muss mitwachsen. Ein Setzling mit Altbaum-Kappe # verschwindet darin - dasselbe Laubpaket-Problem wie bei der # Card-Groesse, nur eine Ebene hoeher. c_faktor = LEAF_CLUSTER_PRESET.get(tree_obj.get("preset") or "", 1.0) overrides["Cluster Radius"] = basis["Cluster Radius"] * g_anteil * c_faktor # Die Voxel-Groesse muss mit der Kappe mitwachsen, sonst kostet der # Setzling dieselbe Gitteraufloesung wie der Altbaum - und der Altbaum # bekommt eine zu grobe Huelle. overrides["Voxel Size"] = basis["Voxel Size"] * g_anteil * c_faktor # Die CLUSTER-Dichte muss GEGEN die Baumgroesse laufen, nicht mit ihr. # # "Dichte" ist Kugeln je m2 Zweigflaeche, und die Flaeche schrumpft mit # dem Quadrat der Groesse. Bei fester Dichte bleiben am Setzling NULL # Kugeln uebrig - gemessen ueber die Stufen 0 / 20 / 41 / 109 Zentren, # S1 stand ohne Huelle und damit ohne ein einziges Blatt da. # # Mit 1/g^2 bleibt die ANZAHL ueber die Stufen etwa konstant, was die # richtige Groesse ist: eine Krone braucht eine bestimmte Zahl Ballen, # damit sie verschmilzt - nicht eine bestimmte Dichte. overrides["Cluster Density"] = (basis["Cluster Density"] / max(g_anteil * g_anteil, 0.01)) 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 _modifier_anstossen(md) return ob def blattwerk_streuen(context, tree_obj, s): """DER Weg zum Blattwerk - fuer beide Knoepfe derselbe. Streut die Cards und regelt die Dichte auf die Ziel-Cardzahl der Stufe ein. Wer Blattwerk erzeugt, ruft das hier und sonst nichts: die frueheren zwei Wege liefen genau so lange auseinander, bis es jemandem im Spiel auffiel. Gibt (Blatt-Objekt, gezaehlte Cards, Ziel-Cards) zurueck; ohne Regelung sind die letzten beiden None. """ lf = make_leaves(context, tree_obj, 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}) # Dichte auf die ZIEL-CARDZAHL einregeln. Muss NACH dem Anlegen passieren, # weil dafuer gemessen werden muss, was tatsaechlich herauskommt - die # Zweigflaeche faellt je Preset voellig anders aus. gt = float(tree_obj.get("growth_t", 1.0)) ziel_cards, _g, _h = _stufen_anteile(gt, tree_obj.get("preset")) if not ziel_cards: return lf, None, None md = lf.modifiers.get("GN_Leaves") ng = md.node_group if md is not None else None if ng is None: return lf, None, None pro = _cards_pro_stueck(s.leaf_card, s.leaf_cards) dichte, ist = _dichte_einregeln(context, lf, md, leaf_socket_ids(ng), ziel_cards, s.leaf_density, pro) print("[TreeGen] %s: %d Cards (Ziel %d) bei Dichte %.0f" % (tree_obj.name, ist, ziel_cards, dichte)) return lf, ist, ziel_cards # --------------------------------------------------------------------------- # Spielfertiger Export: verschmolzener Stand-Baum # --------------------------------------------------------------------------- # LOD-Stufen fuer den Stand-Baum. # # Die Blatt-Reduktion ist ein TEILSET der LOD0-Cards, keine Neustreuung: die # verbleibenden Cards liegen genau dort, wo sie in LOD0 schon waren. Wuerde man # je Stufe neu streuen, saessen sie an anderen Stellen und die Silhouette # spraenge beim LOD-Wechsel sichtbar um. Damit die Krone beim Ausduennen nicht # loechrig wird, werden die Ueberlebenden groesser. # LOD3 ist der Impostor-Ersatz: wenige, dafuer sehr grosse Cards. # # Card-Anteil Card-Groesse Rinden-Decimate LOD_STUFEN = ( (1.00, 1.00, 1.00), # LOD0 - wie der Stand-Baum (0.60, 1.15, 0.70), # LOD1 (0.30, 1.45, 0.45), # LOD2 (0.12, 2.00, 0.25), # LOD3 ) # 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 _cards_ausduennen(ob, slot, anteil, groesse, seed=0): """Behaelt einen TEILSET der Cards und vergroessert die Ueberlebenden. Der Unterschied zum Neustreuen mit geringerer Dichte ist der entscheidende: beim Neustreuen liegen die Cards an ANDEREN Stellen, und beim LOD-Wechsel springt die Silhouette sichtbar um. Ein Teilset laesst die verbleibenden Cards genau dort, wo sie in LOD0 schon waren - die Krone wird duenner, aber ihre Form bleibt. Gearbeitet wird auf zusammenhaengenden Inseln, nicht auf einzelnen Faces: eine Card besteht aus mehreren Dreiecken und muss ganz bleiben oder ganz verschwinden. Die Auswahl ist seed-stabil, damit zwei Exporte desselben Baums dieselbe Kette ergeben. """ import random as _rnd me = ob.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() kandidaten = [f for f in bm.faces if f.material_index == slot] if not kandidaten: bm.free() return 0, 0 # Inseln bilden (zusammenhaengende Flaechen = eine Card) offen = set(kandidaten) inseln = [] 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) gesamt = len(inseln) behalten = max(1, int(round(gesamt * max(0.0, min(1.0, anteil))))) # Seed-stabile Auswahl: sortiert nach einer festen Kennzahl, damit die # Reihenfolge nicht von der bmesh-Iteration abhaengt. inseln.sort(key=lambda ins: tuple(round(c, 4) for c in ins[0].calc_center_median())) rng = _rnd.Random(seed) reihenfolge = list(range(gesamt)) rng.shuffle(reihenfolge) behalte_idx = set(reihenfolge[:behalten]) weg = [] for i, insel in enumerate(inseln): if i not in behalte_idx: weg.extend(insel) if weg: bmesh.ops.delete(bm, geom=weg, context='FACES') # Ueberlebende vergroessern - je Card um IHRE EIGENE Mitte, sonst wandert # sie beim Skalieren von der Zweigstelle weg. if groesse != 1.0: for i, insel in enumerate(inseln): if i not in behalte_idx: continue verts = {v for f in insel for v in f.verts if v.is_valid} if not verts: continue mitte = Vector((0.0, 0.0, 0.0)) for v in verts: mitte += v.co mitte /= len(verts) for v in verts: v.co = mitte + (v.co - mitte) * groesse bm.to_mesh(me) bm.free() me.update() return gesamt, behalten def _merge_for_export(context, tree_ob, leaf_ob, name, bark_decimate=1.0): """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) # Rinde VOR dem Verschmelzen vereinfachen. Danach ginge es nicht mehr: ein # Decimate ueber das fertige Mesh wuerde auch die Blatt-Cards einschmelzen, # und eine halbe Card ist keine Card mehr. if bark_decimate < 0.999 and len(stamm.data.polygons) > 8: dec = stamm.modifiers.new("LODdec", 'DECIMATE') dec.ratio = max(0.02, bark_decimate) dg = context.evaluated_depsgraph_get() neu = bpy.data.meshes.new_from_object( stamm.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg) alt = stamm.data stamm.modifiers.remove(dec) stamm.data = neu if alt.users == 0: bpy.data.meshes.remove(alt) 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, punkte=None): """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. `punkte` uebergeht `quell_objs` und huellt genau die gegebenen Koordinaten - dafuer, dass die Stand-Baum-Huelle nur den STAMM umschliesst und nicht die Krone. """ bm = bmesh.new() if punkte is not None: for co in punkte: bm.verts.new(co) else: 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] # Rohzustand sichern. Das Vereinfachen unten kann bei LANGGESTRECKTEN # Teilen entgleisen - siehe die Notbremse nach dem Aufblasen. bm.verts.index_update() roh_verts = [v.co.copy() for v in bm.verts] roh_faces = [[v.index for v in f.verts] for f in bm.faces] 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. Also wieder aufweiten. # # NICHT durch Skalieren um die Huellenmitte. Das war der erste Ansatz und # entgleist bei langgestreckten Teilen: liegt eine Flaeche fast durch die # Mitte, wird der Hebel winzig und der noetige Faktor explodiert. Gemessen # an einem Stammsegment des schlanken "baum"-Presets (Laenge zu Dicke rund # 13:1) kam Faktor 4.2 heraus - die Huelle war viermal so gross wie das Holz. # Bei der dicken Eiche blieb es bei 1.0-1.4, deshalb ist es lange nicht # aufgefallen. # # Stattdessen je Flaeche den ECHTEN Fehlbetrag messen (wie weit liegt der # schlimmste Quellpunkt vor ihrer Ebene?) und die Vertices um genau diesen # Betrag nach aussen schieben. Das ist eine Verschiebung in Metern, kein # Verhaeltnis - sie kann nicht explodieren. if bm.faces and quelle: def _rest(): """Groesster Abstand, mit dem ein Quellpunkt noch draussen liegt.""" r = 0.0 for f in bm.faces: n = f.normal if n.length_squared < 1e-12: continue d0 = n.dot(f.verts[0].co) r = max(r, max((n.dot(co) - d0) for co in quelle)) return r # ITERATIV. Ein Vertex gehoert mehreren Flaechen an und wandert entlang # SEINER Normale - die Ebene verschiebt sich dadurch nur um den auf sie # projizierten Anteil, also weniger als noetig. Ein einzelner Durchgang # laesst deshalb einen Rest stehen (gemessen, der Test schlug an). Jeder # weitere Durchgang verkleinert ihn; nach wenigen ist er weg. for _ in range(8): rest = _rest() if rest <= 1e-4: break schuld = {} for f in bm.faces: n = f.normal if n.length_squared < 1e-12: continue d0 = n.dot(f.verts[0].co) ueber = max((n.dot(co) - d0) for co in quelle) if ueber > 1e-9: for v in f.verts: if ueber > schuld.get(v, 0.0): schuld[v] = ueber if not schuld: break for v, d in schuld.items(): n = v.normal if n.length_squared < 1e-12: continue v.co = v.co + n.normalized() * d bm.normal_update() # Notbremse: geht es nicht auf, ist die vereinfachte Huelle als # Kollision unbrauchbar - dann lieber die unvereinfachte mit ein paar # Vertices mehr. if _rest() > 1e-4 and roh_faces: bm.free() bm = bmesh.new() neu_v = [bm.verts.new(c) for c in roh_verts] bm.verts.ensure_lookup_table() for f in roh_faces: try: bm.faces.new([neu_v[i] for i in f]) except ValueError: pass # Doppelflaeche - die gibt es schon bm.normal_update() 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 _rinden_punkte(ob): """Weltkoordinaten der RINDEN-Vertices (Material-Slot 0) eines Faell-Teils. Blatt-Cards gehoeren nicht in eine Kollisionshuelle: sie stehen weit vom Holz ab, und der Spieler soll gegen den Ast stossen, nicht gegen das Laub. Gemessen an der Eiche blaeht das Laub die Huellen-Diagonale beim Ast um 8-19 % auf - und beim OBERSTEN Stammsegment, das die Krone traegt, um 67 % (1.75 m statt 1.05 m). Fallback auf alle Vertices, falls ein Teil keinen zweiten Slot hat: ein reines Stammsegment ist ohnehin komplett Rinde. """ me = ob.data if len(me.materials) > 1: idx = {vi for p in me.polygons if p.material_index == 0 for vi in p.vertices} if idx: return [ob.matrix_world @ me.vertices[i].co for i in idx] return [ob.matrix_world @ v.co for v in me.vertices] def _stamm_punkte(tree_obj, matrix): """Stamm-Koordinaten vom Fuss bis zum KRONENANSATZ, in Weltkoordinaten. Fuer die Kollision des Stand-Baums. Bewusst nur der Stamm: eine Huelle um die ganze Krone waere ein Zylinder von mehreren Metern Durchmesser, gegen den der Spieler laeuft, bevor er den Baum ueberhaupt beruehrt. Die Trennung kommt aus `ast_id` (0 = Stamm) - dasselbe Attribut wie beim Ursprung und bei den Faell-Teilen. Der Kronenansatz ist der tiefste Punkt mit ast_id > 0; oberhalb davon steckt der Stamm ohnehin in der Krone. Ohne Aeste (Kaktus-Setzling: `Branch Count 0` schaltet den Ast-Zweig des Graphen ab, dann gibt es gar kein ast_id) ist die ganze Pflanze Stamm - und genau die soll dann auch die Kollision sein. """ dg = bpy.context.evaluated_depsgraph_get() ev = tree_obj.evaluated_get(dg) me = ev.to_mesh() attr = me.attributes.get("ast_id") hat_id = (attr is not None and attr.domain == 'POINT' and len(attr.data) == len(me.vertices)) stamm, aeste = [], [] for i, v in enumerate(me.vertices): co = matrix @ v.co if hat_id and attr.data[i].value > 0: aeste.append(co) else: stamm.append(co) ev.to_mesh_clear() if not stamm: return [] if aeste: # Etwas Luft ueber den Ansatz: der tiefste Astpunkt kann eine # herabgebogene Spitze sein, dann waere die Huelle zu kurz. Ein Zehntel # der Stammhoehe reicht und bleibt weit unter der Krone. zs = [c.z for c in stamm] grenze = min(aeste, key=lambda c: c.z).z + (max(zs) - min(zs)) * 0.1 gekuerzt = [c for c in stamm if c.z <= grenze] if len(gekuerzt) >= 8: return gekuerzt return stamm 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=170.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_s1: IntProperty( name="Budget S1", default=800, min=100, max=20000, description="Tri-Budget der Setzlings-Stufe (ASSETS.md Teil 6)", ) leaf_budget_s2: IntProperty( name="Budget S2", default=1200, min=100, max=20000, description="Tri-Budget der zweiten Stufe (ASSETS.md Teil 6)", ) leaf_budget: IntProperty( name="Budget S3/S4", default=4300, min=0, max=100000, description=("Tri-Budget der ausgewachsenen Stufe. Die urspruenglichen " "2500 waren eine konservative Setzung - bei ISM-Instanzen " "mit LOD-Kette sind Roh-Dreiecke nicht der Engpass, " "sondern Overdraw und Schatten der Cards. Bei " "Ueberschreitung warnt der Blattwerk-Button (0 = aus)"), ) leaf_size: FloatProperty( name="Groesse", default=0.85, min=0.01, max=5.0, description=("Kantenlaenge einer Card. Eine Card ist ein ganzer " "Zweigbueschel, kein einzelnes Blatt - deshalb 0.7 und " "nicht 0.2"), ) leaf_size_growth: FloatProperty( name="Setzlings-Anteil", default=0.45, min=0.05, max=1.0, description=("Wie gross die Bueschel auf der KLEINSTEN Wachstumsstufe " "sind, als Anteil der Endgroesse. 1.0 = keine Skalierung. " "Ein Setzling traegt kleinere Bueschel als der Altbaum"), ) 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.04, 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.9, min=0.0, max=3.14159, description=("Wie stark die Cards aus der Senkrechten kippen. Mehr " "Rotations-Chaos schliesst Sichtluecken in der Krone"), ) make_frucht: BoolProperty( name="_Frucht-Ebene anlegen", default=False, description="Zusaetzlich _Frucht (gleicher Ursprung) fuer die Frucht-Ebene", ) stand_ucx: BoolProperty( name="UCX-Stammhuelle", default=True, description=("Legt dem spielfertigen Baum eine konvexe Huelle um den " "STAMM (Fuss bis Kronenansatz) ins FBX. Bewusst keine " "Kronen-Huelle: gegen die liefe der Spieler, bevor er den " "Baum beruehrt"), ) stand_ucx_verts: IntProperty( name="Stammhuelle max. Verts", default=12, min=8, max=32, description="Ein Stamm braucht keine feine Huelle - 8-12 reichen", ) 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", ) generate_lods: BoolProperty( name="LOD-Kette erzeugen", default=False, description=("Exportiert LOD0-LOD3 als LOD-Gruppe (Empty mit " "fbx_type=LodGroup) wie in der Fels-Pipeline. Die Blaetter " "werden je Stufe duenner gestreut und dafuer groesser - " "eine Card aus zwei Dreiecken laesst sich nicht vereinfachen"), ) 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 _stamm_koordinaten(me): """Koordinaten der reinen STAMM-Vertices, mit absteigender Genauigkeit. 1. "ast_id" == 0 - exakt, kommt aus dem Generator selbst. 2. Material-Slot 0 - Rinde inklusive Aeste, immerhin ohne Blatt-Cards. 3. alles - wenn das Mesh weder Attribut noch zweiten Slot hat. Stufe 2 und 3 sind Notnaegel fuer fremde oder vereinfachte Meshes; jede Stufe weiter unten ist ungenauer, aber keine faellt aus. """ slot0 = None if len(me.materials) > 1: idx = {vi for p in me.polygons if p.material_index == 0 for vi in p.vertices} if idx: slot0 = idx attr = me.attributes.get("ast_id") if attr is not None and attr.domain == 'POINT' and len(attr.data) == len(me.vertices): idx = {i for i in range(len(me.vertices)) if attr.data[i].value == 0} if slot0 is not None: idx &= slot0 if idx: return [me.vertices[i].co for i in idx] if slot0: return [me.vertices[i].co for i in slot0] return [v.co for v in me.vertices] 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 Matrix.Identity(4) # Der Ursprung kommt aus der STAMMBASIS, nicht aus einer Bbox. # # Zwei Stufen dieses Fehlers gab es schon. Zuerst die Gesamt-Bbox: eine # einzige tief haengende Blatt-Card hob den ganzen Baum an. Dann Slot 0 - # aber zum Rinden-Slot gehoeren auch die AESTE. Gemessen an Tanne_01_S1 # (haengende Aeste ab 6 % Stammhoehe): der tiefste Slot-0-Punkt lag bei # -5.35 cm, der tiefste Stammpunkt bei -0.02 cm. Die Stammbasis schwebte # also 5.3 cm ueber dem Boden, weil eine Astspitze den Nullpunkt bestimmte. # # Die exakte Trennung liefert das Attribut "ast_id" (0 = Stamm), das der # Baum ohnehin fuer die Faell-Teile traegt. Ein Schwellwert auf den # Achsabstand waere die naheliegende Alternative und faellt beim Kaktus um: # dessen Stamm ist dicker als jeder sinnvolle Schwellwert. versatz = _stammbasis_versatz(_stamm_koordinaten(me)) me.transform(versatz) return versatz def _stammbasis_versatz(stamm_verts): """Versatz, der die STAMMBASIS auf den Ursprung legt. Eine Stelle fuer beide Wege: der Stand-Baum-Export und die Gesamtdatei der Faell-Teile muessen denselben Nullpunkt benutzen, sonst passen die zusammengesetzten Truemmer nicht auf den Platz des Baums. Zwei Rechnungen fuer dieselbe Frage waeren genau die Fehlerklasse, die hier schon dreimal zugeschlagen hat. """ if not stamm_verts: return Matrix.Identity(4) zs = [c.z for c in stamm_verts] z_fuss = min(zs) # X/Y aus dem UNTERSTEN RING, nicht aus der Bbox des ganzen Stammes: ein # gekruemmter Stamm (Bend 1.5 beim toten Baum) hat seine Bbox-Mitte # irgendwo in der Kruemmung, nicht am Fuss. Der Schwerpunkt des Ringes ist # die Stammachse. hoehe = max(zs) - z_fuss band = [c for c in stamm_verts if c.z <= z_fuss + max(1e-4, hoehe * 0.02)] if band: mx = sum(c.x for c in band) / len(band) my = sum(c.y for c in band) / len(band) else: xs = [c.x for c in stamm_verts] ys = [c.y for c in stamm_verts] mx = (min(xs) + max(xs)) * 0.5 my = (min(ys) + max(ys)) * 0.5 return Matrix.Translation((-mx, -my, -z_fuss)) 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" basis_name = umbenannt[0][1] stufen = LOD_STUFEN if s.generate_lods else LOD_STUFEN[:1] gruppe = None lods = [] for i, (dichte, groesse, dec) in enumerate(stufen): lod_name = ("%s_LOD%d" % (basis_name, i)) if s.generate_lods \ else basis_name # Alle Stufen gehen von DERSELBEN Blatt-Ebene aus. Die # Reduktion passiert weiter unten als Teilset - neu zu # streuen wuerde die Cards an andere Stellen setzen und die # Silhouette beim LOD-Wechsel sichtbar umspringen lassen. merge, gemacht = _merge_for_export( context, baum, blatt, lod_name, bark_decimate=dec) aufraeumen.extend(gemacht) if s.generate_lods and i > 0 and len(merge.data.materials) > 1: vorher, nachher = _cards_ausduennen( merge, 1, dichte, groesse, seed=s.seed) print("[TreeGen] %s: %d von %d Cards behalten, " "%.2fx vergroessert" % (merge.name, nachher, vorher, groesse)) # Kugel-Normalen auf JEDER Stufe - LOD1..3 haben eine eigene # Card-Verteilung, die Normalen muessen dazu passen. mitte = None if s.sphere_normals and len(merge.data.materials) > 1: mitte = _sphere_normals(merge, 1) if mitte is not None: print("[TreeGen] %s: Kugel-Normalen um (%.2f, %.2f, %.2f)" % (merge.name, mitte.x, mitte.y, mitte.z)) lods.append(merge) # Ursprung aus LOD0 - Unreal platziert nach LOD0. Alle Stufen # bekommen DIESELBE Zentrierung, damit sie deckungsgleich bleiben. # # FALLE: _origin_bottom_center backt zuerst die Weltmatrix ins # Mesh und wendet DANN die Zentrierung an - zurueck gibt es aber # nur die Zentrierung. Wer den Stufen 1-3 nur diese aufdrueckt, # laesst ihre Weltmatrix ungebacken: sie landen bei MINUS der # Standposition. Gemeldet aus UE fuer einen Baum bei x = 18 m, # dessen LOD1-3 bei x = -1894 cm sassen - exakt die # Standposition, in Zentimetern wegen FBX_SCALE_ALL. # Deshalb je Stufe erst die EIGENE Weltmatrix backen, dann die # gemeinsame Zentrierung. versatz = _origin_bottom_center(lods[0]) for weitere in lods[1:]: weitere.data.transform(weitere.matrix_world) weitere.matrix_basis = Matrix.Identity(4) weitere.matrix_parent_inverse = Matrix.Identity(4) weitere.data.transform(versatz) # UCX-Stammhuelle. Die Punkte kommen aus dem QUELLBAUM, weil nur # der noch `ast_id` traegt - die Export-Kopie ist verschmolzen # und bei LOD1-3 zusaetzlich dezimiert. Sie durchlaufen dieselbe # Kette wie die Stufen: eigene Weltmatrix, dann die gemeinsame # Zentrierung `versatz`. # # NAME nach dem MESH-Node (LOD0), nicht nach dem LodGroup-Empty. # Der Legacy-Importer matcht `UCX_`; mit dem # LodGroup-Namen lag die Huelle zwar in der Datei, wurde von # Unreal aber STILL verworfen - gemeldet mit convex = 0 an 108 # Stand-Baeumen. # # 1.42 hatte die Konvention vom Fels-Export abgeschaut und dabei # nur die STRUKTUR nachgemessen (Reimport nach Blender: Huelle # da, kein Elternteil, richtiger Name). Dass Unreal sie ANNIMMT, # war ungeprueft - und genau daran lag es. Ohne LOD-Kette heisst # der Mesh-Node wie die Basis; deshalb funktionierten die # Einzel-Teile und Pieces_All die ganze Zeit. stamm_ucx_name = lods[0].name hull = None if s.stand_ucx: pts = _stamm_punkte(baum, baum.matrix_world) pts = [versatz @ c for c in pts] hull = _tree_ucx(None, stamm_ucx_name, max_verts=s.stand_ucx_verts, punkte=pts) if hull is not None: # Der Boden der Huelle MUSS auf Z = 0 liegen. Das # Vereinfachen schneidet Ecken ab, und das anschliessende # Aufblasen (damit wieder alles hineinpasst) skaliert um # die Huellenmitte - dabei rutschte die Unterseite # gemessen 4-6 cm unter den Boden. Im Spiel waere das # eine Kollision, die aus dem Terrain herausragt. for v in hull.data.vertices: if v.co.z < 0.0: v.co.z = 0.0 aufraeumen.append(hull.name) print("[TreeGen] %s: Stamm-UCX mit %d Verts" % (hull.name, len(hull.data.vertices))) _deselect(context) if s.generate_lods: gruppe = bpy.data.objects.new(basis_name + "_LODGroup", None) gruppe.empty_display_type = 'PLAIN_AXES' gruppe["fbx_type"] = "LodGroup" # von Unreal ausgewertet context.scene.collection.objects.link(gruppe) aufraeumen.append(gruppe.name) for lod in lods: lod.parent = gruppe lod.matrix_parent_inverse = Matrix.Identity(4) lod.select_set(True) gruppe.select_set(True) context.view_layer.objects.active = gruppe ziel = os.path.join(verzeichnis, gruppe.name + ".fbx") else: lods[0].select_set(True) context.view_layer.objects.active = lods[0] # Die Huelle wird MITEXPORTIERT, aber NICHT unter das # LodGroup-Empty gehaengt: Unreal liest jedes Kind der Gruppe # als LOD-Stufe. Geschwister, so wie es der Fels-Export macht. if hull is not None: hull.select_set(True) bpy.ops.export_scene.fbx( filepath=ziel, use_selection=True, object_types={'MESH', 'EMPTY'}, # EMPTY = die LodGroup 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 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: # Auch die Gesamtdatei bekommt je Teil seine Huelle - dieselbe # Geschwister-Konvention wie beim Stand-Baum: `UCX_` # neben dem Teil, kein Elternteil. Vorher kam die Datei ohne # Kollision an, waehrend die Einzeldateien welche hatten. # # Sie entstehen HIER und nicht in der Schleife weiter unten: dort # sitzt jedes Teil schon auf seinem eigenen Massenmittelpunkt, in # der Gesamtdatei stehen alle noch an ihrer Originalposition. # URSPRUNG auf die STAMMBASIS, wie beim Stand-Baum seit 1.36. # Vorher trug die Datei den Szenen-Nullpunkt mit: ein Baum bei # x = 18 m exportierte gemessen mit X 16.5..19.3, und die Spielseite # musste beim Import nachzentrieren. Die EINZEL-Teile hatten das # Problem nie - sie sitzen ohnehin auf ihrem Massenmittelpunkt. # # Der Versatz kommt aus DERSELBEN Funktion wie beim Stand-Baum, denn # die zusammengesetzten Truemmer muessen genau auf dem Platz liegen, # wo der Baum stand. basis_versatz = _stammbasis_versatz( [stamm_src.matrix_world @ c for c in _stamm_koordinaten(stamm_src.data)]) for t in teile: t.data.transform(basis_versatz) hullen = [] if s.piece_ucx: for t in teile: h = _tree_ucx(None, t.name, max_verts=s.piece_ucx_verts, punkte=_rinden_punkte(t)) if h is not None: hullen.append(h) _deselect(context) for t in teile: t.select_set(True) for h in hullen: h.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') for h in hullen: hm = h.data bpy.data.objects.remove(h, do_unlink=True) if hm.users == 0: bpy.data.meshes.remove(hm) # Versatz zurueck. Fuer die Einzeldateien ist er egal - die setzen # ihren Pivot ohnehin auf den Massenmittelpunkt -, aber mit "Teile # behalten" staenden sie sonst verschoben neben dem Baum in der # Szene. zurueck = basis_versatz.inverted() for t in teile: t.data.transform(zurueck) # --- 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) # Huelle aus der RINDE, nicht aus allen Vertices: das Laub blaehte # sie beim obersten Stammsegment gemessen um 67 % auf. hull = _tree_ucx(None, t.name, max_verts=s.piece_ucx_verts, punkte=_rinden_punkte(t)) \ 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: # Staffel und Dichte-Regelung stecken in blattwerk_streuen - # damit laeuft dieser Knopf durch dieselbe Stelle wie das # Haekchen beim Erzeugen. lf, _ist, _ziel = blattwerk_streuen(context, t, s) 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)) # Budget PRO STUFE statt global (ASSETS.md Teil 6): S1 <= 800, # S2 <= 1200, S3/S4 <= 4000. Bewusst STUFEN und keine Interpolation - # so steht es in den Unterlagen, und ein Setzling steht nie im # Vordergrund. Die Schwellen liegen so, dass sie auch bei 3 Stufen # (t = 0 / 0.5 / 1.0) noch richtig greifen. limit = 0 for t in trees: limit += _stufen_budget(s, float(t.get("growth_t", 1.0))) if total_leaf == 0: # Passiert regulaer beim Kaktus (kein Ast duenner als Max Radius) - # aber auch, wenn Max Radius fuer den Baum zu klein steht. Beides # sieht in der Szene gleich aus: eine leere _Leaf-Ebene. Ohne # Hinweis sucht man den Fehler im Material. self.report({'WARNING'}, "Keine Blaetter gestreut - kein Ast ist duenner als " "Max Radius (%.3f m). Beim Kaktus ist das richtig, " "sonst Max Radius erhoehen." % s.leaf_max_radius) elif 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, ueber # DIESELBE Stelle wie der Knopf "Blattwerk streuen". # Vorher stand hier ein eigener make_leaves-Aufruf mit # rohem s.leaf_size - die Wachstums-Staffel fehlte # komplett, gemessen trug der S1-Setzling # Altbaum-Bueschel. extra.append(blattwerk_streuen(context, ob, s)[0]) 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_growth") col.prop(s, "leaf_size_var") col.prop(s, "leaf_max_radius") col.prop(s, "leaf_tilt") row = col.row(align=True) row.prop(s, "leaf_budget_s1", text="S1") row.prop(s, "leaf_budget_s2", text="S2") row.prop(s, "leaf_budget", text="S3/S4") 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_pieces(_TreeSub): """Eigenes Unter-Panel: das Export-Panel war mit Stand-Baum, LODs und Faell-Teilen ueberladen - drei Themen in einem Kasten.""" bl_label = "5 - Faell-Teile" def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings layout.prop(s, "piece_trunk_segments") layout.prop(s, "piece_ucx") row = layout.row() row.active = s.piece_ucx row.prop(s, "piece_ucx_verts") layout.prop(s, "piece_export_combined") layout.prop(s, "piece_keep") layout.separator() layout.operator("treegen.export_pieces", icon='MOD_BUILD') layout.label(text="Pivot je Teil = Massenmittelpunkt", icon='INFO') 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, "generate_lods") layout.prop(s, "stand_ucx") row = layout.row() row.active = s.stand_ucx row.prop(s, "stand_ucx_verts") layout.prop(s, "export_dir") layout.operator("treegen.export_game", icon='EXPORT') layout.label(text="Slot 0 = %s, Slot 1 = %s" % (TREE_MATERIALS[0], TREE_MATERIALS[1]), icon='MATERIAL') classes = ( TreeGenSettings, TREEGEN_OT_randomize, TREEGEN_OT_export_game, TREEGEN_OT_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, VIEW3D_PT_tree_pieces, ) 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()