Files
stylized-rock-generator/tests/test_blatt_bueschel.py
T
D4rkst3randClaude Opus 4.8 d02562fb53 Tree Gen 1.33.0: Hoehen-Filter - der Setzling bekommt einen freien Fuss
Befund aus der Turnhalle: die Setzlings-Blaetter verteilen sich ueber die ganze
Stammhoehe, es entsteht eine belaubte Saeule statt einer Krone. Nachgemessen:
44 % der Cards sassen unter 40 % der Baumhoehe, die tiefste bei 6 %.

Der Radius-Filter kann das nicht loesen - er trennt duenn von dick, aber ein
junger Stamm ist UEBERALL duenn. Also ein zweiter Filter ueber die Hoehe: Laub
erst ab dem Kronenansatz, als Anteil der Baumhoehe. Die Schwelle kommt aus der
Bounding Box des Baums, nicht als Meterwert - fest waere sie beim Setzling die
halbe Pflanze und beim Altbaum wirkungslos.

Kronenansatz je Stufe (LEAF_STUFEN): S1 40 %, S2 35 %, S3 30 %, S4 25 %.

               unter Ansatz          tiefste Card
        vorher      nachher      vorher    nachher
  S1      44 %          0 %        6 %       46 %
  S2      32 %          0 %       24 %       36 %
  S3       9 %          0 %       31 %       34 %
  S4       3 %          0 %       34 %       34 %   (unveraendert)

Beim Altbaum ist der Filter praktisch ein Sicherheitsnetz - dort sitzt ohnehin
nichts tief, genau wie gemeldet.

Dichte von S1/S2 nachgezogen: der Filter nimmt dem Setzling sonst die Haelfte
der Cards (9 -> 4), weil dessen Laub vorher ueber die ganze Stammhoehe verteilt
war. Jetzt 11 Cards bei S1, und 486 Tris gegen 800 Budget.

23 Tests bestehen (Suite zweimal durchlaufen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 10:09:04 +02:00

285 lines
12 KiB
Python

"""Blatt-Defaults fuer BUESCHEL-Atlanten (eine Card = ein ganzer Zweigbueschel).
Die alten Werte (Size 0.22, Density 150-500) stammen aus der Blaettchen-Aera:
damit streute der Generator hunderte winziger Cards, wo ein paar Dutzend grosse
hingehoeren. Diese Datei nagelt die neuen Groessenordnungen fest und prueft die
Kopplung an die Wachstums-Stufen.
Aufruf: blender --background --factory-startup --python tests/test_blatt_bueschel.py
"""
import os
import sys
import math
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 = []
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)
s = bpy.context.scene.tree_gen_settings
# --- 1) Auslieferungszustand ---------------------------------------------
print("Defaults: Dichte %.0f, Groesse %.2f, Setzlings-Anteil %.2f"
% (s.leaf_density, s.leaf_size, s.leaf_size_growth))
if not (0.6 <= s.leaf_size <= 1.1):
fails.append("leaf_size ist %.2f - fuer Bueschel-Atlanten werden ~0.85 "
"gebraucht, 0.2 waere ein einzelnes Blaettchen" % s.leaf_size)
# S3/S4 stehen auf 4300, nicht mehr auf 4000: die Spielseite hebt das Budget
# in ASSETS.md an, nachdem der 170er-Export abgenommen wurde. Die Doku folgt dem
# Auge, aber erst nach dem Export - deshalb steht die Zahl hier schon.
if s.leaf_budget_s1 > 800 or s.leaf_budget_s2 > 1200 or s.leaf_budget > 4300:
fails.append("Stufen-Budgets (%d/%d/%d) ueber der Vorgabe (800/1200/4300)"
% (s.leaf_budget_s1, s.leaf_budget_s2, s.leaf_budget))
if not (130.0 <= s.leaf_density <= 200.0):
fails.append("leaf_density ist %.0f - der Sweetspot liegt bei 130-170"
% s.leaf_density)
# Die Deckung steht als Tabelle pro Stufe, nicht als Formel. Card-ANZAHL muss
# ueber die Stufen steigen, Card-Groesse auch.
_d0, _g0, _h0 = tg._stufen_anteile(0.0)
_d1, _g1, _h1 = tg._stufen_anteile(1.0)
if _g0 >= _g1:
fails.append("Groessen-Anteil steigt nicht ueber die Stufen (%.2f -> %.2f)"
% (_g0, _g1))
# Der Volumen-Hebel: bei 0.02 trugen nur die aeussersten Zweigspitzen Laub und
# die Krone war innen nackt.
if s.leaf_max_radius < 0.035:
fails.append("leaf_max_radius ist %.3f - unter 0.035 tragen nur die "
"Zweigspitzen Laub und die Krone bleibt innen nackt"
% s.leaf_max_radius)
if s.leaf_tilt < 0.7:
fails.append("leaf_tilt ist %.2f - unter 0.7 fehlt das Rotations-Chaos, "
"das die Sichtluecken schliesst" % s.leaf_tilt)
if abs(tg.LEAF_DEFAULTS["Size"] - s.leaf_size) > 0.2:
fails.append("LEAF_DEFAULTS['Size'] (%.2f) und die UI (%.2f) laufen "
"auseinander" % (tg.LEAF_DEFAULTS["Size"], s.leaf_size))
def bauen(preset, wachstum=False, stufen=4):
for o in list(bpy.context.scene.objects):
if o is not card:
bpy.data.objects.remove(o, do_unlink=True)
s.preset = preset
s.art_name = preset.capitalize()
s.variant = 1
s.apply_modifier = True
s.use_growth = wachstum
s.stages = stufen
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)
for o in bpy.context.scene.objects:
o.select_set(o in staemme)
bpy.context.view_layer.objects.active = staemme[0]
bpy.ops.object.treegen_leaves()
return staemme
def messen(stamm):
bl = bpy.data.objects.get(stamm.name + "_Leaf")
if bl is None:
return 0, 0.0, 0
dg = bpy.context.evaluated_depsgraph_get()
ev = bl.evaluated_get(dg)
m = ev.to_mesh()
cards = len(m.polygons) // 2
kante = math.sqrt(sum(p.area for p in m.polygons) / cards) if cards else 0.0
ev.to_mesh_clear()
ev2 = stamm.evaluated_get(dg)
m2 = ev2.to_mesh()
tris = sum(len(p.vertices) - 2 for p in m2.polygons) + cards * 2
ev2.to_mesh_clear()
return cards, kante, tris
# --- 2) Alle Presets: Card-Zahl plausibel, Budget gehalten ---------------
print("")
# BUDGET: ASSETS.md nennt 2500 Tris fuer Baum (Stamm + Krone). Mit
# Bueschel-Atlanten liegt LOD0 darueber - das ist eine bewusste Entscheidung der
# Spielseite: der dichtere LOD0 traegt den Look aus der Naehe, und die LOD-Kette
# dampft ihn auf 16 % ein. Die harte Grenze liegt deshalb hier bei 3000; alles
# zwischen 2500 und 3000 wird als Hinweis ausgegeben, damit es nicht unbemerkt
# weiterwaechst.
# Die urspruenglichen 2500 waren eine konservative Setzung. Bei ISM-Instanzen
# mit LOD-Kette sind Roh-Dreiecke nicht der Engpass, sondern der OVERDRAW der
# Cards (die GPU zahlt pro Bildschirmflaeche, nicht pro Dreieck) und die
# Schattenpaesse. Genau deshalb sind 170 grosse Bueschel guenstiger als 500
# kleine - bei gleichem Tri-Zaehler. Die Zahl hier ist die Hartgrenze fuer die
# ausgewachsene Stufe; die endgueltige Freigabe kommt aus einer Messung im
# Level (Testwald, stat unit / stat gpu), nicht aus diesem Test.
LOD0_HART = s.leaf_budget
LOD0_HINWEIS = int(s.leaf_budget * 0.8)
ueber = []
print("%-12s %6s %11s %9s" % ("Preset", "Cards", "Card-Kante", "Tris ges"))
for p in tg.PRESETS:
staemme = bauen(p)
cards, kante, tris = messen(staemme[0])
marke = ""
if tris > LOD0_HART:
marke = "UEBER HARTGRENZE"
elif tris > LOD0_HINWEIS:
marke = "ueber 2500 (LOD0 ok)"
ueber.append((p, tris))
print("%-12s %6d %11.2f %9d %s" % (p, cards, kante, tris, marke))
if tris > LOD0_HART:
fails.append("%s: %d Tris ueber der LOD0-Hartgrenze von %d"
% (p, tris, LOD0_HART))
if p == "kaktus":
continue # kein Ast duenner als Max Radius - richtig so
if cards == 0:
fails.append("%s: keine Cards gestreut" % p)
# Ob es Bueschel oder Blaettchen sind, entscheidet die GROESSE, nicht die
# Anzahl: ein grosser Baum darf 600 Bueschel tragen. Die Menge deckelt das
# Tri-Budget oben ohnehin.
elif kante < 0.30:
fails.append("%s: Card-Kante nur %.2f m - das sind Blaettchen, keine "
"Bueschel" % (p, kante))
if ueber:
print("Hinweis: %s liegen ueber dem Stufenbudget - fuer LOD0 abgesegnet, bei "
"Bedarf ist die Dichte der Feinregler (130-170)."
% ", ".join("%s (%d)" % (p, t) for p, t in ueber))
# --- 3) Wachstum: Bueschel wachsen mit, Setzling ist NICHT kahl ----------
staemme = bauen("baum", wachstum=True, stufen=4)
print("")
print("%-18s %6s %6s %11s" % ("Stufe", "t", "Cards", "Card-Kante"))
kanten, zahlen = [], []
for st in staemme:
cards, kante, _ = messen(st)
gt = float(st.get("growth_t", 1.0))
print("%-18s %6.2f %6d %11.2f" % (st.name, gt, cards, kante))
kanten.append(kante)
zahlen.append(cards)
if cards == 0:
# Genau das war der Fehler: "Dichte" ist Punkte pro m2 Zweigflaeche,
# und ein Setzling hat davon fast keine.
fails.append("%s: keine Cards - ein Setzling darf nicht kahl sein"
% st.name)
for i in range(1, len(kanten)):
if kanten[i] <= kanten[i - 1]:
fails.append("Stufe %d hat Card-Kante %.2f, Stufe %d hatte %.2f - die "
"Bueschel wachsen nicht mit"
% (i + 1, kanten[i], i, kanten[i - 1]))
if kanten and kanten[0] > 0:
anteil = kanten[0] / kanten[-1]
print("Setzlings-Bueschel: %.0f%% der Endgroesse (Regler steht auf %.0f%%)"
% (100 * anteil, 100 * s.leaf_size_growth))
# Der gemessene Anteil darf nicht ueber dem Regler liegen - dann haette die
# Skalierung nicht gegriffen.
if anteil > s.leaf_size_growth + 0.15:
fails.append("Setzlings-Bueschel sind %.0f%% der Endgroesse, der Regler "
"steht auf %.0f%% - die Wachstums-Kopplung greift nicht"
% (100 * anteil, 100 * s.leaf_size_growth))
# --- 3a) Hoehen-Filter: der Fuss bleibt frei -----------------------------
# Der Radius-Filter allein reicht nicht. Er trennt duenn von dick, aber ein
# junger Stamm ist UEBERALL duenn - gemessen sassen beim Setzling 44 % der
# Cards unter 40 % der Hoehe, die tiefste bei 6 %. Das ergibt eine belaubte
# Saeule statt einer Krone. Der Hoehen-Filter setzt den Kronenansatz je Stufe.
print("")
print("%-18s %7s %6s %11s %11s" % ("Stufe", "Hoehe", "Cards", "unter Ansatz",
"tiefste Card"))
for st in staemme:
bl = bpy.data.objects.get(st.name + "_Leaf")
if bl is None:
continue
zs = [v.co.z for v in st.data.vertices]
z0, h = min(zs), max(zs) - min(zs)
gt = float(st.get("growth_t", 1.0))
_, _, ansatz = tg._stufen_anteile(gt)
dg = bpy.context.evaluated_depsgraph_get()
ev = bl.evaluated_get(dg)
m = ev.to_mesh()
# Card-Mitten ueber die Face-Gruppen (die Testkarte hat 2 Faces je Card)
proc = 2
mitten = []
for i in range(0, len(m.polygons), proc):
gruppe = m.polygons[i:i + proc]
mitten.append(sum(p.center.z for p in gruppe) / len(gruppe))
ev.to_mesh_clear()
if not mitten:
continue
unter = 100.0 * sum(1 for z in mitten if (z - z0) / h < ansatz) / len(mitten)
tiefste = 100.0 * (min(mitten) - z0) / h
print("%-18s %7.2f %6d %9.0f%% %10.0f%%"
% (st.name, h, len(mitten), unter, tiefste))
# Ein paar Prozent Toleranz: die Card-Mitte darf leicht unter den Ansatz
# rutschen, weil die Card um ihren Punkt herum sitzt.
if unter > 15.0:
fails.append("%s: %.0f%% der Cards unter dem Kronenansatz (%.0f%% der "
"Hoehe) - der Fuss ist belaubt" % (st.name, unter,
100 * ansatz))
if tiefste < ansatz * 100 * 0.7:
fails.append("%s: tiefste Card bei %.0f%% der Hoehe, Kronenansatz liegt "
"bei %.0f%% - Laub klebt am Stamm"
% (st.name, tiefste, 100 * ansatz))
# Der Ansatz muss beim Setzling HOEHER liegen als beim Altbaum: dessen
# Verteilung passt schon von selbst, der Setzling braucht die Hilfe.
_, _, a_jung = tg._stufen_anteile(0.0)
_, _, a_alt = tg._stufen_anteile(1.0)
print("Kronenansatz: S1 %.0f%%, S4 %.0f%%" % (100 * a_jung, 100 * a_alt))
if a_jung <= a_alt:
fails.append("Kronenansatz beim Setzling (%.2f) nicht hoeher als beim "
"Altbaum (%.2f)" % (a_jung, a_alt))
# --- 3b) Budget je Stufe statt global -----------------------------------
print("")
print("%-18s %6s %8s %8s" % ("Stufe", "t", "Tris", "Budget"))
for st in staemme:
cards, _, tris = messen(st)
gt = float(st.get("growth_t", 1.0))
budget = tg._stufen_budget(s, gt)
print("%-18s %6.2f %8d %8d %s"
% (st.name, gt, tris, budget, "UEBER" if tris > budget else ""))
if tris > budget:
fails.append("%s: %d Tris ueber dem Stufenbudget %d - ein Setzling mit "
"Altbaum-Dichte ist Verschwendung" % (st.name, tris, budget))
# Die Budgets muessen nach oben gehen, sonst ist die Staffelung sinnlos.
if not (s.leaf_budget_s1 < s.leaf_budget_s2 <= s.leaf_budget):
fails.append("Stufen-Budgets steigen nicht: %d / %d / %d"
% (s.leaf_budget_s1, s.leaf_budget_s2, s.leaf_budget))
# --- 4) Ohne Wachstum bleibt alles unveraendert -------------------------
staemme = bauen("baum", wachstum=False)
cards_ohne, kante_ohne, _ = messen(staemme[0])
print("")
print("Ohne Wachstum: %d Cards, Kante %.2f (growth_t=%.2f)"
% (cards_ohne, kante_ohne, float(staemme[0].get("growth_t", 1.0))))
if abs(kante_ohne - kanten[-1]) > 0.02:
fails.append("Ein Baum ohne Wachstums-Stufen (%.2f) sieht anders aus als "
"die letzte Stufe (%.2f)" % (kante_ohne, kanten[-1]))
print("")
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for x in fails:
print(" - " + x)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")