Tree Gen 1.36.0: Ursprung an der Stammbasis, Weide gedeckelt, Birke ueberarbeitet

1) Ursprung: Stammbasis statt tiefstem Slot-0-Punkt
   Zweimal war das schon "behoben". Erst kam der Ursprung aus der Gesamt-Bbox
   (eine Blatt-Card hob den Baum an), dann aus Material-Slot 0 - aber die AESTE
   gehoeren ebenfalls zum Rinden-Slot. Gemessen an Tanne_01_S1: tiefster
   Slot-0-Punkt -5.35 cm, tiefster STAMM-Punkt -0.02 cm. Die Stammbasis
   schwebte 5.3 cm ueber dem Boden.

   Die exakte Trennung liefert ast_id (0 = Stamm) - dasselbe Attribut, das der
   Baum fuer die Faell-Teile ohnehin traegt. Ein Schwellwert auf den
   Achsabstand faellt beim Kaktus um: dessen Stamm ist dicker als jeder
   sinnvolle Schwellwert. X/Y kommt jetzt aus dem untersten RING statt aus der
   Stamm-Bbox; bei Bend 1.5 liegt die Bbox-Mitte in der Kruemmung.

   Neu: tests/test_ursprung.py, gemessen am reimportierten FBX. Die erste
   Fassung des Tests suchte den "tiefsten Punkt nahe der Achse" - die
   Gegenprobe hat sie umgeworfen, weil bei der Tanne eine Astspitze ins
   Achsband faellt. Ein Schwellwert, der die Bugfassung durchlaesst, prueft
   nichts. Jetzt wird ein Stammquerschnitt gesucht: Vertices, die die Achse in
   allen Himmelsrichtungen umschliessen. Gegenprobe bestanden.

   Nebenbefund: ein Baum ohne Aeste traegt gar kein ast_id (Branch Count 0
   schaltet den Ast-Zweig des Graphen ab). Dann ist alles Stamm und der
   Fallback exakt.

2) Weide S4 auf 350 Cards gedeckelt - Entscheidung des Users
   Gemessen 5456-5594 Tris ueber drei Seeds gegen 4300 Budget; nach der
   Deckelung 3890-3962. LEAF_STUFEN_PRESET haelt solche Ausnahmen je Preset.

3) Birke war eine Saeule
   Gemessen B/H 0.15 - schmaler als der Standardbaum (0.22), nicht die Haelfte
   der Eiche (0.36). Ursache: Branch Up 1.1, die Aeste klebten am Stamm.
   Zielband 0.35-0.55 aus Van den Berk (8-12 m bei 15-20 m) und Woodland Trust
   (6-10 m bei 15-25 m); jetzt 0.51.

   Haengende Zweigenden ueber Branch Droop 0.8 + Droop Curve 3.5, nicht ueber
   negatives Sub Up: das wurde von +2.2 bis -2.0 durchgemessen und bewegt
   nichts (1-6 %), weil die Sub-Aeste zu kurz sind. Mit Droop 26 % bei einem
   Fall von 0.033 x Hoehe - wie die Tanne (0.030), weit weg von der Weide
   (0.213).

4) run_tests.ps1 benennt Blender-Abstuerze
   test_export_robust starb rund in jedem sechsten Lauf mit
   EXCEPTION_ACCESS_VIOLATION und der Runner meldete "<keine Ergebniszeile>".
   Der Stack enthaelt keinen einzigen Addon-Frame (RNA_property_int_get ->
   rna_property_rna_or_id_get -> IDP_GetPropertyFromGroup, Null-Deref), und mit
   eingebauten Markern verschwindet der Absturz: Heisenbug in Blender 5.1.2.
   Der Runner wiederholt jetzt einmal und benennt den Absturz.

24 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 11:55:34 +02:00
co-authored by Claude Opus 4.8
parent fa7067d42e
commit 499d507e28
9 changed files with 461 additions and 37 deletions
+119 -28
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Tree Generator",
"author": "D4rkst3r",
"version": (1, 35, 0),
"version": (1, 36, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Tree Gen",
"description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen",
@@ -159,13 +159,35 @@ PRESETS = {
"Branch Bend": 0.9, "Branch Thickness": 0.24, "Sub Count": 3,
"Sub Length": 0.8, "Root Flare": 1.5, "Root Lobe Depth": 0.4,
},
"birke": { # schlank, hoher Ansatz, feine Zweige
# 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": 12, "Branch Length": 1.4, "Branch Up": 1.1,
"Branch Start": 0.45, "Branch End": 0.98,
"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.5, "Root Flare": 0.6,
"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,
@@ -1245,6 +1267,10 @@ def make_object(ng, preset="baum", name=None, growth=None, seed=None):
# 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
@@ -1523,20 +1549,42 @@ LEAF_STUFEN = (
(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},
}
def _stufen_anteile(growth_t):
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 = LEAF_STUFEN[i]
z1, g1, h1 = LEAF_STUFEN[i + 1]
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
@@ -2369,6 +2417,36 @@ def _deselect(context):
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.
@@ -2383,27 +2461,39 @@ def _origin_bottom_center(ob):
if not me.vertices:
return Matrix.Identity(4)
# Der Ursprung kommt aus der STAMM-Geometrie (Slot 0), nicht aus der
# Gesamt-Bbox. Sonst hebt eine einzige tief haengende Blatt-Card den ganzen
# Baum an und er steht in UE in der Luft - gemeldet am S1-Setzling, wo die
# Krone relativ zum kurzen Stamm weit ausladet.
stamm_verts = 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:
stamm_verts = [me.vertices[i].co for i in idx]
if stamm_verts is None:
stamm_verts = [v.co for v in me.vertices]
# 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)
xs = [c.x for c in stamm_verts]
ys = [c.y for c in stamm_verts]
zs = [c.z for c in stamm_verts]
versatz = Matrix.Translation((
-(min(xs) + max(xs)) * 0.5,
-(min(ys) + max(ys)) * 0.5,
-min(zs),
))
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
@@ -2773,7 +2863,8 @@ class TREEGEN_OT_leaves(Operator):
# traegt kleinere Bueschel. Baeume ohne Wachstums-Stufe haben
# growth_t = 1.0 und bleiben unveraendert.
gt = float(t.get("growth_t", 1.0))
ziel_cards, g_anteil, h_anteil = _stufen_anteile(gt)
ziel_cards, g_anteil, h_anteil = _stufen_anteile(
gt, t.get("preset"))
# Der RADIUS-FILTER muss mit dem Baum schrumpfen. Er trennt
# duenne Zweige (Laub) von dickem Geaest (kein Laub) - aber ein