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>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user