Files
stylized-rock-generator/tests/test_blatt_bueschel.py
T
D4rkst3randClaude Opus 4.8 d170e4067f Tree Gen 1.32.0: Deckung pro Stufe, mitwachsender Radius-Filter, LOD als Teilset
Drei Befunde aus der Turnhalle abgearbeitet.

1) Deckung PRO STUFE statt globaler Formel
   Die alte Rechnung (Dichte durch faktor^2) hielt den Setzling vor der
   Kahlheit, gab ihm aber die Deckung eines Altbaums. Jetzt Tabelle
   LEAF_STUFEN. S4 traegt die volle Dichte 170, S2 liegt 15 % darunter, S1
   deutlich. Der Dichte-Anteil bei S1/S2 sieht hoch aus - "Dichte" ist Punkte
   pro m2 ZWEIGFLAECHE, und die waechst ueber die Stufen dramatisch. Was zaehlt
   ist die Anzahl: 17 / 43 / 67 / 130 Cards, Kante 0.19 / 0.29 / 0.37 / 0.45.
   Budget S3/S4 auf 4300 (die Spielseite hebt ASSETS.md nach dem 170er-Export
   entsprechend an).

2) Radius-Filter skaliert mit dem Wachstum (der S3-Befund)
   Der Filter trennt duenne Zweige von dickem Geaest - aber mit starren 0.04 m
   greift er bei jungen Baeumen nicht: gemessen sassen bei S2 81 % der Cards am
   Stamm, bei S3 58 %. Jetzt 48 bzw. 52 %.

   Dabei eine eigene Fehldeutung korrigiert: bei S1 bleiben ~88 % am Stamm, und
   das ist KEIN Defekt. Nachgemessen ist die zulaessige Flaeche dort fuer jeden
   Schwellwert identisch (0.013 m2) - der Saemling hat kein dickes Geaest, an
   dem sich etwas trennen liesse. Blaetter am Stiel sind bei einem Saemling der
   Normalfall. Ein erster Sweep hatte mich in die Irre gefuehrt, weil ich
   growth_t=0 auf einen AUSGEWACHSENEN Baum gesetzt hatte - der hat die volle
   Zweigflaeche und verhaelt sich voellig anders als ein echter Setzling.

3) LOD-Reduktion als TEILSET der LOD0-Cards
   Bisher wurde je Stufe neu gestreut - die Cards sassen an anderen Stellen und
   die Silhouette sprang beim LOD-Wechsel um. Jetzt wird ausgeduennt: jede
   verbleibende Card liegt genau dort, wo sie in LOD0 schon war, die
   Ueberlebenden werden groesser. Gearbeitet wird auf zusammenhaengenden
   INSELN, nicht auf Faces - eine Card muss ganz bleiben oder ganz verschwinden.
   Auswahl ist seed-stabil.

   Gemessen am reimportierten FBX: 260 / 156 / 78 / 32 Blatt-Faces, und 100 %
   der Cards auf LOD1-3 sitzen auf einer LOD0-Position. Der Versatz zu LOD0 ist
   dabei kleiner geworden als beim Neustreuen.

23 Tests bestehen.

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

230 lines
9.7 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 = tg._stufen_anteile(0.0)
_d1, _g1 = 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))
# --- 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")