Files
D4rkst3randClaude Opus 4.8 71b9982441 Panels aufgeraeumt (alle vier Addons) + Test-Suite instandgesetzt
Panel-Umbau
-----------
Jedes Addon hatte seine Einstellungen als Kette dauerhaft offener Boxen - beim
Rock Generator 15 Stueck, man musste am ganzen Panel vorbeiscrollen, um an den
Erzeugen-Knopf zu kommen. Jetzt steht oben nur, was man bei jedem Asset anfasst
(Vorlage, Name, grosser Erzeugen-Knopf, Zufall), der Rest liegt in nummerierten,
eingeklappten Unter-Panels in der Reihenfolge des Arbeitsablaufs:

  Rock Gen    9 Unter-Panels (Benennung ... Textur-Bake)
  Tree Gen    4 (Wachstum, Blattwerk, Zusatz-Ebenen, Export)
  Grass Gen   4 (Form, Haltung, Batch, Kanal-Belegung)
  Asset Utils 5 (war schon so, dient als Vorlage)

Schalter, die ein ganzes Unter-Panel steuern (Vertex-Farben backen,
Wachstums-Stufen), sitzen jetzt als Haekchen im Panel-Kopf - Zustand sichtbar
ohne aufzuklappen.

Gemessen statt geglaubt: test_panels.py registriert alle vier Addons, laesst
jedes Panel zeichnen, prueft Tab-Zugehoerigkeit und Auf-/Zugeklappt-Absicht,
dass jeder angebotene Operator existiert - und vor allem, dass JEDE definierte
Property irgendwo erreichbar bleibt (Zweige wie base_shape='BOX' werden dabei
durchgeschaltet). Ergebnis: 0 von 122 Properties beim Umbau verloren.

Test-Suite
----------
Beim Gegenpruefen kam heraus, dass fuenf Tests still veraltet waren und eine
echte Regression nicht gemeldet haetten:

  test_rock_gen      suchte Objekte mit Praefix "Rock_" - die heissen seit dem
                     Benennungssystem "Fels_A_M_01". Fand 0 von 3.
  test_base_shapes   loeschte am Schleifenanfang ALLE Objekte, auch die eigenen
                     Kopien aus vorigen Durchlaeufen -> StructRNA removed.
  test_ecogame_tree  erwartete eine feste Namensliste ohne die spaeter
                     dazugekommenen _Leaf-Objekte.
  test_tree_addon    dito, ausserdem Zip-Version 1.22.0 fest verdrahtet.
  test_leaves        Zip-Version 1.23.0 fest verdrahtet.

Drei Muster vermieden statt nur die Symptome geflickt: neue Objekte ueber die
Differenz vorher/nachher finden statt ueber ein Namens-Praefix, Zips per glob
statt mit fester Version, Zusatz-Ebenen beim Zaehlen herausfiltern.

Neu: run_tests.ps1 faehrt die komplette Suite mit einem Befehl (-Filter fuer
einzelne). Genau das fehlte - deshalb war das Veralten nicht aufgefallen.
13 von 13 Tests bestehen.

Nebenbei
--------
build.ps1 ruft server-generate jetzt mit --html auf (browsbare index.html fuer
statisches Hosting). Nachgemessen und im README festgehalten: zwei Zips
derselben Extension-ID sind KEIN Versions-Archiv - server-generate warnt
("archive found with duplicates ... conflicting blender versions"), mehrere
Versionen einer ID gehen nur bei verschiedenen Blender-Bereichen.

Versionen: Rock 2.13.0, Tree 1.24.0, Grass 1.1.0, Asset Utils 1.1.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 12:12:38 +02:00

546 lines
21 KiB
Python

bl_info = {
"name": "Stylized Asset Utils",
"author": "D4rkst3r",
"version": (1, 1, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Asset Utils",
"description": (
"Werkzeuge fuer HANDGEBAUTE Assets: Transform/Ursprung, Vertex-Farben, "
"Benennung, Asset-Check und FBX-Export nach EcoGame-Konvention."
),
"category": "Object",
}
# ---------------------------------------------------------------------------
# Wofuer dieses Addon da ist
#
# Rock-, Tree- und Grass-Generator erzeugen Assets nach Konvention. Was von HAND
# gebaut wird (Pilze, Baumstumpf, Erzfels, Props) laeuft daran vorbei. Dieses
# Addon bringt beliebige Meshes auf denselben Stand:
# - Transform anwenden + Ursprung unten-mittig
# - Vertex-Farben R=Cavity / G=Teil-oder-Moos / B=Hoehe / A=1
# - Benennung nach Schema
# - Asset-Check: was wuerde in UE Aerger machen?
# - FBX-Export mit den richtigen Schaltern (Vertex-Farben!)
# ---------------------------------------------------------------------------
import bpy
import os
import math
import random
from bpy.props import (
IntProperty, FloatProperty, EnumProperty, BoolProperty,
StringProperty, PointerProperty,
)
from bpy.types import Operator, Panel, PropertyGroup
# ---------------------------------------------------------------------------
# Helfer
# ---------------------------------------------------------------------------
def _deselect_all(context):
for o in list(context.view_layer.objects):
try:
o.select_set(False)
except (ReferenceError, AttributeError, RuntimeError):
pass
def _ensure_object_mode(context):
if context.mode != 'OBJECT':
try:
bpy.ops.object.mode_set(mode='OBJECT')
except RuntimeError:
pass
def _activate(context, ob):
_deselect_all(context)
context.view_layer.objects.active = ob
ob.select_set(True)
def _tris(me):
return sum(len(p.vertices) - 2 for p in me.polygons)
def _mesh_shells(me):
"""Zusammenhaengende Teile als Mengen von Polygon-Indizes.
Assets, die aus mehreren Objekten mit Strg+J verbunden wurden (Pilz =
Hut + Stiel), bleiben geometrisch getrennt - so laesst sich die Teil-Maske
EXAKT bilden statt sie ueber Normalen zu schaetzen.
"""
adj = {}
for poly in me.polygons:
for ek in poly.edge_keys:
adj.setdefault(ek, []).append(poly.index)
seen, shells = set(), []
for poly in me.polygons:
if poly.index in seen:
continue
stack, comp = [poly.index], set()
while stack:
pi = stack.pop()
if pi in comp:
continue
comp.add(pi)
for ek in me.polygons[pi].edge_keys:
for nb in adj.get(ek, ()):
if nb not in comp:
stack.append(nb)
seen |= comp
shells.append(comp)
return shells
# ---------------------------------------------------------------------------
# Operatoren: Vorbereiten
# ---------------------------------------------------------------------------
class ASSETUTILS_OT_prepare(Operator):
bl_idname = "assetutils.prepare"
bl_label = "Transform anwenden + Ursprung unten"
bl_description = ("Wendet Rotation und Skalierung an und setzt den Ursprung "
"unten-mittig - so steht das Asset in UE bei Scale 1 auf dem Boden")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.asset_utils_settings
targets = [o for o in context.selected_objects if o.type == 'MESH']
if not targets:
self.report({'ERROR'}, "Kein Mesh ausgewaehlt.")
return {'CANCELLED'}
_ensure_object_mode(context)
scene = context.scene
prev_cursor = scene.cursor.location.copy()
for ob in targets:
_activate(context, ob)
if s.apply_transforms:
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
if s.origin_bottom:
mw = ob.matrix_world
co = [mw @ v.co for v in ob.data.vertices]
if co:
cx = sum(c.x for c in co) / len(co)
cy = sum(c.y for c in co) / len(co)
zmin = min(c.z for c in co)
scene.cursor.location = (cx, cy, zmin)
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
if s.recalc_normals:
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
scene.cursor.location = prev_cursor
self.report({'INFO'}, "%d Asset(s) vorbereitet." % len(targets))
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Operatoren: Vertex-Farben
# ---------------------------------------------------------------------------
class ASSETUTILS_OT_bake_vcol(Operator):
bl_idname = "assetutils.bake_vcol"
bl_label = "Vertex-Farben backen"
bl_description = ("Backt R=Cavity, G=Teil-/Moos-Maske, B=Hoehe, A=1 in das "
"Farbattribut 'Cavity'")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.asset_utils_settings
targets = [o for o in context.selected_objects if o.type == 'MESH']
if not targets:
self.report({'ERROR'}, "Kein Mesh ausgewaehlt.")
return {'CANCELLED'}
_ensure_object_mode(context)
notes = []
for ob in targets:
me = ob.data
if not me.polygons:
continue
layer = me.color_attributes.get("Cavity")
if layer is None:
layer = me.color_attributes.new(name="Cavity", type='BYTE_COLOR',
domain='CORNER')
me.color_attributes.active_color = layer
idx = me.color_attributes.find("Cavity")
if idx >= 0:
me.color_attributes.render_color_index = idx
_activate(context, ob)
# FALLE: bpy.ops.paint.vertex_color_set braucht den VERTEX-PAINT-Modus
# und schlaegt im Object-Mode mit "poll() failed" fehl. Steht es mit
# vertex_color_dirt im selben try-Block, wird der ganze Bake
# uebersprungen. Deshalb hier direkt in Python auf Weiss setzen -
# das geht immer und ist schneller.
for i in range(len(layer.data)):
layer.data[i].color = (1.0, 1.0, 1.0, 1.0)
try:
bpy.ops.paint.vertex_color_dirt(
blur_strength=1.0, blur_iterations=s.cavity_blur,
clean_angle=math.pi, dirt_angle=0.0,
dirt_only=False, normalize=True,
)
except RuntimeError as exc:
self.report({'WARNING'}, "%s: Cavity uebersprungen (%s)" % (ob.name, exc))
data = layer.data
cav_raw = [data[i].color[0] for i in range(len(data))]
zs = [v.co.z for v in me.vertices]
zmin, zmax = min(zs), max(zs)
span = max(zmax - zmin, 1e-6)
part = {}
if s.g_mode == 'SHELLS':
shells = _mesh_shells(me)
if len(shells) >= 2:
infos = []
for sh in shells:
vs = {vi for pi in sh for vi in me.polygons[pi].vertices}
zt = max(me.vertices[v].co.z for v in vs)
xs = [me.vertices[v].co.x for v in vs]
ys = [me.vertices[v].co.y for v in vs]
infos.append((sh, zt, max(max(xs) - min(xs), max(ys) - min(ys))))
top = max(infos, key=lambda t: (t[1], t[2]))
for sh, _, _ in infos:
for pi in sh:
part[pi] = 1.0 if sh is top[0] else 0.0
notes.append("%s: %d Teile" % (ob.name, len(shells)))
else:
# Nur ein Teil -> nichts markieren, statt etwas zu behaupten
part = {p.index: 0.0 for p in me.polygons}
notes.append("%s: 1 Teil (G=0)" % ob.name)
rng = random.Random(s.moss_seed)
vnoise = [rng.random() for _ in range(len(me.vertices))]
for poly in me.polygons:
nz = poly.normal.z
for li in poly.loop_indices:
vi = me.loops[li].vertex_index
c_raw = cav_raw[li]
r = max(0.0, min(1.0, 1.0 - (1.0 - c_raw) * s.cavity_strength))
b = max(0.0, min(1.0, (me.vertices[vi].co.z - zmin) / span))
if s.g_mode == 'SHELLS':
g = part.get(poly.index, 0.0)
else:
up = max(0.0, nz)
crev = 1.0 - c_raw
n = 1.0 - s.moss_noise + s.moss_noise * vnoise[vi]
g = max(0.0, min(1.0, (up * 0.7 + crev * 0.5) * n * s.moss_amount * 1.6))
data[li].color = (r, g, b, 1.0)
msg = "Vertex-Farben auf %d Mesh(es)." % len(targets)
if notes:
msg += " " + ", ".join(notes[:4])
self.report({'INFO'}, msg)
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Operatoren: Benennung
# ---------------------------------------------------------------------------
class ASSETUTILS_OT_rename(Operator):
bl_idname = "assetutils.rename"
bl_label = "Umbenennen"
bl_description = "Benennt die Auswahl nach <Basis>_<NN> und zieht die Mesh-Daten mit"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.asset_utils_settings
base = s.rename_base.strip()
if not base:
self.report({'ERROR'}, "Bitte einen Basisnamen eingeben.")
return {'CANCELLED'}
targets = sorted([o for o in context.selected_objects if o.type == 'MESH'],
key=lambda o: o.name)
if not targets:
self.report({'ERROR'}, "Kein Mesh ausgewaehlt.")
return {'CANCELLED'}
for i, ob in enumerate(targets):
name = base if (len(targets) == 1 and not s.rename_numbered) \
else "%s_%02d" % (base, s.rename_start + i)
ob.name = name
ob.data.name = name
self.report({'INFO'}, "%d Objekt(e) umbenannt." % len(targets))
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Operatoren: Asset-Check
# ---------------------------------------------------------------------------
class ASSETUTILS_OT_check(Operator):
bl_idname = "assetutils.check"
bl_label = "Asset-Check"
bl_description = ("Prueft die Auswahl auf die Dinge, die in UE Aerger machen: "
"Transform, Ursprung, Tri-Budget, UV, Vertex-Farben, Normalen")
bl_options = {'REGISTER'}
def execute(self, context):
s = context.scene.asset_utils_settings
targets = [o for o in context.selected_objects if o.type == 'MESH']
if not targets:
self.report({'ERROR'}, "Kein Mesh ausgewaehlt.")
return {'CANCELLED'}
problems = 0
print("\n=== Asset-Check ===")
for ob in targets:
me = ob.data
issues = []
if any(abs(v - 1.0) > 1e-4 for v in ob.scale):
issues.append("Scale nicht angewendet %s"
% [round(v, 3) for v in ob.scale])
if any(abs(v) > 1e-4 for v in ob.rotation_euler):
issues.append("Rotation nicht angewendet")
zs = [(ob.matrix_world @ v.co).z - ob.location.z for v in me.vertices]
if zs and abs(min(zs)) > s.origin_tol:
issues.append("Ursprung nicht unten (min_z %.3f)" % min(zs))
t = _tris(me)
if s.tri_budget and t > s.tri_budget:
issues.append("%d Tris > Budget %d" % (t, s.tri_budget))
if not me.uv_layers:
issues.append("kein UV")
if not me.color_attributes:
issues.append("keine Vertex-Farben")
# link_faces gibt es nur in bmesh - auf einem Mesh ueber die
# tatsaechlich benutzten Vertex-Indizes gehen.
used = {vi for poly in me.polygons for vi in poly.vertices}
n_loose = len(me.vertices) - len(used)
if n_loose:
issues.append("%d lose Vertices" % n_loose)
if me.has_custom_normals:
issues.append("Custom Split Normals (UE ggf. 'Import Normals' noetig)")
shells = len(_mesh_shells(me))
info = "%-24s %5d Tris %d Teil(e)" % (ob.name, t, shells)
if issues:
problems += 1
print(" %s\n -> %s" % (info, "; ".join(issues)))
else:
print(" %s ok" % info)
print("=== %d von %d Asset(s) mit Hinweisen ===\n" % (problems, len(targets)))
if problems:
self.report({'WARNING'},
"%d von %d Asset(s) mit Hinweisen - Details in der System-Konsole."
% (problems, len(targets)))
else:
self.report({'INFO'}, "Alle %d Asset(s) in Ordnung." % len(targets))
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Operatoren: Export
# ---------------------------------------------------------------------------
class ASSETUTILS_OT_export(Operator):
bl_idname = "assetutils.export"
bl_label = "FBX exportieren"
bl_description = "Exportiert jedes ausgewaehlte Mesh als eigene FBX (EcoGame-Settings)"
bl_options = {'REGISTER'}
def execute(self, context):
s = context.scene.asset_utils_settings
folder = bpy.path.abspath(s.export_dir) if s.export_dir else ""
if not folder or not os.path.isdir(folder):
self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.")
return {'CANCELLED'}
targets = [o for o in context.selected_objects if o.type == 'MESH']
if not targets:
self.report({'ERROR'}, "Kein Mesh ausgewaehlt.")
return {'CANCELLED'}
_ensure_object_mode(context)
done = 0
for ob in targets:
_activate(context, ob)
fp = os.path.join(folder, ob.name + ".fbx")
try:
bpy.ops.export_scene.fbx(
filepath=fp, use_selection=True, object_types={'MESH'},
use_mesh_modifiers=True,
mesh_smooth_type='FACE' if s.smooth_type == 'FACE' else 'EDGE',
use_triangles=True,
colors_type='SRGB', # Vertex-Farben mitnehmen
add_leaf_bones=False, bake_anim=False, path_mode='AUTO',
)
done += 1
except RuntimeError as exc:
self.report({'WARNING'}, "%s: %s" % (ob.name, exc))
self.report({'INFO'}, "%d FBX nach %s" % (done, folder))
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Properties
# ---------------------------------------------------------------------------
class AssetUtilsSettings(PropertyGroup):
apply_transforms: BoolProperty(name="Rotation + Scale anwenden", default=True)
origin_bottom: BoolProperty(name="Ursprung unten-mittig", default=True)
recalc_normals: BoolProperty(name="Normalen neu berechnen", default=False)
g_mode: EnumProperty(
name="G-Kanal",
items=[
('SHELLS', "Teil-Maske", "Oberster getrennter Teil = 1 (z.B. Pilzhut)"),
('MOSS', "Moos/Schmutz", "Oberseite + Ritzen + Rauschen"),
],
default='SHELLS',
)
cavity_strength: FloatProperty(name="Cavity-Staerke", default=0.7, min=0.0, max=1.0)
cavity_blur: IntProperty(name="Weichzeichnen", default=1, min=0, max=5)
moss_amount: FloatProperty(name="Moos-Menge", default=0.55, min=0.0, max=1.0)
moss_noise: FloatProperty(name="Moos-Aufbruch", default=0.55, min=0.0, max=1.0)
moss_seed: IntProperty(name="Moos-Seed", default=0, min=0, max=9999)
rename_base: StringProperty(name="Basisname", default="")
rename_start: IntProperty(name="Startnummer", default=1, min=0, max=99)
rename_numbered: BoolProperty(name="Immer nummerieren", default=False)
tri_budget: IntProperty(
name="Tri-Budget", default=2500, min=0, max=100000,
description="0 = nicht pruefen. ASSETS.md: Gras 400, Busch 800, Baum 2500",
)
origin_tol: FloatProperty(name="Ursprung-Toleranz (m)", default=0.01,
min=0.0, max=0.5)
export_dir: StringProperty(name="Export-Ordner", default="", subtype='DIR_PATH')
smooth_type: EnumProperty(
name="Smoothing",
items=[('FACE', "Faceted", "Harte Facetten"),
('EDGE', "Edge", "Weiche Kanten mit Sharp-Edges")],
default='FACE',
)
# ---------------------------------------------------------------------------
# Panels (Haupt-Panel + einklappbare Unter-Panels)
# ---------------------------------------------------------------------------
class VIEW3D_PT_asset_utils(Panel):
bl_label = "Asset Utils"
bl_idname = "VIEW3D_PT_asset_utils"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Asset Utils"
def draw(self, context):
n = len([o for o in context.selected_objects if o.type == 'MESH'])
self.layout.label(text="%d Mesh(es) ausgewaehlt" % n,
icon='OUTLINER_OB_MESH' if n else 'INFO')
class _Sub(Panel):
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Asset Utils"
bl_parent_id = "VIEW3D_PT_asset_utils"
bl_options = {'DEFAULT_CLOSED'}
class VIEW3D_PT_au_prepare(_Sub):
bl_label = "1 - Vorbereiten"
bl_options = set() # offen lassen
def draw(self, context):
s = context.scene.asset_utils_settings
col = self.layout.column(align=True)
col.prop(s, "apply_transforms")
col.prop(s, "origin_bottom")
col.prop(s, "recalc_normals")
self.layout.operator("assetutils.prepare", icon='OBJECT_ORIGIN')
class VIEW3D_PT_au_vcol(_Sub):
bl_label = "2 - Vertex-Farben"
bl_options = set()
def draw(self, context):
s = context.scene.asset_utils_settings
lay = self.layout
lay.prop(s, "g_mode", expand=True)
col = lay.column(align=True)
col.prop(s, "cavity_strength")
col.prop(s, "cavity_blur")
if s.g_mode == 'MOSS':
col.separator()
col.prop(s, "moss_amount")
col.prop(s, "moss_noise")
col.prop(s, "moss_seed")
lay.label(text="R=Cavity G=%s B=Hoehe"
% ("Teil" if s.g_mode == 'SHELLS' else "Moos"), icon='INFO')
lay.operator("assetutils.bake_vcol", icon='BRUSH_DATA')
class VIEW3D_PT_au_name(_Sub):
bl_label = "3 - Benennung"
def draw(self, context):
s = context.scene.asset_utils_settings
col = self.layout.column(align=True)
col.prop(s, "rename_base")
row = col.row(align=True)
row.prop(s, "rename_start")
row.prop(s, "rename_numbered", text="", icon='LINENUMBERS_ON')
self.layout.operator("assetutils.rename", icon='SORTALPHA')
class VIEW3D_PT_au_check(_Sub):
bl_label = "4 - Pruefen"
bl_options = set()
def draw(self, context):
s = context.scene.asset_utils_settings
col = self.layout.column(align=True)
col.prop(s, "tri_budget")
col.prop(s, "origin_tol")
self.layout.operator("assetutils.check", icon='CHECKMARK')
class VIEW3D_PT_au_export(_Sub):
bl_label = "5 - Export"
def draw(self, context):
s = context.scene.asset_utils_settings
col = self.layout.column(align=True)
col.prop(s, "export_dir")
col.prop(s, "smooth_type", expand=True)
self.layout.operator("assetutils.export", icon='EXPORT')
self.layout.label(text="UE: Vertex Color = Replace", icon='INFO')
classes = (
AssetUtilsSettings,
ASSETUTILS_OT_prepare,
ASSETUTILS_OT_bake_vcol,
ASSETUTILS_OT_rename,
ASSETUTILS_OT_check,
ASSETUTILS_OT_export,
VIEW3D_PT_asset_utils,
VIEW3D_PT_au_prepare,
VIEW3D_PT_au_vcol,
VIEW3D_PT_au_name,
VIEW3D_PT_au_check,
VIEW3D_PT_au_export,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.asset_utils_settings = PointerProperty(type=AssetUtilsSettings)
def unregister():
del bpy.types.Scene.asset_utils_settings
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()