Die Stand-Baeume bekommen eine konvexe Kollisionshuelle um den STAMM, vom Fuss bis zum Kronenansatz - keine Kronen-Huelle: gegen die liefe der Spieler, bevor er den Baum beruehrt. Die Trennung kommt aus ast_id (0 = Stamm), dasselbe Attribut wie beim Ursprung und bei den Faell-Teilen. Kronenansatz = tiefster Punkt mit ast_id > 0 plus ein Zehntel Stammhoehe Luft (der tiefste Astpunkt kann eine herabgebogene Spitze sein). Ohne Aeste ist die ganze Pflanze Stamm - beim Kaktus genau richtig. Name und Hierarchie sind NICHT geraten, sondern am Fels-Export nachgemessen, der in der Pipeline schon abgenommen ist: UCX_<LODGroup-Name>, als GESCHWISTER des LodGroup-Empty. Ein Kind der Gruppe wuerde Unreal als LOD-Stufe lesen. Zwei Befunde aus der Messung: - Die Huelle rutschte 4-6 cm UNTER den Boden. Das Vereinfachen auf 12 Verts schneidet Ecken ab, das anschliessende Aufblasen skaliert um die Huellenmitte und schiebt dabei die Unterseite nach unten. Im Spiel waere das eine Kollision, die aus dem Terrain ragt. Boden wird jetzt auf Z = 0 geklemmt. - Der erste Anlauf des Tests holte die FBX-Skala aus der Gesamthoehe - die Blatt-Cards ragen ueber die Rinde hinaus, die Skala kam zu gross heraus und die Huelle sah zu klein aus. Massstab kommt jetzt aus Material-Slot 0. Neu: tests/test_stamm_ucx.py, vier Faelle (gerader Stamm, krummer Stamm mit Bend 1.5, Obstbaum, einmal ohne LODs), gemessen im reimportierten FBX. Gegenprobe ohne Boden-Klemmung: vier Fehler. test_baum_export, test_baum_lod und test_ursprung zaehlten die Huelle als Render-Mesh mit und schlugen zu Recht an - sie filtern jetzt UCX_. Schalter im Export-Panel: "UCX-Stammhuelle" (an) + max. Verts (12). 27 Tests bestehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
248 lines
9.9 KiB
Python
248 lines
9.9 KiB
Python
"""Der Ursprung sitzt an der STAMMBASIS, nicht an der tiefsten Astspitze.
|
|
|
|
Gemeldet von der Spielseite an Tanne_01_S1: der Baum "liegt am Ast" - die
|
|
Stammbasis schwebt sichtbar ueber dem Boden. Nachgemessen an der Export-Kopie
|
|
lag der tiefste Punkt des Rinden-Slots bei -5.35 cm, der tiefste STAMM-Punkt
|
|
bei -0.02 cm. Die Tanne setzt ihre Aeste ab 6 % Stammhoehe an und laesst sie
|
|
haengen; eine davon bestimmte den Nullpunkt und hob den ganzen Baum an.
|
|
|
|
Der frueher gebaute Fix nahm nur die Blatt-Cards aus der Rechnung. Das reicht
|
|
nicht: zum Rinden-Slot gehoeren die Aeste mit.
|
|
|
|
Warum die Tanne der Pruefling ist: am Standardbaum sitzen die Aeste hoch und
|
|
haengen nicht - dort waere der Fehler unsichtbar geblieben, genau wie er es
|
|
monatelang war.
|
|
|
|
Gemessen wird im REIMPORTIERTEN FBX, nicht in Blender. Der Schwellwert fuer
|
|
"nahe der Achse" wird nicht geraten, sondern vor dem Export am echten Stamm
|
|
abgenommen und ueber die Hoehe skalenfrei gemacht.
|
|
|
|
Aufruf: blender --background --factory-startup --python tests/test_ursprung.py
|
|
"""
|
|
import os
|
|
import sys
|
|
import math
|
|
import tempfile
|
|
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 = []
|
|
OUT = tempfile.mkdtemp()
|
|
|
|
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)
|
|
|
|
|
|
def stamm_mass(ob):
|
|
"""Fussradius und Hoehe des reinen Stammes - ueber ast_id, nicht geschaetzt.
|
|
|
|
Ein Schwellwert auf den Achsabstand waere die naheliegende Alternative und
|
|
faellt beim Kaktus um: dessen Stamm ist dicker als jeder sinnvolle
|
|
Schwellwert. Das Attribut kommt aus dem Generator und ist exakt.
|
|
"""
|
|
dg = bpy.context.evaluated_depsgraph_get()
|
|
ev = ob.evaluated_get(dg)
|
|
m = ev.to_mesh()
|
|
attr = m.attributes.get("ast_id")
|
|
if attr is None:
|
|
# Nachgemessen: ein Baum OHNE Aeste traegt gar kein ast_id - das
|
|
# Attribut entsteht auf dem Ast-Zweig des Graphen, und den schaltet
|
|
# "Branch Count 0" komplett ab (Kaktus-Setzling: keine Arme vor 50-70
|
|
# Jahren, NPS). Dann ist aber ohnehin alles Stamm, und der Fallback ist
|
|
# exakt statt bloss ausreichend.
|
|
co = [v.co.copy() for v in m.vertices]
|
|
else:
|
|
co = [m.vertices[i].co.copy() for i in range(len(m.vertices))
|
|
if attr.data[i].value == 0]
|
|
alle_z = [v.co.z for v in m.vertices]
|
|
ev.to_mesh_clear()
|
|
if not co:
|
|
return None
|
|
z0 = min(c.z for c in co)
|
|
h = max(alle_z) - min(alle_z)
|
|
band = [c for c in co if c.z <= z0 + h * 0.02]
|
|
r = max((math.hypot(c.x, c.y) for c in band), default=0.0)
|
|
return r, h, z0
|
|
|
|
|
|
ergebnisse = []
|
|
for preset, stufe in (("tanne", 1), ("tanne", 4), ("toter_baum", 1),
|
|
("weide", 1), ("kaktus", 1)):
|
|
for o in list(bpy.context.scene.objects):
|
|
if o is not card:
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
# Eigenes Verzeichnis je Fall: reimportiert wird erst nach der Schleife,
|
|
# ein gemeinsamer Ordner wuerde die vorherigen Dateien ueberschreiben.
|
|
ziel_dir = os.path.join(OUT, "%s_s%d" % (preset, stufe))
|
|
os.makedirs(ziel_dir, exist_ok=True)
|
|
|
|
s = bpy.context.scene.tree_gen_settings
|
|
s.preset = preset
|
|
s.art_name = preset.capitalize()
|
|
s.variant = 1
|
|
s.apply_modifier = True
|
|
s.use_growth = True
|
|
s.stages = 4
|
|
s.export_dir = ziel_dir
|
|
s.generate_lods = False
|
|
s.leaf_card = card
|
|
bpy.ops.object.treegen_create()
|
|
|
|
staemme = 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)
|
|
baum = staemme[stufe - 1]
|
|
|
|
# Der Baum steht BEWUSST nicht am Ursprung - eine verschobene Stufe faellt
|
|
# bei (0,0,0) nicht auf, weil minus null eben null ist.
|
|
baum.location = (12.0, -4.0, 0.0)
|
|
mass = stamm_mass(baum)
|
|
if mass is None:
|
|
fails.append("%s S%d: kein ast_id am Stamm" % (preset, stufe))
|
|
continue
|
|
r_stamm, h_blender, _ = mass
|
|
|
|
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")
|
|
if bl is not None:
|
|
bl.location = baum.location
|
|
|
|
for o in bpy.context.scene.objects:
|
|
o.select_set(o is baum)
|
|
bpy.context.view_layer.objects.active = baum
|
|
bpy.ops.treegen.export_game()
|
|
|
|
dateien = sorted(f for f in os.listdir(ziel_dir)
|
|
if f.lower().endswith(".fbx"))
|
|
if not dateien:
|
|
fails.append("%s S%d: kein FBX geschrieben" % (preset, stufe))
|
|
continue
|
|
pfad = os.path.join(ziel_dir, dateien[0])
|
|
ergebnisse.append((preset, stufe, pfad, r_stamm / h_blender))
|
|
|
|
print("%-12s %3s %9s %11s %11s %10s"
|
|
% ("Preset", "St", "Achs-R", "Stammfuss", "tiefster Pkt", "Achs-Mitte"))
|
|
|
|
for preset, stufe, pfad, r_rel in ergebnisse:
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_scene.fbx(filepath=pfad)
|
|
# 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_")]
|
|
if not meshes:
|
|
fails.append("%s S%d: kein Mesh im FBX" % (preset, stufe))
|
|
continue
|
|
ob = meshes[0]
|
|
m = ob.data
|
|
co = [ob.matrix_world @ v.co for v in m.vertices]
|
|
zlo = min(c.z for c in co)
|
|
zhi = max(c.z for c in co)
|
|
h = zhi - zlo
|
|
|
|
# Gesucht ist die tiefste Hoehe, auf der ein STAMMQUERSCHNITT liegt.
|
|
#
|
|
# Ein blosses "tiefster Punkt nahe der Achse" reicht nicht - das ist die
|
|
# erste Fassung dieses Tests gewesen, und die Gegenprobe hat sie umgeworfen:
|
|
# bei der Tanne haengt ein Ast so dicht am Stamm, dass seine Spitze ins
|
|
# Achsband faellt. Der Test meldete Fuss = 0, obwohl der Stamm 5 cm darueber
|
|
# schwebte. Ein Threshold, der die Bugfassung durchlaesst, prueft nichts.
|
|
#
|
|
# Ein Querschnitt unterscheidet sich von einer Astspitze in zwei messbaren
|
|
# Punkten: er umschliesst die Achse (Vertices in ALLEN Himmelsrichtungen)
|
|
# und er ist kompakt (kein Vertex weit draussen). Beides ohne geratene
|
|
# Konstante: der Vergleichsradius kommt aus dem echten Stamm.
|
|
r_max = r_rel * h * 2.5
|
|
SEKTOREN = 8
|
|
|
|
def querschnitt(zu, zo):
|
|
# Erst auf das Achsband einschraenken, DANN auf einen Ring pruefen.
|
|
# Andersherum (Slab verwerfen, sobald irgendein Punkt weit draussen
|
|
# liegt) faellt der echte Stammfuss durch: auf seiner Hoehe zieht bei
|
|
# der Tanne ein haengender Ast vorbei.
|
|
pts = [c for c in co
|
|
if zu <= c.z <= zo and math.hypot(c.x, c.y) <= r_max]
|
|
if len(pts) < SEKTOREN:
|
|
return None
|
|
sek = {int((math.atan2(c.y, c.x) + math.pi) / (2 * math.pi) * SEKTOREN)
|
|
% SEKTOREN for c in pts}
|
|
if len(sek) < SEKTOREN - 2:
|
|
return None # einseitig -> Zweig, kein Ring
|
|
# Schwerpunkt aus einem DICKEREN Band als dem Suchschritt. Nachgemessen
|
|
# am Kaktus: ein 1-%-Band erwischt nur 11 der 16 Ring-Vertices (die
|
|
# Rippen legen den Ring in Z leicht schief) und der Schwerpunkt kippt um
|
|
# 19 % der Hoehe aus der Achse. Mit 4 % ist der Ring vollstaendig und
|
|
# der Schwerpunkt exakt 0.0000 - geprueft an Kaktus, totem Baum, Tanne.
|
|
ring = [c for c in co
|
|
if zu <= c.z <= zu + h * 0.04
|
|
and math.hypot(c.x, c.y) <= r_max]
|
|
return (sum(c.x for c in ring) / len(ring),
|
|
sum(c.y for c in ring) / len(ring))
|
|
|
|
dicke = h * 0.01
|
|
fuss = None
|
|
mitte = (0.0, 0.0)
|
|
# Ueberlappende Schritte: mit buendigen Slabs kann eine Slab-Grenze mitten
|
|
# durch den Ring laufen, dann haelt keine der beiden Haelften die
|
|
# Sektor-Pruefung und der Fuss wird gar nicht gefunden. Halber Schritt.
|
|
z = zlo
|
|
while z < zhi:
|
|
tr = querschnitt(z, z + dicke)
|
|
if tr is not None:
|
|
fuss, mitte = z, tr
|
|
break
|
|
z += dicke * 0.5
|
|
if fuss is None:
|
|
# Kein Ring innerhalb r_max ueber die ganze Hoehe: der Stamm steht
|
|
# nicht ueber dem Ursprung. Genau das passiert mit der alten Fassung -
|
|
# dort bestimmte eine haengende Astspitze den Nullpunkt, und zwar in
|
|
# allen drei Achsen, nicht nur in Z. Der Baum haengt dann an der
|
|
# Astspitze statt auf dem Fuss zu stehen.
|
|
fails.append("%s S%d: auf keiner Hoehe ein Stammquerschnitt im "
|
|
"Achsband (R %.3f) - der Stamm steht seitlich versetzt "
|
|
"zum Ursprung" % (preset, stufe, r_max))
|
|
continue
|
|
achs = math.hypot(*mitte)
|
|
|
|
print("%-12s S%-2d %9.4f %11.4f %11.4f %10.4f"
|
|
% (preset, stufe, r_max, fuss, zlo, achs))
|
|
|
|
# DAS ist der Punkt: die Stammbasis liegt auf Z = 0.
|
|
if abs(fuss) > h * 0.01:
|
|
fails.append("%s S%d: Stammfuss bei Z %.4f statt 0 (%.1f %% der Hoehe) "
|
|
"- der Baum schwebt bzw. steckt im Boden"
|
|
% (preset, stufe, fuss, 100 * abs(fuss) / h))
|
|
# Und der Stamm steht ueber dem Ursprung, nicht daneben. Ohne diese Zeile
|
|
# koennte die Bbox-Mitte den Pivot bei einem einseitigen Baum vom Stamm
|
|
# wegziehen - derselbe Fehler, nur in X/Y.
|
|
if achs > h * 0.02:
|
|
fails.append("%s S%d: Stammachse %.4f neben dem Ursprung (%.1f %% der "
|
|
"Hoehe)" % (preset, stufe, achs, 100 * achs / h))
|
|
|
|
print("")
|
|
if fails:
|
|
print("ERGEBNIS: %d FEHLER" % len(fails))
|
|
for f in fails:
|
|
print(" - " + f)
|
|
sys.exit(1)
|
|
print("ERGEBNIS: ALLE CHECKS OK")
|