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
+62
View File
@@ -563,6 +563,68 @@ Der Kaktus erzeugt regulaer **0 Cards** — kein Ast ist duenner als `Max Radius
und das ist botanisch richtig. Vorher lief das still durch und man stand vor und das ist botanisch richtig. Vorher lief das still durch und man stand vor
einer leeren `_Leaf`-Ebene; jetzt sagt der Operator es und nennt den Regler. einer leeren `_Leaf`-Ebene; jetzt sagt der Operator es und nennt den Regler.
### Der Ursprung sitzt an der Stammbasis, nicht am tiefsten Ast
Zweimal war dieser Fehler schon "behoben". Erst kam der Ursprung aus der
Gesamt-Bbox - eine tief haengende Blatt-Card hob den ganzen Baum an. Dann aus
Material-Slot 0, was die Cards ausnahm, aber die AESTE gehoeren ebenfalls zum
Rinden-Slot. Gemessen an Tanne_01_S1 (Aeste ab 6 % Stammhoehe, haengend): der
tiefste Slot-0-Punkt lag bei **-5.35 cm**, der tiefste STAMM-Punkt bei
**-0.02 cm**. Die Stammbasis schwebte 5.3 cm ueber dem Boden, weil eine
Astspitze den Nullpunkt bestimmte.
Die exakte Trennung liefert `ast_id` (0 = Stamm) - dasselbe Attribut, 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. X/Y kommt aus dem UNTERSTEN RING statt
aus der Stamm-Bbox; bei einem krummen Stamm (Bend 1.5 am toten Baum) liegt die
Bbox-Mitte irgendwo in der Kruemmung.
`tests/test_ursprung.py` misst am reimportierten FBX. Die erste Fassung des
Tests suchte den "tiefsten Punkt nahe der Achse" - und die Gegenprobe hat sie
umgeworfen: bei der Tanne faellt eine Astspitze ins Achsband, der Test meldete
Fuss = 0, obwohl der Stamm 5 cm darueber schwebte. Ein Schwellwert, der die
Bugfassung durchlaesst, prueft nichts. Jetzt wird ein STAMMQUERSCHNITT gesucht:
Vertices, die die Achse in allen Himmelsrichtungen umschliessen. Eine Astspitze
tut das nicht.
Nebenbefund: ein Baum ohne Aeste traegt gar kein `ast_id` - das Attribut
entsteht auf dem Ast-Zweig des Graphen, und den schaltet `Branch Count 0`
komplett ab (Kaktus-Setzling). Dann ist ohnehin alles Stamm, der Fallback ist
also exakt und nicht bloss ausreichend.
### Weide: Ziel-Cardzahl je Preset
"Volle Dichte" auf S4 heisst: das Ergebnis haengt an der Zweigflaeche, und die
faellt je Art verschieden aus. Die Weide traegt die meiste und landete gemessen
bei 5456-5594 Tris ueber drei Seeds, gegen ein Stufenbudget von 4300. Mit der
Deckelung auf 350 Cards: 3890-3962 Tris.
`LEAF_STUFEN_PRESET` haelt solche Ausnahmen. Das macht die Tabelle
preset-abhaengig - bewusste Entscheidung der Spielseite, statt das Budget fuer
alle anzuheben: die Weide ist der Ausreisser, nicht die Regel.
### Birke: keine Saeule
Gemessen war die alte Birke Breite/Hoehe **0.15** - schmaler 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 Quellen, 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 rund 0.35-0.55;
gemessen jetzt **0.51**.
Den Habitus nennt Van den Berk woertlich: "spreading branches have clearly
pendulous and thin twigs". Das ist der Palmen-Bogen im Kleinen - der Ast steigt
erst und kippt dann ab -, deshalb `Branch Droop` mit spaeter `Droop Curve`.
Negatives `Sub Up` waere der naheliegende Griff gewesen und bewegt hier nichts:
durchgemessen 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). Die Zweige haengen, der Baum nicht.
### Die Node-Gruppe wird WIEDERVERWENDET, nicht neu gebaut ### Die Node-Gruppe wird WIEDERVERWENDET, nicht neu gebaut
`build_group()` loeschte die Gruppe `GN_Tree` bei jedem Erzeugen und legte sie `build_group()` loeschte die Gruppe `GN_Tree` bei jedem Erzeugen und legte sie
+1 -1
View File
@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "stylized_tree_generator" id = "stylized_tree_generator"
version = "1.35.0" version = "1.36.0"
name = "Stylized Tree Generator" name = "Stylized Tree Generator"
tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen"
maintainer = "D4rkst3r" maintainer = "D4rkst3r"
+3 -3
View File
@@ -50,17 +50,17 @@
<td>34.1KB</td> <td>34.1KB</td>
</tr> </tr>
<tr> <tr>
<td><tt><a href="./stylized_tree_generator-1.35.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.35.0</a></tt></td> <td><tt><a href="./stylized_tree_generator-1.36.0.zip?repository=.%2Findex.json&blender_version_min=4.2.0">stylized_tree_generator-1.36.0</a></tt></td>
<td>Stylized Tree Generator</td> <td>Stylized Tree Generator</td>
<td>Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen</td> <td>Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen</td>
<td><a href="https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator">link</a></td> <td><a href="https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator">link</a></td>
<td>4.2.0 - ~</td> <td>4.2.0 - ~</td>
<td>all</td> <td>all</td>
<td>all</td> <td>all</td>
<td>41.2KB</td> <td>43.0KB</td>
</tr> </tr>
</table> </table>
<center><p>Built 2026-08-06, 09:08</p></center> <center><p>Built 2026-08-06, 09:54</p></center>
</body> </body>
</html> </html>
+4 -4
View File
@@ -82,7 +82,7 @@
"id": "stylized_tree_generator", "id": "stylized_tree_generator",
"name": "Stylized Tree Generator", "name": "Stylized Tree Generator",
"tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen",
"version": "1.35.0", "version": "1.36.0",
"type": "add-on", "type": "add-on",
"maintainer": "D4rkst3r", "maintainer": "D4rkst3r",
"license": [ "license": [
@@ -98,9 +98,9 @@
"Mesh", "Mesh",
"Modeling" "Modeling"
], ],
"archive_url": "./stylized_tree_generator-1.35.0.zip", "archive_url": "./stylized_tree_generator-1.36.0.zip",
"archive_size": 42185, "archive_size": 44058,
"archive_hash": "sha256:db4de9904c876c1336879fe4de7a183f1a1f2c7dc52c55e8278e0bf7e43bd323" "archive_hash": "sha256:ef7577536d2ea895b0f601de020c99bd2bf060c15b60ba92ade46f4a625fae0b"
} }
] ]
} }
Binary file not shown.
Binary file not shown.
+27 -1
View File
@@ -23,15 +23,38 @@ $tests = Get-ChildItem (Join-Path $root "tests") -Filter "test_$Filter.py" | Sor
if (-not $tests) { throw "Keine Tests gefunden fuer Filter '$Filter'" } if (-not $tests) { throw "Keine Tests gefunden fuer Filter '$Filter'" }
$failed = @() $failed = @()
$abgestuerzt = @()
foreach ($t in $tests) { foreach ($t in $tests) {
# Ein Lauf kann an einem BLENDER-ABSTURZ sterben statt an einem Testfehler.
# Nachgemessen an test_export_robust: rund jeder sechste Lauf endet mit
# EXCEPTION_ACCESS_VIOLATION - und zwar ohne einen einzigen Addon-Frame im
# Stack (RNA_property_int_get -> rna_property_rna_or_id_get ->
# IDP_GetPropertyFromGroup, Null-Deref beim Lesen einer Integer-Property).
# Baut man Marker in denselben Test ein, verschwindet der Absturz: ein
# Heisenbug, also Blender 5.1.2 und nicht unser Code. Wenn der Lauf
# durchlaeuft, bestehen ALLE Checks des Tests.
# Deshalb einmal wiederholen und den Absturz benennen, statt ihn als
# stillen Testfehler auszugeben - "<keine Ergebniszeile>" hat monatelang
# niemandem gesagt, was eigentlich los war.
$out = & $Blender --background --factory-startup --python $t.FullName 2>&1 | Out-String $out = & $Blender --background --factory-startup --python $t.FullName 2>&1 | Out-String
$crash = $false
if ($out -match "EXCEPTION_ACCESS_VIOLATION") {
$crash = $true
$out = & $Blender --background --factory-startup --python $t.FullName 2>&1 | Out-String
$abgestuerzt += if ($out -match "EXCEPTION_ACCESS_VIOLATION") {
$t.BaseName + " (auch im zweiten Lauf)"
} else { $t.BaseName }
}
# Die Tests melden ihr Ergebnis als letzte Zeile mit "ERGEBNIS:" bzw. # Die Tests melden ihr Ergebnis als letzte Zeile mit "ERGEBNIS:" bzw.
# "Alle Checks bestanden." - alles andere ist Blender-Rauschen. # "Alle Checks bestanden." - alles andere ist Blender-Rauschen.
$line = ($out -split "`n" | $line = ($out -split "`n" |
Where-Object { $_ -match "ERGEBNIS|Alle Checks bestanden|FEHLGESCHLAGEN" } | Where-Object { $_ -match "ERGEBNIS|Alle Checks bestanden|FEHLGESCHLAGEN" } |
Select-Object -Last 1) Select-Object -Last 1)
if (-not $line) { $line = "<keine Ergebniszeile>" } if (-not $line) {
$line = if ($crash) { "<Blender abgestuerzt, auch im zweiten Lauf>" }
else { "<keine Ergebniszeile>" }
}
$line = $line.Trim() $line = $line.Trim()
$ok = ($line -match "ALLE|OK$|bestanden") -and ($line -notmatch "FEHLER|FEHLGESCHLAGEN") $ok = ($line -match "ALLE|OK$|bestanden") -and ($line -notmatch "FEHLER|FEHLGESCHLAGEN")
@@ -42,6 +65,9 @@ foreach ($t in $tests) {
} }
Write-Host "" Write-Host ""
if ($abgestuerzt) {
Write-Host ("Blender-Absturz (wiederholt): {0}" -f ($abgestuerzt -join ", ")) -ForegroundColor Yellow
}
if ($failed) { if ($failed) {
Write-Host ("FEHLGESCHLAGEN ({0}): {1}" -f $failed.Count, ($failed -join ", ")) -ForegroundColor Red Write-Host ("FEHLGESCHLAGEN ({0}): {1}" -f $failed.Count, ($failed -join ", ")) -ForegroundColor Red
exit 1 exit 1
+119 -28
View File
@@ -1,7 +1,7 @@
bl_info = { bl_info = {
"name": "Stylized Tree Generator", "name": "Stylized Tree Generator",
"author": "D4rkst3r", "author": "D4rkst3r",
"version": (1, 35, 0), "version": (1, 36, 0),
"blender": (4, 2, 0), "blender": (4, 2, 0),
"location": "View3D > Sidebar > Tree Gen", "location": "View3D > Sidebar > Tree Gen",
"description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", "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, "Branch Bend": 0.9, "Branch Thickness": 0.24, "Sub Count": 3,
"Sub Length": 0.8, "Root Flare": 1.5, "Root Lobe Depth": 0.4, "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, "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 Count": 16, "Branch Length": 2.1, "Branch Up": 0.55,
"Branch Start": 0.45, "Branch End": 0.98, "Branch Start": 0.38, "Branch End": 0.98,
"Crown Taper": 0.1, "Crown Bulge": 0.25, "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, "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 "weide": { # haengende Zweige (Droop positiv), breit ausladend
"Height": 5.0, "Trunk Radius": 0.18, "Bend": 0.8, "Taper": 0.8, "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 # 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. # 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))) 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 bpy.context.view_layer.objects.active = ob
return ob return ob
@@ -1523,20 +1549,42 @@ LEAF_STUFEN = (
(None, 1.00, 0.25), # S4 - volle Dichte, keine Regelung (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. """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 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 Stuetzstellen wird linear gemischt, damit auch drei oder fuenf Stufen
sinnvolle Werte bekommen. 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) n = len(LEAF_STUFEN)
x = max(0.0, min(1.0, growth_t)) * (n - 1) x = max(0.0, min(1.0, growth_t)) * (n - 1)
i = min(int(x), n - 2) i = min(int(x), n - 2)
f = x - i f = x - i
z0, g0, h0 = LEAF_STUFEN[i] z0, g0, h0 = _ziel(i), LEAF_STUFEN[i][1], LEAF_STUFEN[i][2]
z1, g1, h1 = LEAF_STUFEN[i + 1] 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 # Groesse und Kronenansatz werden gemischt, die Ziel-ANZAHL nicht: sie ist
# eine Stueckzahl, kein Verlauf. Es gilt die naeher liegende Stuetzstelle. # eine Stueckzahl, kein Verlauf. Es gilt die naeher liegende Stuetzstelle.
ziel = z0 if f < 0.5 else z1 ziel = z0 if f < 0.5 else z1
@@ -2369,6 +2417,36 @@ def _deselect(context):
pass 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): def _origin_bottom_center(ob):
"""Ursprung auf Bbox-Unterseite Mitte - identisch zur Fels-Pipeline. """Ursprung auf Bbox-Unterseite Mitte - identisch zur Fels-Pipeline.
@@ -2383,27 +2461,39 @@ def _origin_bottom_center(ob):
if not me.vertices: if not me.vertices:
return Matrix.Identity(4) return Matrix.Identity(4)
# Der Ursprung kommt aus der STAMM-Geometrie (Slot 0), nicht aus der # Der Ursprung kommt aus der STAMMBASIS, nicht aus einer Bbox.
# 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 # Zwei Stufen dieses Fehlers gab es schon. Zuerst die Gesamt-Bbox: eine
# Krone relativ zum kurzen Stamm weit ausladet. # einzige tief haengende Blatt-Card hob den ganzen Baum an. Dann Slot 0 -
stamm_verts = None # aber zum Rinden-Slot gehoeren auch die AESTE. Gemessen an Tanne_01_S1
if len(me.materials) > 1: # (haengende Aeste ab 6 % Stammhoehe): der tiefste Slot-0-Punkt lag bei
idx = {vi for p in me.polygons if p.material_index == 0 # -5.35 cm, der tiefste Stammpunkt bei -0.02 cm. Die Stammbasis schwebte
for vi in p.vertices} # also 5.3 cm ueber dem Boden, weil eine Astspitze den Nullpunkt bestimmte.
if idx: #
stamm_verts = [me.vertices[i].co for i in idx] # Die exakte Trennung liefert das Attribut "ast_id" (0 = Stamm), das der
if stamm_verts is None: # Baum ohnehin fuer die Faell-Teile traegt. Ein Schwellwert auf den
stamm_verts = [v.co for v in me.vertices] # 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] zs = [c.z for c in stamm_verts]
versatz = Matrix.Translation(( z_fuss = min(zs)
-(min(xs) + max(xs)) * 0.5, # X/Y aus dem UNTERSTEN RING, nicht aus der Bbox des ganzen Stammes: ein
-(min(ys) + max(ys)) * 0.5, # gekruemmter Stamm (Bend 1.5 beim toten Baum) hat seine Bbox-Mitte
-min(zs), # 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) me.transform(versatz)
return versatz return versatz
@@ -2773,7 +2863,8 @@ class TREEGEN_OT_leaves(Operator):
# traegt kleinere Bueschel. Baeume ohne Wachstums-Stufe haben # traegt kleinere Bueschel. Baeume ohne Wachstums-Stufe haben
# growth_t = 1.0 und bleiben unveraendert. # growth_t = 1.0 und bleiben unveraendert.
gt = float(t.get("growth_t", 1.0)) 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 # Der RADIUS-FILTER muss mit dem Baum schrumpfen. Er trennt
# duenne Zweige (Laub) von dickem Geaest (kein Laub) - aber ein # duenne Zweige (Laub) von dickem Geaest (kein Laub) - aber ein
+245
View File
@@ -0,0 +1,245 @@
"""Der Ursprung sitzt an der STAMMBASIS, nicht an der tiefsten Astspitze.
Gemeldet von der Spielseite an Tanne_01_S1: der Baum "liegt am Ast" - die
Stammbasis schwebt sichtbar ueber dem Boden. Nachgemessen an der Export-Kopie
lag der tiefste Punkt des Rinden-Slots bei -5.35 cm, der tiefste STAMM-Punkt
bei -0.02 cm. Die Tanne setzt ihre Aeste ab 6 % Stammhoehe an und laesst sie
haengen; eine davon bestimmte den Nullpunkt und hob den ganzen Baum an.
Der frueher gebaute Fix nahm nur die Blatt-Cards aus der Rechnung. Das reicht
nicht: zum Rinden-Slot gehoeren die Aeste mit.
Warum die Tanne der Pruefling ist: am Standardbaum sitzen die Aeste hoch und
haengen nicht - dort waere der Fehler unsichtbar geblieben, genau wie er es
monatelang war.
Gemessen wird im REIMPORTIERTEN FBX, nicht in Blender. Der Schwellwert fuer
"nahe der Achse" wird nicht geraten, sondern vor dem Export am echten Stamm
abgenommen und ueber die Hoehe skalenfrei gemacht.
Aufruf: blender --background --factory-startup --python tests/test_ursprung.py
"""
import os
import sys
import math
import tempfile
import importlib.util
import bpy
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
spec = importlib.util.spec_from_file_location(
"tg", os.path.join(ROOT, "stylized_tree_generator.py"))
tg = importlib.util.module_from_spec(spec)
sys.modules["tg"] = tg
spec.loader.exec_module(tg)
tg.register()
fails = []
OUT = tempfile.mkdtemp()
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
me = bpy.data.meshes.new("Leaf_Card")
me.from_pydata([(-0.5, 0, 0), (0, 0, 0.7), (0.5, 0, 0), (0, 0, -0.1)],
[], [(0, 1, 2), (0, 2, 3)])
me.update()
card = bpy.data.objects.new("Leaf_Card", me)
bpy.context.scene.collection.objects.link(card)
def stamm_mass(ob):
"""Fussradius und Hoehe des reinen Stammes - ueber ast_id, nicht geschaetzt.
Ein Schwellwert auf den Achsabstand waere die naheliegende Alternative und
faellt beim Kaktus um: dessen Stamm ist dicker als jeder sinnvolle
Schwellwert. Das Attribut kommt aus dem Generator und ist exakt.
"""
dg = bpy.context.evaluated_depsgraph_get()
ev = ob.evaluated_get(dg)
m = ev.to_mesh()
attr = m.attributes.get("ast_id")
if attr is None:
# Nachgemessen: ein Baum OHNE Aeste traegt gar kein ast_id - das
# Attribut entsteht auf dem Ast-Zweig des Graphen, und den schaltet
# "Branch Count 0" komplett ab (Kaktus-Setzling: keine Arme vor 50-70
# Jahren, NPS). Dann ist aber ohnehin alles Stamm, und der Fallback ist
# exakt statt bloss ausreichend.
co = [v.co.copy() for v in m.vertices]
else:
co = [m.vertices[i].co.copy() for i in range(len(m.vertices))
if attr.data[i].value == 0]
alle_z = [v.co.z for v in m.vertices]
ev.to_mesh_clear()
if not co:
return None
z0 = min(c.z for c in co)
h = max(alle_z) - min(alle_z)
band = [c for c in co if c.z <= z0 + h * 0.02]
r = max((math.hypot(c.x, c.y) for c in band), default=0.0)
return r, h, z0
ergebnisse = []
for preset, stufe in (("tanne", 1), ("tanne", 4), ("toter_baum", 1),
("weide", 1), ("kaktus", 1)):
for o in list(bpy.context.scene.objects):
if o is not card:
bpy.data.objects.remove(o, do_unlink=True)
# Eigenes Verzeichnis je Fall: reimportiert wird erst nach der Schleife,
# ein gemeinsamer Ordner wuerde die vorherigen Dateien ueberschreiben.
ziel_dir = os.path.join(OUT, "%s_s%d" % (preset, stufe))
os.makedirs(ziel_dir, exist_ok=True)
s = bpy.context.scene.tree_gen_settings
s.preset = preset
s.art_name = preset.capitalize()
s.variant = 1
s.apply_modifier = True
s.use_growth = True
s.stages = 4
s.export_dir = ziel_dir
s.generate_lods = False
s.leaf_card = card
bpy.ops.object.treegen_create()
staemme = sorted((o for o in bpy.context.scene.objects
if o.type == 'MESH' and o is not card
and not o.name.endswith(("_Leaf", "_Frucht"))),
key=lambda o: o.name)
baum = staemme[stufe - 1]
# Der Baum steht BEWUSST nicht am Ursprung - eine verschobene Stufe faellt
# bei (0,0,0) nicht auf, weil minus null eben null ist.
baum.location = (12.0, -4.0, 0.0)
mass = stamm_mass(baum)
if mass is None:
fails.append("%s S%d: kein ast_id am Stamm" % (preset, stufe))
continue
r_stamm, h_blender, _ = mass
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
bpy.ops.object.treegen_leaves()
bl = bpy.data.objects.get(baum.name + "_Leaf")
if bl is not None:
bl.location = baum.location
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
bpy.ops.treegen.export_game()
dateien = sorted(f for f in os.listdir(ziel_dir)
if f.lower().endswith(".fbx"))
if not dateien:
fails.append("%s S%d: kein FBX geschrieben" % (preset, stufe))
continue
pfad = os.path.join(ziel_dir, dateien[0])
ergebnisse.append((preset, stufe, pfad, r_stamm / h_blender))
print("%-12s %3s %9s %11s %11s %10s"
% ("Preset", "St", "Achs-R", "Stammfuss", "tiefster Pkt", "Achs-Mitte"))
for preset, stufe, pfad, r_rel in ergebnisse:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=pfad)
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
if not meshes:
fails.append("%s S%d: kein Mesh im FBX" % (preset, stufe))
continue
ob = meshes[0]
m = ob.data
co = [ob.matrix_world @ v.co for v in m.vertices]
zlo = min(c.z for c in co)
zhi = max(c.z for c in co)
h = zhi - zlo
# Gesucht ist die tiefste Hoehe, auf der ein STAMMQUERSCHNITT liegt.
#
# Ein blosses "tiefster Punkt nahe der Achse" reicht nicht - das ist die
# erste Fassung dieses Tests gewesen, und die Gegenprobe hat sie umgeworfen:
# bei der Tanne haengt ein Ast so dicht am Stamm, dass seine Spitze ins
# Achsband faellt. Der Test meldete Fuss = 0, obwohl der Stamm 5 cm darueber
# schwebte. Ein Threshold, der die Bugfassung durchlaesst, prueft nichts.
#
# Ein Querschnitt unterscheidet sich von einer Astspitze in zwei messbaren
# Punkten: er umschliesst die Achse (Vertices in ALLEN Himmelsrichtungen)
# und er ist kompakt (kein Vertex weit draussen). Beides ohne geratene
# Konstante: der Vergleichsradius kommt aus dem echten Stamm.
r_max = r_rel * h * 2.5
SEKTOREN = 8
def querschnitt(zu, zo):
# Erst auf das Achsband einschraenken, DANN auf einen Ring pruefen.
# Andersherum (Slab verwerfen, sobald irgendein Punkt weit draussen
# liegt) faellt der echte Stammfuss durch: auf seiner Hoehe zieht bei
# der Tanne ein haengender Ast vorbei.
pts = [c for c in co
if zu <= c.z <= zo and math.hypot(c.x, c.y) <= r_max]
if len(pts) < SEKTOREN:
return None
sek = {int((math.atan2(c.y, c.x) + math.pi) / (2 * math.pi) * SEKTOREN)
% SEKTOREN for c in pts}
if len(sek) < SEKTOREN - 2:
return None # einseitig -> Zweig, kein Ring
# Schwerpunkt aus einem DICKEREN Band als dem Suchschritt. Nachgemessen
# am Kaktus: ein 1-%-Band erwischt nur 11 der 16 Ring-Vertices (die
# Rippen legen den Ring in Z leicht schief) und der Schwerpunkt kippt um
# 19 % der Hoehe aus der Achse. Mit 4 % ist der Ring vollstaendig und
# der Schwerpunkt exakt 0.0000 - geprueft an Kaktus, totem Baum, Tanne.
ring = [c for c in co
if zu <= c.z <= zu + h * 0.04
and math.hypot(c.x, c.y) <= r_max]
return (sum(c.x for c in ring) / len(ring),
sum(c.y for c in ring) / len(ring))
dicke = h * 0.01
fuss = None
mitte = (0.0, 0.0)
# Ueberlappende Schritte: mit buendigen Slabs kann eine Slab-Grenze mitten
# durch den Ring laufen, dann haelt keine der beiden Haelften die
# Sektor-Pruefung und der Fuss wird gar nicht gefunden. Halber Schritt.
z = zlo
while z < zhi:
tr = querschnitt(z, z + dicke)
if tr is not None:
fuss, mitte = z, tr
break
z += dicke * 0.5
if fuss is None:
# Kein Ring innerhalb r_max ueber die ganze Hoehe: der Stamm steht
# nicht ueber dem Ursprung. Genau das passiert mit der alten Fassung -
# dort bestimmte eine haengende Astspitze den Nullpunkt, und zwar in
# allen drei Achsen, nicht nur in Z. Der Baum haengt dann an der
# Astspitze statt auf dem Fuss zu stehen.
fails.append("%s S%d: auf keiner Hoehe ein Stammquerschnitt im "
"Achsband (R %.3f) - der Stamm steht seitlich versetzt "
"zum Ursprung" % (preset, stufe, r_max))
continue
achs = math.hypot(*mitte)
print("%-12s S%-2d %9.4f %11.4f %11.4f %10.4f"
% (preset, stufe, r_max, fuss, zlo, achs))
# DAS ist der Punkt: die Stammbasis liegt auf Z = 0.
if abs(fuss) > h * 0.01:
fails.append("%s S%d: Stammfuss bei Z %.4f statt 0 (%.1f %% der Hoehe) "
"- der Baum schwebt bzw. steckt im Boden"
% (preset, stufe, fuss, 100 * abs(fuss) / h))
# Und der Stamm steht ueber dem Ursprung, nicht daneben. Ohne diese Zeile
# koennte die Bbox-Mitte den Pivot bei einem einseitigen Baum vom Stamm
# wegziehen - derselbe Fehler, nur in X/Y.
if achs > h * 0.02:
fails.append("%s S%d: Stammachse %.4f neben dem Ursprung (%.1f %% der "
"Hoehe)" % (preset, stufe, achs, 100 * achs / h))
print("")
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for f in fails:
print(" - " + f)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")