Files
stylized-rock-generator/tests/test_faell_teile.py
T
D4rkst3randClaude Opus 4.8 20e8e24322 Tree Gen 1.43.0: UCX in _Pieces_All.fbx - und zwei Fehler, die dabei auffielen
Die Gesamtdatei kam ohne Kollision an, waehrend die Einzeldateien welche
hatten. Jetzt bekommt jedes Teil auch dort seine Huelle, gleiche Konvention wie
beim Stand-Baum: UCX_<Teilname> als Geschwister, kein Elternteil. Sie entstehen
VOR dem Umsetzen der Pivots, weil in der Gesamtdatei alle Teile noch an ihrer
Originalposition stehen.

Beim Nachmessen kamen zwei Fehler heraus, die auch die BESTEHENDEN
Einzeldateien betrafen:

1) Die Huellen umschlossen das Laub mit
   _tree_ucx las alle Vertices eines Teils, also auch die Blatt-Cards. Gemessen
   am baum-Preset war die Huelle dadurch 1.4- bis 2.1-mal so gross wie das Holz.
   Jetzt nur noch Material-Slot 0. Die Spielseite hatte das fuer ihre
   Interim-Boxen laengst so gemacht - zu Recht.

2) Das Aufweiten war fuer schlanke Teile kaputt
   Nach dem Vereinfachen wurde die Huelle durch Skalieren um die Huellenmitte
   aufgeblasen. Bei langgestreckten Teilen entgleist das: liegt eine Flaeche
   fast durch die Mitte, wird der Hebel winzig und der Faktor explodiert -
   gemessen 4.2 an einem Stammsegment mit Laenge zu Dicke 13:1. An der dicken
   Eiche blieb es bei 1.0-1.4, deshalb ist es nie aufgefallen; erst der Wechsel
   auf Rinde-only machte die Teile schlank genug.

   Jetzt wird je Flaeche der ECHTE Fehlbetrag gemessen und die Vertices um genau
   diesen Betrag nach aussen geschoben - eine Verschiebung in Metern, kein
   Verhaeltnis. Iterativ, weil ein Vertex mehreren Flaechen angehoert und
   entlang seiner eigenen Normale wandert (ein Durchgang liess einen Rest
   stehen, der Test schlug an). Bleibt danach etwas uebrig, faellt die Funktion
   auf die unvereinfachte Huelle zurueck.

Ergebnis am 12-teiligen Baum: alle Huellen 14-31 Verts (Budget 32), keine
groesser als das 1.35-fache ihres Holzes.

test_faell_teile prueft die Gesamtdatei jetzt auf: je Teil genau eine Huelle,
richtiger Name, kein Elternteil, und Huellengroesse gegen die RINDE.
Gegenproben: ohne Rinde-Filter 10 Fehler (1.41x bis 2.06x), ohne Huellen in der
Gesamtdatei 1 Fehler.

27 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 06:53:28 +02:00

246 lines
9.6 KiB
Python

"""Punkt 4: Faell-Teile (Stammsegmente + Aeste mit ihren Blatt-Cards).
Gemessen wird am REIMPORTIERTEN FBX, nicht in der Szene - gleiche Messlatte wie
bei den Punkten 1-3.
Die Kernaussage ist die Card-Bilanz: die Summe der Blatt-Faces ueber alle Teile
muss der Blatt-Zahl des Stand-Baums entsprechen. Das ist nur pruefbar, weil die
Ast-Zugehoerigkeit als Attribut `ast_id` durch die Instanzierung geschleift wird
- mit einer raeumlichen Nearest-Neighbor-Schaetzung waere "stimmt die Zuordnung"
keine beantwortbare Frage, sondern Ansichtssache.
Aufruf: blender --background --factory-startup --python tests/test_faell_teile.py
"""
import os
import sys
import math
import tempfile
import importlib.util
from collections import Counter
import bpy
import bmesh
from mathutils import Vector
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)
s = bpy.context.scene.tree_gen_settings
s.preset = 'baum'
s.use_growth = False
s.apply_modifier = True
s.art_name = "Eiche"
s.variant = 1
s.export_dir = OUT
s.piece_trunk_segments = 3
s.piece_ucx = True
s.piece_export_combined = True
bpy.ops.object.treegen_create()
baum = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and o.name.startswith("Eiche_01")
and not o.name.endswith(("_Leaf", "_Frucht"))][0]
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
s.leaf_card = card
bpy.ops.object.treegen_leaves()
blatt = bpy.data.objects.get(baum.name + "_Leaf")
# Referenz: wie viele Blatt-Faces hat der Stand-Baum?
dg = bpy.context.evaluated_depsgraph_get()
ev = blatt.evaluated_get(dg)
mb = ev.to_mesh()
cards_gesamt = len(mb.polygons)
attr = mb.attributes.get("ast_id")
ids_stand = Counter(d.value for d in attr.data) if attr else Counter()
ev.to_mesh_clear()
print("Stand-Baum: %d Blatt-Faces, ast_id auf %d Gruppen"
% (cards_gesamt, len(ids_stand)))
if not ids_stand:
fails.append("Die Blatt-Cards tragen kein ast_id - die Zuordnung waere "
"nur zu raten")
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
namen_vorher = sorted(o.name for o in bpy.context.scene.objects)
bpy.ops.treegen.export_pieces()
if sorted(o.name for o in bpy.context.scene.objects) != namen_vorher:
fails.append("Szene veraendert - die Arbeitsebene soll bleiben")
dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx"))
einzeln = [f for f in dateien if "_Piece_" in f and not f.endswith("_All.fbx")]
gesamt = [f for f in dateien if f.endswith("_Pieces_All.fbx")]
stamm = [f for f in einzeln if "_Stamm" in f]
aeste = [f for f in einzeln if "_Ast" in f]
print("Export: %d Teile (%d Stamm, %d Ast), Gesamtdatei: %s"
% (len(einzeln), len(stamm), len(aeste), "ja" if gesamt else "NEIN"))
if len(stamm) != s.piece_trunk_segments:
fails.append("%d Stammsegmente, erwartet %d"
% (len(stamm), s.piece_trunk_segments))
if not aeste:
fails.append("Keine Ast-Teile exportiert")
if not gesamt:
fails.append("Keine Gesamtdatei mit gemeinsamem Ursprung")
print("")
print("%-30s %6s %6s %6s %7s %s" %
("Teil", "Rinde", "Blatt", "UCX", "|Pivot|", "Topologie"))
blatt_summe = 0
for f in sorted(einzeln):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, f))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
hull = [o for o in meshes if o.name.startswith("UCX_")]
koerper = [o for o in meshes if not o.name.startswith("UCX_")]
if not koerper:
fails.append("%s: kein Mesh" % f)
continue
ob = koerper[0]
m = ob.data
rinde = sum(1 for p in m.polygons if p.material_index == 0)
laub = sum(1 for p in m.polygons if p.material_index == 1)
blatt_summe += laub
# Pivot = Massenmittelpunkt: der Schwerpunkt muss NAHE am Ursprung liegen,
# sonst torkelt das Teil als Physik-Actor um eine Ecke.
schwer = tg._mass_center(m)
abstand = (ob.matrix_world @ schwer).length
diag = max((max(v.co[i] for v in m.vertices) - min(v.co[i] for v in m.vertices))
for i in range(3)) if m.vertices else 1.0
bm = bmesh.new(); bm.from_mesh(m)
nm = sum(1 for e in bm.edges if len(e.link_faces) > 2)
bm.free()
nv = len(hull[0].data.vertices) if hull else 0
print("%-30s %6d %6d %6s %7.3f %s"
% (f[:-4], rinde, laub, nv or "-", abstand,
"ok" if nm == 0 else "%d non-manifold" % nm))
if len(m.materials) != 2:
fails.append("%s: %d Slots, erwartet 2" % (f, len(m.materials)))
if not hull:
fails.append("%s: keine UCX-Huelle" % f)
else:
if hull[0].name != "UCX_" + f[:-4]:
fails.append("%s: Huelle heisst '%s', Unreal erwartet 'UCX_%s'"
% (f, hull[0].name, f[:-4]))
if nv > s.piece_ucx_verts:
fails.append("%s: UCX hat %d Verts, Budget %d"
% (f, nv, s.piece_ucx_verts))
# Der Schwerpunkt darf nur einen Bruchteil der groessten Ausdehnung
# vom Ursprung entfernt liegen.
if abstand > diag * 0.15:
fails.append("%s: Schwerpunkt %.3f m vom Pivot entfernt (Ausdehnung "
"%.2f m) - als Physik-Actor wuerde das Teil torkeln"
% (f, abstand, diag))
# --- Die Bilanz: Summe der Cards ueber alle Teile == Stand-Baum ----------
print("")
print("Blatt-Bilanz: %d Cards in den Teilen, %d im Stand-Baum"
% (blatt_summe, cards_gesamt))
if blatt_summe != cards_gesamt:
fails.append("Blatt-Bilanz stimmt nicht: %d in den Teilen, %d im Stand-Baum "
"- es gehen Cards verloren oder werden doppelt zugeordnet"
% (blatt_summe, cards_gesamt))
# --- Gesamtdatei: Teile stehen an ihrer Originalposition ----------------
if gesamt:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, gesamt[0]))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
teile = [o for o in meshes if not o.name.startswith("UCX_")]
hullen = [o for o in meshes if o.name.startswith("UCX_")]
alle = [o.matrix_world @ v.co for o in teile for v in o.data.vertices]
hoehe = max(c.z for c in alle) - min(c.z for c in alle)
print("Gesamtdatei: %d Teile, %d Huellen, Hoehe %.2f m"
% (len(teile), len(hullen), hoehe))
if len(teile) != len(einzeln):
fails.append("Gesamtdatei hat %d Teile, einzeln waren es %d"
% (len(teile), len(einzeln)))
# Die Gesamtdatei kam frueher OHNE Huellen an, waehrend die Einzeldateien
# welche hatten - gemeldet von der Spielseite, die interimsweise eigene
# Boxen setzen musste. Je Teil GENAU eine Huelle, gleiche Konvention wie
# beim Stand-Baum: `UCX_<Teilname>` als Geschwister, kein Elternteil.
namen = {o.name for o in teile}
for h in hullen:
if h.parent is not None:
fails.append("Gesamtdatei: Huelle '%s' haengt unter '%s'"
% (h.name, h.parent.name))
if h.name[4:] not in namen:
fails.append("Gesamtdatei: Huelle '%s' gehoert zu keinem Teil"
% h.name)
fehlend = sorted(n for n in namen if ("UCX_" + n) not in
{h.name for h in hullen})
if fehlend:
fails.append("Gesamtdatei: %d Teile ohne Huelle (%s)"
% (len(fehlend), ", ".join(fehlend[:3])))
# Und die Huelle umschliesst das HOLZ, nicht das Laub. Beim obersten
# Stammsegment, das die Krone traegt, war der Unterschied gemessen 67 %
# (Diagonale 1.75 m statt 1.05 m) - eine Kollision, die weit ins Laub ragt.
nach_name = {o.name: o for o in teile}
for h in hullen:
koerper = nach_name.get(h.name[4:])
if koerper is None:
continue
hc = [h.matrix_world @ v.co for v in h.data.vertices]
rinde_idx = {vi for p in koerper.data.polygons
if p.material_index == 0 for vi in p.vertices}
if not rinde_idx:
continue
rc = [koerper.matrix_world @ koerper.data.vertices[i].co
for i in rinde_idx]
def spanne(pts):
lo = [min(c[i] for c in pts) for i in range(3)]
hi = [max(c[i] for c in pts) for i in range(3)]
return math.dist(lo, hi)
h_d, r_d = spanne(hc), spanne(rc)
if h_d < r_d * 0.95:
fails.append("%s: Huelle %.2f kleiner als die Rinde %.2f"
% (h.name, h_d, r_d))
if h_d > r_d * 1.35:
fails.append("%s: Huelle %.2f gegen Rinde %.2f (%.2fx) - sie "
"umschliesst das Laub mit"
% (h.name, h_d, r_d, h_d / r_d))
# Zusammengesetzt muss der Baum wieder seine volle Hoehe haben - das ist
# der Beleg, dass die Teile deckungsgleich rekonstruierbar sind.
if hoehe < 3.0:
fails.append("Gesamtdatei nur %.2f m hoch - die Teile stehen nicht an "
"ihrer Originalposition" % hoehe)
print("")
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for x in fails:
print(" - " + x)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")