1) NEUE BAUFORM: Blattwerk als Halbkugel-Cluster
Jeder gestreute Punkt ist jetzt ein Cluster-ZENTRUM, die Cards sitzen auf
der oberen Halbkugel darum herum. Untere Haelfte weggelassen - von unten
sieht man die Krone gegen den Himmel, das waere Overdraw fuer nichts.
Zwei Annahmen kassiert die Messung: GeometryNodeMeshIcoSphere liefert bei
Subdivisions 0 UND 1 dasselbe Ikosaeder, ein Cluster hat SECHS Cards statt
der angenommenen 26. Und die Stufen-Ziele muessen Cluster-Vielfache sein
(26/78/208/390) - S1 stand auf 10, unter der kleinsten Einheit, die Regelung
waere nie konvergiert. S4 ist seither geregelt statt "volle Dichte":
ungeregelt lieferte die Schale 2706 Cards an der Eiche.
ast_id ueberlebt Instanzieren + Realize, an den Cards nachgewiesen.
2) SPITZEN-DECKUNG: zwei echte Ursachen, beide vorher nur vermutet
- Der Kronenansatz-Filter stand auf 25 % der Baumhoehe, die Tanne setzt ihre
Aeste ab 12 % an: bei 5 von 22 Aesten lag das aeussere Fuenftel KOMPLETT
unter dem Filter. Jetzt auf den Branch Start der Art gedeckelt.
- Haengende Spitzen fielen unter den Filter. Jetzt zaehlt der ANSATZ des
Astes (anchor) mit, nicht nur die Punkthoehe.
Ergebnis: Eiche/Baum 100 %, Weide 100/90, Obstbaum 100/93, Tanne 86/86,
Birke 100/72. Die 85-%-Schwelle der Spielseite ist damit erreicht, ohne dass
die Rinden-Ersparnis noetig war.
Die Metrik war zweimal falsch: der Abstand zur Card-MITTE ist zu streng -
eine 0.85-m-Card deckt die Spitze auch aus 0.45 m Abstand, bei 1.9 m
Astlaenge kamen so 24 % heraus, obwohl das Polster genau auf der Spitze sass.
Jetzt zaehlt Deckung, nicht Abstand.
3) GROESSERE BAEUME, proportional skaliert
Eiche 9.3 m, Birke 11.0, Tanne 13.0, Weide 7.0 - Hoehe, Astlaenge und
Stammradius zusammen, sonst wird der Baum eine Stange mit Minikrone. Alle
unter dem 4300er-Deckel; die Birke brauchte eine Card-Deckelung auf 340.
4) Kugel-Kennwert RELATIV, Bbox-Kriterium ersetzt
Die Schalen schieben jede Card in ALLE Richtungen vom Ast weg: die Krone
gewinnt ringsum Breite, in der Hoehe nur oben. Der Obstbaum fiel dadurch von
1.35-1.47 auf 0.95-1.23, ohne dass an seiner Form etwas falsch war.
Zwei Hypothesen sind an der Messung gescheitert, beide klangen plausibel:
den Kappen-Faktor entkoppeln (0.93-1.14 -> 0.95-1.23) und den Kappen-Radius
vergroessern (59-77 % -> 58-75 %). Der Radius waechst in alle Richtungen und
verschiebt das Verhaeltnis deshalb kaum. Was wirkt, ist die Kronenbreite.
Schranke jetzt relativ: hoechstens 65 % des Eichenwerts aus DEMSELBEN Lauf.
Eine neue Konstante haette bei der naechsten Bauform dasselbe Schicksal.
LEAF_CLUSTER_PRESET bleibt trotzdem eine eigene Tabelle - Card-Grobheit und
Polster-Abstand sind zwei verschiedene Fragen.
"Bbox-Mitte == 0" ist raus. Nachgemessen ueber drei Presets und drei Seeds:
Stammachse 0.0000 m in allen neun Faellen, Bbox-Exzentrik 0.08-0.39 m mit
dem Seed streuend, 3-13 % des Kronenradius. Eine Verschiebung traefe den
Stamm mit und waere ueber die Seeds konstant - es ist Kronen-Schieflage.
An seine Stelle tritt der Bounds-Radius, an dem das Culling haengt, geprueft
gegen die Blender-Messung statt gegen eine Konstante.
27 Tests bestehen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
241 lines
9.2 KiB
Python
241 lines
9.2 KiB
Python
"""Spielfertiger Stand-Baum: ein Mesh, zwei Slots, Kugel-Normalen.
|
|
|
|
Der wichtigste Teil ist die NORMALEN-Messung am reimportierten FBX. Custom
|
|
Normals sind die Sorte Daten, die der Exporter gerne stillschweigend
|
|
plattbuegelt - dann sieht der Baum in Blender richtig aus und in UE flimmert
|
|
die Krone. Am Objekt in der Szene zu messen wuerde das nicht zeigen.
|
|
|
|
Aufruf: blender --background --factory-startup --python tests/test_baum_export.py
|
|
"""
|
|
import os
|
|
import sys
|
|
import math
|
|
import tempfile
|
|
import importlib.util
|
|
|
|
import bpy
|
|
from mathutils import Vector
|
|
|
|
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 = []
|
|
OUT = tempfile.mkdtemp()
|
|
|
|
for o in list(bpy.data.objects):
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
|
|
|
|
def diamant(name="Leaf_Card"):
|
|
"""Diamond-Card wie im Projekt: vier Ecken, zwei Tris."""
|
|
me = bpy.data.meshes.new(name)
|
|
me.from_pydata(
|
|
[(-0.5, 0.0, 0.0), (0.0, 0.0, 0.7), (0.5, 0.0, 0.0), (0.0, 0.0, -0.1)],
|
|
[], [(0, 1, 2), (0, 2, 3)])
|
|
me.update()
|
|
ob = bpy.data.objects.new(name, me)
|
|
bpy.context.scene.collection.objects.link(ob)
|
|
return ob
|
|
|
|
|
|
s = bpy.context.scene.tree_gen_settings
|
|
s.preset = 'baum'
|
|
s.use_growth = False
|
|
s.apply_modifier = True
|
|
s.art_name = "Eiche"
|
|
s.variant = 1
|
|
s.export_dir = OUT
|
|
s.sphere_normals = True
|
|
|
|
card = diamant()
|
|
bpy.ops.object.treegen_create()
|
|
|
|
baum = [o for o in bpy.context.scene.objects
|
|
if o.type == 'MESH' and o.name.startswith("Eiche_01")
|
|
and not o.name.endswith(("_Leaf", "_Frucht"))]
|
|
if not baum:
|
|
print("ERGEBNIS: FEHLER: kein Baum erzeugt")
|
|
sys.exit(1)
|
|
baum = baum[0]
|
|
|
|
# Der Blattwerk-Operator arbeitet auf der Auswahl.
|
|
for o in bpy.context.scene.objects:
|
|
o.select_set(o is baum)
|
|
bpy.context.view_layer.objects.active = baum
|
|
s.leaf_card = card
|
|
bpy.ops.object.treegen_leaves()
|
|
blatt = bpy.data.objects.get(baum.name + "_Leaf")
|
|
if blatt is None:
|
|
fails.append("Keine _Leaf-Ebene erzeugt")
|
|
|
|
for o in bpy.context.scene.objects:
|
|
o.select_set(o is baum)
|
|
bpy.context.view_layer.objects.active = baum
|
|
namen_vorher = sorted(o.name for o in bpy.context.scene.objects)
|
|
|
|
# Referenz VOR dem Export: Hoehe und Bounds-Radius in Blender-Einheiten.
|
|
_dg = bpy.context.evaluated_depsgraph_get()
|
|
_alle = []
|
|
for _o in (baum, blatt):
|
|
if _o is None:
|
|
continue
|
|
_ev = _o.evaluated_get(_dg)
|
|
_m = _ev.to_mesh()
|
|
_alle += [_o.matrix_world @ _v.co for _v in _m.vertices]
|
|
_ev.to_mesh_clear()
|
|
blender_h = (max(c.z for c in _alle) - min(c.z for c in _alle)) if _alle else 0.0
|
|
blender_r = max((math.hypot(c.x, c.y) for c in _alle), default=0.0)
|
|
|
|
bpy.ops.treegen.export_game()
|
|
|
|
# Die Arbeitsebene muss unangetastet bleiben.
|
|
namen_nachher = sorted(o.name for o in bpy.context.scene.objects)
|
|
if namen_nachher != namen_vorher:
|
|
fehlt = [n for n in namen_vorher if n not in namen_nachher]
|
|
neu = [n for n in namen_nachher if n not in namen_vorher]
|
|
fails.append("Szene veraendert - fehlt: %s, neu: %s" % (fehlt, neu))
|
|
|
|
dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx"))
|
|
if not dateien:
|
|
print("ERGEBNIS: FEHLER: kein FBX geschrieben")
|
|
sys.exit(1)
|
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, dateien[0]))
|
|
# Die UCX-Stammhuelle ist KEIN Render-Mesh - sie gehoert nicht in diese Zaehlung.
|
|
meshes = [o for o in bpy.context.scene.objects
|
|
if o.type == 'MESH' and not o.name.startswith("UCX_")]
|
|
print("")
|
|
print("FBX %s: %d Mesh(es)" % (dateien[0], len(meshes)))
|
|
if len(meshes) != 1:
|
|
fails.append("%d Meshes im FBX, erwartet genau eines (verschmolzen)"
|
|
% len(meshes))
|
|
|
|
ob = meshes[0]
|
|
me = ob.data
|
|
slots = [m.name if m else "-" for m in me.materials]
|
|
print("Slots: %s" % slots)
|
|
if len(slots) != 2:
|
|
fails.append("%d Material-Slots, erwartet 2 (Rinde + Blaetter)" % len(slots))
|
|
else:
|
|
if tg.TREE_MATERIALS[0] not in slots[0]:
|
|
fails.append("Slot 0 heisst '%s', erwartet '%s' (Rinde)"
|
|
% (slots[0], tg.TREE_MATERIALS[0]))
|
|
if tg.TREE_MATERIALS[1] not in slots[1]:
|
|
fails.append("Slot 1 heisst '%s', erwartet '%s' (Blaetter)"
|
|
% (slots[1], tg.TREE_MATERIALS[1]))
|
|
|
|
# --- Ursprung und Bounds -------------------------------------------------
|
|
#
|
|
# Die BBOX-MITTE wird bewusst NICHT mehr auf 0 geprueft. Das Kriterium stammt
|
|
# aus der Zeit vor der Stammbasis-Konvention (1.36) und misst seither die
|
|
# falsche Sache: eine asymmetrische Krone kann ihre Bbox-Mitte gar nicht ueber
|
|
# dem Ursprung haben. Nachgemessen ueber drei Presets und drei Seeds:
|
|
#
|
|
# Stammachse am Fuss 0.0000 m in ALLEN neun Faellen
|
|
# Bbox-Exzentrik 0.08-0.39 m, streut mit dem Seed
|
|
# ...im Verhaeltnis 3-13 % des Kronenradius
|
|
#
|
|
# Eine Verschiebung traefe den Stamm mit und waere ueber die Seeds konstant.
|
|
# Beides ist nicht der Fall - es ist Kronen-Schieflage. Der Nachfolger des
|
|
# Kriteriums ist tests/test_ursprung.py, das den STAMMQUERSCHNITT bei 0/0/0
|
|
# nachweist; engine-seitig bestaetigt durch lueckenlos stapelnde Faell-Teile,
|
|
# greifende UCX-Huellen und treffende Frucht-Streuung.
|
|
#
|
|
# Was stattdessen zaehlt, ist der BOUNDS-RADIUS: laeuft der aus dem Ruder,
|
|
# greift das Culling daneben (die Spielseite hatte den Fall mit Radius 3.5
|
|
# statt 350 - ein Skalenfehler). Geprueft wird er nicht gegen eine Konstante,
|
|
# sondern gegen die Blender-Messung: Hoehe und Radius muessen DENSELBEN
|
|
# Skalenfaktor tragen.
|
|
co = [ob.matrix_world @ v.co for v in me.vertices]
|
|
lo = [min(c[i] for c in co) for i in range(3)]
|
|
hi = [max(c[i] for c in co) for i in range(3)]
|
|
mid = ((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5)
|
|
fbx_h = hi[2] - lo[2]
|
|
fbx_r = max(math.hypot(c[0], c[1]) for c in co)
|
|
print("Ursprung: min.Z %.3f, Hoehe %.2f, Bounds-Radius %.2f, Bbox-Mitte "
|
|
"%.3f/%.3f (nur zur Info)" % (lo[2], fbx_h, fbx_r, mid[0], mid[1]))
|
|
if abs(lo[2]) > 0.01:
|
|
fails.append("min.Z = %.3f, erwartet 0" % lo[2])
|
|
if blender_h > 0 and blender_r > 0:
|
|
sk_h = fbx_h / blender_h
|
|
sk_r = fbx_r / blender_r
|
|
print("Skala aus Hoehe %.3f, aus Radius %.3f" % (sk_h, sk_r))
|
|
if abs(sk_r - sk_h) > sk_h * 0.05:
|
|
fails.append("Bounds-Radius traegt eine andere Skala als die Hoehe "
|
|
"(%.3f gegen %.3f) - so greift das Culling daneben"
|
|
% (sk_r, sk_h))
|
|
|
|
# --- Der eigentliche Punkt: Kugel-Normalen im FBX ------------------------
|
|
blatt_polys = [p for p in me.polygons if p.material_index == 1]
|
|
rinde_polys = [p for p in me.polygons if p.material_index == 0]
|
|
print("Faces: %d Rinde, %d Blaetter" % (len(rinde_polys), len(blatt_polys)))
|
|
if not blatt_polys:
|
|
fails.append("Keine Blatt-Faces im FBX (Slot 1 leer)")
|
|
else:
|
|
pts = [me.vertices[vi].co for p in blatt_polys for vi in p.vertices]
|
|
mitte = Vector((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)))
|
|
|
|
if hasattr(me, "calc_normals_split"):
|
|
me.calc_normals_split()
|
|
treffer, gesamt, summe = 0, 0, 0.0
|
|
for p in blatt_polys:
|
|
for li in p.loop_indices:
|
|
n = Vector(me.loops[li].normal)
|
|
if n.length_squared < 1e-9:
|
|
continue
|
|
radial = me.vertices[me.loops[li].vertex_index].co - mitte
|
|
if radial.length_squared < 1e-9:
|
|
continue
|
|
d = n.normalized().dot(radial.normalized())
|
|
summe += d
|
|
gesamt += 1
|
|
if d > 0.8:
|
|
treffer += 1
|
|
quote = 100.0 * treffer / gesamt if gesamt else 0.0
|
|
print("Blatt-Normalen: %d Loops, mittlere Radialitaet %.3f, "
|
|
"%.0f%% ueber 0.8" % (gesamt, summe / gesamt if gesamt else 0, quote))
|
|
# Bei echten Kugel-Normalen liegt die Radialitaet nahe 1. Flache
|
|
# Card-Normalen streuen um 0 - das waere der Zustand, den wir vermeiden.
|
|
if gesamt == 0:
|
|
fails.append("Keine auswertbaren Blatt-Normalen im FBX")
|
|
elif quote < 90.0:
|
|
fails.append("Nur %.0f%% der Blatt-Normalen zeigen radial nach aussen "
|
|
"(mittlere Radialitaet %.3f) - der FBX-Export hat die "
|
|
"Custom Normals plattgebuegelt"
|
|
% (quote, summe / gesamt))
|
|
|
|
# Gegenprobe: die Rinde darf NICHT radial sein, sonst haben wir die
|
|
# Normalen des ganzen Baums ueberschrieben.
|
|
if rinde_polys:
|
|
rs, rn = 0.0, 0
|
|
for p in rinde_polys:
|
|
for li in p.loop_indices:
|
|
n = Vector(me.loops[li].normal)
|
|
radial = me.vertices[me.loops[li].vertex_index].co - mitte
|
|
if n.length_squared < 1e-9 or radial.length_squared < 1e-9:
|
|
continue
|
|
rs += n.normalized().dot(radial.normalized())
|
|
rn += 1
|
|
mittel = rs / rn if rn else 0.0
|
|
print("Rinden-Normalen: mittlere Radialitaet %.3f (soll deutlich "
|
|
"niedriger sein)" % mittel)
|
|
if mittel > 0.75:
|
|
fails.append("Auch die Rinde ist radial (%.3f) - die Kugel-Normalen "
|
|
"wurden auf den ganzen Baum gelegt" % mittel)
|
|
|
|
print("")
|
|
if fails:
|
|
print("ERGEBNIS: %d FEHLER" % len(fails))
|
|
for x in fails:
|
|
print(" - " + x)
|
|
sys.exit(1)
|
|
print("ERGEBNIS: ALLE CHECKS OK")
|