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>
348 lines
15 KiB
Python
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")
|