Der Baum erfuellte Kronenradius, Deckung, Kronenansatz und Tri-Budget - und sah trotzdem falsch aus. Sichtbar wurde es erst am BREITENPROFIL der Krone in Dritteln (unten/mitte/oben, Messung der Spielseite nachgestellt): Eiche 3.0 / 2.8 / 2.6 oben/mitte 0.90 (liest sich als Baum) Obstbaum 3.3 / 4.5 / 4.7 oben/mitte 1.07 (Trichter) Kein einzelner Regler hat das geloest: Crown Taper (-0.10 bis +0.50), Branch Droop (0.6-1.2), Crown Bulge (1.0-1.8) und Attraction Up einzeln durchgefahren liessen oben/mitte bei rund 1.0 stehen. Der Grund ist strukturell: die Kronenform folgt nicht der Astlaenge je Hoehe, sondern dem WEG DER ASTSPITZEN. Solange die Aeste aufwaerts schwingen, landet das aeusserste Laub oben. Loesung ist eine andere Bauform, kein Feintuning - kurze Aeste an einem hohen Stamm statt langer Aeste an einem kurzen: Height 3.8 -> 4.9, Branch Length 2.55 -> 1.25, Branch Start 0.52 -> 0.40, Attraction Up +0.40 -> -0.05, Crown Taper -0.30 -> +0.15, Crown Bulge 0.60 -> 1.10, Branch Droop 0.35 -> 0.40. Ueber alle neun Produktions-Seeds: oben/mitte 0.63-0.84, Kronenradius 1.88-2.01 m, Deckung 100-134 % der Eiche, 3752-4166 Tris, Ansatz 39 %. Beim Einregeln kippte die Form zwischendurch in eine PILZform (unten am breitesten) - deshalb steht das Profil jetzt als Test da. test_obstbaum prueft oben/mitte <= 0.85. Gegenprobe mit den 1.39er Werten: neun Fehler bei 0.98-1.11. B/H liegt jetzt bei 0.77-0.82 (Band der Spielseite 0.55-0.75). Bleibt bewusst ungeprueft, wie beim Kronenradius entschieden: die Vorgaben Kronenradius >= 1.80 und B/H <= 0.75 sind bei 4-5 m Hoehe nur im Mittel gleichzeitig erfuellbar. 26 Tests bestehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3364 lines
154 KiB
Python
3364 lines
154 KiB
Python
bl_info = {
|
|
"name": "Stylized Tree Generator",
|
|
"author": "D4rkst3r",
|
|
"version": (1, 40, 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": 4.9, "Trunk Radius": 0.13, "Bend": 0.3, "Taper": 0.7,
|
|
"Branch Count": 9, # offene Krone: wenige Leitaeste, Licht durch
|
|
# Kurze Aeste an einem HOHEN Stamm. Vorher genau umgekehrt (Height 3.8,
|
|
# Branch Length 2.55) - daraus wurde ein Trichter, siehe unten.
|
|
"Branch Length": 1.25, "Branch Up": 0.7,
|
|
"Branch Start": 0.40, "Branch End": 0.90,
|
|
"Crown Taper": 0.15, # oben kuerzere Aeste (war -0.30 = besenfoermig)
|
|
"Crown Bulge": 1.1, # Bauch in der Mitte
|
|
"Attraction Up": -0.05, # KEIN Aufwaertsschwung mehr ...
|
|
"Branch Droop": 0.4, "Droop Curve": 3.0, # ... Spitzen kippen ab
|
|
"Branch Bend": 0.9, "Branch Thickness": 0.22,
|
|
"Sub Count": 3, "Sub Length": 0.7, "Root Flare": 1.0,
|
|
},
|
|
# Birke: schlanker Stamm, aber KEINE Saeule. Der vorherige Satz war eine -
|
|
# gemessen Breite/Hoehe 0.15, weniger als der Standardbaum (0.22) und nicht
|
|
# einmal die Haelfte der Eiche (0.36). Ursache war "Branch Up" 1.1: die
|
|
# Aeste zeigten fast senkrecht nach oben und klebten am Stamm.
|
|
#
|
|
# Zielband aus zwei Baumschul-/Fachquellen, die sich ueberlappen:
|
|
# Van den Berk nennt fuer Betula pendula 15-20 m Hoehe bei 8-12 m
|
|
# Kronenbreite (B/H 0.40-0.80), der Woodland Trust 15-25 m bei 6-10 m
|
|
# (0.24-0.67). Schnittmenge also rund 0.35-0.55; gemessen jetzt 0.45.
|
|
#
|
|
# Der Habitus steht bei Van den Berk woertlich: "spreading branches have
|
|
# clearly pendulous and thin twigs", Krone "irregularly oval to oblong,
|
|
# half-open". Das ist der Palmen-Bogen im Kleinen - der Ast steigt erst und
|
|
# kippt dann ab -, deshalb Branch Droop mit spaeter Droop Curve statt
|
|
# negativem "Sub Up". Sub Up wurde durchgemessen und bewegt hier nichts:
|
|
# von +2.2 bis -2.0 blieb der Anteil haengender Vertices bei 1-6 %, weil
|
|
# die Sub-Aeste zu kurz sind, um die Silhouette zu tragen. Mit Droop 0.8
|
|
# sind es 26 % bei einem Fall von 0.033 x Hoehe - vergleichbar der Tanne
|
|
# (0.030), weit weg von der Weide (0.213). Zweige haengen, der Baum nicht.
|
|
"birke": {
|
|
"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()
|