diff --git a/README.md b/README.md index 488c4d7..69e1eb1 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,40 @@ Gemessen (dicht, ~2700 Tris): Erz-Anteil 0.08 → 4 %, 0.18 → 9 %, 0.35 → 15 der Oberflaeche. A faellt auf 0.00 und erreicht 1.00, und die Maske ueberlebt den FBX-Roundtrip. +## Bruchstuecke (Steine zerschlagen) + +Kein Laufzeit-Fracturing im Spiel: die Teile werden beim Anschlagen als +Physik-Actors gespawnt. Sie entstehen deshalb hier als vollwertige Assets. +Knopf **In Bruchstuecke zerlegen** arbeitet auf der Auswahl und liefert +`_Piece_01..NN`. Regler: `Bruchstuecke` (2-4), `Bruch-Seed`, +`Ein Stueck mit Erz`, `Mutterstein ausblenden`. + +Jedes Teil bekommt: Vertex-Layout v2, Ursprung unten-mittig, die **gleichen +Material-Slots** wie der Mutterstein (die UE-Skripte mappen darueber) und beim +Export eine eigene `UCX_`-Huelle. + +**Punkt 4a:** mit `Ein Stueck mit Erz` heisst ein zufaelliges Teil +`_Piece_Erz` und traegt Erz-Adern im A-Kanal. Alle anderen Teile behalten +A = 1.0. Ob das Erz-Stueck gespawnt wird, entscheidet die Loot-Logik im Spiel. + +**Wie die Bruchkanten zusammenpassen:** Voronoi ueber Halbraeume. Fuer jedes +andere Zentrum wird an der Mittelsenkrechten geschnitten und die abgewandte +Seite verworfen. Benachbarte Zellen schneiden dadurch an *genau derselben* +Ebene - die Bruchflaechen passen zusammen, ohne dass man sie abstimmen muesste. +Bewusst ohne das Cell-Fracture-Addon: das muesste beim User aktiviert sein und +haengt an Operator-Kontext, bmesh laeuft immer. + +> **Die entscheidende Falle.** Jeder Schnitt muss **sofort** geschlossen werden, +> und zwar ueber genau die Kanten, die `bisect_plane` als `geom_cut` zurueckgibt. +> Der naheliegende Weg - am Ende alle Randkanten suchen und fuellen - erzeugte +> Kanten mit vier Flaechen; raeumte man die ab, blieben Loecher. Aufgefallen ist +> das am **Volumen**: die Teile kamen zusammen auf 72.7 % des Muttersteins, +> weil `calc_volume` bei offenen Meshes Unsinn liefert. Jetzt sind es **100.2 %** +> bei 2, 3 und 4 Teilen, mit 0 offenen und 0 non-manifold Kanten. + +`tests/test_bruchstuecke.py` prueft genau diesen Volumen-Vergleich - er ist +zugleich das Warnsignal fuer kaputte Topologie. + ## Smooth & dicht Knopf im Form-Panel: setzt Unterteilung 5, Decimate 0.40/0.80 und Auto-Smooth. @@ -484,6 +518,7 @@ gefiltert `.\run_tests.ps1 -Filter panels`. | Datei | prueft | |---|---| | `tests/test_proportionen.py` | Baum-Proportionen: Palmenkrone, Strauch-Breite, Setzling-Schlankheit, Branch Count 0 | +| `tests/test_bruchstuecke.py` | Bruchstuecke: Volumen 100 % des Muttersteins, dichte Topologie, genau ein Erz-Stueck, Material-Slots, Ursprung, UCX je Teil | | `tests/test_erz.py` | Erz-Maske: A=1.0 ohne Erz, Anteil monoton, Set-Namen/Groessen, Einstellungen zurueckgesetzt, FBX-Roundtrip, Tri-Fenster | | `tests/test_export_robust.py` | Export am **reimportierten** FBX: Ursprung erzwungen, flach = liegend, UCX geschlossen/umschliessend/im Budget, LOD+Vertex-Farben | | `tests/test_panels.py` | Alle vier Addons: registrieren, jedes Panel zeichnet, Unter-Panels im richtigen Tab, **jede Property irgendwo erreichbar** | diff --git a/blender_manifest.toml b/blender_manifest.toml index 12a6276..4602900 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.15.0" +version = "2.16.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/dist/index.html b/dist/index.html index 474edf9..f514219 100644 --- a/dist/index.html +++ b/dist/index.html @@ -40,14 +40,14 @@ 4.9KB - stylized_rock_generator-2.15.0 + stylized_rock_generator-2.16.0 Stylized Rock Generator Batch-Generator fuer stylized Rocks mit UE5-Export link 4.2.0 - ~ all all - 28.9KB + 31.3KB stylized_tree_generator-1.25.0 @@ -61,6 +61,6 @@ -

Built 2026-08-03, 06:13

+

Built 2026-08-03, 06:23

diff --git a/dist/index.json b/dist/index.json index d2d3988..0514d69 100644 --- a/dist/index.json +++ b/dist/index.json @@ -57,7 +57,7 @@ "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.15.0", + "version": "2.16.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -73,9 +73,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.15.0.zip", - "archive_size": 29580, - "archive_hash": "sha256:f57604a8176506626352d61e79a5a449f8d1aedced3aeed3a288a1abe5efcd51" + "archive_url": "./stylized_rock_generator-2.16.0.zip", + "archive_size": 32002, + "archive_hash": "sha256:cb9cb1d36073da86d67d5997b9266a7d3192587591995a0fd59f6fa12f57b3e1" }, { "schema_version": "1.0.0", diff --git a/dist/stylized_rock_generator-2.15.0.zip b/dist/stylized_rock_generator-2.15.0.zip deleted file mode 100644 index a1106a8..0000000 Binary files a/dist/stylized_rock_generator-2.15.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.16.0.zip b/dist/stylized_rock_generator-2.16.0.zip new file mode 100644 index 0000000..fb72402 Binary files /dev/null and b/dist/stylized_rock_generator-2.16.0.zip differ diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index d3b818b..810f587 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, 15, 0), + "version": (2, 16, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -1007,6 +1007,26 @@ class RockGenSettings(PropertyGroup): description="Andere Verteilung der Nuggets bei gleicher Form", ) + # --- Bruchstuecke --- + piece_count: IntProperty( + name="Bruchstuecke", default=3, min=2, max=4, + description="In wie viele Teile ein Stein zerfaellt", + ) + piece_with_ore: BoolProperty( + name="Ein Stueck mit Erz", default=False, + description=("Ein zufaelliges Bruchstueck heisst _Piece_Erz und " + "traegt Erz im A-Kanal. Ob es gespawnt wird, entscheidet " + "die Loot-Logik im Spiel"), + ) + piece_seed: IntProperty( + name="Bruch-Seed", default=0, min=0, max=9999, + description="Andere Bruchlinien bei gleicher Form", + ) + piece_hide_source: BoolProperty( + name="Mutterstein ausblenden", default=True, + description="Blendet den ungebrochenen Stein aus, statt ihn zu loeschen", + ) + part_mask_from_shells: BoolProperty( name="G = Teil-Maske (Shells)", default=False, description=("Statt der Moos-Maske: getrennte Mesh-Teile markieren. " @@ -1565,6 +1585,169 @@ def _safe_filename(name): return "".join(c for c in name if c.isalnum() or c in keep).strip() or "Rock" +def _voronoi_cells(me, punkte): + """Zerlegt ein Mesh in Voronoi-Zellen. Liefert je Zelle ein neues Mesh. + + Verfahren: eine Zelle ist der Schnitt aller Halbraeume. Fuer jedes andere + Zentrum wird an der Mittelsenkrechten zwischen beiden Zentren geschnitten und + die abgewandte Seite verworfen. Der Clou: benachbarte Zellen schneiden an + GENAU DERSELBEN Ebene - dadurch passen die Bruchflaechen zusammen, ohne dass + man sie aufeinander abstimmen muesste. + + Bewusst ohne das Cell-Fracture-Addon: das muesste beim User aktiviert sein + und arbeitet ueber Operatoren mit Kontext-Anforderungen. bmesh laeuft immer. + + Wichtig ist, JEDEN Schnitt sofort zu schliessen und dafuer genau die Kanten + zu nehmen, die `bisect_plane` als `geom_cut` zurueckgibt. Der Umweg ueber + "am Ende alle Randkanten suchen und fuellen" war nicht verlaesslich: er hat + Kanten mit vier Flaechen erzeugt, und wenn man die abgeraeumt hat, blieben + Loecher. Ein offenes Bruchstueck ist als Physik-Actor unbrauchbar. + """ + zellen = [] + for i, pi in enumerate(punkte): + bm = bmesh.new() + bm.from_mesh(me) + for j, pj in enumerate(punkte): + if i == j: + continue + normale = pj - pi + laenge = normale.length + if laenge < 1e-6: + continue + normale = normale / laenge + mitte = (pi + pj) * 0.5 + ergebnis = bmesh.ops.bisect_plane( + bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], + plane_co=mitte, plane_no=normale, + clear_outer=True, # alles jenseits der Ebene verwerfen + use_snap_center=False, + ) + if not bm.faces: + break + # Genau die Schnittkanten schliessen - das ist die Bruchflaeche. + schnitt = [g for g in ergebnis.get("geom_cut", []) + if isinstance(g, bmesh.types.BMEdge) and g.is_valid] + if schnitt: + try: + bmesh.ops.edgeloop_fill(bm, edges=schnitt) + except (TypeError, ValueError, RuntimeError): + bmesh.ops.holes_fill(bm, edges=schnitt, sides=0) + + if not bm.faces: + bm.free() + zellen.append(None) + continue + + # Sicherheitsnetz, falls ein Schnitt doch etwas offen gelassen hat. + rand = [e for e in bm.edges if len(e.link_faces) == 1] + if rand: + bmesh.ops.holes_fill(bm, edges=rand, sides=0) + + gross = [f for f in bm.faces if len(f.verts) > 4] + if gross: + bmesh.ops.triangulate(bm, faces=gross) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) + bm.normal_update() + + neu = bpy.data.meshes.new(me.name + "_cell%d" % i) + bm.to_mesh(neu) + bm.free() + zellen.append(neu) + return zellen + + +class ROCKGEN_OT_fracture(Operator): + """Zerlegt die ausgewaehlten Steine in Bruchstuecke. + + Kein Laufzeit-Fracturing im Spiel: die Teile werden beim Anschlagen als + Physik-Actors gespawnt. Deshalb entstehen sie hier als vollwertige Assets - + Vertex-Layout v2, Ursprung unten-mittig, gleiche Material-Slots, und beim + Export bekommt jedes Teil seine eigene UCX-Huelle. + """ + bl_idname = "rockgen.fracture" + bl_label = "In Bruchstuecke zerlegen" + bl_description = ("Erzeugt _Piece_01..NN mit zusammenpassenden " + "Bruchkanten. Optional ein Stueck mit Erz-Adern") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.rock_gen_settings + _ensure_object_mode(context) + quellen = [o for o in context.selected_objects if o.type == 'MESH'] + if not quellen: + self.report({'ERROR'}, "Keine Mesh-Objekte ausgewaehlt.") + return {'CANCELLED'} + + rng = random.Random(s.piece_seed) + gesamt, leer = 0, 0 + + for src in quellen: + me = src.data + co = [v.co for v in me.vertices] + if not co: + continue + lo = Vector((min(c.x for c in co), min(c.y for c in co), + min(c.z for c in co))) + hi = Vector((max(c.x for c in co), max(c.y for c in co), + max(c.z for c in co))) + + # Zentren im Inneren streuen, mit Abstand zum Rand: sonst entstehen + # hauchduenne Splitter statt brauchbarer Brocken. + n = s.piece_count + punkte = [Vector(( + lo.x + (hi.x - lo.x) * (0.2 + 0.6 * rng.random()), + lo.y + (hi.y - lo.y) * (0.2 + 0.6 * rng.random()), + lo.z + (hi.z - lo.z) * (0.2 + 0.6 * rng.random()), + )) for _ in range(n)] + + zellen = _voronoi_cells(me, punkte) + # Punkt 4a: ein Stueck traegt optional das Erz. Ob es gespawnt wird, + # entscheidet die Loot-Logik im Spiel. + erz_idx = rng.randrange(n) if s.piece_with_ore else -1 + + for i, zelle in enumerate(zellen): + if zelle is None or len(zelle.polygons) == 0: + leer += 1 + if zelle is not None: + bpy.data.meshes.remove(zelle) + continue + ist_erz = (i == erz_idx) + name = "%s_Piece_%s" % (src.name, + "Erz" if ist_erz else "%02d" % (i + 1)) + zelle.name = name + ob = bpy.data.objects.new(name, zelle) + context.collection.objects.link(ob) + ob.matrix_world = src.matrix_world.copy() + + # Gleiche Material-Slots wie der Mutterstein - die UE-Skripte + # mappen ueber die Slot-Namen. + for mat in me.materials: + zelle.materials.append(mat) + + _apply_shading(context, ob, s) + if s.bake_vertex_cavity: + merker = s.bake_ore + s.bake_ore = ist_erz + try: + _bake_vertex_cavity(context, ob, s) + except RockGenError as exc: + print("[RockGen] Cavity auf %s: %s" % (name, exc)) + finally: + s.bake_ore = merker + _set_origin_bottom(context, ob) + gesamt += 1 + + if s.piece_hide_source: + src.hide_set(True) + + if leer: + self.report({'WARNING'}, "%d Bruchstueck(e), %d Zelle(n) leer " + "(Zentren lagen zu dicht)." % (gesamt, leer)) + else: + self.report({'INFO'}, "%d Bruchstueck(e) erzeugt." % gesamt) + return {'FINISHED'} + + class ROCKGEN_OT_dense_preset(Operator): """Stellt auf smooth und dicht um (~2700 Tris statt ~620).""" bl_idname = "rockgen.dense_preset" @@ -2389,6 +2572,15 @@ class VIEW3D_PT_rock_vcol(_RockSub): sub.prop(s, "ore_seed") sub.separator() sub.operator("rockgen.ore_set", icon='MOD_EXPLODE') + layout.separator() + box = layout.box() + box.label(text="Bruchstuecke", icon='MOD_BUILD') + row = box.row(align=True) + row.prop(s, "piece_count") + row.prop(s, "piece_seed", text="") + box.prop(s, "piece_with_ore") + box.prop(s, "piece_hide_source") + box.operator("rockgen.fracture", icon='MOD_BUILD') col.label(text="R=Cavity G=%s B=Hoehe A=%s" % ("Teil-Maske" if s.part_mask_from_shells else "Moos", "Erz" if s.bake_ore else "1.0"), @@ -2503,6 +2695,7 @@ classes = ( OBJECT_OT_bake_rock_textures, ROCKGEN_OT_bake_cavity_selection, ROCKGEN_OT_dense_preset, + ROCKGEN_OT_fracture, ROCKGEN_OT_ore_set, ROCKGEN_OT_randomize, ROCKGEN_OT_preset_save, diff --git a/tests/test_bruchstuecke.py b/tests/test_bruchstuecke.py new file mode 100644 index 0000000..5000a63 --- /dev/null +++ b/tests/test_bruchstuecke.py @@ -0,0 +1,160 @@ +"""Bruchstuecke (Wunschliste 4 und 4a). + +Kein Laufzeit-Fracturing im Spiel: die Teile werden beim Anschlagen als +Physik-Actors gespawnt. Sie muessen deshalb vollwertige Assets sein. + +Der wichtigste Test ist der VOLUMEN-Vergleich: die Teile muessen zusammen den +Mutterstein ergeben. Er ist auch das Warnsignal fuer schlechte Topologie - +`calc_volume` liefert bei offenen Meshes Unsinn. Genau daran ist eine fruehere +Fassung aufgefallen: sie kam auf 72.7 %, weil das Fuellen der Bruchflaechen +Loecher hinterliess. + +Aufruf: blender --background --factory-startup --python tests/test_bruchstuecke.py +""" +import os +import sys +import tempfile +import importlib.util + +import bpy +import bmesh + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +spec = importlib.util.spec_from_file_location( + "rg", os.path.join(ROOT, "stylized_rock_generator.py")) +rg = importlib.util.module_from_spec(spec) +sys.modules["rg"] = rg +spec.loader.exec_module(rg) +rg.register() + +fails = [] + + +def topologie(ob): + bm = bmesh.new() + bm.from_mesh(ob.data) + offen = sum(1 for e in bm.edges if len(e.link_faces) == 1) + nm = sum(1 for e in bm.edges if len(e.link_faces) > 2) + draht = sum(1 for e in bm.edges if len(e.link_faces) == 0) + bm.free() + return offen, nm, draht + + +def volumen(ob): + bm = bmesh.new() + bm.from_mesh(ob.data) + bm.transform(ob.matrix_world) + v = abs(bm.calc_volume(signed=True)) + bm.free() + return v + + +for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + +s = bpy.context.scene.rock_gen_settings +s.batch_count = 1 +s.use_modal = False +s.assign_material = True # Material-Slots muessen mitwandern +s.generate_lods = False +s.bake_vertex_cavity = True +bpy.ops.rockgen.dense_preset() +bpy.ops.object.generate_rocks_batch() + +mutter = [o for o in bpy.context.scene.objects if o.type == 'MESH'][0] +v_mutter = volumen(mutter) +slots_mutter = [m.name for m in mutter.data.materials] + +for anzahl in (2, 3, 4): + for o in list(bpy.context.scene.objects): + if "_Piece_" in o.name: + bpy.data.objects.remove(o, do_unlink=True) + mutter.hide_set(False) + for o in bpy.context.scene.objects: + o.select_set(o is mutter) + bpy.context.view_layer.objects.active = mutter + + s.piece_count = anzahl + s.piece_with_ore = True + s.piece_seed = 2 + bpy.ops.rockgen.fracture() + + teile = [o for o in bpy.context.scene.objects if "_Piece_" in o.name] + if len(teile) != anzahl: + fails.append("%d Teile angefordert, %d bekommen" % (anzahl, len(teile))) + + v_teile = sum(volumen(o) for o in teile) + quote = 100.0 * v_teile / v_mutter if v_mutter else 0.0 + print("%d Teile: Volumen %.1f %% des Muttersteins" % (anzahl, quote)) + # Die Teile muessen den Stein ergeben. Toleranz fuer die triangulierten + # Bruchflaechen; deutlich weniger heisst Loecher oder verlorene Zellen. + if not (97.0 <= quote <= 103.0): + fails.append("%d Teile ergeben %.1f %% des Muttersteins (erwartet 97-103)" + % (anzahl, quote)) + + erz = [o for o in teile if o.name.endswith("_Piece_Erz")] + if len(erz) != 1: + fails.append("%d Teile: %d Stueck mit Erz, erwartet genau 1" + % (anzahl, len(erz))) + + for ob in sorted(teile, key=lambda o: o.name): + offen, nm, draht = topologie(ob) + lay = ob.data.color_attributes.get("Cavity") + a = [c.color[3] for c in lay.data] if lay else [] + co = [ob.matrix_world @ v.co for v in ob.data.vertices] + unten = ob.matrix_world.translation.z - min(c.z for c in co) + + if offen or nm or draht: + fails.append("%s: offen %d, non-manifold %d, Draht %d - als " + "Physik-Actor unbrauchbar" % (ob.name, offen, nm, draht)) + if lay is None: + fails.append("%s: kein Vertex-Layout v2" % ob.name) + elif ob.name.endswith("_Piece_Erz"): + if max(a) < 0.9 or min(a) > 0.01: + fails.append("%s: traegt keine Erz-Maske (A %.2f..%.2f)" + % (ob.name, min(a), max(a))) + else: + if min(a) < 0.999: + fails.append("%s: A ist nicht 1.0 (min %.2f) - nur das " + "Erz-Stueck darf Erz tragen" % (ob.name, min(a))) + if abs(unten) > 0.01: + fails.append("%s: Ursprung nicht unten (%.3f m)" % (ob.name, unten)) + if [m.name for m in ob.data.materials] != slots_mutter: + fails.append("%s: Material-Slots %s statt %s - die UE-Skripte " + "mappen darueber" + % (ob.name, [m.name for m in ob.data.materials], + slots_mutter)) + +# --- Export: je Teil eine eigene UCX-Huelle ------------------------------ +OUT = tempfile.mkdtemp() +s.export_dir = OUT +s.export_selected_only = False +s.export_ucx = True +bpy.ops.object.export_rocks_fbx() + +dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx")) +piece_fbx = [f for f in dateien if "_Piece_" in f] +print("") +print("Export: %d FBX, davon %d Bruchstuecke" % (len(dateien), len(piece_fbx))) +if not piece_fbx: + fails.append("Bruchstuecke wurden nicht exportiert") +else: + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, piece_fbx[0])) + meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] + hull = [o for o in meshes if o.name.startswith("UCX_")] + koerper = [o for o in meshes if not o.name.startswith("UCX_")] + print(" %s: %d Mesh, %d UCX" % (piece_fbx[0], len(koerper), len(hull))) + if not hull: + fails.append("%s: keine UCX-Huelle - fuer die Physik-Bruchstuecke " + "brauchen wir echte Konvexe" % piece_fbx[0]) + if koerper and not koerper[0].data.color_attributes: + fails.append("%s: Vertex-Farben nach dem Roundtrip weg" % piece_fbx[0]) + +print("") +if fails: + print("ERGEBNIS: %d FEHLER" % len(fails)) + for x in fails: + print(" - " + x) + sys.exit(1) +print("ERGEBNIS: ALLE CHECKS OK")