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>
246 lines
9.8 KiB
Python
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")
|