diff --git a/README.md b/README.md index 9d2b8fa..2c7571f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ > | **Stylized Rock Generator** | *Rock Gen* | Batch-Rocks + Textur-Bake + FBX/LOD-Export | > | **Stylized Tree Generator** | *Tree Gen* | Baeume/Palmen/Bueschen/Kakteen (Geometry Nodes) mit Wachstums-Stufen | > | **Stylized Grass Generator** | *Grass Gen* | Gras/Blumen/Farne mit Wind-UV fuer `SimpleGrassWind` | +> | **Stylized Asset Utils** | *Asset Utils* | **Handgebaute** Assets auf die Konvention bringen | > > Ein Blender-Extension-Repo kann mehrere Extensions ausliefern — `server-generate` > listet jedes Zip in `dist/`. Ein „Check for Updates" aktualisiert also beide. @@ -88,6 +89,33 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: --- +## Stylized Asset Utils + +Fuer alles, was **von Hand** gebaut wird (Pilze, Baumstumpf, Erzfels, Props) und +deshalb nicht durch einen Generator laeuft. Panel in fuenf einklappbaren Schritten: + +1. **Vorbereiten** — Rotation/Scale anwenden, Ursprung unten-mittig, optional + Normalen neu berechnen. Damit steht das Asset in UE bei Scale 1 auf dem Boden. +2. **Vertex-Farben** — dasselbe Layout wie beim Rock Generator + (`R` = Cavity, `G` = Maske, `B` = Hoehe, `A` = 1). + `G` wahlweise als **Teil-Maske**: bei Assets, die mit **Strg+J** aus mehreren + Objekten verbunden wurden (Pilz = Hut + Stiel), bekommt der oberste Teil 1 und + der Rest 0 — exakt statt geschaetzt. **Ein** Material toent damit beide Teile, + ein zweiter Material-Slot entfaellt. Einteilige Meshes bleiben unmarkiert. +3. **Benennung** — Auswahl nach `_` umbenennen, Mesh-Daten ziehen mit. +4. **Pruefen** — Asset-Check: nicht angewendete Transforms, Ursprung nicht unten, + Tri-Budget, fehlende UV/Vertex-Farben, lose Vertices, Custom Split Normals. + Details in der System-Konsole. +5. **Export** — FBX je Mesh mit `colors_type='SRGB'`, Triangulate und waehlbarem + Smoothing. + +**Falle, die dabei auffiel:** `bpy.ops.paint.vertex_color_set` braucht den +Vertex-Paint-Modus und scheitert im Object-Mode mit „poll() failed". Steht es mit +`vertex_color_dirt` im selben `try`, wird der ganze Bake stillschweigend +uebersprungen. Die Initialisierung laeuft deshalb direkt ueber Python. + +--- + ## Stylized Grass Generator Gras-, Blumen- und Farn-Bueschel. Der Kern ist die **Wind-UV**: diff --git a/blender_manifest_utils.toml b/blender_manifest_utils.toml new file mode 100644 index 0000000..3dd78e3 --- /dev/null +++ b/blender_manifest_utils.toml @@ -0,0 +1,22 @@ +schema_version = "1.0.0" + +id = "stylized_asset_utils" +version = "1.0.0" +name = "Stylized Asset Utils" +tagline = "Handgebaute Assets auf EcoGame-Konvention bringen" +maintainer = "D4rkst3r" +type = "add-on" + +website = "https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator" + +tags = ["Object", "Mesh", "Modeling"] + +blender_version_min = "4.2.0" + +license = [ + "SPDX:GPL-3.0-or-later", +] + +copyright = [ + "2026 D4rkst3r", +] diff --git a/build.ps1 b/build.ps1 index a2adef7..261bfa1 100644 --- a/build.ps1 +++ b/build.ps1 @@ -27,7 +27,8 @@ New-Item -ItemType Directory -Force -Path $dist | Out-Null $extensions = @( @{ name = "rock"; manifest = "blender_manifest.toml"; source = "stylized_rock_generator.py" }, @{ name = "tree"; manifest = "blender_manifest_tree.toml"; source = "stylized_tree_generator.py" }, - @{ name = "grass"; manifest = "blender_manifest_grass.toml"; source = "stylized_grass_generator.py" } + @{ name = "grass"; manifest = "blender_manifest_grass.toml"; source = "stylized_grass_generator.py" }, + @{ name = "utils"; manifest = "blender_manifest_utils.toml"; source = "stylized_asset_utils.py" } ) # Alte Zips weg, damit geloeschte Versionen nicht im Listing haengen bleiben. diff --git a/dist/index.json b/dist/index.json index ddd8d1b..fe9de26 100644 --- a/dist/index.json +++ b/dist/index.json @@ -2,6 +2,31 @@ "version": "v1", "blocklist": [], "data": [ + { + "schema_version": "1.0.0", + "id": "stylized_asset_utils", + "name": "Stylized Asset Utils", + "tagline": "Handgebaute Assets auf EcoGame-Konvention bringen", + "version": "1.0.0", + "type": "add-on", + "maintainer": "D4rkst3r", + "license": [ + "SPDX:GPL-3.0-or-later" + ], + "blender_version_min": "4.2.0", + "website": "https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator", + "copyright": [ + "2026 D4rkst3r" + ], + "tags": [ + "Object", + "Mesh", + "Modeling" + ], + "archive_url": "./stylized_asset_utils-1.0.0.zip", + "archive_size": 6340, + "archive_hash": "sha256:af8f9e95054432e4e7eea09b520b30a659ab354795191dcab0e5504c518306d3" + }, { "schema_version": "1.0.0", "id": "stylized_grass_generator", diff --git a/dist/stylized_asset_utils-1.0.0.zip b/dist/stylized_asset_utils-1.0.0.zip new file mode 100644 index 0000000..0e017fe Binary files /dev/null and b/dist/stylized_asset_utils-1.0.0.zip differ diff --git a/stylized_asset_utils.py b/stylized_asset_utils.py new file mode 100644 index 0000000..c7d781a --- /dev/null +++ b/stylized_asset_utils.py @@ -0,0 +1,545 @@ +bl_info = { + "name": "Stylized Asset Utils", + "author": "D4rkst3r", + "version": (1, 0, 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() diff --git a/tests/test_asset_utils.py b/tests/test_asset_utils.py new file mode 100644 index 0000000..2c0c426 --- /dev/null +++ b/tests/test_asset_utils.py @@ -0,0 +1,85 @@ +import bpy, sys, importlib.util, os, tempfile, math +spec=importlib.util.spec_from_file_location("sau", r"A:\eco\stylized_rock_generator\stylized_asset_utils.py") +u=importlib.util.module_from_spec(spec); sys.modules["sau"]=u; spec.loader.exec_module(u) +try: u.unregister() +except Exception: pass +u.register() +fails=[] +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) + +# Pilz wie beim User: Hut + Stiel getrennt, dann Join. Mit ABSICHT unangewendeter +# Transform und Ursprung nicht unten - das soll der Check finden. +bpy.ops.mesh.primitive_cylinder_add(vertices=10, radius=0.028, depth=0.126, location=(0,0,0.5)) +st=bpy.context.active_object +bpy.ops.mesh.primitive_uv_sphere_add(segments=14, ring_count=7, radius=0.042, location=(0,0,0.567)) +hu=bpy.context.active_object; hu.scale=(1,1,0.5); bpy.ops.object.transform_apply(scale=True) +for o in bpy.context.selected_objects: o.select_set(False) +st.select_set(True); hu.select_set(True); bpy.context.view_layer.objects.active=st +bpy.ops.object.join() +p=bpy.context.active_object; p.name="Pilz_roh" +p.rotation_euler=(0.16,0.11,-2.26); p.scale=(0.6,0.6,0.6) +bpy.ops.mesh.primitive_uv_sphere_add(radius=0.05, location=(1,0,0)) # UV vorhanden +p.select_set(True); bpy.context.view_layer.objects.active=p +for o in bpy.context.selected_objects: + if o is not p: o.select_set(False) + +s=bpy.context.scene.asset_utils_settings +s.tri_budget=400; s.g_mode='SHELLS' + +print("--- Check VOR dem Vorbereiten (soll meckern) ---") +bpy.ops.assetutils.check() +print("Scale vorher: %s Rot: %s" % ([round(v,2) for v in p.scale], + [round(math.degrees(v),1) for v in p.rotation_euler])) + +bpy.ops.assetutils.prepare() +zs=[(p.matrix_world @ v.co).z - p.location.z for v in p.data.vertices] +print("NACH prepare: Scale %s Rot %s min_z %.4f" + % ([round(v,3) for v in p.scale], + [round(math.degrees(v),1) for v in p.rotation_euler], min(zs))) +if any(abs(v-1)>1e-4 for v in p.scale): fails.append("scale_nicht_angewendet") +if any(abs(v)>1e-4 for v in p.rotation_euler): fails.append("rot_nicht_angewendet") +if abs(min(zs))>0.01: fails.append("ursprung_nicht_unten") + +bpy.ops.assetutils.bake_vcol() +ca=p.data.color_attributes.get("Cavity") +if ca is None: fails.append("keine_vcol") +else: + shells=u._mesh_shells(p.data) + infos=[] + for sh in shells: + vs={vi for pi in sh for vi in p.data.polygons[pi].vertices} + infos.append((sh, max(p.data.vertices[v].co.z for v in vs))) + infos.sort(key=lambda t:-t[1]) + g_top={round(ca.data[li].color[1],3) for pi in infos[0][0] for li in p.data.polygons[pi].loop_indices} + g_rest={round(ca.data[li].color[1],3) for sh,_ in infos[1:] for pi in sh for li in p.data.polygons[pi].loop_indices} + R=[d.color[0] for d in ca.data]; B=[d.color[2] for d in ca.data]; A=[d.color[3] for d in ca.data] + print("VCol: G_Hut=%s G_Stiel=%s | R %.2f..%.2f | B %.2f..%.2f | A %.2f" + % (sorted(g_top), sorted(g_rest), min(R),max(R),min(B),max(B),min(A))) + if g_top!={1.0}: fails.append("G_hut") + if g_rest!={0.0}: fails.append("G_stiel") + if max(B)-min(B)<0.8: fails.append("B_flach") + if min(A)<0.99: fails.append("A") + +s.rename_base="Pilz_01"; s.rename_numbered=False +bpy.ops.assetutils.rename() +print("Umbenannt zu: %s (Mesh: %s)" % (p.name, p.data.name)) +if p.name!="Pilz_01": fails.append("rename") + +print("--- Check NACH dem Vorbereiten (soll sauber sein) ---") +bpy.ops.assetutils.check() + +out=tempfile.mkdtemp(); s.export_dir=out +bpy.ops.assetutils.export() +fbx=[f for f in os.listdir(out) if f.endswith(".fbx")] +print("Export:", fbx) +if not fbx: fails.append("export") +else: + for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) + bpy.ops.import_scene.fbx(filepath=os.path.join(out,fbx[0])) + imp=[o for o in bpy.data.objects if o.type=='MESH'][0] + ic=[a.name for a in imp.data.color_attributes] + ig={round(d.color[1],2) for d in imp.data.color_attributes[0].data} if ic else set() + print("Nach FBX: VCol=%s G-Werte=%s" % (ic, sorted(ig))) + if not ic: fails.append("fbx_vcol_verloren") +u.unregister() +print("ERGEBNIS:", "ALLE CHECKS OK" if not fails else "FEHLER: "+", ".join(fails))