diff --git a/README.md b/README.md index c4e1a5c..f7b07cb 100644 --- a/README.md +++ b/README.md @@ -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 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 `build_group()` loeschte die Gruppe `GN_Tree` bei jedem Erzeugen und legte sie diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 7a60f24..9a2d9d7 100644 --- a/blender_manifest_tree.toml +++ b/blender_manifest_tree.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_tree_generator" -version = "1.35.0" +version = "1.36.0" name = "Stylized Tree Generator" tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" maintainer = "D4rkst3r" diff --git a/dist/index.html b/dist/index.html index 8adcbf2..a2fcf83 100644 --- a/dist/index.html +++ b/dist/index.html @@ -50,17 +50,17 @@ 34.1KB - stylized_tree_generator-1.35.0 + stylized_tree_generator-1.36.0 Stylized Tree Generator Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen link 4.2.0 - ~ all all - 41.2KB + 43.0KB -

Built 2026-08-06, 09:08

+

Built 2026-08-06, 09:54

diff --git a/dist/index.json b/dist/index.json index 3ccfb09..cdc6c20 100644 --- a/dist/index.json +++ b/dist/index.json @@ -82,7 +82,7 @@ "id": "stylized_tree_generator", "name": "Stylized Tree Generator", "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", - "version": "1.35.0", + "version": "1.36.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -98,9 +98,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.35.0.zip", - "archive_size": 42185, - "archive_hash": "sha256:db4de9904c876c1336879fe4de7a183f1a1f2c7dc52c55e8278e0bf7e43bd323" + "archive_url": "./stylized_tree_generator-1.36.0.zip", + "archive_size": 44058, + "archive_hash": "sha256:ef7577536d2ea895b0f601de020c99bd2bf060c15b60ba92ade46f4a625fae0b" } ] } \ No newline at end of file diff --git a/dist/stylized_tree_generator-1.35.0.zip b/dist/stylized_tree_generator-1.35.0.zip deleted file mode 100644 index d750edb..0000000 Binary files a/dist/stylized_tree_generator-1.35.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.36.0.zip b/dist/stylized_tree_generator-1.36.0.zip new file mode 100644 index 0000000..d6b1fc6 Binary files /dev/null and b/dist/stylized_tree_generator-1.36.0.zip differ diff --git a/run_tests.ps1 b/run_tests.ps1 index 656c2ce..6c6e4b5 100644 --- a/run_tests.ps1 +++ b/run_tests.ps1 @@ -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'" } $failed = @() +$abgestuerzt = @() 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 - "" hat monatelang + # niemandem gesagt, was eigentlich los war. $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. # "Alle Checks bestanden." - alles andere ist Blender-Rauschen. $line = ($out -split "`n" | Where-Object { $_ -match "ERGEBNIS|Alle Checks bestanden|FEHLGESCHLAGEN" } | Select-Object -Last 1) - if (-not $line) { $line = "" } + if (-not $line) { + $line = if ($crash) { "" } + else { "" } + } $line = $line.Trim() $ok = ($line -match "ALLE|OK$|bestanden") -and ($line -notmatch "FEHLER|FEHLGESCHLAGEN") @@ -42,6 +65,9 @@ foreach ($t in $tests) { } Write-Host "" +if ($abgestuerzt) { + Write-Host ("Blender-Absturz (wiederholt): {0}" -f ($abgestuerzt -join ", ")) -ForegroundColor Yellow +} if ($failed) { Write-Host ("FEHLGESCHLAGEN ({0}): {1}" -f $failed.Count, ($failed -join ", ")) -ForegroundColor Red exit 1 diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index 778746a..2741ae1 100644 --- a/stylized_tree_generator.py +++ b/stylized_tree_generator.py @@ -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 diff --git a/tests/test_ursprung.py b/tests/test_ursprung.py new file mode 100644 index 0000000..ac473dd --- /dev/null +++ b/tests/test_ursprung.py @@ -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")