Files
stylized-rock-generator/tests/test_ursprung.py
T
D4rkst3randClaude Opus 4.8 499d507e28 Tree Gen 1.36.0: Ursprung an der Stammbasis, Weide gedeckelt, Birke ueberarbeitet
1) Ursprung: Stammbasis statt tiefstem Slot-0-Punkt
   Zweimal war das schon "behoben". Erst kam der Ursprung aus der Gesamt-Bbox
   (eine Blatt-Card hob den Baum an), dann aus Material-Slot 0 - aber die AESTE
   gehoeren ebenfalls zum Rinden-Slot. Gemessen an Tanne_01_S1: tiefster
   Slot-0-Punkt -5.35 cm, tiefster STAMM-Punkt -0.02 cm. Die Stammbasis
   schwebte 5.3 cm ueber dem Boden.

   Die exakte Trennung liefert ast_id (0 = Stamm) - dasselbe Attribut, das der
   Baum fuer die Faell-Teile ohnehin traegt. Ein Schwellwert auf den
   Achsabstand faellt beim Kaktus um: dessen Stamm ist dicker als jeder
   sinnvolle Schwellwert. X/Y kommt jetzt aus dem untersten RING statt aus der
   Stamm-Bbox; bei Bend 1.5 liegt die Bbox-Mitte in der Kruemmung.

   Neu: tests/test_ursprung.py, gemessen am reimportierten FBX. Die erste
   Fassung des Tests suchte den "tiefsten Punkt nahe der Achse" - die
   Gegenprobe hat sie umgeworfen, weil bei der Tanne eine Astspitze ins
   Achsband faellt. Ein Schwellwert, der die Bugfassung durchlaesst, prueft
   nichts. Jetzt wird ein Stammquerschnitt gesucht: Vertices, die die Achse in
   allen Himmelsrichtungen umschliessen. Gegenprobe bestanden.

   Nebenbefund: ein Baum ohne Aeste traegt gar kein ast_id (Branch Count 0
   schaltet den Ast-Zweig des Graphen ab). Dann ist alles Stamm und der
   Fallback exakt.

2) Weide S4 auf 350 Cards gedeckelt - Entscheidung des Users
   Gemessen 5456-5594 Tris ueber drei Seeds gegen 4300 Budget; nach der
   Deckelung 3890-3962. LEAF_STUFEN_PRESET haelt solche Ausnahmen je Preset.

3) Birke war eine Saeule
   Gemessen B/H 0.15 - schmaler als der Standardbaum (0.22), nicht die Haelfte
   der Eiche (0.36). Ursache: Branch Up 1.1, die Aeste klebten am Stamm.
   Zielband 0.35-0.55 aus Van den Berk (8-12 m bei 15-20 m) und Woodland Trust
   (6-10 m bei 15-25 m); jetzt 0.51.

   Haengende Zweigenden ueber Branch Droop 0.8 + Droop Curve 3.5, nicht ueber
   negatives Sub Up: das wurde von +2.2 bis -2.0 durchgemessen und bewegt
   nichts (1-6 %), weil die Sub-Aeste zu kurz sind. Mit Droop 26 % bei einem
   Fall von 0.033 x Hoehe - wie die Tanne (0.030), weit weg von der Weide
   (0.213).

4) run_tests.ps1 benennt Blender-Abstuerze
   test_export_robust starb rund in jedem sechsten Lauf mit
   EXCEPTION_ACCESS_VIOLATION und der Runner meldete "<keine Ergebniszeile>".
   Der Stack enthaelt keinen einzigen Addon-Frame (RNA_property_int_get ->
   rna_property_rna_or_id_get -> IDP_GetPropertyFromGroup, Null-Deref), und mit
   eingebauten Markern verschwindet der Absturz: Heisenbug in Blender 5.1.2.
   Der Runner wiederholt jetzt einmal und benennt den Absturz.

24 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 11:55:34 +02:00

246 lines
9.8 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)
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
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")