Files
stylized-rock-generator/tests/test_obstbaum.py
T
D4rkst3randClaude Opus 4.8 263b8507d8 Tree Gen 1.41.0: Obstbaum-Kugelkrone (Lollipop) ueber den Kronenansatz
Zielbild der Spielseite ist der Lollipop der stilisierten Vektor-Illustration.
Der Weg dorthin fuehrt NICHT ueber mehr Baumhoehe (4.9 m sind die
Ernte-Reichweite), sondern ueber einen tieferen Kronenansatz: 39 % -> 29 %,
Stamm 1.9 -> 1.5 m. Die Krone wird dadurch von selbst hoeher (3.8 -> 4.3 m).
Dazu Crown Taper 0.15 -> 0.35, Crown Bulge 1.1 -> 1.5, Branch Droop 0.4 -> 0.25.

Gemessen ueber alle neun Produktions-Seeds: Kronenansatz 29 %, unten/mitte
0.86-1.01, oben/mitte 0.63-0.74, Kugel 1.35-1.47, Kronenradius 2.00-2.15 m,
Deckung 81-103 % der Eiche, Hoehe 4.90 m.

Das Messen war schwieriger als das Einstellen; zwei eigene Fehler, beide vor
der Abnahme gefunden:

1) Die Kronenbreite je Drittel als MAXIMUM ueber die Card-Punkte zu messen
   haengt an EINER Card. Unten/mitte sprang je Seed zwischen 0.89 und 1.00 bei
   stabilem Mittelwert 0.94 - Rauschen, keine Form. Mit dem 90. PERZENTIL wurde
   es stabil und zeigte sofort etwas anderes: die Krone war unten tatsaechlich
   am breitesten (1.04), was das Maximum verdeckt hatte.
2) Der Kugel-Kennwert bekam erst die Schranke 0.88 - eine Zahl aus einem
   Messskript mit anderer Breitendefinition. Mit der Test-Formel liegen die
   Werte um 1.4, die Schranke haette alles durchgelassen. Zum zweiten Mal
   dieselbe Falle nach der Deckungs-Schwelle.

Deshalb sind jetzt ALLE Referenzformen mit demselben Mass gemessen und stehen
als Tabelle im Test (Eiche, Trichter, Haube, Pilz, Kugel). Die Tabelle zeigt
auch, was NICHT trennt: der Kugel-Kennwert unterscheidet Kugel und Haube nicht
(1.35-1.47 gegen 1.26-1.44) - das tut der Kronenansatz, und der wird geprueft.
Die Kugel-Schranke bleibt als zweites Netz gegen Trichter und Pilz drin, mit
genau diesem Vermerk.

Das Wunschband der Spielseite (u/m 0.85-0.95) ist schmaler als die Streuung von
Seed zu Seed (0.86-1.01). Die Schranken kommen deshalb aus der Messung aller
Formen, nicht aus dem Band.

Gegenproben: Haube 9 Fehler (Kronenansatz), Trichter 37 (alle vier Kriterien).

26 Tests bestehen.

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

348 lines
15 KiB
Python

"""Obstbaum: Krone gross genug fuer Fruechte auf der Aussenhaut.
Die Frucht-Streuung der Spielseite setzt Fruechte auf die AUSSENHAUT der Krone
und schiebt sie radial nach aussen. Das setzt eine Krone voraus, die groesser
ist als eine einzelne Blatt-Card - sonst gibt es keine Haut, auf die sich etwas
setzen laesst. Am generischen "baum" war der Kronenradius 0.78 m bei 1.28 m
Card-Diagonale: die Cards waren groesser als die Krone.
Deshalb ist das hier KEIN Silhouetten-Test mit Geschmacksurteil, sondern eine
funktionale Bedingung: Kronenradius > Card-Groesse.
Vorgaben der Spielseite, die mitgeprueft werden:
Kronenansatz ~30 %, Kronenradius S4 >= 1.8 m, Hoehe 4-5 m,
Breitenprofil + Kugel-Kennwert (Lollipop: Kronenhoehe ~ Kronenbreite),
Deckung gegen die Eiche.
B/H gilt fuer dieses Preset NICHT - siehe Kommentar unten.
Aufruf: blender --background --factory-startup --python tests/test_obstbaum.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
s.apply_modifier = True
s.leaf_card = card
if "obstbaum" not in tg.PRESETS:
print("ERGEBNIS: FEHLER: Preset 'obstbaum' fehlt")
sys.exit(1)
def inseln(m):
nachbarn = {}
for e in m.edges:
a, b = e.vertices
nachbarn.setdefault(a, []).append(b)
nachbarn.setdefault(b, []).append(a)
gesehen, out = set(), []
for start in range(len(m.vertices)):
if start in gesehen:
continue
stapel, gruppe = [start], []
gesehen.add(start)
while stapel:
v = stapel.pop()
gruppe.append(v)
for w in nachbarn.get(v, ()):
if w not in gesehen:
gesehen.add(w)
stapel.append(w)
out.append(gruppe)
return out
def deckung_messen(preset, seed):
"""Blattflaeche je m3 Kronenzylinder - fuer beliebiges Preset.
Der Wert haengt an der verwendeten Card (Flaeche!), taugt also NICHT als
absolute Schwelle. Verglichen wird deshalb gegen die Eiche, die in
demselben Lauf mit DERSELBEN Card gemessen wird.
"""
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 = "Ref"
s.variant = 1
s.seed = seed
s.use_growth = False
bpy.ops.object.treegen_create()
baum = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and o is not card
and not o.name.endswith(("_Leaf", "_Frucht"))][0]
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")
dg = bpy.context.evaluated_depsgraph_get()
ev = bl.evaluated_get(dg)
m = ev.to_mesh()
flaeche = sum(p.area for p in m.polygons)
mitten = []
for grp in inseln(m):
pts = [m.vertices[i].co for i in grp]
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)))
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
# 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)
print("")
# Die Seeds sind die der Spielseite: Apfel 1/15/22, Kirsche 2/16/23,
# Birne 3/17/24. Ein Preset, das nur bei einem Seed passt, ist keines.
SEEDS = (1, 15, 22, 2, 16, 23, 3, 17, 24)
print("%-6s %7s %6s %9s %9s %7s %8s %-15s %s"
% ("Seed", "Hoehe", "B/H", "KronR", "KronR/Cd", "Ansatz", "m2/m3",
"Profil u/m/o", "o/m"))
verh = []
deckungen = []
obenmitte = []
untenmitte = []
kugeln = []
for seed in SEEDS:
for o in list(bpy.context.scene.objects):
if o is not card:
bpy.data.objects.remove(o, do_unlink=True)
s.preset = "obstbaum"
s.art_name = "Obst"
s.variant = 1
s.seed = seed
s.use_growth = False
bpy.ops.object.treegen_create()
baum = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and o is not card
and not o.name.endswith(("_Leaf", "_Frucht"))][0]
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")
dg = bpy.context.evaluated_depsgraph_get()
ev = baum.evaluated_get(dg)
m = ev.to_mesh()
vs = [v.co.copy() for v in m.vertices]
attr = m.attributes.get("ast_id")
aid = [attr.data[i].value for i in range(len(vs))] if attr else [0] * len(vs)
ev.to_mesh_clear()
z0 = min(v.z for v in vs)
hoehe = max(v.z for v in vs) - z0
kron_r = max(math.hypot(v.x, v.y) for v in vs)
astz = [v.z for v, i in zip(vs, aid) if i > 0]
ansatz = 100.0 * (min(astz) - z0) / hoehe if astz else 0.0
card_max = 0.0
blatt_flaeche = 0.0
kron_zyl = 0.0
if bl is not None:
ev = bl.evaluated_get(dg)
m2 = ev.to_mesh()
blatt_flaeche = sum(p.area for p in m2.polygons)
blatt_pts = [v.co.copy() for v in m2.vertices]
mitten = []
for grp in inseln(m2):
pts = [m2.vertices[i].co for i in grp]
card_max = max(card_max,
max((a - b).length for a in pts for b in pts))
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)))
ev.to_mesh_clear()
if mitten:
lr = max(math.hypot(x, y) for x, y, _z in mitten)
lz = [z for _x, _y, z in mitten]
kron_zyl = math.pi * lr ** 2 * (max(lz) - min(lz))
# BREITENPROFIL der Krone in Dritteln.
#
# Ohne diesen Check kann die Krone alle bisherigen Zahlen erfuellen und
# trotzdem falsch aussehen: der erste Obstbaum war ein TRICHTER - nach oben
# breiter, unten hohl (gemessen oben/mitte 1.07 gegen 0.90 der Eiche). Die
# Laubmasse sass an den top-aussen endenden Astspitzen. Kronenradius,
# Deckung und Tri-Zahl waren dabei alle in Ordnung.
profil = [0.0, 0.0, 0.0]
if blatt_pts:
pz = [p.z for p in blatt_pts]
pz0, pz1 = min(pz), max(pz)
drittel = (pz1 - pz0) / 3.0
for kk in range(3):
teil = [math.hypot(p.x, p.y) for p in blatt_pts
if pz0 + kk * drittel <= p.z <= pz0 + (kk + 1) * drittel]
# 90. PERZENTIL statt Maximum. Das Maximum haengt an EINER Card:
# bei ~400 Cards von 1.25 m Diagonale reicht eine einzige weit
# aussen haengende, um die Breite eines Drittels zu setzen.
# Gemessen sprang unten/mitte dadurch je Seed zwischen 0.89 und
# 1.00, waehrend der Mittelwert stabil bei 0.94 lag - das war
# Rauschen, keine Formabweichung. Das Perzentil misst, wo die
# Laubmasse endet, nicht wo die letzte Card haengt.
teil.sort()
profil[kk] = 2 * (teil[int(0.9 * (len(teil) - 1))] if teil else 0.0)
om = profil[2] / profil[1] if profil[1] else 0.0
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.
kugel = 0.0
if blatt_pts and max(profil) > 0:
pz = [p.z for p in blatt_pts]
kugel = (max(pz) - min(pz)) / max(profil)
kugeln.append(kugel)
obenmitte.append(om)
untenmitte.append(um)
v = kron_r / card_max if card_max else 0.0
verh.append(v)
deckung = blatt_flaeche / kron_zyl if kron_zyl else 0.0
deckungen.append(deckung)
print("%-6d %7.2f %6.2f %9.2f %9.2f %6.0f%% %8.1f %.1f/%.1f/%.1f %.2f"
% (seed, hoehe, 2 * kron_r / hoehe, kron_r, v, ansatz, deckung,
profil[0], profil[1], profil[2], om))
# Kronen-DECKUNG: Blattflaeche je m3 Kronenvolumen.
#
# Die Card-ZAHL sagt das nicht: 400 Cards koennen eine geschlossene Haube
# sein oder fuenf belaubte Schlaeuche mit Himmel dazwischen ("behaarte
# Finger", von der Spielseite abgenommen). Was es sagt, ist die Flaeche pro
# Volumen - und die trennt die beiden Faelle sauber:
# Eiche (schliesst) 465 m2 / 18.9 m3 = 24.6
# Obstbaum vorher (offen) 414 m2 / 36.2 m3 = 11.4
# Die Obstbaum-Krone hat fast das doppelte Volumen; bei gleicher Card-Zahl
# blieb die halbe Deckung uebrig.
#
# Verglichen wird gegen die im selben Lauf gemessene Eiche, NICHT gegen
# eine feste Zahl: die Deckung haengt an der Card-Flaeche, und eine
# 6-Tri-Buescheldkarte bringt das Dreifache einer flachen Raute. Eine
# hart notierte 20 waere nur fuer genau eine Testcard richtig gewesen -
# der erste Anlauf dieses Checks ist genau daran gescheitert.
# 60 % der Eiche: der Obstbaum liegt bauartbedingt darunter (weitere
# Krone bei gleichem Budget), lag vorher aber bei 46 %.
if deckung < EICHE * 0.60:
fails.append("Seed %d: nur %.1f m2 Blatt je m3 Krone, Eiche %.1f "
"(%.0f %%) - die Krone liest sich als einzelne "
"Ast-Schlaeuche" % (seed, deckung, EICHE,
100 * deckung / EICHE))
# Die Krone muss sich nach OBEN schliessen. Die Eiche, die laut Abnahme als
# Baum liest, kommt auf 0.90; der Trichter lag bei 1.07. 0.85 als Grenze
# laesst Spielraum nach oben, schliesst den Trichter aber sicher aus.
# Die Grenzen kommen aus einer Messung ALLER Formen mit DIESEM Mass, nicht
# aus dem Wunschband - die Streuung von Seed zu Seed ist breiter als das
# Band, das die Spielseite genannt hat (u/m 0.85-0.95). Gemessen ueber neun
# Seeds:
# Eiche u/m 1.04-1.15 o/m 0.77-0.88
# Trichter u/m 0.66-0.72 o/m 1.18-1.31
# Haube u/m 0.99-1.13 o/m 0.69-0.80
# Pilz u/m 0.75-0.90 o/m 0.78-0.94
# Kugel u/m 0.86-1.01 o/m 0.63-0.74
# Die Schranken trennen die Kugel von Trichter und Pilz; von der Haube
# trennt sie der Kugel-Kennwert weiter unten.
if om > 0.80:
fails.append("Seed %d: Krone oben %.1f m breit, in der Mitte %.1f "
"(%.2f) - sie schliesst sich oben nicht (Trichter lag bei "
"1.18-1.31)" % (seed, profil[2], profil[1], om))
if um < 0.82:
fails.append("Seed %d: Krone unten %.1f m breit, in der Mitte %.1f "
"(%.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))
# DIE funktionale Bedingung.
if v < 1.3:
fails.append("Seed %d: Kronenradius %.2f m bei %.2f m Card - die Krone "
"hat keine Aussenhaut fuer Fruechte" % (seed, kron_r, card_max))
if kron_r < 1.80:
fails.append("Seed %d: Kronenradius %.2f m, Vorgabe >= 1.80"
% (seed, kron_r))
if not (4.0 <= hoehe <= 5.2):
fails.append("Seed %d: Hoehe %.2f m, Vorgabe 4-5 m (5.2 als Toleranz)"
% (seed, hoehe))
if not (25.0 <= ansatz <= 35.0):
fails.append("Seed %d: Kronenansatz %.0f %%, Vorgabe ~30 %% "
"(25-35 als Toleranz)" % (seed, ansatz))
# B/H gilt fuer dieses Preset NICHT MEHR - Entscheidung der Spielseite,
# 2026-08-06. Es wird nur noch mitgedruckt.
#
# Vorgeschichte: das urspruengliche Band war 0.55-0.75, zusammen mit
# Kronenradius >= 1.80 m bei 4-5 m Hoehe. Beides zusammen laesst bei H = 5.0 nur
# B zwischen 3.60 und 3.75 zu - ein Fenster von 4 %, waehrend die Streuung von
# Seed zu Seed rund 12 % betraegt. Zweimal ist die Abwaegung deshalb zugunsten
# des Kronenradius ausgefallen (er traegt die Frucht-Streuung); beim zweiten Mal
# hat die Spielseite daraus eine Regel gemacht:
#
# Kronenradius >= 1.80 schlaegt B/H <= 0.75.
# Die gemessenen 0.77-0.82 sind der Preis der Frucht-Tragflaeche.
#
# Die Form sichert ohnehin das BREITENPROFIL schaerfer ab, als B/H es je
# konnte: B/H haette sowohl den Trichter (oben/mitte 1.07) als auch die
# Pilzform (unten/mitte 1.17) durchgelassen.
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("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 "
"(%.0f-%.0f %%)"
% (min(deckungen), max(deckungen), EICHE,
100 * min(deckungen) / EICHE, 100 * max(deckungen) / EICHE))
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for f in fails:
print(" - " + f)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")