Files
D4rkst3randClaude Opus 4.8 ae7806e082 Tree Gen 1.42.0: UCX-Stammhuelle im Spielfertig-Export
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>
2026-08-07 06:18:29 +02:00

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")