"""Obstbaum: Kronenform, Deckung und ein Kronenradius-Boden. ZUR HERKUNFT DES RADIUS-KRITERIUMS, damit es nicht als falsche Zusicherung weiterlebt (Stand 2026-08-06, von der Spielseite klargestellt): Urspruenglich war "Kronenradius >= 1.80 m" eine TECHNISCHE Voraussetzung. Die Frucht-Streuung schob die Fruechte mit einem festen Radial-Schub (0.24 m) ueber die Card-Schale hinaus; bei zu kleiner Krone haette dieser feste Betrag sie nach draussen geschoben. Am generischen "baum" lag der Kronenradius bei 0.78 m gegen 1.28 m Card-Diagonale - die Cards waren groesser als die Krone. Diese Kopplung ist WEG. Seit dem Umbau auf die Bueschel-Schalen arbeitet die Streuung mit einer Huellen-Tabelle (16 Winkel-Sektoren x 6 Hoehenbaender) und setzt die Fruechte auf 94 % der LOKALEN Huelle - vollstaendig relativ, ohne absolute Distanz, damit unabhaengig von der Kronengroesse. Das Kriterium bleibt trotzdem, aber als LOOK- UND GAMEPLAY-BODEN: unter einem Obstbaum soll man ernten koennen. Und als stabile Groesse - max ueber die RINDE ist von den Schalen unberuehrt, waehrend jedes Blatt-Mass mit der Bauform wandert. Auf ein Blatt-Perzentil umzustellen wuerde die Zahl still umdeuten und zwei Kalibrier-Runden neu aufmachen, ohne etwas zu gewinnen. Was die Streuung tatsaechlich braucht, ist Laub in ausreichend vielen Winkel-Sektoren - eine einseitige Krone wuerde die Fruechte verklumpen. Das ist derzeit ueberall erfuellt (Kronen-Exzentrik 3-13 % des Radius) und deshalb hier nicht geprueft. 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))) # FALLE: erst kopieren, DANN freigeben. `to_mesh_clear` macht jede # Referenz auf das Mesh ungueltig ("StructRNA of type Mesh has been # removed") - die Kugel-Rechnung stand zuerst danach. pts = [v.co.copy() for v in m.vertices] ev.to_mesh_clear() # 90. PERZENTIL statt Maximum, wie beim Breitenprofil. Von der Spielseite # gemeldet und hier nachgemessen: seit den Bueschel-Schalen streut der # Kronenradius viel breiter (Obstbaum p50 1.35 / p90 2.12 / max 3.17), und # das Maximum haengt an einer einzelnen weit aussen sitzenden Card. Mit max # faellt die Deckung 2.3-mal pessimistischer aus - und weil das Verhaeltnis # max/p90 je Art verschieden ist (Tanne 1.57, Eiche 1.40), verzerrt es auch # den VERGLEICH gegen die Eiche, den dieser Test fuehrt. lr = sorted(math.hypot(x, y) for x, y, _z in mitten) lr = lr[min(len(lr) - 1, int(0.9 * (len(lr) - 1)))] lz = [z for _x, _y, z in mitten] vol = math.pi * lr ** 2 * (max(lz) - min(lz)) # Kugel-Kennwert mit DERSELBEN Formel wie unten: Kronenhoehe geteilt durch # den groessten Perzentil-Durchmesser. pz = [q.z for q in pts] d3 = (max(pz) - min(pz)) / 3.0 breiten = [] for kk in range(3): teil = sorted(math.hypot(q.x, q.y) for q in pts if min(pz) + kk * d3 <= q.z <= min(pz) + (kk + 1) * d3) breiten.append(2 * (teil[int(0.9 * (len(teil) - 1))] if teil else 0.0)) kug = (max(pz) - min(pz)) / max(breiten) if max(breiten) else 0.0 return (flaeche / vol if vol else 0.0), kug # 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. _ref = [deckung_messen("eiche", sd) for sd in (1, 2, 3)] EICHE = sum(r[0] for r in _ref) / 3.0 EICHE_KUGEL = sum(r[1] for r in _ref) / 3.0 print("Massstab Eiche: %.2f m2 Blatt je m3 Krone, Kugel-Kennwert %.2f " "(mit DIESER Testcard)" % (EICHE, EICHE_KUGEL)) 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: # p90 wie oben - dieselbe Formel wie beim Massstab Eiche. lr = sorted(math.hypot(x, y) for x, y, _z in mitten) lr = lr[min(len(lr) - 1, int(0.9 * (len(lr) - 1)))] 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. # # RELATIV zur Eiche, nicht als feste Zahl. Die Bueschel-Schalen schieben # jede Card um bis zu einen Kappenradius vom Ast weg, und zwar in ALLE # Richtungen - die Krone gewinnt ringsum Breite, in der Hoehe aber nur oben. # Das Verhaeltnis kippt dadurch bauartbedingt: der Obstbaum fiel von # 1.35-1.47 auf 0.95-1.23, ohne dass an seiner Form etwas falsch war. # # Eine neue Konstante haette dasselbe Schicksal bei der naechsten Bauform. # Die Vorgabe der Spielseite lautet deshalb: der Obstbaum bleibt die # RUNDESTE Art, also deutlich unter dem Eichenwert aus demselben Lauf. # Dasselbe Prinzip wie bei der Deckungs-Schranke. kugel = 0.0 if blatt_pts and max(profil) > 0: pz = [q.z for q 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 > EICHE_KUGEL * 0.65: fails.append("Seed %d: Kugel-Kennwert %.2f, Eiche %.2f (%.0f %%) - der " "Obstbaum muss die RUNDESTE Art bleiben, Richtwert <= 65 %%" % (seed, kugel, EICHE_KUGEL, 100 * kugel / EICHE_KUGEL)) # Kronenradius gegen Card-Groesse. Historisch die funktionale Bedingung der # Frucht-Streuung, heute ein Plausibilitaets-Boden - siehe Dateikopf. 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. # --- Der HOCHSTAMM muss auf JEDER Wachstums-Stufe stehen ----------------- # # Bis 1.46 war nur S4 kalibriert. Im Spiel ist der User auf einen S3 gestossen, # der sich als duenne Astgabel mit Laubklumpen las statt als Obstbaeumchen - # gemeldet aus dem Welt-Test. Gemessen lag der Kronenansatz bei 10 / 16 / 22 / # 29 % ueber die Stufen: den freien Stamm gab es nur ganz am Ende. # # Ursache war der allgemeine Setzlings-Faktor auf "Branch Start", der die # Selbstbeschneidung eines WILDEN Baums abbildet. Ein Hochstamm wird aber # gezogen: die Baumschule veredelt hoch und nimmt die unteren Triebe weg, der # freie Stamm ist von Anfang an da. Deshalb GROWTH_OVERRIDES. print("") print("%-6s %7s %7s %7s" % ("Stufe", "Hoehe", "Cards", "Ansatz")) 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 = "Stufen" s.variant = 1 s.seed = 1 s.use_growth = True s.stages = 4 bpy.ops.object.treegen_create() _st = 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 _st) bpy.context.view_layer.objects.active = _st[0] bpy.ops.object.treegen_leaves() for _k, _x in enumerate(_st, 1): _dg = bpy.context.evaluated_depsgraph_get() _ev = _x.evaluated_get(_dg) _m = _ev.to_mesh() _vs = [v.co.copy() for v in _m.vertices] _a = _m.attributes.get("ast_id") _aid = [_a.data[i].value for i in range(len(_vs))] if _a else [0] * len(_vs) _ev.to_mesh_clear() _z0 = min(v.z for v in _vs) _h = max(v.z for v in _vs) - _z0 _astz = [v.z for v, i in zip(_vs, _aid) if i > 0] _an = 100.0 * (min(_astz) - _z0) / _h if _astz else 0.0 _bl = bpy.data.objects.get(_x.name + "_Leaf") _n = 0 if _bl is not None: _ev = _bl.evaluated_get(_dg) _m2 = _ev.to_mesh() _n = len(_m2.polygons) // 2 _ev.to_mesh_clear() print("%-6s %7.2f %7d %6.0f%%" % ("S%d" % _k, _h, _n, _an)) if not (23.0 <= _an <= 35.0): fails.append("S%d: Kronenansatz %.0f %% - der Hochstamm muss auf JEDER " "Stufe stehen, nicht nur bei S4 (Vorgabe ~29 %%)" % (_k, _an)) if _n == 0: fails.append("S%d: keine Cards" % _k) 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, Eiche %.2f " "(%.0f-%.0f %% - Vorgabe <= 65 %%)" % (min(kugeln), max(kugeln), EICHE_KUGEL, 100 * min(kugeln) / EICHE_KUGEL, 100 * max(kugeln) / EICHE_KUGEL)) 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")