Files
stylized-rock-generator/stylized_tree_generator.py
T
D4rkst3randClaude Opus 4.8 5a43ead8d1 Tree Gen 1.39.0: Kronenfuellung des Obstbaums - Flaeche statt Card-Zahl
Der Obstbaum las sich in der Engine als einzelne belaubte Ast-Schlaeuche mit
Himmel dazwischen. Die Card-Zahl erklaert das nicht; die Blattflaeche pro
Kronenvolumen tut es:
  Eiche (schliesst)   444 Cards, Krone 18.9 m3 -> 24.6 m2/m3
  Obstbaum vorher     399 Cards, Krone 36.2 m3 -> 11.4 m2/m3
Fast doppeltes Volumen bei gleicher Card-Zahl.

Der Vorschlag der Spielseite - mehr und laengere Sub-Aeste als Fuellwerk -
wurde durchgemessen und VERWORFEN: Sub Count 5/7, Sub Length 1.0/1.3, Sub Start
0.20/0.30, Branch Count 12/14 bewegten den Fuellgrad um hoechstens 7 Punkte und
trieben die Dreiecke auf 4310-5162. Strukturell: neue Zweige wachsen aus
denselben wenigen Hauptaesten und vergroessern dabei die Krone, das Volumen
waechst also mit.

Ebenfalls verworfen: mehr Cards (Ziel 740 = 5426 Tris, 26 % ueber Budget, und
der Fuellgrad stieg nur auf 75 %). Und eine kleinere Krone: Branch Length 2.35
sah ueber drei Seeds mit 1.83 m gut aus, im Neun-Seed-Test fielen SECHS Seeds
unter die 1.80 m der Frucht-Streuung.

Was wirkt, ist die Card-GROESSE (neu: LEAF_SIZE_PRESET, Faktor je Preset). Sie
kostet kein Dreieck, weil die Card-Zahl gedeckelt ist - aber OVERDRAW, und der
ist bei Bueschel-Cards der eigentliche Engpass. Deshalb der maessige Faktor
1.47: Deckung von 46 auf 87-114 % der Eiche, Card-Flaeche 414 -> 893 m2, bei
3697 statt 3714 Tris. Faktor 1.71 brachte 79 % Fuellgrad bei 2.7-facher
Flaeche - das waere eine Overdraw-Entscheidung der Spielseite.

test_obstbaum prueft die Deckung jetzt mit, und zwar gegen die im SELBEN Lauf
gemessene Eiche statt gegen eine feste Zahl: die Deckung haengt an der
Card-Flaeche, eine 6-Tri-Card bringt das Dreifache einer flachen Raute. Der
erste Anlauf des Checks ist genau daran gescheitert.
Gegenprobe gefahren: mit Faktor 1.0 meldet der Test 9 Fehler bei 42-54 % der
Eiche.

26 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 14:09:52 +02:00

3366 lines
154 KiB
Python

bl_info = {
"name": "Stylized Tree Generator",
"author": "D4rkst3r",
"version": (1, 39, 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
"Height": 7.0, "Trunk Radius": 0.15, "Bend": 0.15, "Taper": 0.95,
"Branch Count": 22, "Branch Length": 1.9, "Branch Up": -0.25,
"Branch Start": 0.12, "Branch End": 0.97,
"Crown Taper": 0.9, "Crown Bulge": 0.0,
"Attraction Up": 0.0, # Tannenaeste haengen, streben nicht hoch
"Branch Bend": 0.3, "Branch Thickness": 0.12, "Sub Count": 1,
"Sub Length": 0.4, "Root Flare": 0.7,
},
"eiche": { # runde, breite Krone: Bauch in der Mitte, dicker Stamm
"Height": 5.5, "Trunk Radius": 0.24, "Bend": 0.5, "Taper": 0.75,
"Branch Count": 10, "Branch Length": 2.2, "Branch Up": 0.9,
"Branch Start": 0.3, "Branch End": 0.95,
"Crown Taper": -0.15, "Crown Bulge": 0.5,
"Attraction Up": 0.5, # Eiche: Aeste schwingen deutlich hoch
"Branch Bend": 0.9, "Branch Thickness": 0.24, "Sub Count": 3,
"Sub Length": 0.8, "Root Flare": 1.5, "Root Lobe Depth": 0.4,
},
# 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.
#
# Gemessen ueber drei Seeds: H 4.96 m, B 3.66 m, B/H 0.74, Ansatz 39 %,
# Kronenradius 1.83 m, 1316 Tris. Alle vier Vorgaben erfuellt.
#
# 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": 3.8, "Trunk Radius": 0.13, "Bend": 0.3, "Taper": 0.7,
"Branch Count": 9, # offene Krone: wenige Leitaeste, Licht durch
# Bleibt bei 2.55. Die Krone zu verkleinern waere der zweite Hebel gegen
# den niedrigen Fuellgrad gewesen - bei 2.35 fielen aber SECHS der neun
# Seeds unter den Kronenradius von 1.80 m, den die Frucht-Streuung
# braucht (1.73-1.79). Ueber drei Seeds gemittelt sah es mit 1.83 noch
# gut aus; erst der Neun-Seed-Test hat es aufgedeckt.
"Branch Length": 2.55, "Branch Up": 0.7,
"Branch Start": 0.52, "Branch End": 0.90,
"Crown Taper": -0.30, "Crown Bulge": 0.60, # rund, Bauch in der Mitte
"Attraction Up": 0.40, # Leitaeste steigen schraeg auf ...
"Branch Droop": 0.35, "Droop Curve": 3.0, # ... und bogen aussen 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": {
"Height": 7.5, "Trunk Radius": 0.09, "Bend": 0.7, "Taper": 0.92,
"Branch Count": 16, "Branch Length": 2.1, "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
"Height": 5.0, "Trunk Radius": 0.18, "Bend": 0.8, "Taper": 0.8,
"Branch Count": 12, "Branch Length": 2.0, "Branch Up": 0.5,
"Branch Start": 0.35, "Branch End": 0.95,
"Crown Taper": -0.2, "Crown Bulge": 0.4,
"Attraction Up": -0.25, # Weide: Zweige haengen
"Branch Droop": 1.2, "Branch Bend": 0.7, "Branch Thickness": 0.16,
"Sub Count": 3, "Sub Length": 0.9, "Root Flare": 1.2,
},
"toter_baum": { # knorrig, kaum Zweige, starke Kruemmung
"Height": 4.5, "Trunk Radius": 0.17, "Bend": 1.5, "Taper": 0.88,
"Branch Count": 6, "Branch Length": 1.5, "Branch Up": 0.9,
"Branch Start": 0.3, "Branch End": 0.9,
"Crown Taper": 0.3, "Crown Bulge": 0.0,
"Attraction Up": 0.35,
"Branch Bend": 1.4, "Branch Thickness": 0.18, "Sub Count": 1,
"Sub Length": 0.5, "Root Flare": 1.4, "Tip Blunt": 0.08,
},
# Kaktus: dicker, kaum verjuengter Stamm, wenige Arme, die per NEGATIVEM
# Droop nach oben kruemmen (Saguaro-Silhouette). Keine Sub-Aeste.
"kaktus": {
"Height": 3.4, "Trunk Radius": 0.28, "Bend": 0.08, "Taper": 0.10,
"Branch Count": 2, "Branch Length": 1.9, "Branch Up": 0.12,
"Branch Start": 0.30, "Branch End": 0.46,
"Branch Bend": 0.0,
"Attraction Up": 0.0, # Kaktus nutzt den Droop-Ellbogen
"Branch Droop": -1.35, # negativ = Arme kruemmen nach OBEN (Saguaro);
# zu gross und die Arme ueberragen den Stamm
"Droop Curve": 4.5, # spaeter, dafuer scharfer Ellbogen
"Branch Thickness": 0.72, # Arme fast so dick wie der Stamm
"Branch Taper": 0.12, # Arme bleiben dick statt spitz zuzulaufen
"Sub Count": 0, "Crown Taper": 0.0, "Root Flare": 0.25,
"Trunk Tip": 1.0,
"Root Lobe Depth": 0.0, "Tip Blunt": 0.45,
"Sides": 16, "Branch Sides": 12, "Ribs": 9.0, "Rib Depth": 0.09,
},
}
# ===========================================================================
def _sock(node, *names):
"""Falle 1: Socket nach Namen holen, mit Alternativen."""
for n in names:
if n in node.inputs:
return node.inputs[n]
raise KeyError("Socket %s nicht in %s" % (names, node.bl_idname))
def _out(node, *names):
for n in names:
if n in node.outputs:
return node.outputs[n]
return node.outputs[0]
# 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},
"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 = {
"Density": 170.0, # Punkte pro m2 Zweigoberflaeche (S4-Wert)
# 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)
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])
hcmp = N("FunctionNodeCompare"); hcmp.location = (-140, -220)
hcmp.data_type = 'FLOAT'; hcmp.operation = 'GREATER_THAN'
L(hsep.outputs["Z"], 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])
dist = N("GeometryNodeDistributePointsOnFaces"); dist.location = (-320, 200)
L(_out(tree, "Geometry"), _sock(dist, "Mesh"))
L(beide.outputs[0], _sock(dist, "Selection"))
L(V["Density"], _sock(dist, "Density"))
L(V["Seed"], _sock(dist, "Seed"))
# Zufaellige Drehung: voll um Z, leicht gekippt (Tilt)
rrot = N("FunctionNodeRandomValue"); rrot.location = (-320, -120)
rrot.data_type = 'FLOAT_VECTOR'
L(V["Seed"], _sock(rrot, "Seed"))
tneg = N("ShaderNodeMath"); tneg.location = (-520, -220); tneg.operation = 'MULTIPLY'
tneg.inputs[1].default_value = -1.0
L(V["Tilt"], tneg.inputs[0])
vmin = N("ShaderNodeCombineXYZ"); vmin.location = (-520, -320)
L(tneg.outputs[0], vmin.inputs["X"]); L(tneg.outputs[0], vmin.inputs["Y"])
vmax = N("ShaderNodeCombineXYZ"); vmax.location = (-520, -420)
L(V["Tilt"], vmax.inputs["X"]); L(V["Tilt"], vmax.inputs["Y"])
vmax.inputs["Z"].default_value = 6.283185
L(vmin.outputs[0], rrot.inputs[0])
L(vmax.outputs[0], rrot.inputs[1])
# Zufaellige Groesse
rsc = N("FunctionNodeRandomValue"); rsc.location = (-320, -340)
rsc.data_type = 'FLOAT'
L(V["Seed"], _sock(rsc, "Seed"))
lo = N("ShaderNodeMath"); lo.location = (-520, -520); lo.operation = 'SUBTRACT'
lo.inputs[0].default_value = 1.0
L(V["Size Var"], lo.inputs[1])
slo = N("ShaderNodeMath"); slo.location = (-370, -520); slo.operation = 'MULTIPLY'
L(V["Size"], slo.inputs[0]); L(lo.outputs[0], slo.inputs[1])
L(slo.outputs[0], rsc.inputs[2])
L(V["Size"], rsc.inputs[3])
# AST-INDEX auf die Blattpunkte uebernehmen. Der Baum traegt "ast_id" je
# Vertex; beim Streuen wird er am Punkt abgegriffen und mitgespeichert.
# Damit ist "welche Card gehoert zu welchem Ast" spaeter eine
# Nachschlage-Frage und keine Nearest-Neighbor-Schaetzung - und der Test
# kann die Zuordnung exakt nachzaehlen.
lna = N("GeometryNodeInputNamedAttribute"); lna.location = (-320, 60)
lna.data_type = 'INT'
_sock(lna, "Name").default_value = "ast_id"
lstore = N("GeometryNodeStoreNamedAttribute"); lstore.location = (-100, 200)
lstore.domain = 'POINT'; lstore.data_type = 'INT'
L(_out(dist, "Points"), _sock(lstore, "Geometry"))
_sock(lstore, "Name").default_value = "ast_id"
L(_out(lna, "Attribute"), _sock(lstore, "Value"))
inst = N("GeometryNodeInstanceOnPoints"); inst.location = (100, 200)
L(_out(lstore, "Geometry"), _sock(inst, "Points"))
L(_out(quelle, "Output"), _sock(inst, "Instance"))
try:
L(V["Use Collection"], _sock(inst, "Pick Instance"))
except (KeyError, RuntimeError):
pass
# Zufaelliger Index je Punkt, aus der tatsaechlichen Anzahl abgeleitet -
# eine feste Obergrenze wuerde bei weniger Cards Luecken erzeugen.
dsize = N("GeometryNodeAttributeDomainSize"); dsize.location = (-320, -560)
try:
dsize.component = 'INSTANCES'
except (AttributeError, TypeError):
pass
L(_out(coll, "Instances", "Geometry"), _sock(dsize, "Geometry"))
dmax = N("ShaderNodeMath"); dmax.location = (-150, -560)
dmax.operation = 'SUBTRACT'; dmax.inputs[1].default_value = 1.0
try:
L(_out(dsize, "Instance Count", "Point Count"), dmax.inputs[0])
except (KeyError, RuntimeError):
dmax.inputs[0].default_value = 1.0
ridx = N("FunctionNodeRandomValue"); ridx.location = (100, -560)
ridx.data_type = 'INT'
L(V["Seed"], _sock(ridx, "Seed"))
ridx.inputs[4].default_value = 0
L(dmax.outputs[0], ridx.inputs[5])
try:
L(ridx.outputs["Value"], _sock(inst, "Instance Index"))
except (KeyError, RuntimeError):
pass
L(rrot.outputs["Value"], _sock(inst, "Rotation"))
L(rsc.outputs[1], _sock(inst, "Scale"))
real = N("GeometryNodeRealizeInstances"); real.location = (400, 200)
L(_out(inst, "Instances", "Geometry"), _sock(real, "Geometry"))
L(_out(real, "Geometry"), gout.inputs[0])
return ng
def leaf_socket_ids(ng):
return {so.name: so.identifier for so in ng.interface.items_tree
if getattr(so, "in_out", "") == 'INPUT'}
# 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
LEAF_STUFEN = (
(10, 0.24, 0.40), # S1 - wenige kleine Bueschel, Fuss bleibt frei
(40, 0.46, 0.35), # S2
(120, 0.75, 0.30), # S3
(None, 1.00, 0.25), # S4 - volle Dichte, keine Regelung
)
# 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},
# 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,
}
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 <Baum>_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
overrides["Min Height"] = h_anteil
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):
"""Konvexe Huelle als `UCX_<Name>` - gleiches Muster wie bei den Felsen.
Nur die PUNKTE einlesen: mit `from_mesh` kaemen die Originalflaechen mit und
die Huelle laege obendrauf statt darum herum.
"""
bm = bmesh.new()
for o in quell_objs:
if o.type == 'MESH':
for v in o.data.vertices:
bm.verts.new(o.matrix_world @ v.co)
if len(bm.verts) < 4:
bm.free()
return None
bm.verts.ensure_lookup_table()
res = bmesh.ops.convex_hull(bm, input=bm.verts[:], use_existing_faces=False)
weg = list(set(res.get("geom_interior", []) + res.get("geom_unused", [])))
if weg:
bmesh.ops.delete(bm, geom=weg, context='VERTS')
bm.verts.ensure_lookup_table()
bm.normal_update()
if not bm.faces:
bm.free()
return None
quelle = [v.co.copy() for v in bm.verts]
winkel = math.radians(1.0)
while len(bm.verts) > max_verts and winkel < math.radians(60.0):
bmesh.ops.dissolve_limit(bm, angle_limit=winkel,
verts=bm.verts[:], edges=bm.edges[:])
bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces],
context='VERTS')
bm.verts.ensure_lookup_table()
if not bm.faces:
break
winkel += math.radians(3.0)
bm.normal_update()
# Das Vereinfachen schneidet Ecken ab - die Huelle waere danach KLEINER als
# das Teil und der Spieler wuerde einsinken. Exakt so weit aufblasen, dass
# wieder alles hineinpasst.
if bm.faces and quelle:
mitte = Vector((0.0, 0.0, 0.0))
for v in bm.verts:
mitte += v.co
mitte /= max(len(bm.verts), 1)
faktor = 1.0
for f in bm.faces:
n = f.normal
if n.length_squared < 1e-12:
continue
d = n.dot(f.verts[0].co)
basis = d - n.dot(mitte)
if basis <= 1e-9:
continue
for co in quelle:
noetig = (n.dot(co) - n.dot(mitte)) / basis
if noetig > faktor:
faktor = noetig
if faktor > 1.0:
for v in bm.verts:
v.co = mitte + (v.co - mitte) * faktor
bmesh.ops.triangulate(bm, faces=bm.faces[:])
me = bpy.data.meshes.new("UCX_" + name)
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new("UCX_" + name, me)
bpy.context.scene.collection.objects.link(ob)
return ob
def _mass_center(me):
"""Massenmittelpunkt als flaechengewichteter Schwerpunkt der Faces.
Fuer einen Physik-Actor ist der Pivot der Drehpunkt - liegt er in einer Ecke,
torkelt das Teil beim Fallen. Der reine Vertex-Mittelwert taugt nicht: dort,
wo das Mesh dicht tesselliert ist (Astansatz), zieht er den Punkt hin.
Flaechengewichtet trifft es deutlich besser und funktioniert auch bei
offener Geometrie - die Blatt-Cards sind offen.
"""
gesamt = 0.0
acc = Vector((0.0, 0.0, 0.0))
for p in me.polygons:
a = p.area
if a <= 0.0:
continue
acc += p.center * a
gesamt += a
if gesamt <= 0.0:
if not me.vertices:
return Vector((0.0, 0.0, 0.0))
m = Vector((0.0, 0.0, 0.0))
for v in me.vertices:
m += v.co
return m / len(me.vertices)
return acc / gesamt
def _teil_aus_ids(context, quellen, ids, name, z_von=None, z_bis=None):
"""Baut ein Teil-Objekt aus allen Faces, deren ast_id in `ids` liegt.
`quellen` ist eine Liste (Objekt, Material-Slot). Optional wird zusaetzlich
nach Hoehe geschnitten - so entstehen die Stammsegmente.
"""
rinde = _get_or_create_mat(TREE_MATERIALS[0], (0.29, 0.21, 0.14, 1.0))
laub = _get_or_create_mat(TREE_MATERIALS[1], (0.18, 0.34, 0.14, 1.0))
bm = bmesh.new()
lay = None
for quelle, slot in quellen:
me = quelle.data
attr = me.attributes.get("ast_id")
if attr is None:
continue
werte = [d.value for d in attr.data]
tmp = bmesh.new()
tmp.from_mesh(me)
tmp.transform(quelle.matrix_world)
tmp.verts.ensure_lookup_table()
tmp.faces.ensure_lookup_table()
# Faces behalten, deren Vertices die passende ID tragen.
weg = []
for f in tmp.faces:
fid = werte[f.verts[0].index] if f.verts[0].index < len(werte) else 0
if fid not in ids:
weg.append(f)
if weg:
bmesh.ops.delete(tmp, geom=weg, context='FACES')
if z_von is not None or z_bis is not None:
if slot == 1:
# Blatt-Cards NICHT schneiden. Eine Card ist ein unteilbares
# Billboard - halbiert man sie, entstehen zwei Faces mit
# kaputten Atlas-UVs, und die Bilanz stimmt nicht mehr
# (gemessen 178 Cards in den Teilen gegen 170 im Stand-Baum).
# Stattdessen wandert jede Card als GANZES in das Segment, in
# dem ihre Inselmitte liegt.
inseln = []
offen = set(tmp.faces)
while offen:
start = offen.pop()
insel = [start]
stapel = [start]
while stapel:
f = stapel.pop()
for e in f.edges:
for nf in e.link_faces:
if nf in offen:
offen.discard(nf)
insel.append(nf)
stapel.append(nf)
inseln.append(insel)
weg2 = []
for insel in inseln:
mz = sum(f.calc_center_median().z for f in insel) / len(insel)
drin = ((z_von is None or mz >= z_von)
and (z_bis is None or mz < z_bis))
if not drin:
weg2.extend(insel)
if weg2:
bmesh.ops.delete(tmp, geom=weg2, context='FACES')
else:
if z_von is not None:
bmesh.ops.bisect_plane(
tmp, geom=tmp.verts[:] + tmp.edges[:] + tmp.faces[:],
plane_co=Vector((0, 0, z_von)), plane_no=Vector((0, 0, -1)),
clear_outer=True)
if z_bis is not None and tmp.faces:
bmesh.ops.bisect_plane(
tmp, geom=tmp.verts[:] + tmp.edges[:] + tmp.faces[:],
plane_co=Vector((0, 0, z_bis)), plane_no=Vector((0, 0, 1)),
clear_outer=True)
# Schlagflaechen schliessen - offene Segmente taugen nicht als
# Physik-Actor und die UCX-Huelle waere sinnlos.
rand = [e for e in tmp.edges if len(e.link_faces) == 1]
if rand:
bmesh.ops.holes_fill(tmp, edges=rand, sides=0)
if not tmp.faces:
tmp.free()
continue
# Material-Index setzen, bevor es in den Sammel-bmesh geht.
for f in tmp.faces:
f.material_index = slot
hilf = bpy.data.meshes.new("__t")
tmp.to_mesh(hilf)
tmp.free()
bm.from_mesh(hilf)
bpy.data.meshes.remove(hilf)
if not bm.faces:
bm.free()
return None
me_neu = bpy.data.meshes.new(name)
bm.to_mesh(me_neu)
bm.free()
me_neu.materials.append(rinde)
me_neu.materials.append(laub)
ob = bpy.data.objects.new(name, me_neu)
context.scene.collection.objects.link(ob)
return ob
# ---------------------------------------------------------------------------
# Addon-UI
# ---------------------------------------------------------------------------
from bpy.props import (
IntProperty, FloatProperty, EnumProperty, BoolProperty, StringProperty,
PointerProperty,
)
from bpy.types import Operator, Panel, PropertyGroup
def _preset_enum(self, context):
return [(k, k.capitalize(), "") for k in PRESETS]
class TreeGenSettings(PropertyGroup):
preset: EnumProperty(name="Preset", items=_preset_enum)
seed: IntProperty(name="Seed", default=0, min=0, max=9999)
use_growth: BoolProperty(
name="Wachstums-Stufen", default=False,
description="Erzeugt mehrere Stufen (Setzling .. ausgewachsen) mit gleichem Seed",
)
stages: IntProperty(name="Anzahl Stufen", default=4, min=2, max=8)
art_name: StringProperty(
name="Art", default="",
description=("Art-Name fuer den Export, z.B. 'Birke'. Leer = Preset-Name. "
"Schema: <Art>_<Variante>_S<Stufe>"),
)
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 <Name>_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 <Name>_Frucht (gleicher Ursprung) fuer die Frucht-Ebene",
)
piece_trunk_segments: IntProperty(
name="Stamm-Segmente", default=3, min=2, max=3,
description="In wie viele Stuecke der Stamm beim Faellen zerfaellt",
)
piece_ucx: BoolProperty(
name="UCX je Teil", default=True,
description="Legt je Teil eine konvexe Kollisionshuelle ins FBX",
)
piece_ucx_verts: IntProperty(
name="UCX max. Verts", default=32, min=8, max=128,
description="Obergrenze fuer die Kollisionshuelle je Teil",
)
piece_export_combined: BoolProperty(
name="Zusatzdatei mit gemeinsamem Ursprung", default=True,
description=("Schreibt zusaetzlich <Name>_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 <Art>_<Variante>_S<Stufe>."""
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.
stamm_verts = _stamm_koordinaten(me)
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
versatz = Matrix.Translation((-mx, -my, -z_fuss))
me.transform(versatz)
return versatz
class TREEGEN_OT_export_game(Operator):
"""Exportiert den ausgewaehlten Baum spielfertig: EIN Mesh, zwei Slots.
Stamm und _Leaf-Ebene bleiben im Blend getrennt - das ist die Arbeitsebene.
Verschmolzen wird nur die Export-Kopie.
"""
bl_idname = "treegen.export_game"
bl_label = "Spielfertig exportieren"
bl_description = ("Verschmilzt Stamm + Blattebene zu einem Mesh mit zwei "
"Material-Slots, setzt Kugel-Normalen auf die Blaetter "
"und schreibt eine FBX")
bl_options = {'REGISTER'}
def execute(self, context):
s = context.scene.tree_gen_settings
verzeichnis = bpy.path.abspath(s.export_dir) if s.export_dir else ""
if not verzeichnis or not os.path.isdir(verzeichnis):
self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.")
return {'CANCELLED'}
# Baeume = ausgewaehlte Meshes, die KEINE Zusatzebene sind.
baeume = [o for o in context.selected_objects
if o.type == 'MESH'
and not o.name.endswith(("_Leaf", "_Frucht"))]
if not baeume:
self.report({'ERROR'}, "Keinen Baum ausgewaehlt (Stamm-Objekt waehlen).")
return {'CANCELLED'}
geschrieben, fehler = 0, 0
for baum in baeume:
blatt = bpy.data.objects.get(baum.name + "_Leaf")
ziel = os.path.join(verzeichnis, _safe_name(baum.name) + ".fbx")
aufraeumen = []
umbenannt = []
try:
# Die Export-Kopie muss EXAKT so heissen wie der Baum - Unreal
# leitet den Asset-Namen davon ab. Solange das Original den
# Namen haelt, haengt Blender ".001" an die Kopie. Also das
# Original fuer die Dauer des Exports beiseite benennen.
for quelle in (baum, blatt):
if quelle is not None:
umbenannt.append((quelle, quelle.name))
quelle.name = quelle.name + ".__src"
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)
_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]
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:
_deselect(context)
for t in teile:
t.select_set(True)
context.view_layer.objects.active = teile[0]
bpy.ops.export_scene.fbx(
filepath=os.path.join(verzeichnis, basis + "_Pieces_All.fbx"),
use_selection=True, object_types={'MESH'},
use_mesh_modifiers=False, mesh_smooth_type='OFF',
use_triangles=True, use_custom_props=True, colors_type='SRGB',
add_leaf_bones=False, bake_anim=False, path_mode='AUTO',
apply_scale_options='FBX_SCALE_ALL')
# --- Je Teil eine Datei, Pivot = Massenmittelpunkt --------------
geschrieben = 0
for t in teile:
schwer = _mass_center(t.data)
t.data.transform(Matrix.Translation(-schwer))
t.matrix_basis = Matrix.Identity(4)
hull = _tree_ucx([t], t.name, max_verts=s.piece_ucx_verts) \
if s.piece_ucx else None
_deselect(context)
t.select_set(True)
if hull is not None:
hull.select_set(True)
context.view_layer.objects.active = t
bpy.ops.export_scene.fbx(
filepath=os.path.join(verzeichnis, t.name + ".fbx"),
use_selection=True, object_types={'MESH'},
use_mesh_modifiers=False, mesh_smooth_type='OFF',
use_triangles=True, use_custom_props=True, colors_type='SRGB',
add_leaf_bones=False, bake_anim=False, path_mode='AUTO',
apply_scale_options='FBX_SCALE_ALL')
geschrieben += 1
if hull is not None:
hm = hull.data
bpy.data.objects.remove(hull, do_unlink=True)
if hm.users == 0:
bpy.data.meshes.remove(hm)
if not s.piece_keep:
for t in teile:
me = t.data
bpy.data.objects.remove(t, do_unlink=True)
if me.users == 0:
bpy.data.meshes.remove(me)
for n in temp:
o = bpy.data.objects.get(n)
if o is not None:
me = o.data
bpy.data.objects.remove(o, do_unlink=True)
if me is not None and me.users == 0:
bpy.data.meshes.remove(me)
return geschrieben
class TREEGEN_OT_randomize(Operator):
bl_idname = "object.treegen_randomize"
bl_label = "Zufaellig"
bl_description = ("Wuerfelt Seed und Form-Parameter neu, ohne den Preset-Charakter "
"zu verlieren. 'Streuung' bestimmt die Abweichung")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.tree_gen_settings
import random as _r
rng = _r.Random()
s.seed = rng.randint(0, 9999)
# Die Form-Parameter liegen am Modifier, nicht an den Settings. Deshalb
# merken wir uns die Streuung und wenden sie beim Erzeugen an.
s["_rand_jitter"] = s.random_amount
self.report({'INFO'}, "Neuer Seed %d, Streuung %.0f%%."
% (s.seed, s.random_amount * 100))
return {'FINISHED'}
class TREEGEN_OT_leaves(Operator):
bl_idname = "object.treegen_leaves"
bl_label = "Blattwerk erzeugen"
bl_description = ("Streut die Blatt-Card auf die ZWEIGE der ausgewaehlten Baeume "
"und legt sie als <Name>_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, "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()