Der Obstbaum las sich in der Engine als einzelne belaubte Ast-Schlaeuche mit Himmel dazwischen. Die Card-Zahl erklaert das nicht; die Blattflaeche pro Kronenvolumen tut es: Eiche (schliesst) 444 Cards, Krone 18.9 m3 -> 24.6 m2/m3 Obstbaum vorher 399 Cards, Krone 36.2 m3 -> 11.4 m2/m3 Fast doppeltes Volumen bei gleicher Card-Zahl. Der Vorschlag der Spielseite - mehr und laengere Sub-Aeste als Fuellwerk - wurde durchgemessen und VERWORFEN: Sub Count 5/7, Sub Length 1.0/1.3, Sub Start 0.20/0.30, Branch Count 12/14 bewegten den Fuellgrad um hoechstens 7 Punkte und trieben die Dreiecke auf 4310-5162. Strukturell: neue Zweige wachsen aus denselben wenigen Hauptaesten und vergroessern dabei die Krone, das Volumen waechst also mit. Ebenfalls verworfen: mehr Cards (Ziel 740 = 5426 Tris, 26 % ueber Budget, und der Fuellgrad stieg nur auf 75 %). Und eine kleinere Krone: Branch Length 2.35 sah ueber drei Seeds mit 1.83 m gut aus, im Neun-Seed-Test fielen SECHS Seeds unter die 1.80 m der Frucht-Streuung. Was wirkt, ist die Card-GROESSE (neu: LEAF_SIZE_PRESET, Faktor je Preset). Sie kostet kein Dreieck, weil die Card-Zahl gedeckelt ist - aber OVERDRAW, und der ist bei Bueschel-Cards der eigentliche Engpass. Deshalb der maessige Faktor 1.47: Deckung von 46 auf 87-114 % der Eiche, Card-Flaeche 414 -> 893 m2, bei 3697 statt 3714 Tris. Faktor 1.71 brachte 79 % Fuellgrad bei 2.7-facher Flaeche - das waere eine Overdraw-Entscheidung der Spielseite. test_obstbaum prueft die Deckung jetzt mit, und zwar gegen die im SELBEN Lauf gemessene Eiche statt gegen eine feste Zahl: die Deckung haengt an der Card-Flaeche, eine 6-Tri-Card bringt das Dreifache einer flachen Raute. Der erste Anlauf des Checks ist genau daran gescheitert. Gegenprobe gefahren: mit Faktor 1.0 meldet der Test 9 Fehler bei 42-54 % der Eiche. 26 Tests bestehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
255 lines
9.7 KiB
Python
255 lines
9.7 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 %9s %7s %9s"
|
|
% ("Seed", "Hoehe", "B/H", "KronR", "Card-Max", "KronR/Card", "Ansatz",
|
|
"m2/m3"))
|
|
|
|
verh = []
|
|
deckungen = []
|
|
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)
|
|
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))
|
|
|
|
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 %9.2f %6.0f%% %9.1f"
|
|
% (seed, hoehe, 2 * kron_r / hoehe, kron_r, card_max, v, ansatz,
|
|
deckung))
|
|
|
|
# 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 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("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")
|