diff --git a/README.md b/README.md index 70ca002..9d2b8fa 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: **Moosfarbe** werden pro Material-Instanz geregelt, nicht im Mesh festgelegt. Regler: `Cavity-Staerke`, `Weichzeichnen`, `Moos-Menge`, `Moos-Aufbruch`, `Moos-Seed`. + **G alternativ als Teil-Maske:** Haken `G = Teil-Maske (Shells)` markiert + getrennte Mesh-Teile statt Moos — der oberste/breiteste Teil bekommt 1, der + Rest 0. Assets, die aus mehreren Objekten mit **Strg+J** verbunden wurden + (Pilz = Hut + Stiel), lassen sich damit EXAKT trennen statt ueber Normalen + geschaetzt. Ein Material toent dann beide Teile unterschiedlich — **kein + zweiter Material-Slot noetig**. Einteilige Meshes bleiben unmarkiert (G=0). + **Der Button `Vertex-Cavity auf Auswahl` backt das Layout auf JEDES markierte + Mesh** — also auch auf handgebaute Assets, nicht nur auf generierte Steine. Verifiziert: R ohne Hoehenabhaengigkeit (unten 0.62 / oben 0.59), B steigt sauber (0.08 -> 0.92), G oben staerker (0.20 -> 0.57), A konstant 1, Seed aendert die Verteilung, und alle Kanaele ueberleben den FBX-Roundtrip. diff --git a/blender_manifest.toml b/blender_manifest.toml index 0db2713..73dd23d 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.11.0" +version = "2.12.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/dist/index.json b/dist/index.json index e153d8e..ddd8d1b 100644 --- a/dist/index.json +++ b/dist/index.json @@ -32,7 +32,7 @@ "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.11.0", + "version": "2.12.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -48,9 +48,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.11.0.zip", - "archive_size": 20382, - "archive_hash": "sha256:50eeed1d105ad68487e25020fdf28f5e3b8d81e83f9a21b9d33968eaa06aff2d" + "archive_url": "./stylized_rock_generator-2.12.0.zip", + "archive_size": 21754, + "archive_hash": "sha256:6e18e32b841d627edfa6530587c99080c50d02ac4b0a532ed6079796bfa2aaf4" }, { "schema_version": "1.0.0", diff --git a/dist/stylized_rock_generator-2.11.0.zip b/dist/stylized_rock_generator-2.11.0.zip deleted file mode 100644 index 61dcccc..0000000 Binary files a/dist/stylized_rock_generator-2.11.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.12.0.zip b/dist/stylized_rock_generator-2.12.0.zip new file mode 100644 index 0000000..e47815d Binary files /dev/null and b/dist/stylized_rock_generator-2.12.0.zip differ diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index a7f3243..b1ef431 100644 --- a/stylized_rock_generator.py +++ b/stylized_rock_generator.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", - "version": (2, 11, 0), + "version": (2, 12, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -61,7 +61,7 @@ PRESET_KEYS = ( "randomize_shape", "noise_scale_max", "displace_strength_max", "nonuniform_scale", "flatten_bottom", "flatten_ratio", "set_origin_bottom", "keep_size", "bake_vertex_cavity", "cavity_strength", "cavity_blur", - "moss_amount", "moss_noise", "moss_seed", + "moss_amount", "moss_noise", "moss_seed", "part_mask_from_shells", "use_class_size", "base_size", ) @@ -865,6 +865,13 @@ class RockGenSettings(PropertyGroup): name="Weichzeichnen", default=1, min=0, max=5, description="Glaettet den Cavity-Verlauf ueber benachbarte Vertices", ) + part_mask_from_shells: BoolProperty( + name="G = Teil-Maske (Shells)", default=False, + description=("Statt der Moos-Maske: getrennte Mesh-Teile markieren. " + "Der oberste/breiteste Teil bekommt G=1 (z.B. Pilzhut), " + "der Rest 0 (Stiel). Nutzt Strg+J-verbundene Objekte aus - " + "damit tont EIN Material beide Teile unterschiedlich"), + ) moss_amount: FloatProperty( name="Moos-Menge", default=0.55, min=0.0, max=1.0, description=("Wie viel der Oberflaeche die Moos-/Schmutzmaske (Kanal G) " @@ -1505,6 +1512,102 @@ class OBJECT_OT_bake_rock_textures(Operator): # Operatoren: Presets # --------------------------------------------------------------------------- +def _mesh_shells(me): + """Zusammenhaengende Teile (Shells) als Listen von Polygon-Indizes. + + Bei Assets, die aus mehreren Objekten mit Strg+J verbunden wurden (z.B. + Pilz = Hut + Stiel), bleiben die Teile geometrisch getrennt. Damit 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 = set() + shells = [] + for poly in me.polygons: + if poly.index in seen: + continue + stack = [poly.index] + comp = 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 + + +def _shell_part_mask(me): + """G-Wert je Polygon: 1 fuer den OBEREN/breiteren Teil (z.B. Hut), 0 sonst. + + Bei nur einer Shell gibt es nichts zu trennen -> alles 0, damit die Maske + nicht faelschlich etwas markiert. + """ + shells = _mesh_shells(me) + if len(shells) < 2: + return {p.index: 0.0 for p in me.polygons}, len(shells) + infos = [] + for sh in shells: + vs = {vi for pi in sh for vi in me.polygons[pi].vertices} + zs = [me.vertices[i].co.z for i in vs] + xs = [me.vertices[i].co.x for i in vs] + ys = [me.vertices[i].co.y for i in vs] + infos.append((sh, max(zs), max(max(xs) - min(xs), max(ys) - min(ys)))) + # Oberster Teil, bei Gleichstand der breitere -> das ist der Hut/Deckel + top = max(infos, key=lambda t: (t[1], t[2])) + out = {} + for sh, _, _ in infos: + val = 1.0 if sh is top[0] else 0.0 + for pi in sh: + out[pi] = val + return out, len(shells) + + +class ROCKGEN_OT_bake_cavity_selection(Operator): + bl_idname = "rockgen.bake_cavity_selection" + bl_label = "Vertex-Cavity auf Auswahl" + bl_description = ("Backt das Layout R=Cavity / G=Teil-Maske / B=Hoehe auf ALLE " + "ausgewaehlten Meshes - auch auf handgebaute Assets") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.rock_gen_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) + done, notes = 0, [] + for ob in targets: + try: + _bake_vertex_cavity(context, ob, s) + if s.part_mask_from_shells: + mask, n = _shell_part_mask(ob.data) + layer = ob.data.color_attributes.get("Cavity") + if layer is not None: + for poly in ob.data.polygons: + g = mask.get(poly.index, 0.0) + for li in poly.loop_indices: + c = layer.data[li].color + layer.data[li].color = (c[0], g, c[2], 1.0) + notes.append("%s: %d Teil(e)" % (ob.name, n)) + done += 1 + except Exception as exc: # noqa: BLE001 + self.report({'WARNING'}, "%s: %s" % (ob.name, exc)) + msg = "Cavity auf %d Mesh(es) gebacken." % done + if notes: + msg += " " + ", ".join(notes[:4]) + self.report({'INFO'}, msg) + return {'FINISHED'} + + class ROCKGEN_OT_randomize(Operator): bl_idname = "rockgen.randomize" bl_label = "Zufaellig" @@ -1732,7 +1835,12 @@ class VIEW3D_PT_rock_generator(Panel): sub.prop(settings, "moss_amount") sub.prop(settings, "moss_noise") sub.prop(settings, "moss_seed") - sub.label(text="R=Cavity G=Moos B=Hoehe", icon='INFO') + sub.prop(settings, "part_mask_from_shells") + sub.label(text="R=Cavity G=%s B=Hoehe" + % ("Teil-Maske" if settings.part_mask_from_shells else "Moos"), + icon='INFO') + box.separator() + box.operator("rockgen.bake_cavity_selection", icon='BRUSH_DATA') box = layout.box() box.label(text="Material") @@ -1803,6 +1911,7 @@ classes = ( OBJECT_OT_generate_rocks, OBJECT_OT_export_rocks_fbx, OBJECT_OT_bake_rock_textures, + ROCKGEN_OT_bake_cavity_selection, ROCKGEN_OT_randomize, ROCKGEN_OT_preset_save, ROCKGEN_OT_preset_load, diff --git a/tests/test_part_mask.py b/tests/test_part_mask.py new file mode 100644 index 0000000..663d34d --- /dev/null +++ b/tests/test_part_mask.py @@ -0,0 +1,70 @@ +import bpy, sys, importlib.util, bmesh +spec=importlib.util.spec_from_file_location("srg", r"A:\eco\stylized_rock_generator\stylized_rock_generator.py") +r=importlib.util.module_from_spec(spec); sys.modules["srg"]=r; spec.loader.exec_module(r) +try: r.unregister() +except Exception: pass +r.register() +fails=[] +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) + +# Pilz nachbauen: Stiel (Zylinder) + Hut (UV-Kugel oben), dann Strg+J +bpy.ops.mesh.primitive_cylinder_add(vertices=10, radius=0.028, depth=0.126, location=(0,0,0.063)) +stiel=bpy.context.active_object; stiel.name="Stiel" +bpy.ops.mesh.primitive_uv_sphere_add(segments=14, ring_count=7, radius=0.042, location=(0,0,0.13)) +hut=bpy.context.active_object; hut.name="Hut" +hut.scale=(1,1,0.5); bpy.ops.object.transform_apply(scale=True) +for o in bpy.context.selected_objects: o.select_set(False) +stiel.select_set(True); hut.select_set(True) +bpy.context.view_layer.objects.active=stiel +bpy.ops.object.join() +pilz=bpy.context.active_object; pilz.name="Mushroom_Test" +print("Pilz: %d Tris" % sum(len(p.vertices)-2 for p in pilz.data.polygons)) + +s=bpy.context.scene.rock_gen_settings +s.bake_vertex_cavity=True; s.cavity_strength=0.7 +s.part_mask_from_shells=True +pilz.select_set(True); bpy.context.view_layer.objects.active=pilz +res=bpy.ops.rockgen.bake_cavity_selection() +print("Operator:", list(res)) + +me=pilz.data +ca=me.color_attributes.get("Cavity") +if ca is None: fails.append("kein_attribut") +else: + # Hut liegt oben -> dort muss G=1 sein, am Stiel G=0 + # Direkt gegen die Shell-Zerlegung pruefen statt ueber z-Schwellen: + # der oberste Teil (Hut) muss ueberall G=1 haben, alle anderen Teile G=0. + shells = r._mesh_shells(me) + infos = [] + for sh in shells: + vs = {vi for pi in sh for vi in me.polygons[pi].vertices} + infos.append((sh, max(me.vertices[v].co.z for v in vs))) + infos.sort(key=lambda t: -t[1]) + top_sh = infos[0][0] + g_top = {round(ca.data[li].color[1],3) + for pi in top_sh for li in me.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 me.polygons[pi].loop_indices} + print("Shells: %d | G im obersten Teil: %s | G im Rest: %s" + % (len(shells), sorted(g_top), sorted(g_rest))) + if g_top != {1.0}: fails.append("hut_nicht_1") + if g_rest != {0.0}: fails.append("stiel_nicht_0") + # G darf NUR 0 oder 1 sein (harte Maske) + vals={round(d.color[1],3) for d in ca.data} + print("G Werte insgesamt: %s" % sorted(vals)) + if not vals <= {0.0,1.0}: fails.append("G_nicht_binaer") + R=[d.color[0] for d in ca.data]; B=[d.color[2] for d in ca.data] + print("R %.2f..%.2f B %.2f..%.2f" % (min(R),max(R),min(B),max(B))) + if max(B)-min(B) < 0.8: fails.append("B_kein_verlauf") + +# Einzel-Shell-Fall: darf nichts faelschlich markieren +bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=2, radius=0.1, location=(1,0,0.1)) +solo=bpy.context.active_object +for o in bpy.context.selected_objects: o.select_set(False) +solo.select_set(True); bpy.context.view_layer.objects.active=solo +bpy.ops.rockgen.bake_cavity_selection() +g2={round(d.color[1],3) for d in solo.data.color_attributes["Cavity"].data} +print("Einzelteil-Mesh -> G Werte: %s" % sorted(g2)) +if g2 != {0.0}: fails.append("einzelshell_markiert") +r.unregister() +print("ERGEBNIS:", "ALLE CHECKS OK" if not fails else "FEHLER: "+", ".join(fails))