diff --git a/README.md b/README.md index 8353933..0e58ddd 100644 --- a/README.md +++ b/README.md @@ -702,6 +702,84 @@ Gegenprobe ohne die Boden-Klemmung: vier Fehler. Drei bestehende Tests zaehlten die Huelle als Render-Mesh mit und schlugen zu Recht an - sie filtern jetzt `UCX_`. +### Blattwerk als Halbkugel-Bueschel, und groessere Baeume + +Drei Aenderungen, die zusammengehoeren. + +**Neue Bauform.** Die Cards sassen einzeln auf der Astkurve - aus der Naehe sah +man deshalb nackte Ast-Enden mit einem Mini-Bueschel daran. Jetzt wird jeder +gestreute Punkt ein CLUSTER-ZENTRUM, und die Cards sitzen auf der oberen +Halbkugel darum herum. Die Astspitze verschwindet im Polster, weil das Zentrum +auf ihr liegt. Untere Halbkugel weggelassen: von unten sieht man die Krone +gegen den Himmel, das waere die Haelfte des Overdraws fuer nichts. + +Zwei Annahmen kassiert die Messung: `GeometryNodeMeshIcoSphere` liefert bei +Subdivisions 0 UND 1 dasselbe Ikosaeder - ein Cluster hat **sechs** Cards, nicht +26. Und die Stufen-Ziele muessen Cluster-Vielfache sein (26/78/208/390): S1 +stand auf 10, unter der kleinsten Einheit, die Regelung waere nie konvergiert. +S4 ist seither GEREGELT statt "volle Dichte" - ungeregelt lieferte die Schale +das Sechsfache (2706 Cards an der Eiche). + +**Ast-Spitzen-Deckung: zwei echte Ursachen.** Der Kronenansatz-Filter stand auf +25 % der Baumhoehe, die Tanne setzt ihre Aeste aber ab 12 % an - bei 5 von 22 +Aesten lag das aeussere Fuenftel KOMPLETT unter dem Filter. Er ist jetzt auf den +tatsaechlichen `Branch Start` der Art gedeckelt. Und haengende Spitzen fielen +unter den Filter: es zaehlt jetzt der ANSATZ des Astes (`anchor`) mit, nicht nur +die Punkthoehe. + +**Groessere Baeume**, proportional skaliert (Hoehe, Astlaenge, Stammradius +zusammen - Hoehe allein macht Stangen mit Minikrone): Eiche 9.3 m, Birke 11.0, +Tanne 13.0, Weide 7.0. Alle unter dem 4300er-Deckel. + +Gemessene Spitzen-Deckung (Anteil der Aeste mit Laub im aeusseren Fuenftel): + +| | Aeste gedeckt | aeusseres Fuenftel | +|---|---|---| +| Eiche, Baum | 100 % | 100 % | +| Weide | 100 % | 90 % | +| Obstbaum | 100 % | 93 % | +| Tanne | 86 % | 86 % | +| Birke | 100 % | 72 % | + +**Die Metrik selbst musste zweimal korrigiert werden.** Erst mass sie den +Abstand von der Astspitze zur naechsten Card-MITTE - viel zu streng: eine Card +ist 0.85 m gross, ihre Mitte darf einen halben Meter weg sein und die Spitze +trotzdem im Laub liegen. Bei 1.9 m Astlaenge kamen so 24 % heraus, obwohl das +Polster genau auf der Spitze sass. Jetzt zaehlt DECKUNG: das aeussere Fuenftel +gilt als gedeckt, wenn dort eine Card-Mitte naeher ist als eine halbe +Cardlaenge. + +### Kugel-Kennwert relativ, Bbox-Kriterium ersetzt + +Die Bueschel-Schalen schieben jede Card um bis zu einen Kappenradius vom Ast +weg, und zwar in ALLE Richtungen: die Krone gewinnt ringsum Breite, in der Hoehe +aber nur oben. Der Obstbaum fiel dadurch von Kugel 1.35-1.47 auf 0.95-1.23, +ohne dass an seiner Form etwas falsch war. + +Zwei Hypothesen dazu sind an der Messung gescheitert - beide klangen plausibel: +der Kappen-Faktor 1.47 des Obstbaums (entkoppelt: 0.93-1.14 -> 0.95-1.23, fast +nichts) und ein groesserer Kappen-Radius (1.3 und 1.6: 59-77 % -> 58-75 %, fast +nichts). Der Radius waechst in alle Richtungen und verschiebt das Verhaeltnis +deshalb kaum. Was wirkt, ist die Kronenbreite selbst. + +Die Schranke ist jetzt RELATIV: der Obstbaum muss die rundeste Art bleiben, +hoechstens 65 % des Eichenwerts aus demselben Lauf. Eine neue Konstante haette +bei der naechsten Bauform dasselbe Schicksal gehabt. + +**Und das Bbox-Kriterium ist raus.** "Bbox-Mitte == 0" stammt aus der Zeit vor +der Stammbasis-Konvention und misst seither die falsche Sache - eine +asymmetrische Krone kann ihre Bbox-Mitte gar nicht ueber dem Ursprung haben. +Nachgemessen ueber drei Presets und drei Seeds: + + Stammachse am Fuss 0.0000 m in ALLEN neun Faellen + Bbox-Exzentrik 0.08-0.39 m, streut mit dem Seed + im Verhaeltnis 3-13 % des Kronenradius + +Eine Verschiebung traefe den Stamm mit und waere ueber die Seeds konstant. +Beides ist nicht der Fall. An seine Stelle tritt der BOUNDS-RADIUS, an dem das +Culling haengt - geprueft nicht gegen eine Konstante, sondern gegen die +Blender-Messung: Hoehe und Radius muessen denselben Skalenfaktor tragen. + ### Vom Trichter ueber die Haube zur Kugel Zielbild ist der **Lollipop** der stilisierten Vektor-Illustration: kurzer diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 95dd365..9017e76 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.45.0" +version = "1.46.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 e988979..94c29f5 100644 --- a/dist/index.html +++ b/dist/index.html @@ -50,17 +50,17 @@ 34.5KB - stylized_tree_generator-1.45.0 + stylized_tree_generator-1.46.0 Stylized Tree Generator Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen link 4.2.0 - ~ all all - 49.9KB + 53.0KB -

Built 2026-08-07, 05:18

+

Built 2026-08-07, 10:21

diff --git a/dist/index.json b/dist/index.json index ccc6576..dcd9cbf 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.45.0", + "version": "1.46.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -98,9 +98,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.45.0.zip", - "archive_size": 51048, - "archive_hash": "sha256:c01f5c4e4b25e35ca8ffbe3b7266306bebe718f533784ffa490f581c373bba0b" + "archive_url": "./stylized_tree_generator-1.46.0.zip", + "archive_size": 54250, + "archive_hash": "sha256:1a8edadf9d76525c7b27aabbeb14e5197f153592eb5ce1fb3f9d4c343e986a18" } ] } \ No newline at end of file diff --git a/dist/stylized_tree_generator-1.45.0.zip b/dist/stylized_tree_generator-1.45.0.zip deleted file mode 100644 index e2662df..0000000 Binary files a/dist/stylized_tree_generator-1.45.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.46.0.zip b/dist/stylized_tree_generator-1.46.0.zip new file mode 100644 index 0000000..368c4ab Binary files /dev/null and b/dist/stylized_tree_generator-1.46.0.zip differ diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index 4f46103..586edba 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, 45, 0), + "version": (1, 46, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", @@ -156,8 +156,11 @@ PRESETS = { }, # --- 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, + # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren + # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius + # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. + "Height": 13.0, "Trunk Radius": 0.28, "Bend": 0.15, "Taper": 0.95, + "Branch Count": 22, "Branch Length": 3.55, "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 @@ -165,8 +168,11 @@ PRESETS = { "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, + # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren + # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius + # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. + "Height": 7.9, "Trunk Radius": 0.34, "Bend": 0.5, "Taper": 0.75, + "Branch Count": 10, "Branch Length": 3.15, "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 @@ -211,7 +217,7 @@ PRESETS = { "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 Length": 1.55, "Branch Up": 0.7, # Kronenansatz bei 30 % statt 40 %: das ist der Weg zur KUGEL, ohne den # Baum hoeher zu machen (4.9 m haengen an der Ernte-Reichweite). Die # Krone wird dadurch 4.3 m hoch bei 4.5 m Breite - Kugelverhaeltnis @@ -244,8 +250,11 @@ PRESETS = { # 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, + # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren + # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius + # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. + "Height": 11.0, "Trunk Radius": 0.13, "Bend": 0.7, "Taper": 0.92, + "Branch Count": 16, "Branch Length": 3.05, "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 @@ -255,8 +264,11 @@ PRESETS = { "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, + # Groessen-Vorgabe der Spielseite (Welt-Test): unsere Baeume waren + # Jungwald. Proportional skaliert - Hoehe, Astlaenge und Stammradius + # zusammen, sonst wird aus dem Baum eine Stange mit Minikrone. + "Height": 7.0, "Trunk Radius": 0.25, "Bend": 0.8, "Taper": 0.8, + "Branch Count": 12, "Branch Length": 2.8, "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 @@ -1354,7 +1366,12 @@ LEAF_GROUP_NAME = "GN_TreeLeaves" # 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) + # Punkte pro m2 Zweigoberflaeche. Seit der Bueschel-Schale sind das + # CLUSTER-Zentren, nicht mehr einzelne Cards - jedes Zentrum traegt SECHS + # Cards auf seiner Halbkugel. Die Dichte liegt entsprechend niedriger; die + # Bisektion regelt sie ohnehin auf die Ziel-Cardzahl ein. + "Density": 30.0, + "Cluster Radius": 0.45, # 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 @@ -1417,6 +1434,10 @@ def build_leaf_group(rebuild=False): 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) + # Radius der Bueschel-Kappe um jedes Cluster-Zentrum. Siehe den Block + # "BUESCHEL-SCHALE" weiter unten. + add_in("Cluster Radius", 'NodeSocketFloat', + LEAF_DEFAULTS["Cluster Radius"], 0.02, 5.0) gin = N("NodeGroupInput"); gin.location = (-900, 0) gout = N("NodeGroupOutput"); gout.location = (900, 0) @@ -1474,9 +1495,31 @@ def build_leaf_group(rebuild=False): hpos = N("GeometryNodeInputPosition"); hpos.location = (-560, -220) hsep = N("ShaderNodeSeparateXYZ"); hsep.location = (-420, -220) L(hpos.outputs[0], hsep.inputs[0]) + + # Der Filter darf HAENGENDE Astspitzen nicht wegschneiden. Ein Ast der Tanne + # setzt bei 12 % der Hoehe an und haengt; seine Spitze faellt unter den + # Kronenansatz und flog damit aus der Auswahl - gemessen blieben 4 von 22 + # Aesten ohne Card an der Spitze, bei der Weide 3 von 12. Genau die + # nackten Ast-Enden, die die Spielseite gemeldet hat. + # + # Der Baum traegt den ANSATZPUNKT jedes Astpunkts als Attribut "anchor". + # Ein Punkt zaehlt, wenn ENTWEDER er selbst oder der Ansatz seines Astes + # ueber der Schwelle liegt. Beides braucht es: nur ueber den Ansatz zu + # gehen wuerde beim Setzling das Stamm-Laub ausschliessen (der Stamm hat + # keinen Ansatz und laege bei 0). + hank = N("GeometryNodeInputNamedAttribute"); hank.location = (-560, -300) + hank.data_type = 'FLOAT_VECTOR' + _sock(hank, "Name").default_value = "anchor" + hasep = N("ShaderNodeSeparateXYZ"); hasep.location = (-420, -300) + L(_out(hank, "Attribute"), hasep.inputs[0]) + hmax = N("ShaderNodeMath"); hmax.location = (-280, -260) + hmax.operation = 'MAXIMUM' + L(hsep.outputs["Z"], hmax.inputs[0]) + L(hasep.outputs["Z"], hmax.inputs[1]) + hcmp = N("FunctionNodeCompare"); hcmp.location = (-140, -220) hcmp.data_type = 'FLOAT'; hcmp.operation = 'GREATER_THAN' - L(hsep.outputs["Z"], hcmp.inputs[0]) + L(hmax.outputs[0], hcmp.inputs[0]) L(bschwelle.outputs[0], hcmp.inputs[1]) beide = N("FunctionNodeBooleanMath"); beide.location = (0, 20) @@ -1484,10 +1527,45 @@ def build_leaf_group(rebuild=False): L(cmp.outputs[0], beide.inputs[0]) L(hcmp.outputs[0], beide.inputs[1]) + # DICHTE ZUR SPITZE HIN GEWICHTEN. + # + # "Distribute Points on Faces" verteilt nach FLAECHE. Eine Astspitze ist + # duenn und hat entsprechend wenig davon - sie bekommt also am seltensten + # ein Cluster, und genau das hat die Spielseite gemeldet: nackte Ast-Enden + # mit einem Mini-Bueschel daran. Gemessen als Abstand Astspitze zur + # naechsten Card, relativ zur Astlaenge: Tanne 48 % im Mittel, im + # schlimmsten Fall 123 % - dort sass gar nichts mehr am Ende. + # + # Der Astradius liegt als "uv_r" am Mesh. Duenn bekommt volle Dichte, dick + # nur einen Bruchteil - damit wandern die Cluster nach aussen, wo das Laub + # hingehoert. + wmap = N("ShaderNodeMapRange"); wmap.location = (-460, 300) + wmap.clamp = True + L(_out(na, "Attribute"), wmap.inputs["Value"]) + # Und der Bezug ist ein BRUCHTEIL von Max Radius, nicht der ganze Bereich: + # oberhalb eines Viertels der Schwelle ist der Zweig fuer ein Polster schon + # zu dick. + wmap.inputs["From Min"].default_value = 0.0 + wviertel = N("ShaderNodeMath"); wviertel.location = (-600, 300) + wviertel.operation = 'MULTIPLY'; wviertel.inputs[1].default_value = 0.25 + L(V["Max Radius"], wviertel.inputs[0]) + L(wviertel.outputs[0], wmap.inputs["From Max"]) + # Der Kontrast muss KRAEFTIG sein. Mit 1.0 -> 0.2 war die Gewichtung + # wirkungslos: die Zweigradien liegen bei 0.001-0.003, Max Radius bei 0.04, + # also bekamen praktisch alle waehlbaren Punkte fast dasselbe Gewicht - + # und die Flaeche entschied weiter. Mit 1.0 -> 0.02 zieht es die Cluster + # wirklich nach aussen. + wmap.inputs["To Min"].default_value = 1.0 + wmap.inputs["To Max"].default_value = 0.02 + wmul = N("ShaderNodeMath"); wmul.location = (-320, 300) + wmul.operation = 'MULTIPLY' + L(V["Density"], wmul.inputs[0]) + L(wmap.outputs["Result"], wmul.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(wmul.outputs[0], _sock(dist, "Density")) L(V["Seed"], _sock(dist, "Seed")) # Zufaellige Drehung: voll um Z, leicht gekippt (Tilt) @@ -1531,8 +1609,59 @@ def build_leaf_group(rebuild=False): _sock(lstore, "Name").default_value = "ast_id" L(_out(lna, "Attribute"), _sock(lstore, "Value")) + # ------------------------------------------------------------------ + # BUESCHEL-SCHALE: aus jedem gestreuten Punkt wird ein Cluster-ZENTRUM, + # und die Cards sitzen auf der oberen Halbkugel darum herum. + # + # Vorher sassen die Cards direkt auf der Astkurve. Aus der Naehe sah man + # deshalb nackte Ast-Enden mit einem Mini-Bueschel daran - das Laub folgte + # der Linie des Astes statt ein Polster zu bilden. Jetzt bildet jeder + # Cluster eine Kappe, die AUF dem Ast sitzt; die Astspitze verschwindet + # darin, weil das Zentrum auf ihr liegt und die Schale sie ueberdeckt. + # + # Untere Halbkugel weglassen: von unten sieht man die Krone gegen den + # Himmel, dort braucht es keine Cards - und es waere die Haelfte des + # Overdraws fuer nichts. + # Ikosaeder: 12 Vertices, davon 6 auf der oberen Haelfte - also SECHS Cards + # je Cluster. "Subdivisions" 0 und 1 liefern beide das Ikosaeder (gemessen), + # erst 2 unterteilt auf 42. Sechs reicht: mehr Cards je Cluster heisst bei + # festem Budget weniger Cluster, und dann bleiben Aeste ganz ohne Polster. + ico = N("GeometryNodeMeshIcoSphere"); ico.location = (-320, 420) + _sock(ico, "Radius").default_value = 1.0 + _sock(ico, "Subdivisions").default_value = 1 + + spos = N("GeometryNodeInputPosition"); spos.location = (-320, 560) + ssep = N("ShaderNodeSeparateXYZ"); ssep.location = (-180, 560) + L(spos.outputs[0], ssep.inputs[0]) + soben = N("FunctionNodeCompare"); soben.location = (-40, 560) + soben.data_type = 'FLOAT'; soben.operation = 'GREATER_THAN' + L(ssep.outputs["Z"], soben.inputs[0]) + # Knapp unter den Aequator, damit die Kappe seitlich geschlossen wirkt. + soben.inputs[1].default_value = -0.15 + + m2p = N("GeometryNodeMeshToPoints"); m2p.location = (-40, 420) + L(_out(ico, "Mesh"), _sock(m2p, "Mesh")) + L(soben.outputs[0], _sock(m2p, "Selection")) + + # Cluster-Radius mit Streuung, damit die Polster nicht uniform wirken. + rrad = N("FunctionNodeRandomValue"); rrad.location = (-180, 320) + rrad.data_type = 'FLOAT' + L(V["Seed"], _sock(rrad, "Seed")) + rlo = N("ShaderNodeMath"); rlo.location = (-320, 260); rlo.operation = 'MULTIPLY' + rlo.inputs[1].default_value = 0.7 + L(V["Cluster Radius"], rlo.inputs[0]) + L(rlo.outputs[0], rrad.inputs[2]) + L(V["Cluster Radius"], rrad.inputs[3]) + + schale = N("GeometryNodeInstanceOnPoints"); schale.location = (120, 420) + L(_out(lstore, "Geometry"), _sock(schale, "Points")) + L(_out(m2p, "Points"), _sock(schale, "Instance")) + L(rrad.outputs[1], _sock(schale, "Scale")) + sreal = N("GeometryNodeRealizeInstances"); sreal.location = (300, 420) + L(_out(schale, "Instances", "Geometry"), _sock(sreal, "Geometry")) + inst = N("GeometryNodeInstanceOnPoints"); inst.location = (100, 200) - L(_out(lstore, "Geometry"), _sock(inst, "Points")) + L(_out(sreal, "Geometry"), _sock(inst, "Points")) L(_out(quelle, "Output"), _sock(inst, "Instance")) try: L(V["Use Collection"], _sock(inst, "Pick Instance")) @@ -1620,11 +1749,22 @@ LEAF_RADIUS_YOUNG = 0.20 # macht ihn kahl. Faktor 1.5 statt 3 ist der Kompromiss. # # Ziel-Cards Groesse Kronenansatz +# Seit der Bueschel-Schale ist die kleinste Einheit ein CLUSTER (rund 26 Cards +# auf einer Halbkugel), nicht mehr die einzelne Card. Ziele unter 26 sind damit +# unerreichbar - S1 stand vorher auf 10 und die Regelung haette nie konvergiert. +# Die Zahlen sind deshalb auf Cluster-Vielfache gelegt: 1 / 3 / 8 / 15. +# +# S4 ist jetzt GEREGELT (vorher None = volle Dichte). Ohne Regelung lieferte die +# Schale das 26-fache der frueheren Cardzahl - gemessen 2706 statt 444 an der +# Eiche. "Volle Dichte" war eine sinnvolle Vorgabe, solange ein Punkt eine Card +# war; jetzt ist sie es nicht mehr. +# +# 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 + (26, 0.24, 0.40), # S1 - EIN Bueschel, Fuss bleibt frei + (78, 0.46, 0.35), # S2 - drei + (208, 0.75, 0.30), # S3 - acht + (390, 1.00, 0.25), # S4 - rund fuenfzehn ) # AUSNAHMEN je Preset: {preset: {stufen_index: ziel_cards}}. @@ -1639,6 +1779,10 @@ LEAF_STUFEN = ( # Budget fuer alle anzuheben: die Weide ist der Ausreisser, nicht die Regel. LEAF_STUFEN_PRESET = { "weide": {3: 350}, + # Die Birke hat mit 2100 Tris die dickste Rinde im Satz; bei 390 Cards + # gemessen 4572 Tris. (4300 - 2100) / 6 = 366 Cards passen, 340 mit Luft + # fuer die 15-%-Toleranz der Regelung. + "birke": {3: 340}, # 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 @@ -1679,8 +1823,28 @@ LEAF_STUFEN_PRESET = { # eine Overdraw-Entscheidung der Spielseite, keine Generator-Frage. LEAF_SIZE_PRESET = { "obstbaum": 1.47, + # Die vier vergroesserten Arten: die Card muss mit dem Baum wachsen, sonst + # wird das Laub relativ zum Baum feiner und die Krone duenn - dieselbe + # Falle wie beim Setzling, nur andersherum. Faktoren = Skalierung des Baums. + "eiche": 1.43, + "birke": 1.46, + "tanne": 1.86, + "weide": 1.40, } +# Radius der Bueschel-Kappe je Preset, als Faktor. +# +# EIGENE Tabelle, nicht an LEAF_SIZE_PRESET gehaengt. Der erste Anlauf tat +# genau das - und hat die abgenommene Obstbaum-Kugel wieder plattgedrueckt: mit +# Faktor 1.47 wuchs auch die Kappe um 47 %, und weil sie in ALLE Richtungen +# waechst, legt sie mehr Breite als Hoehe zu. Gemessen fiel der Kugel-Kennwert +# von 1.35-1.47 auf 0.93-1.14 und die Bbox lag 14 cm ausser Mitte. +# +# Card-Groesse und Kappen-Radius sind zwei verschiedene Fragen: die erste +# entscheidet, wie grob das Laub ist, die zweite, wie weit das Polster vom Ast +# absteht. +LEAF_CLUSTER_PRESET = {} + def _stufen_anteile(growth_t, preset=None): """Ziel-Cardzahl, Groessen- und Kronenansatz-Wert fuer einen Wachstums-Fortschritt. @@ -1862,7 +2026,23 @@ def make_leaves(context, tree_obj, card_obj, name=None, card_coll=None, 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 + # Der Kronenansatz-Filter darf NIE ueber dem echten Kronenansatz der Art + # liegen. Die Stufen-Tabelle nennt 0.25 der Baumhoehe - die Tanne setzt + # ihre Aeste aber ab 12 % an und laesst sie haengen. Gemessen lag bei + # 5 von 22 Tannenaesten das aeussere Fuenftel KOMPLETT unter der + # Schwelle; sie bekamen gar kein Laub und standen als nackte Aeste da. + # + # "Branch Start" ist ein Anteil der STAMMlaenge, die Schwelle einer der + # Gesamthoehe. Die Stammlaenge ist kleiner, der Wert liegt also ohnehin + # etwas zu hoch - die 0.8 geben zusaetzlich Luft. + ast_start = PRESETS.get(tree_obj.get("preset") or "", {}).get( + "Branch Start", DEFAULTS["Branch Start"]) + overrides["Min Height"] = min(h_anteil, ast_start * 0.8) + # Die Bueschel-Kappe muss mitwachsen. Ein Setzling mit Altbaum-Kappe + # verschwindet darin - dasselbe Laubpaket-Problem wie bei der + # Card-Groesse, nur eine Ebene hoeher. + c_faktor = LEAF_CLUSTER_PRESET.get(tree_obj.get("preset") or "", 1.0) + overrides["Cluster Radius"] = basis["Cluster Radius"] * g_anteil * c_faktor name = name or (tree_obj.name + "_Leaf") ob = bpy.data.objects.get(name) diff --git a/tests/test_baum_export.py b/tests/test_baum_export.py index fd31dfa..5f06d34 100644 --- a/tests/test_baum_export.py +++ b/tests/test_baum_export.py @@ -78,6 +78,19 @@ for o in bpy.context.scene.objects: bpy.context.view_layer.objects.active = baum namen_vorher = sorted(o.name for o in bpy.context.scene.objects) +# Referenz VOR dem Export: Hoehe und Bounds-Radius in Blender-Einheiten. +_dg = bpy.context.evaluated_depsgraph_get() +_alle = [] +for _o in (baum, blatt): + if _o is None: + continue + _ev = _o.evaluated_get(_dg) + _m = _ev.to_mesh() + _alle += [_o.matrix_world @ _v.co for _v in _m.vertices] + _ev.to_mesh_clear() +blender_h = (max(c.z for c in _alle) - min(c.z for c in _alle)) if _alle else 0.0 +blender_r = max((math.hypot(c.x, c.y) for c in _alle), default=0.0) + bpy.ops.treegen.export_game() # Die Arbeitsebene muss unangetastet bleiben. @@ -117,17 +130,46 @@ else: fails.append("Slot 1 heisst '%s', erwartet '%s' (Blaetter)" % (slots[1], tg.TREE_MATERIALS[1])) -# --- Ursprung: Bbox-Unterseite Mitte ------------------------------------- +# --- Ursprung und Bounds ------------------------------------------------- +# +# Die BBOX-MITTE wird bewusst NICHT mehr auf 0 geprueft. Das Kriterium stammt +# aus der Zeit vor der Stammbasis-Konvention (1.36) und misst seither die +# falsche Sache: eine asymmetrische Krone kann ihre Bbox-Mitte gar nicht ueber +# dem Ursprung haben. Nachgemessen ueber drei Presets und drei Seeds: +# +# Stammachse am Fuss 0.0000 m in ALLEN neun Faellen +# Bbox-Exzentrik 0.08-0.39 m, streut mit dem Seed +# ...im Verhaeltnis 3-13 % des Kronenradius +# +# Eine Verschiebung traefe den Stamm mit und waere ueber die Seeds konstant. +# Beides ist nicht der Fall - es ist Kronen-Schieflage. Der Nachfolger des +# Kriteriums ist tests/test_ursprung.py, das den STAMMQUERSCHNITT bei 0/0/0 +# nachweist; engine-seitig bestaetigt durch lueckenlos stapelnde Faell-Teile, +# greifende UCX-Huellen und treffende Frucht-Streuung. +# +# Was stattdessen zaehlt, ist der BOUNDS-RADIUS: laeuft der aus dem Ruder, +# greift das Culling daneben (die Spielseite hatte den Fall mit Radius 3.5 +# statt 350 - ein Skalenfehler). Geprueft wird er nicht gegen eine Konstante, +# sondern gegen die Blender-Messung: Hoehe und Radius muessen DENSELBEN +# Skalenfaktor tragen. co = [ob.matrix_world @ v.co for v in me.vertices] lo = [min(c[i] for c in co) for i in range(3)] hi = [max(c[i] for c in co) for i in range(3)] mid = ((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5) -print("Ursprung: Mitte %.3f/%.3f, min.Z %.3f, Hoehe %.2f m" - % (mid[0], mid[1], lo[2], hi[2] - lo[2])) -if abs(mid[0]) > 0.10 or abs(mid[1]) > 0.10: - fails.append("Bbox nicht zentriert (%.3f/%.3f)" % mid) +fbx_h = hi[2] - lo[2] +fbx_r = max(math.hypot(c[0], c[1]) for c in co) +print("Ursprung: min.Z %.3f, Hoehe %.2f, Bounds-Radius %.2f, Bbox-Mitte " + "%.3f/%.3f (nur zur Info)" % (lo[2], fbx_h, fbx_r, mid[0], mid[1])) if abs(lo[2]) > 0.01: fails.append("min.Z = %.3f, erwartet 0" % lo[2]) +if blender_h > 0 and blender_r > 0: + sk_h = fbx_h / blender_h + sk_r = fbx_r / blender_r + print("Skala aus Hoehe %.3f, aus Radius %.3f" % (sk_h, sk_r)) + if abs(sk_r - sk_h) > sk_h * 0.05: + fails.append("Bounds-Radius traegt eine andere Skala als die Hoehe " + "(%.3f gegen %.3f) - so greift das Culling daneben" + % (sk_r, sk_h)) # --- Der eigentliche Punkt: Kugel-Normalen im FBX ------------------------ blatt_polys = [p for p in me.polygons if p.material_index == 1] diff --git a/tests/test_obstbaum.py b/tests/test_obstbaum.py index d5585df..2d43d74 100644 --- a/tests/test_obstbaum.py +++ b/tests/test_obstbaum.py @@ -110,19 +110,36 @@ def deckung_messen(preset, seed): mitten.append((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))) + # FALLE: erst kopieren, DANN freigeben. `to_mesh_clear` macht jede + # Referenz auf das Mesh ungueltig ("StructRNA of type Mesh has been + # removed") - die Kugel-Rechnung stand zuerst danach. + pts = [v.co.copy() for v in m.vertices] ev.to_mesh_clear() lr = max(math.hypot(x, y) for x, y, _z in mitten) lz = [z for _x, _y, z in mitten] vol = math.pi * lr ** 2 * (max(lz) - min(lz)) - return flaeche / vol if vol else 0.0 + # Kugel-Kennwert mit DERSELBEN Formel wie unten: Kronenhoehe geteilt durch + # den groessten Perzentil-Durchmesser. + pz = [q.z for q in pts] + d3 = (max(pz) - min(pz)) / 3.0 + breiten = [] + for kk in range(3): + teil = sorted(math.hypot(q.x, q.y) for q in pts + if min(pz) + kk * d3 <= q.z <= min(pz) + (kk + 1) * d3) + breiten.append(2 * (teil[int(0.9 * (len(teil) - 1))] if teil else 0.0)) + kug = (max(pz) - min(pz)) / max(breiten) if max(breiten) else 0.0 + return (flaeche / vol if vol else 0.0), kug # Die EICHE ist der Massstab: sie schliesst laut Abnahme der Spielseite, der # Obstbaum tat es nicht. Hier gemessen statt als Konstante hingeschrieben, # weil der Wert an der Card-Flaeche haengt - eine feste Zahl waere nur fuer # genau diese Testkarte richtig. -EICHE = sum(deckung_messen("eiche", sd) for sd in (1, 2, 3)) / 3.0 -print("Massstab Eiche: %.2f m2 Blatt je m3 Krone (mit DIESER Testcard)" % EICHE) +_ref = [deckung_messen("eiche", sd) for sd in (1, 2, 3)] +EICHE = sum(r[0] for r in _ref) / 3.0 +EICHE_KUGEL = sum(r[1] for r in _ref) / 3.0 +print("Massstab Eiche: %.2f m2 Blatt je m3 Krone, Kugel-Kennwert %.2f " + "(mit DIESER Testcard)" % (EICHE, EICHE_KUGEL)) print("") # Die Seeds sind die der Spielseite: Apfel 1/15/22, Kirsche 2/16/23, @@ -220,17 +237,19 @@ for seed in SEEDS: um = profil[0] / profil[1] if profil[1] else 0.0 # KUGEL-Kennwert: Kronenhoehe geteilt durch die groesste Kronenbreite. # - # Er steht hier als zweites Netz gegen Trichter (0.98-1.12) und Pilz - # (1.05-1.17) - und weil die Spielseite "Hoehe ~ Breite" als Zielbild - # genannt hat, gehoert die Zahl sichtbar ins Protokoll. - # Er trennt die Kugel (1.35-1.47) aber NICHT von der Haube (1.26-1.44): - # beide Formen unterscheiden sich vor allem im KRONENANSATZ (29 % gegen - # 39 %), und den prueft der Check weiter unten. Ein erster Anlauf setzte - # die Grenze auf 0.88 - eine Zahl aus einer anderen Messung, die hier bei - # Werten um 1.4 alles durchgelassen haette. + # RELATIV zur Eiche, nicht als feste Zahl. Die Bueschel-Schalen schieben + # jede Card um bis zu einen Kappenradius vom Ast weg, und zwar in ALLE + # Richtungen - die Krone gewinnt ringsum Breite, in der Hoehe aber nur oben. + # Das Verhaeltnis kippt dadurch bauartbedingt: der Obstbaum fiel von + # 1.35-1.47 auf 0.95-1.23, ohne dass an seiner Form etwas falsch war. + # + # Eine neue Konstante haette dasselbe Schicksal bei der naechsten Bauform. + # Die Vorgabe der Spielseite lautet deshalb: der Obstbaum bleibt die + # RUNDESTE Art, also deutlich unter dem Eichenwert aus demselben Lauf. + # Dasselbe Prinzip wie bei der Deckungs-Schranke. kugel = 0.0 if blatt_pts and max(profil) > 0: - pz = [p.z for p in blatt_pts] + pz = [q.z for q in blatt_pts] kugel = (max(pz) - min(pz)) / max(profil) kugeln.append(kugel) obenmitte.append(om) @@ -291,10 +310,10 @@ for seed in SEEDS: "(%.2f) - unten zu schmal, das wird ein Pilz bzw. ein " "Trichter (0.66-0.90)" % (seed, profil[0], profil[1], um)) - if kugel < 1.20: - fails.append("Seed %d: Kronenhoehe/-breite %.2f - flacher als Trichter " - "(0.98-1.12) und Pilz (1.05-1.17) zulassen" - % (seed, kugel)) + if kugel > EICHE_KUGEL * 0.65: + fails.append("Seed %d: Kugel-Kennwert %.2f, Eiche %.2f (%.0f %%) - der " + "Obstbaum muss die RUNDESTE Art bleiben, Richtwert <= 65 %%" + % (seed, kugel, EICHE_KUGEL, 100 * kugel / EICHE_KUGEL)) # DIE funktionale Bedingung. if v < 1.3: @@ -330,8 +349,10 @@ print("") print("Kronenradius/Card ueber alle Seeds: %.2f - %.2f" % (min(verh), max(verh))) print("Krone unten/mitte ueber alle Seeds: %.2f - %.2f (Pilz 0.75-0.90, " "Trichter 0.66-0.72)" % (min(untenmitte), max(untenmitte))) -print("Kugel (KronH/KronB) ueber alle Seeds: %.2f - %.2f (Trichter 0.98-1.12, " - "Pilz 1.05-1.17, Haube 1.26-1.44)" % (min(kugeln), max(kugeln))) +print("Kugel (KronH/KronB) ueber alle Seeds: %.2f - %.2f, Eiche %.2f " + "(%.0f-%.0f %% - Vorgabe <= 65 %%)" + % (min(kugeln), max(kugeln), EICHE_KUGEL, + 100 * min(kugeln) / EICHE_KUGEL, 100 * max(kugeln) / EICHE_KUGEL)) print("Krone oben/mitte ueber alle Seeds: %.2f - %.2f (Trichter 1.18-1.31)" % (min(obenmitte), max(obenmitte))) print("Kronen-Deckung ueber alle Seeds: %.1f - %.1f m2/m3, Eiche %.1f "