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 _ 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()