Files
D4rkst3randClaude Opus 4.8 914a1af967 Tree Gen 1.29.0: LOD-Kette (Punkt 5) + Aufraeumen/Pruefen (Punkt 6)
Punkt 5 - LOD-Kette fuer den Stand-Baum
  Haken "LOD-Kette erzeugen" liefert <Name>_LODGroup.fbx mit _LOD0..3 unter
  einem fbx_type=LodGroup-Empty, wie die Fels-Pipeline. NUR der Stand-Baum -
  die Faell-Teile leben Sekunden als Physik-Actor, dort reicht LOD0.

  Blatt-Reduktion ueber die DICHTE, nicht ueber Decimate: eine Card besteht aus
  zwei Dreiecken und laesst sich nicht vereinfachen, man muss weniger davon
  setzen. Die verbleibenden werden groesser, damit die Krone nicht ausduennt.

    LOD0  578 Rinde / 170 Blatt / 1306 Tris
    LOD1  477 / 118 /  912
    LOD2  364 /  66 /  575
    LOD3  221 /  28 /  302   (16 % der Cards von LOD0)

  Kugel-Normalen auf JEDER Stufe gemessen, nicht nur LOD0 - LOD1-3 haben eine
  eigene Card-Verteilung. Am reimportierten FBX: 100 % radial auf allen vier.

  Zwei Reihenfolge-Sachen: das Rinden-Decimate laeuft VOR dem Verschmelzen,
  danach wuerde es die Blatt-Cards mit einschmelzen. Und der Ursprung kommt aus
  LOD0 (Unreal platziert danach); alle Stufen werden um denselben Betrag
  verschoben, sonst springt der Baum beim LOD-Wechsel.

Punkt 6 - Aufraeumen und Pruefen
  Blatt-Streuung ueber alle neun Presets nachgemessen. Der Max-Radius-Filter
  arbeitet richtig: 0-22 % der Cards landen am Stamm (meist unter 9 %), und
  alle Presets bleiben unter dem 2500-Tri-Budget (Maximum weide 2246).

  Messfalle, die ich dabei selbst produziert habe: zuerst hatte ich den Anteil
  der VERTICES unter der Radius-Schwelle gemessen - beim Busch 95 %, sah nach
  einem Problem aus. Das misst aber die Vertex-Verteilung, nicht wo Blaetter
  landen; ein Busch besteht fast nur aus duennen Zweigen. Die richtige Frage
  beantwortet erst ast_id.

  Der Kaktus erzeugt regulaer 0 Cards (kein Ast duenner als Max Radius, botanisch
  richtig). Das lief still durch und man stand vor einer leeren _Leaf-Ebene -
  jetzt sagt der Operator es und nennt den Regler beim Namen.

  Panels: das Export-Panel trug Stand-Baum, LODs und Faell-Teile in einem
  Kasten. Die Teile haben jetzt "5 - Faell-Teile" fuer sich.

Neu: tests/test_baum_lod.py. 22 Tests bestehen.

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

235 lines
8.8 KiB
Python

"""Registriert alle vier Addons und prueft die Panel-Struktur.
Warum das eigene Test-Datei ist: py_compile sagt nur, dass die Datei syntaktisch
gueltig ist. Ob Blender die Panels ANNIMMT, ist eine andere Frage - bl_parent_id
muss auf ein bereits registriertes Panel zeigen, bl_category muss zwischen
Eltern- und Unter-Panel uebereinstimmen (sonst landet das Unter-Panel in einem
anderen Tab und ist praktisch unsichtbar), und jedes Panel muss zeichnen koennen,
ohne zu werfen.
Aufruf:
blender --background --factory-startup --python tests/test_panels.py
"""
import os
import sys
import importlib.util
import bpy
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Addon-Datei -> (Haupt-Panel-idname, Anzahl Unter-Panels, die offen sein DUERFEN)
#
# In den Generatoren sind Unter-Panels Einstellungen, die man selten anfasst -
# die gehoeren alle zugeklappt, damit der Erzeugen-Knopf oben sichtbar bleibt.
# In Asset Utils ist jedes Unter-Panel ein Arbeitsschritt: 1/2/4 macht man bei
# JEDEM Asset, die bleiben offen. Deshalb steht die Erwartung hier pro Addon
# statt als pauschale Regel.
ADDONS = {
"stylized_rock_generator.py": ("VIEW3D_PT_rock_generator", 9,
"rock_gen_settings", set()),
"stylized_tree_generator.py": ("VIEW3D_PT_tree_generator", 5,
"tree_gen_settings", set()),
"stylized_grass_generator.py": ("VIEW3D_PT_grass_generator", 4,
"grass_gen_settings", set()),
"stylized_asset_utils.py": ("VIEW3D_PT_asset_utils", 5,
"asset_utils_settings",
{"1 - Vorbereiten", "2 - Vertex-Farben",
"4 - Pruefen"}),
}
failures = []
def load(path, name):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
class FakeSelf:
"""Ersatz fuer die Panel-Instanz: draw() braucht nur self.layout."""
def __init__(self, layout):
self.layout = layout
class FakeLayout:
"""Minimal-Layout, das jeden UI-Aufruf schluckt und mitzaehlt.
Damit laesst sich draw() ohne echten UI-Kontext ausfuehren - der ist im
--background-Modus nicht verfuegbar.
"""
def __init__(self, counter):
self._c = counter
def _child(self, *a, **k):
return FakeLayout(self._c)
row = column = box = split = _child
def prop(self, data, name, **k):
# Zugriff erzwingen: schlaegt fehl, wenn die Property nicht existiert.
getattr(data, name)
self._c["prop"] += 1
def operator(self, idname, **k):
self._c["op"] += 1
self._c["ops"].add(idname)
return self
def label(self, **k):
self._c["label"] += 1
def separator(self, **k):
pass
def menu(self, *a, **k):
pass
def template_ID(self, *a, **k):
pass
def __setattr__(self, k, v):
# scale_y / active / enabled einfach annehmen
object.__setattr__(self, k, v)
class CollectLayout(FakeLayout):
"""Wie FakeLayout, sammelt aber die Namen der gezeichneten Properties."""
def __init__(self, names):
FakeLayout.__init__(self, {"prop": 0, "op": 0, "label": 0, "ops": set()})
self._names = names
def _child(self, *a, **k):
return CollectLayout(self._names)
row = column = box = split = _child
def prop(self, data, name, **k):
self._names.add(name)
for filename, (parent_id, want_subs, settings_attr, open_ok) in ADDONS.items():
path = os.path.join(ROOT, filename)
mod = load(path, filename[:-3])
try:
mod.register()
except Exception as exc:
failures.append("%s: register() wirft %s" % (filename, exc))
continue
panels = [c for c in mod.classes if issubclass(c, bpy.types.Panel)]
parent = [p for p in panels if getattr(p, "bl_idname", "") == parent_id]
if not parent:
failures.append("%s: Haupt-Panel %s fehlt" % (filename, parent_id))
mod.unregister()
continue
parent = parent[0]
subs = [p for p in panels if getattr(p, "bl_parent_id", "") == parent_id]
if len(subs) != want_subs:
failures.append("%s: %d Unter-Panels, erwartet %d"
% (filename, len(subs), want_subs))
for sub in subs:
# Gleicher Tab wie das Eltern-Panel?
if sub.bl_category != parent.bl_category:
failures.append("%s/%s: bl_category '%s' != Eltern-Tab '%s'"
% (filename, sub.__name__, sub.bl_category,
parent.bl_category))
# Eingeklappt - ausser bei den ausdruecklich als offen gewollten.
is_closed = 'DEFAULT_CLOSED' in getattr(sub, "bl_options", set())
if not is_closed and sub.bl_label not in open_ok:
failures.append("%s/%s (%s): offen, sollte eingeklappt sein"
% (filename, sub.__name__, sub.bl_label))
if is_closed and sub.bl_label in open_ok:
failures.append("%s/%s (%s): eingeklappt, sollte offen sein"
% (filename, sub.__name__, sub.bl_label))
# Wirklich registriert? Blender haengt bl_rna an registrierte Klassen.
if not hasattr(sub, "bl_rna"):
failures.append("%s/%s: nicht registriert"
% (filename, sub.__name__))
# Jedes Panel einmal zeichnen lassen.
total = {"prop": 0, "op": 0, "label": 0, "ops": set()}
for p in [parent] + subs:
counter = {"prop": 0, "op": 0, "label": 0, "ops": set()}
# FALLE: registrierte Panel-Klassen lassen sich nicht instanziieren
# ("bpy_struct.__new__(struct): expected a single argument"). draw() ist
# aber eine normale Python-Funktion - self darf alles sein, was .layout hat.
inst = FakeSelf(FakeLayout(counter))
try:
p.draw(inst, bpy.context)
if "draw_header" in p.__dict__:
p.draw_header(inst, bpy.context)
except Exception as exc:
failures.append("%s/%s: draw() wirft %s: %s"
% (filename, p.__name__, type(exc).__name__, exc))
continue
if counter["prop"] + counter["op"] + counter["label"] == 0:
failures.append("%s/%s: zeichnet gar nichts"
% (filename, p.__name__))
for k in ("prop", "op", "label"):
total[k] += counter[k]
total["ops"] |= counter["ops"]
# Jede definierte Property muss irgendwo im Panel erreichbar sein.
#
# Das ist der eigentliche Regressionstest beim Umbauen der Panels: beim
# Verschieben von Reglern zwischen Panels faellt leicht einer heraus, und
# dann gibt es eine Einstellung, an die man in Blender nicht mehr rankommt.
# Ein paar Regler haengen an einem Zweig (BOX-Proportionen nur bei
# base_shape='BOX'), deshalb werden diese Zweige vorher durchgeschaltet.
st = getattr(bpy.context.scene, settings_attr, None)
if st is not None:
drawn = set()
branches = [{}]
for attr, values in (("base_shape", ('CUBE', 'BOX', 'ICOSPHERE')),
("g_mode", ('SHELLS', 'MOSS')),
("shading_mode", ('AUTO', 'FLAT', 'SMOOTH'))):
if hasattr(st, attr):
branches += [{attr: v} for v in values]
for combo in branches:
for attr, val in combo.items():
try:
setattr(st, attr, val)
except TypeError:
continue
for p in [parent] + subs:
try:
p.draw(FakeSelf(CollectLayout(drawn)), bpy.context)
if "draw_header" in p.__dict__:
p.draw_header(FakeSelf(CollectLayout(drawn)), bpy.context)
except Exception:
pass
defined = set(type(st).__annotations__)
unreachable = sorted(defined - drawn)
if unreachable:
failures.append("%s: Property(s) in keinem Panel erreichbar: %s"
% (filename, ", ".join(unreachable)))
# Jeder Operator, den ein Panel anbietet, muss es auch geben.
for idname in sorted(total["ops"]):
grp, _, op = idname.partition(".")
if not hasattr(getattr(bpy.ops, grp, None), op):
failures.append("%s: Panel ruft unbekannten Operator %s"
% (filename, idname))
print("%-32s %d Unter-Panels %3d Props %2d Buttons %2d Labels"
% (filename, len(subs), total["prop"], total["op"], total["label"]))
mod.unregister()
print("")
if failures:
print("ERGEBNIS: %d FEHLER" % len(failures))
for f in failures:
print(" - " + f)
sys.exit(1)
print("ERGEBNIS: ALLE PANELS OK")