diff --git a/README.md b/README.md index 256b8b1..2b6a793 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,31 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: --- +## Schnellstart-Sorten + +Ganz oben im Panel: Sorte waehlen, Haken druecken, erzeugen. Deckt den Alltag +ab — die neun Unter-Panels bleiben fuer den Feinschliff. + +| Sorte | Form | Dissolve | gemessen | +|---|---|---|---| +| **Findling** | rund, weiche Kanten | 17–25° | ~1160 V / ~2320 Tris | +| **Platte** | flach, liegt satt auf | 17–25° | ~670 V / ~1340 Tris | +| **Kantig** | Bevel + Weighted Normal, Flat | 8–14° | ~350 V / ~690 Tris | +| **Erzbrocken** | dicht genug fuer die Nugget-Maske, Slot `M_Rock_Ore` | 17–25° | ~1280 V / ~2560 Tris | + +**Der Dissolve-Winkel ist ein Bereich, kein Wert.** Ein fester Winkel passt nie +fuer alle Steine einer Sorte — man stellt ihn sonst bei jedem Stein nach. Je +Stein wird einer aus dem Bereich gezogen; gemessen an sechs Findlingen bei +17–25°: 1936–2928 Tris. Beide Regler gleich setzen = fester Winkel wie frueher. + +> **Warum die Sorten unterschiedlich dicht sind.** Die Erz-Maske haengt an der +> Vertex-Dichte, ein normaler Stein braucht das nicht. Deshalb hat der +> Erzbrocken ein hoeheres Decimate als der Findling — sonst waere entweder das +> Erz verschmiert oder der normale Stein ueber dem Tri-Fenster. Der Satz hinter +> dem **S+M-Set** ist noch etwas dichter (0.70/0.92), weil dort auch kleine +> S-Brocken vorkommen: mit dem Findling-Satz fielen die im schlechtesten Fall +> auf 708 Verts und damit unter die Schwelle fuer eine saubere Maske. + ## Erzfels-Set (A-Kanal) **EIN neutrales Set fuer ALLE Erzsorten.** Die Maske im A-Kanal sagt nur, *wo* @@ -549,6 +574,7 @@ gefiltert `.\run_tests.ps1 -Filter panels`. |---|---| | `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_schnellstart.py` | Schnellstart: jede Sorte liefert ohne Nacharbeit Geometrie + Slot, Erz-Sorte ueber der Dichte-Schwelle, Dissolve-Bereich streut je Stein | | `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 d75e940..3d6c709 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.18.0" +version = "2.19.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 ddfa83f..a6ae283 100644 --- a/dist/index.html +++ b/dist/index.html @@ -40,14 +40,14 @@ 4.9KB - stylized_rock_generator-2.18.0 + stylized_rock_generator-2.19.0 Stylized Rock Generator Batch-Generator fuer stylized Rocks mit UE5-Export link 4.2.0 - ~ all all - 32.2KB + 34.1KB stylized_tree_generator-1.25.0 @@ -61,6 +61,6 @@ -

Built 2026-08-03, 12:08

+

Built 2026-08-03, 12:20

diff --git a/dist/index.json b/dist/index.json index d057c54..dea8d7e 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.18.0", + "version": "2.19.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -73,9 +73,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.18.0.zip", - "archive_size": 33014, - "archive_hash": "sha256:c690c130cff1bfd69f91c4d9685ba9fcb56b017cfc1b1211cbd2df91d15ba001" + "archive_url": "./stylized_rock_generator-2.19.0.zip", + "archive_size": 34968, + "archive_hash": "sha256:2b412f9324263a3951b1b519648ab46025e7511b09f54b6017edf6d834a17bc6" }, { "schema_version": "1.0.0", diff --git a/dist/stylized_rock_generator-2.18.0.zip b/dist/stylized_rock_generator-2.18.0.zip deleted file mode 100644 index 8176e8b..0000000 Binary files a/dist/stylized_rock_generator-2.18.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.19.0.zip b/dist/stylized_rock_generator-2.19.0.zip new file mode 100644 index 0000000..4fd493f Binary files /dev/null and b/dist/stylized_rock_generator-2.19.0.zip differ diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index 640b07d..86c65b3 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, 18, 0), + "version": (2, 19, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -51,6 +51,76 @@ MATERIAL_PALETTE = ( ("M_Rock_Mossy", (0.24, 0.30, 0.18, 1.0)), ) +# Schnellstart: fertige Einstellungs-Saetze fuer die Steinsorten, die in +# EcoGame tatsaechlich vorkommen. Deckt den Alltag ab, ohne dass man sich durch +# neun Unter-Panels klickt - die bleiben fuer den Feinschliff. +# +# Der Limited-Dissolve-Winkel ist bewusst ein BEREICH: ein fester Wert passt nie +# fuer alle Steine einer Sorte, man stellt ihn sonst bei jedem Stein nach. +ROCK_KINDS = { + 'FINDLING': { + "label": "Findling", + "desc": "Runder Brocken, weiche Kanten - der Allerwelts-Stein", + "values": { + "base_shape": 'ICOSPHERE', "subsurf_levels": 6, + # Leichter als der Erz-Satz: ein normaler Stein braucht die hohe + # Dichte nicht und bleibt so im Tri-Fenster 1500-3000. Nur die + # Erz-Maske haengt an der Vertex-Dichte. + "decimate_ratio_1": 0.40, "decimate_ratio_2": 0.80, + "dissolve_angle": 17.0, "dissolve_angle_max": 25.0, + "shading_mode": 'AUTO', "add_bevel": False, + "flatten_bottom": True, "randomize_shape": True, + }, + }, + 'PLATTE': { + "label": "Platte", + "desc": "Flacher Brocken, liegt satt auf dem Boden", + "values": { + "base_shape": 'BOX', "box_proportions": (2.2, 1.6, 0.5), + "box_variation": 0.25, "subsurf_levels": 6, + # Hoeher als bei den runden Sorten: eine flache Grundform hat + # weniger Oberflaeche, mit 0.40/0.80 landete sie bei 930 Tris. + "decimate_ratio_1": 0.60, "decimate_ratio_2": 0.90, + # Flache Flaechen verschmelzen beim Dissolve staerker als gewoelbte: + # mit 20-28 Grad fiel die Platte auf 848 Tris. Gleicher Bereich wie + # die anderen Sorten haelt sie im Fenster. + "dissolve_angle": 17.0, "dissolve_angle_max": 25.0, + "shading_mode": 'AUTO', "add_bevel": False, + "flatten_bottom": True, "randomize_shape": True, + }, + }, + 'KANTIG': { + "label": "Kantig", + "desc": "Gebrochener Fels mit harten Kanten (Bevel + Weighted Normal)", + "values": { + "base_shape": 'CUBE', "subsurf_levels": 4, + "decimate_ratio_1": 0.35, "decimate_ratio_2": 0.70, + "dissolve_angle": 8.0, "dissolve_angle_max": 14.0, + "shading_mode": 'FLAT', "add_bevel": True, + "add_weighted_normal": True, + "flatten_bottom": True, "randomize_shape": True, + }, + }, + 'ERZ': { + "label": "Erzbrocken", + "desc": ("Dicht genug fuer eine saubere Erz-Maske, Slot M_Rock_Ore. " + "Fuer das komplette S+M-Set den Knopf im Vertex-Farben-Panel"), + "values": { + "base_shape": 'ICOSPHERE', "subsurf_levels": 6, + # Knapper als der Satz hinter dem S+M-SET: der einzelne Brocken hier + # ist M-gross, das Set enthaelt auch kleine S-Brocken und braucht + # dort mehr Reserve, um ORE_MIN_VERTS zu halten. Gemessen ueber + # fuenf Seeds: min 1078 Verts, max 2368 Tris. + "decimate_ratio_1": 0.45, "decimate_ratio_2": 0.82, + "dissolve_angle": 17.0, "dissolve_angle_max": 25.0, + "shading_mode": 'AUTO', "add_bevel": False, + "flatten_bottom": True, "randomize_shape": True, + "bake_vertex_cavity": True, "bake_ore": True, + }, + }, +} + + # Basis-Groesse je Groessen-Klasse (Meter-Faktor auf das Grundmesh). # WICHTIG - Unterschied zur Zufalls-Streuung: DIESE Groesse ist gewollt und # unterscheidet die Klassen. Die Streuung (scale_min/max) variiert dagegen nur @@ -60,10 +130,21 @@ MATERIAL_PALETTE = ( # "Smooth & dicht": kein Low-Poly-Look, und die Vertex-Masken (Cavity, Moos, # Erz) haengen direkt an der Vertex-Dichte. Gemessen ueber drei Seeds: # ~1350 Verts / ~2694 Tris - im Zielfenster 1500-3000 Tris. +# Der Dissolve-Winkel gehoert MIT hier hinein. Er frisst deutlich Geometrie: +# derselbe Satz liefert bei niedrigem Standardwinkel ~2700 Tris, bei 17-25 Grad +# nur noch ~1030 und faellt damit unter ORE_MIN_VERTS - das Erz-Set haette seine +# eigene Warnung ausgeloest, sobald jemand vorher eine Sorte mit hohem Winkel +# gewaehlt hatte. Gemessen ueber drei Seeds bei 17-25 Grad: 1162 Verts / 2320 Tris. +# Decimate bewusst hoch: ueber vier Seeds und beide Groessenklassen gemessen +# liefert 0.40/0.80 im schlechtesten Fall nur 708 Verts und faellt damit unter +# ORE_MIN_VERTS. 0.70/0.92 haelt den Worst Case bei 847 Verts und bleibt mit +# maximal 2806 Tris im Zielfenster. DENSE_PRESET = { - "subsurf_levels": 5, - "decimate_ratio_1": 0.40, - "decimate_ratio_2": 0.80, + "subsurf_levels": 6, + "decimate_ratio_1": 0.70, + "decimate_ratio_2": 0.92, + "dissolve_angle": 17.0, + "dissolve_angle_max": 25.0, "shading_mode": 'AUTO', } @@ -432,7 +513,12 @@ def _build_rock_mesh(context, settings, index, location, rng, name=None): context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.mesh.dissolve_limited(angle_limit=math.radians(settings.dissolve_angle)) + # Der Winkel wird je Stein aus dem Bereich gezogen. Ein fester Wert passt + # nie fuer alle Steine einer Sorte - sonst stellt man ihn bei jedem Stein + # nach. Bereich zu = fester Wert wie vorher. + lo_a, hi_a = sorted((settings.dissolve_angle, settings.dissolve_angle_max)) + eff_angle = rng.uniform(lo_a, hi_a) if hi_a > lo_a else lo_a + bpy.ops.mesh.dissolve_limited(angle_limit=math.radians(eff_angle)) bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.normals_make_consistent(inside=False) bpy.ops.object.mode_set(mode='OBJECT') @@ -957,6 +1043,19 @@ class RockGenSettings(PropertyGroup): # --- Decimate / Cleanup --- decimate_ratio_1: FloatProperty(name="Decimate 1", default=0.2, min=0.01, max=1.0) decimate_ratio_2: FloatProperty(name="Decimate 2", default=0.5, min=0.01, max=1.0) + rock_kind: EnumProperty( + name="Sorte", + items=[(k, v["label"], v["desc"]) for k, v in ROCK_KINDS.items()], + default='FINDLING', + description="Schnellstart-Sorte - der Knopf daneben uebernimmt die Werte", + ) + dissolve_angle_max: FloatProperty( + name="Winkel bis", default=25.0, min=0.0, max=80.0, + description=("Obergrenze fuer den Limited-Dissolve-Winkel. Je Stein wird " + "ein Wert aus dem Bereich gezogen - gleich gross wie der " + "untere Wert heisst fester Winkel"), + ) + dissolve_angle: FloatProperty( name="Dissolve Winkel (Grad)", default=5.0, min=0.0, max=45.0, description="Groesserer Winkel = mehr Faces werden zusammengefasst", @@ -1583,8 +1682,13 @@ def _prepare_export_copies(context, top, lay_flat=True, want_ucx=True, top_copy.matrix_parent_inverse = Matrix.Identity(4) context.view_layer.update() - # 1) Ursprung auf unten-mittig der gemeinsamen Bbox. - lo, hi = _bbox_of(meshes) + # 1) Ursprung auf unten-mittig - berechnet aus LOD0, NICHT aus der + # gemeinsamen Bbox aller Stufen. Eine stark dezimierte Stufe kann tiefer + # reichen als LOD0; nimmt man die gemeinsame Box, sitzt LOD0 danach ueber + # dem Pivot (gemessen 7 cm) und der Stein schwebt. Unreal platziert nach + # LOD0, also gibt LOD0 den Ursprung vor. + referenz = max(meshes, key=lambda o: len(o.data.vertices)) if meshes else None + lo, hi = _bbox_of([referenz] if referenz is not None else []) if lo is not None: shift = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z)) off = Matrix.Translation(shift) @@ -1798,6 +1902,27 @@ class ROCKGEN_OT_fracture(Operator): return {'FINISHED'} +class ROCKGEN_OT_apply_kind(Operator): + """Uebernimmt die Werte einer Schnellstart-Sorte.""" + bl_idname = "rockgen.apply_kind" + bl_label = "Sorte uebernehmen" + bl_description = "Setzt Form, Dichte, Dissolve-Bereich und Shading der gewaehlten Sorte" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.rock_gen_settings + eintrag = ROCK_KINDS.get(s.rock_kind) + if eintrag is None: + self.report({'ERROR'}, "Unbekannte Sorte: %s" % s.rock_kind) + return {'CANCELLED'} + for k, v in eintrag["values"].items(): + setattr(s, k, v) + self.report({'INFO'}, "Sorte '%s' uebernommen - Dissolve %.0f-%.0f Grad." + % (eintrag["label"], s.dissolve_angle, + s.dissolve_angle_max)) + return {'FINISHED'} + + class ROCKGEN_OT_dense_preset(Operator): """Stellt auf smooth und dicht um (~2700 Tris statt ~620).""" bl_idname = "rockgen.dense_preset" @@ -2477,6 +2602,13 @@ class VIEW3D_PT_rock_generator(Panel): layout = self.layout s = context.scene.rock_gen_settings + # Schnellstart zuerst: deckt den Alltag ab, ohne durch neun + # Unter-Panels zu klicken. + row = layout.row(align=True) + row.prop(s, "rock_kind", text="") + row.operator("rockgen.apply_kind", text="", icon='CHECKMARK') + layout.separator() + row = layout.row(align=True) row.prop(s, "active_preset", text="") row.operator("rockgen.preset_load", text="", icon='IMPORT') @@ -2565,7 +2697,9 @@ class VIEW3D_PT_rock_detail(_RockSub): col = layout.column(align=True) col.prop(s, "decimate_ratio_1") col.prop(s, "decimate_ratio_2") - layout.prop(s, "dissolve_angle") + row = layout.row(align=True) + row.prop(s, "dissolve_angle", text="Dissolve von") + row.prop(s, "dissolve_angle_max", text="bis") layout.separator() layout.prop(s, "add_bevel") sub = layout.column(align=True) @@ -2748,6 +2882,7 @@ classes = ( OBJECT_OT_export_rocks_fbx, OBJECT_OT_bake_rock_textures, ROCKGEN_OT_bake_cavity_selection, + ROCKGEN_OT_apply_kind, ROCKGEN_OT_dense_preset, ROCKGEN_OT_fracture, ROCKGEN_OT_ore_set, diff --git a/tests/test_schnellstart.py b/tests/test_schnellstart.py new file mode 100644 index 0000000..854ef27 --- /dev/null +++ b/tests/test_schnellstart.py @@ -0,0 +1,118 @@ +"""Schnellstart-Sorten + Dissolve-Bereich. + +Der Schnellstart soll den Alltag abdecken, ohne dass man sich durch neun +Unter-Panels klickt. Damit das etwas wert ist, muss jede Sorte OHNE +Nacharbeit ein brauchbares Ergebnis liefern - genau das prueft diese Datei. + +Der Dissolve-Winkel ist ein BEREICH, weil ein fester Wert nie fuer alle Steine +einer Sorte passt. Geprueft wird deshalb auch, dass der Bereich tatsaechlich je +Stein streut und nicht nur als Zahl im Panel steht. + +Aufruf: blender --background --factory-startup --python tests/test_schnellstart.py +""" +import os +import sys +importlib_util = __import__("importlib.util", fromlist=["util"]) + +import bpy + +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 = [] +s = bpy.context.scene.rock_gen_settings +s.use_modal = False + + +def bauen(anzahl=3, seed=0): + for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + s.batch_count = anzahl + s.seed = seed + bpy.ops.object.generate_rocks_batch() + return sorted((o for o in bpy.context.scene.objects if o.type == 'MESH'), + key=lambda o: o.name) + + +print("%-12s %7s %7s %10s %s" % ("Sorte", "Verts", "Tris", "Dissolve", "Slot")) +for kind, eintrag in rg.ROCK_KINDS.items(): + s.rock_kind = kind + res = bpy.ops.rockgen.apply_kind() + if 'FINISHED' not in res: + fails.append("Sorte %s: apply_kind lieferte %s" % (kind, res)) + continue + + obs = bauen() + if not obs: + fails.append("Sorte %s erzeugt keine Geometrie" % kind) + continue + + verts = sum(len(o.data.vertices) for o in obs) // len(obs) + tris = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) + for o in obs) // len(obs) + slots = [m.name for m in obs[0].data.materials] + print("%-12s %7d %7d %5.0f-%-4.0f %s" + % (eintrag["label"], verts, tris, s.dissolve_angle, + s.dissolve_angle_max, slots or "KEINE")) + + if not slots: + fails.append("Sorte %s: kein Material-Slot" % eintrag["label"]) + elif not slots[0].startswith("M_Rock_"): + fails.append("Sorte %s: Slot '%s' folgt nicht M_Rock_" + % (eintrag["label"], slots[0])) + + # Der Bereich muss sinnvoll sein - untere Grenze nicht ueber der oberen. + if s.dissolve_angle > s.dissolve_angle_max: + fails.append("Sorte %s: Dissolve-Bereich verdreht (%.0f > %.0f)" + % (eintrag["label"], s.dissolve_angle, s.dissolve_angle_max)) + + # Die Erz-Sorte muss dicht genug fuer eine saubere Nugget-Maske sein - + # sonst loest die eigene Vorlage die eigene Warnung aus. + if eintrag["values"].get("bake_ore"): + if slots != [rg.ORE_MATERIAL[0]]: + fails.append("Sorte %s: Slot %s, erwartet ['%s']" + % (eintrag["label"], slots, rg.ORE_MATERIAL[0])) + duenn = [o for o in obs if len(o.data.vertices) < rg.ORE_MIN_VERTS] + if duenn: + fails.append("Sorte %s: %d von %d Steinen unter %d Verts - die " + "eigene Vorlage wuerde die eigene Dichte-Warnung " + "ausloesen" % (eintrag["label"], len(duenn), len(obs), + rg.ORE_MIN_VERTS)) + else: + if slots == [rg.ORE_MATERIAL[0]]: + fails.append("Sorte %s traegt den Erz-Slot ohne Erz" + % eintrag["label"]) + +# --- Der Dissolve-Bereich muss je Stein streuen -------------------------- +s.rock_kind = 'FINDLING' +bpy.ops.rockgen.apply_kind() +obs = bauen(anzahl=6, seed=3) +tris = [sum(len(p.vertices) - 2 for p in o.data.polygons) for o in obs] +print("") +print("Findling x6 bei %.0f-%.0f Grad: %s" + % (s.dissolve_angle, s.dissolve_angle_max, tris)) +if len(set(tris)) < 3: + fails.append("Dissolve-Bereich streut nicht je Stein: %s" % tris) + +# Bereich zu = fester Winkel wie frueher. Muss weiter funktionieren. +s.dissolve_angle_max = s.dissolve_angle +obs = bauen(anzahl=2, seed=3) +if not obs: + fails.append("Geschlossener Dissolve-Bereich erzeugt keine Geometrie") +else: + print("Bereich zu (%.0f Grad): %d Steine, %d Tris" + % (s.dissolve_angle, len(obs), + sum(len(p.vertices) - 2 for p in obs[0].data.polygons))) + +print("") +if fails: + print("ERGEBNIS: %d FEHLER" % len(fails)) + for x in fails: + print(" - " + x) + sys.exit(1) +print("ERGEBNIS: ALLE CHECKS OK")