Files
stylized-rock-generator/tests/test_faell_teile.py
T
D4rkst3randClaude Opus 4.8 95ddcb40b3 Tree Gen 1.28.0: Faell-Teile mit exakter Ast-Zuordnung (Punkt 4)
Knopf "Faell-Teile exportieren": Stamm in 2-3 Segmente, jeder Ast als eigenes
Teil SAMT seinen Sub-Aesten und Blatt-Cards, dazu <Name>_Pieces_All.fbx mit
gemeinsamem Ursprung. Je Teil zwei Material-Slots, Pivot am Massenmittelpunkt,
eigene UCX-Huelle.

Die Ast-Zugehoerigkeit ist ein ATTRIBUT, keine Schaetzung - wie von der
UE-Seite vorgeschlagen. Der Generator legt "ast_id" an den Astpunkten ab
(0 = Stamm, 1..N = Aeste). Sub-Aeste erben die ID ihres Astes automatisch, weil
sie aus Punkten auf dessen Kurve entstehen; nachgemessen: bei Sub Count 0 hat
jeder Ast 28 Verts, bei 3 dann 64, der Stamm bleibt konstant bei 156. Beim
Blatt-Streuen wird ast_id am Punkt abgegriffen und auf die Card gespeichert.

Damit ist die Zuordnung exakt nachzaehlbar, und genau das prueft der Test:
Summe der Cards ueber alle Teile == Cards des Stand-Baums. 170 = 170.

Die Falle, die diese Bilanz aufgedeckt hat: die Z-Schnitte am Stamm zerschnitten
anfangs auch Blatt-Cards. Aus einer wurden zwei, die Bilanz stand auf 178 gegen
170 - und die Atlas-UVs der Haelften waeren kaputt gewesen. Eine Card ist ein
unteilbares Billboard; sie wandert jetzt als GANZES in das Segment, in dem ihre
Inselmitte liegt. Nur die Rinde wird geschnitten, ihre Schlagflaechen werden
gedeckelt.

Massenmittelpunkt flaechengewichtet statt Vertex-Mittelwert: wo das Mesh dicht
tesselliert ist (Astansatz), wuerde der Mittelwert den Pivot dorthin ziehen und
das Teil torkelte beim Fallen.

Alles am REIMPORTIERTEN FBX gemessen: 12 Teile (3 Stamm, 9 Ast), UCX 18-32
Verts im Budget, Pivot maximal 16 mm vom Schwerpunkt, 0 non-manifold Kanten,
Gesamtdatei 5.41 m hoch - also volle Baumhoehe und deckungsgleich
rekonstruierbar.

Nebenbei: _deselect fiel ueber None-Eintraege in der Objektliste, die beim
Entfernen von Objekten zwischen den Teilen staendig auftreten.

Neu: tests/test_faell_teile.py. 21 Tests bestehen.

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

194 lines
7.3 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 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]))
teile = [o for o in bpy.context.scene.objects if o.type == 'MESH']
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, Hoehe %.2f m" % (len(teile), hoehe))
if len(teile) != len(einzeln):
fails.append("Gesamtdatei hat %d Teile, einzeln waren es %d"
% (len(teile), len(einzeln)))
# 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")