Die Zusammenbau-Datei trug den SZENEN-Nullpunkt mit: ein Baum bei x = 18 m exportierte gemessen mit X 16.5..19.3, Y -8.7..-6.2. Die Einzel-Teile hatten das Problem nie (sie sitzen auf ihrem Massenmittelpunkt), der Stand-Baum folgt der Stammbasis seit 1.36 - nur diese eine Datei fiel heraus. Der Versatz kommt aus DERSELBEN Funktion wie beim Stand-Baum: die Rechnung ist aus _origin_bottom_center als _stammbasis_versatz herausgeloest und wird von beiden Wegen benutzt. Zwei Rechnungen fuer denselben Nullpunkt waeren genau die Fehlerklasse, die hier schon dreimal zugeschlagen hat - und hier haette sie besonders weh getan, weil die zusammengesetzten Truemmer exakt auf dem Platz des Baums liegen muessen. Angewendet nur fuer diesen Export, danach zurueckgenommen: mit "Teile behalten" staenden sie sonst verschoben neben dem Baum in der Szene. test_faell_teile stellt den Baum jetzt bewusst auf (18, -7.5) und prueft den Stammfuss ueber den QUERSCHNITT auf Fusshoehe, nicht ueber die Bbox-Mitte - die Krone haengt einseitig und wuerde den Fehler verwischen. Gegenprobe ohne Versatz: Stammfuss bei 18.000 / -7.500, exakt der gemeldete Wert. 27 Tests bestehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
11 KiB
Python
273 lines
11 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]
|
|
# Der Baum steht BEWUSST nicht am Ursprung. Die Gesamtdatei trug den
|
|
# Szenen-Nullpunkt mit (gemessen X 16.5..19.3 bei einem Baum auf x = 18), und
|
|
# bei (0,0,0) faellt genau das nicht auf.
|
|
STANDORT = (18.0, -7.5, 0.0)
|
|
baum.location = STANDORT
|
|
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")
|
|
if blatt is not None:
|
|
blatt.location = STANDORT
|
|
|
|
# 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)
|
|
|
|
# URSPRUNG auf der Stammbasis, wie beim Stand-Baum. Geprueft ueber den
|
|
# STAMMQUERSCHNITT, nicht ueber die Bbox-Mitte: die Krone haengt einseitig,
|
|
# und eine Bbox-Mitte wuerde den Fehler verwischen. Gesucht wird auf
|
|
# Fusshoehe ein Ring von Vertices, der die Achse umschliesst.
|
|
stamm_teile = [o for o in teile if "_Piece_Stamm" in o.name]
|
|
if stamm_teile:
|
|
sco = [o.matrix_world @ v.co for o in stamm_teile
|
|
for v in o.data.vertices]
|
|
zmin = min(c.z for c in sco)
|
|
band = [c for c in sco if c.z <= zmin + hoehe * 0.03]
|
|
mx = sum(c.x for c in band) / len(band)
|
|
my = sum(c.y for c in band) / len(band)
|
|
print("Gesamtdatei: Stammfuss bei %.3f / %.3f / %.3f" % (mx, my, zmin))
|
|
if math.hypot(mx, my) > hoehe * 0.02:
|
|
fails.append("Gesamtdatei: Stammfuss liegt bei %.2f/%.2f statt "
|
|
"ueber dem Ursprung - die Datei traegt den "
|
|
"Szenen-Nullpunkt mit" % (mx, my))
|
|
if abs(zmin) > hoehe * 0.02:
|
|
fails.append("Gesamtdatei: Stammfuss bei Z %.3f statt 0" % zmin)
|
|
|
|
print("")
|
|
if fails:
|
|
print("ERGEBNIS: %d FEHLER" % len(fails))
|
|
for x in fails:
|
|
print(" - " + x)
|
|
sys.exit(1)
|
|
print("ERGEBNIS: ALLE CHECKS OK")
|