Files
stylized-rock-generator/tests/test_obstbaum.py
T
D4rkst3randClaude Opus 4.8 0b6f0deff9 Tree Gen 1.40.0: Obstbaum-Krone schliesst sich oben (Trichter behoben)
Der Baum erfuellte Kronenradius, Deckung, Kronenansatz und Tri-Budget - und sah
trotzdem falsch aus. Sichtbar wurde es erst am BREITENPROFIL der Krone in
Dritteln (unten/mitte/oben, Messung der Spielseite nachgestellt):
  Eiche      3.0 / 2.8 / 2.6   oben/mitte 0.90  (liest sich als Baum)
  Obstbaum   3.3 / 4.5 / 4.7   oben/mitte 1.07  (Trichter)

Kein einzelner Regler hat das geloest: Crown Taper (-0.10 bis +0.50), Branch
Droop (0.6-1.2), Crown Bulge (1.0-1.8) und Attraction Up einzeln durchgefahren
liessen oben/mitte bei rund 1.0 stehen. Der Grund ist strukturell: die
Kronenform folgt nicht der Astlaenge je Hoehe, sondern dem WEG DER ASTSPITZEN.
Solange die Aeste aufwaerts schwingen, landet das aeusserste Laub oben.

Loesung ist eine andere Bauform, kein Feintuning - kurze Aeste an einem hohen
Stamm statt langer Aeste an einem kurzen:
  Height 3.8 -> 4.9, Branch Length 2.55 -> 1.25, Branch Start 0.52 -> 0.40,
  Attraction Up +0.40 -> -0.05, Crown Taper -0.30 -> +0.15,
  Crown Bulge 0.60 -> 1.10, Branch Droop 0.35 -> 0.40.

Ueber alle neun Produktions-Seeds: oben/mitte 0.63-0.84, Kronenradius
1.88-2.01 m, Deckung 100-134 % der Eiche, 3752-4166 Tris, Ansatz 39 %.

Beim Einregeln kippte die Form zwischendurch in eine PILZform (unten am
breitesten) - deshalb steht das Profil jetzt als Test da. test_obstbaum prueft
oben/mitte <= 0.85. Gegenprobe mit den 1.39er Werten: neun Fehler bei 0.98-1.11.

B/H liegt jetzt bei 0.77-0.82 (Band der Spielseite 0.55-0.75). Bleibt bewusst
ungeprueft, wie beim Kronenradius entschieden: die Vorgaben Kronenradius >= 1.80
und B/H <= 0.75 sind bei 4-5 m Hoehe nur im Mittel gleichzeitig erfuellbar.

26 Tests bestehen.

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

287 lines
11 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 35-40 %, Kronenradius S4 >= 1.8 m, Hoehe 4-5 m.
B/H ist bewusst NICHT hart geprueft - 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 = []
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 = [p for p in blatt_pts
if pz0 + kk * drittel <= p.z <= pz0 + (kk + 1) * drittel]
profil[kk] = 2 * max((math.hypot(p.x, p.y) for p in teil),
default=0.0)
om = profil[2] / profil[1] if profil[1] else 0.0
obenmitte.append(om)
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.
if om > 0.85:
fails.append("Seed %d: Krone oben %.1f m breit, in der Mitte %.1f "
"(%.2f) - sie wird nach oben breiter statt sich zu "
"schliessen" % (seed, profil[2], profil[1], om))
# 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 (33.0 <= ansatz <= 45.0):
fails.append("Seed %d: Kronenansatz %.0f %%, Vorgabe 35-40 %% "
"(33-45 als Toleranz)" % (seed, ansatz))
# B/H wird BEWUSST nicht hart geprueft.
#
# Die Spielseite nennt 0.55-0.75 UND 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.
# Die Vorgaben sind also nur im Mittel gleichzeitig erfuellbar. Vorrang hat die
# funktionale (Kronenradius), weil an ihr die Frucht-Streuung haengt.
print("")
print("Kronenradius/Card ueber alle Seeds: %.2f - %.2f" % (min(verh), max(verh)))
print("Krone oben/mitte ueber alle Seeds: %.2f - %.2f (Eiche 0.90, "
"Trichter war 1.07)" % (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")