commit c5bb3e2612e7df1b6149638065f0d9fa556f3047 Author: D4rkst3r Date: Wed Jul 29 06:24:06 2026 +0200 Stylized Rock Generator v2.0.0: Haertung, Presets, LODs, Material, Tests - Fix: smart_project/dissolve_limited angle_limit jetzt in Radiant (Blender 4.x/5.x) - Fix: Lightmap-UV-Kanal vor lightmap_pack aktiv setzen (UV0 nicht mehr ueberschrieben) - Fehlerbehandlung pro bpy.ops-Call; einzelner Rock-Fehler bricht Batch nicht ab - Modaler Batch-Modus mit Progress-Bar (ab modal_threshold) - Bevel + Weighted Normal Toggle - LOD-Set als fbx_type=LodGroup-Gruppe fuer Unreal - Preset-System (JSON) + eingebaute Presets - Platzhalter-Material-Zuweisung - Headless-Testskript (tests/test_rock_gen.py) Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa22445 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ + +# Addon-Laufzeitdaten (eigene Presets bleiben lokal) +rock_presets.json + +# Blender +*.blend1 +*.blend2 + +# OS / Editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c708023 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Stylized Rock Generator - Blender Addon +Copyright (C) 2026 D4rkst3r + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . + +Note: Blender-Addons, die das `bpy`-Modul nutzen, werden ueblicherweise unter +GPL lizenziert. Bei Bedarf hier den vollstaendigen GPLv3-Text ergaenzen +(https://www.gnu.org/licenses/gpl-3.0.txt) oder auf eine andere Lizenz wechseln. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f801a3f --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Stylized Rock Generator (Blender Addon) + +Batch-Generator fuer stylized Rocks in Blender, ausgelegt auf den Export nach +**Unreal Engine 5** ueber FBX. + +Pipeline pro Rock: Basis-Mesh (Cube / Ico-Sphere) → Subdivision Surface → +Displace mit prozeduraler Noise-Textur (Voronoi / Clouds / Distorted Noise) → +zweistufiges Decimate → Limited Dissolve → Normalen-Reparatur → +UV0 (Smart Project) + UV1 (Lightmap) → optional Bevel/Weighted Normal, Material, +LOD-Set, randomisierte Skalierung/Rotation. + +Getestet mit **Blender 5.1.2** (Minimum laut `bl_info`: 4.2). + +--- + +## Installation + +1. `stylized_rock_generator.py` herunterladen. +2. Blender → *Edit → Preferences → Add-ons → Install…* → die `.py` waehlen. +3. Haken bei **Stylized Rock Generator** setzen. +4. Panel: *View3D → Sidebar (N) → Reiter "Rock Gen"*. + +Update: einfach die neue `.py` erneut installieren (gleicher Modulname) oder das +Repo lokal ziehen und den Addon-Pfad in den Preferences auf den Repo-Ordner +zeigen lassen. + +--- + +## Features + +- **Basis-Formen:** Cube oder Ico-Sphere, einstellbare Subsurf-Stufen. +- **Noise/Displace:** Voronoi / Clouds / Distorted Noise, Scale + Staerke, Seed. + Seed-Variation ueber einen moderaten Welt-Offset (±25) vor dem Displace-Apply – + Blenders prozedurale Texturen haben keinen echten Seed-Parameter. +- **Cleanup:** zweistufiges Decimate + Limited Dissolve, danach + `normals_make_consistent` (verhindert eingefallene/dunkle Flaechen). +- **UV fuer Unreal:** UV0 (Smart Project) fuer Material-Texturen, UV1 (Lightmap) + als separater, nicht ueberlappender Kanal fuer Static-Mesh-Lightmaps. +- **Hard-Surface (optional):** Bevel + Weighted Normal fuer einen "clean" + Kantenlook. +- **Material (Platzhalter):** weist pro Rock ein Material zu und rotiert durch + eine kleine Palette (Granite/Basalt/Sandstone/Mossy), damit nichts materiallos + exportiert wird. +- **LOD-Set:** erzeugt LOD0–LODn als LOD-Gruppe fuer Unreal (siehe unten). +- **Presets:** eingebaute Presets (Kleiner Kiesel, Grosser Bruchfels, Lava-Rock) + plus eigene Presets als JSON (`rock_presets.json` neben dem Addon). +- **Batch:** synchron fuer kleine Mengen, ab `modal_threshold` (Default 25) + automatisch **modal mit Progress-Bar** – die UI bleibt bedienbar, ESC bricht ab. +- **Robustheit:** Faellt ein einzelner Rock aus (z. B. 0 Faces nach zu + aggressivem Decimate), wird nur dieser uebersprungen und aufgeraeumt, der Rest + des Batches bleibt erhalten. Fehler landen in der System-Konsole. + +--- + +## LOD-Export nach Unreal + +Blenders FBX-Exporter kennt **keine** eigene LOD-Option. Unreal erkennt eine +LOD-Gruppe aber, wenn das Eltern-**Empty** die Custom Property +`fbx_type = "LodGroup"` traegt und die LOD-Meshes in Reihenfolge (LOD0…LODn) +darunter geparentet sind. Genau das erzeugt der Generator. + +Beim FBX-Export darauf achten: + +- **Custom Properties** aktivieren (sonst geht `fbx_type` verloren). +- Nur die LOD-Gruppe (Empty + Kinder) selektieren, `use_selection` an. + +Beim Import in Unreal: + +- Im FBX-Import-Dialog **Import LODs** aktivieren. + +Referenz: +[Importing Static Mesh LODs Using FBX (UE Docs)](https://dev.epicgames.com/documentation/en-us/unreal-engine/importing-static-mesh-lods-using-fbx-in-unreal-engine). + +--- + +## Tests + +```bash +blender --background --python tests/test_rock_gen.py +``` + +Erzeugt 3 Rocks, prueft je Rock: keine losen Vertices, manifold, Normalen nach +aussen (signed volume > 0), UV0 + UV1 vorhanden, und gibt eine Zusammenfassung +(Vertex-/Face-Count) aus. Exit-Code 0 = alles bestanden, 1 = Fehler. + +--- + +## Changelog v2.0.0 + +Gehaertet & erweitert gegenueber v1.0.0: + +- **Bugfix:** `smart_project(angle_limit=…)` ist in Blender 4.x/5.x in **Radiant** + (`[0, 1.5708]`) – der alte Wert `66.0` war ungueltig. Jetzt `radians(66°)`. +- **Bugfix:** `dissolve_limited(angle_limit=…)` erwartet **Radiant**; der + Grad-Wert wurde ungewandelt durchgereicht → uebermaessiges Dissolve. Jetzt + `radians(dissolve_angle)`. +- **Bugfix:** Der Lightmap-Kanal wurde vor `lightmap_pack` nicht als aktiver + UV-Layer gesetzt → UV0 konnte ueberschrieben werden. Jetzt explizit gesetzt, + UV0 danach wieder als Render-Kanal. +- Fehlerbehandlung um jeden `bpy.ops.*`-Aufruf; einzelne Rock-Fehler brechen den + Batch nicht mehr ab. +- Nicht-blockierender modaler Batch-Modus mit Progress-Bar. +- Bevel + Weighted Normal als Toggle. +- LOD-Set als `fbx_type=LodGroup`-Gruppe. +- Preset-System (JSON) mit Panel-Dropdown. +- Platzhalter-Material-Zuweisung. diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py new file mode 100644 index 0000000..994467a --- /dev/null +++ b/stylized_rock_generator.py @@ -0,0 +1,882 @@ +bl_info = { + "name": "Stylized Rock Generator", + "author": "D4rkst3r", + "version": (2, 0, 0), + "blender": (4, 2, 0), + "location": "View3D > Sidebar > Rock Gen", + "description": ( + "Batch-Generator fuer stylized Rocks " + "(Subsurf -> Displace -> Decimate -> Limited Dissolve) " + "mit Presets, LOD-Gruppen, Material- und UV-Setup fuer Unreal Engine." + ), + "category": "Object", +} + +import bpy +import os +import json +import math +import random +import traceback + +from bpy.props import ( + IntProperty, FloatProperty, EnumProperty, BoolProperty, + StringProperty, PointerProperty, +) +from bpy.types import Operator, Panel, PropertyGroup + + +# --------------------------------------------------------------------------- +# Konstanten +# --------------------------------------------------------------------------- + +TWO_PI = math.pi * 2.0 + +# Platzhalter-Materialien: (Name, Base-Color RGBA). Werden pro Rock rotiert. +MATERIAL_PALETTE = ( + ("M_Rock_Granite", (0.32, 0.30, 0.28, 1.0)), + ("M_Rock_Basalt", (0.11, 0.11, 0.13, 1.0)), + ("M_Rock_Sandstone", (0.55, 0.42, 0.30, 1.0)), + ("M_Rock_Mossy", (0.24, 0.30, 0.18, 1.0)), +) + +# Property-Keys, die von Presets gespeichert/geladen werden. +PRESET_KEYS = ( + "base_shape", "subsurf_levels", "texture_type", "noise_scale", + "displace_strength", "seed", "decimate_ratio_1", "decimate_ratio_2", + "dissolve_angle", "generate_uv0", "generate_lightmap_uv", + "randomize_transform", "scale_min", "scale_max", + "add_bevel", "bevel_width", "bevel_segments", "add_weighted_normal", + "assign_material", "generate_lods", "lod_count", "lod_ratio_step", + "batch_count", "spacing", +) + +# Eingebaute Presets (nicht ueberschreibbar/loeschbar). +BUILTIN_PRESETS = { + "Kleiner Kiesel": { + "base_shape": "ICOSPHERE", "subsurf_levels": 3, "texture_type": "CLOUDS", + "noise_scale": 0.6, "displace_strength": 0.18, "decimate_ratio_1": 0.3, + "decimate_ratio_2": 0.6, "dissolve_angle": 6.0, "scale_min": 0.4, + "scale_max": 0.7, "spacing": 1.5, + }, + "Grosser Bruchfels": { + "base_shape": "CUBE", "subsurf_levels": 5, "texture_type": "VORONOI", + "noise_scale": 1.4, "displace_strength": 0.5, "decimate_ratio_1": 0.2, + "decimate_ratio_2": 0.45, "dissolve_angle": 4.0, "scale_min": 1.5, + "scale_max": 2.5, "spacing": 5.0, + }, + "Lava-Rock": { + "base_shape": "ICOSPHERE", "subsurf_levels": 4, + "texture_type": "DISTORTED_NOISE", "noise_scale": 1.0, + "displace_strength": 0.35, "decimate_ratio_1": 0.25, + "decimate_ratio_2": 0.5, "dissolve_angle": 5.0, "scale_min": 0.9, + "scale_max": 1.4, "spacing": 3.0, + }, +} + + +class RockGenError(Exception): + """Kontrollierter Fehler innerhalb der Rock-Generierung. + + Wird pro Rock gefangen, damit der Batch weiterlaeuft statt komplett + abzustuerzen. + """ + pass + + +# --------------------------------------------------------------------------- +# Kleine Helfer (Fehlerbehandlung / Kontext) +# --------------------------------------------------------------------------- + +def _apply_modifier(context, obj, mod_name): + """Wendet einen Modifier an und wirft bei Fehlern RockGenError.""" + context.view_layer.objects.active = obj + try: + res = bpy.ops.object.modifier_apply(modifier=mod_name) + except RuntimeError as exc: + raise RockGenError("Modifier '%s' Apply fehlgeschlagen: %s" % (mod_name, exc)) + if 'CANCELLED' in res: + raise RockGenError("Modifier '%s' Apply wurde abgebrochen." % mod_name) + + +def _face_count(obj): + return len(obj.data.polygons) + + +def _deselect_all(context): + for o in context.view_layer.objects: + if o.select_get(): + o.select_set(False) + + +def _ensure_object_mode(context): + if context.mode != 'OBJECT': + try: + bpy.ops.object.mode_set(mode='OBJECT') + except RuntimeError: + pass + + +# --------------------------------------------------------------------------- +# Preset-Speicher (JSON neben dem Addon, Fallback: User-Config) +# --------------------------------------------------------------------------- + +def _addon_dir(): + try: + return os.path.dirname(os.path.abspath(__file__)) + except NameError: + # z.B. wenn aus dem Text-Editor ausgefuehrt + return bpy.utils.user_resource('CONFIG', path="stylized_rock_gen", create=True) + + +def _preset_path(): + directory = _addon_dir() + if not os.access(directory, os.W_OK): + directory = bpy.utils.user_resource('CONFIG', path="stylized_rock_gen", create=True) + return os.path.join(directory, "rock_presets.json") + + +def _load_user_presets(): + path = _preset_path() + if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + return data + except (OSError, ValueError): + print("[RockGen] Konnte Presets nicht lesen:", path) + return {} + + +def _save_user_presets(data): + path = _preset_path() + try: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, ensure_ascii=False) + return True, path + except OSError as exc: + return False, str(exc) + + +def _settings_to_dict(settings): + out = {} + for key in PRESET_KEYS: + out[key] = getattr(settings, key) + return out + + +def _apply_dict_to_settings(settings, data): + for key in PRESET_KEYS: + if key in data: + try: + setattr(settings, key, data[key]) + except (TypeError, ValueError): + print("[RockGen] Preset-Wert ignoriert:", key, data[key]) + + +# Referenz-Cache: dynamische EnumProperty-Items muessen am Leben gehalten +# werden, sonst kann Blender abstuerzen (bekannter bpy-Fallstrick). +_PRESET_ENUM_CACHE = [] + + +def _preset_items(self, context): + _PRESET_ENUM_CACHE.clear() + _PRESET_ENUM_CACHE.append(('__NONE__', "- Preset waehlen -", "")) + for name in BUILTIN_PRESETS: + _PRESET_ENUM_CACHE.append(("builtin::" + name, name, "Eingebautes Preset")) + for name in sorted(_load_user_presets().keys()): + _PRESET_ENUM_CACHE.append(("user::" + name, name, "Eigenes Preset")) + return _PRESET_ENUM_CACHE + + +# --------------------------------------------------------------------------- +# Material-Zuweisung (Platzhalter) +# --------------------------------------------------------------------------- + +def _get_or_create_material(name, color): + mat = bpy.data.materials.get(name) + if mat is None: + mat = bpy.data.materials.new(name=name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + if "Base Color" in bsdf.inputs: + bsdf.inputs["Base Color"].default_value = color + if "Roughness" in bsdf.inputs: + bsdf.inputs["Roughness"].default_value = 0.85 + return mat + + +def _assign_material(obj, index): + name, color = MATERIAL_PALETTE[index % len(MATERIAL_PALETTE)] + mat = _get_or_create_material(name, color) + obj.data.materials.clear() + obj.data.materials.append(mat) + + +# --------------------------------------------------------------------------- +# UV-Setup (UV0 = Smart Project, UV1 = Lightmap) +# --------------------------------------------------------------------------- + +def _make_uv0(context, obj): + """Smart UV Project auf Kanal 0 (angle_limit ist in Blender 4.x/5.x rad!).""" + # Primitive bringen bereits eine "UVMap" mit -> Kanal 0 nutzen, sonst neu. + if not obj.data.uv_layers: + obj.data.uv_layers.new(name="UV0") + obj.data.uv_layers.active_index = 0 + context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + try: + bpy.ops.uv.smart_project( + angle_limit=math.radians(66.0), + island_margin=0.02, + ) + except RuntimeError as exc: + _ensure_object_mode(context) + raise RockGenError("Smart UV Project fehlgeschlagen: %s" % exc) + bpy.ops.object.mode_set(mode='OBJECT') + # Kanal 0 einheitlich benennen (Unreal nutzt die Reihenfolge, nicht den Namen) + if obj.data.uv_layers and obj.data.uv_layers[0].name != "Lightmap": + obj.data.uv_layers[0].name = "UV0" + + +def _make_lightmap_uv(context, obj): + """Separates Lightmap-UV auf Kanal 1, danach UV0 wieder aktiv/Render.""" + _deselect_all(context) + context.view_layer.objects.active = obj + obj.select_set(True) + lm = obj.data.uv_layers.get("Lightmap") + if lm is None: + lm = obj.data.uv_layers.new(name="Lightmap") + # WICHTIG: aktiven UV-Layer explizit auf Lightmap setzen, sonst wuerde + # lightmap_pack UV0 ueberschreiben. + obj.data.uv_layers.active = lm + try: + bpy.ops.uv.lightmap_pack( + PREF_CONTEXT='ALL_FACES', + PREF_PACK_IN_ONE=True, + PREF_NEW_UVLAYER=False, + PREF_BOX_DIV=12, + PREF_MARGIN_DIV=0.2, + ) + except RuntimeError as exc: + raise RockGenError("Lightmap Pack fehlgeschlagen: %s" % exc) + # UV0 wieder als Kanal 0 / Render-UV setzen (Unreal nutzt Kanal-Reihenfolge) + if obj.data.uv_layers: + obj.data.uv_layers.active_index = 0 + obj.data.uv_layers[0].active_render = True + + +# --------------------------------------------------------------------------- +# Mesh-Aufbau +# --------------------------------------------------------------------------- + +def _build_rock_mesh(context, settings, index, location, rng): + """Baut das reine Rock-Mesh (ohne Transform/Material). Kann RockGenError werfen.""" + # 1. Basis-Mesh + if settings.base_shape == 'CUBE': + bpy.ops.mesh.primitive_cube_add(size=2, location=location) + else: + bpy.ops.mesh.primitive_ico_sphere_add( + subdivisions=1, radius=1.0, location=location + ) + obj = context.active_object + obj.name = "Rock_%03d" % index + + # 2. Subdivision Surface -> Apply + subsurf = obj.modifiers.new(name="Subsurf", type='SUBSURF') + subsurf.levels = settings.subsurf_levels + subsurf.render_levels = settings.subsurf_levels + _apply_modifier(context, obj, subsurf.name) + + # 3. Displace + Noise-Textur -> Apply + tex = bpy.data.textures.new(name="RockTex_%03d" % index, type=settings.texture_type) + if settings.texture_type == 'VORONOI': + tex.noise_scale = settings.noise_scale + elif settings.texture_type == 'CLOUDS': + tex.noise_scale = settings.noise_scale + tex.noise_depth = 2 + elif settings.texture_type == 'DISTORTED_NOISE': + tex.noise_scale = settings.noise_scale + tex.distortion = 1.0 + + displace = obj.modifiers.new(name="Displace", type='DISPLACE') + displace.texture = tex + displace.texture_coords = 'GLOBAL' + displace.strength = settings.displace_strength + displace.mid_level = 0.5 + + # Variation pro Instanz: Objekt vor dem Apply an eine zufaellige (moderate!) + # Weltposition schieben, damit ein anderer Ausschnitt der Noise-Textur + # gesampelt wird. Offset klein halten -> keine Float-Praezisionsprobleme. + original_loc = obj.location.copy() + obj.location = ( + original_loc.x + rng.uniform(-25.0, 25.0), + original_loc.y + rng.uniform(-25.0, 25.0), + original_loc.z + rng.uniform(-25.0, 25.0), + ) + context.view_layer.update() + try: + _apply_modifier(context, obj, displace.name) + finally: + obj.location = original_loc + if tex.users == 0: + bpy.data.textures.remove(tex) + + # 4. Decimate, zweistufig + dec1 = obj.modifiers.new(name="Decimate1", type='DECIMATE') + dec1.ratio = settings.decimate_ratio_1 + _apply_modifier(context, obj, dec1.name) + if _face_count(obj) == 0: + raise RockGenError("Nach Decimate 1 keine Faces mehr (ratio zu niedrig).") + + dec2 = obj.modifiers.new(name="Decimate2", type='DECIMATE') + dec2.ratio = settings.decimate_ratio_2 + _apply_modifier(context, obj, dec2.name) + if _face_count(obj) == 0: + raise RockGenError("Nach Decimate 2 keine Faces mehr (ratio zu niedrig).") + + # 5. Limited Dissolve + Normalen neu berechnen (angle_limit ist rad!) + 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)) + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.normals_make_consistent(inside=False) + bpy.ops.object.mode_set(mode='OBJECT') + if _face_count(obj) == 0: + raise RockGenError("Nach Limited Dissolve keine Faces mehr uebrig.") + + # 6. UVs + if settings.generate_uv0: + _make_uv0(context, obj) + if settings.generate_lightmap_uv: + _make_lightmap_uv(context, obj) + + return obj + + +def _apply_hardsurface(context, obj, settings): + """Optional: Bevel + Weighted Normal fuer einen 'clean' Kantenlook.""" + if settings.add_bevel: + bev = obj.modifiers.new(name="Bevel", type='BEVEL') + bev.width = settings.bevel_width + bev.segments = settings.bevel_segments + bev.limit_method = 'ANGLE' + bev.angle_limit = math.radians(30.0) + _apply_modifier(context, obj, bev.name) + if settings.add_weighted_normal: + _deselect_all(context) + context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.object.shade_smooth() + wn = obj.modifiers.new(name="WeightedNormal", type='WEIGHTED_NORMAL') + wn.keep_sharp = True + _apply_modifier(context, obj, wn.name) + + +def _apply_random_transform(context, obj, settings, rng): + if not settings.randomize_transform: + return + scale_factor = rng.uniform(settings.scale_min, settings.scale_max) + obj.scale = (scale_factor, scale_factor, scale_factor) + obj.rotation_euler = ( + rng.uniform(0.0, TWO_PI), + rng.uniform(0.0, TWO_PI), + rng.uniform(0.0, TWO_PI), + ) + _deselect_all(context) + context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) + + +# --------------------------------------------------------------------------- +# LOD-Gruppen (fbx_type = LodGroup -> von Unreal beim FBX-Import erkannt) +# --------------------------------------------------------------------------- + +def _duplicate_object(context, src, new_name): + new_obj = src.copy() + new_obj.data = src.data.copy() + new_obj.name = new_name + new_obj.data.name = new_name + context.collection.objects.link(new_obj) + return new_obj + + +def _parent_keep_transform(child, parent): + child.parent = parent + child.matrix_parent_inverse = parent.matrix_world.inverted() + + +def _build_lod_group(context, settings, index, location, rng): + base_name = "Rock_%03d" % index + + lod0 = _build_rock_mesh(context, settings, index, location, rng) + _apply_hardsurface(context, lod0, settings) + _apply_random_transform(context, lod0, settings, rng) + if settings.assign_material: + _assign_material(lod0, index) + lod0.name = base_name + "_LOD0" + lod0.data.name = base_name + "_LOD0" + + created = [lod0] + + ratio = 1.0 + for lvl in range(1, settings.lod_count): + ratio *= settings.lod_ratio_step + lod = _duplicate_object(context, lod0, "%s_LOD%d" % (base_name, lvl)) + dec = lod.modifiers.new(name="LODdec%d" % lvl, type='DECIMATE') + dec.ratio = max(0.01, ratio) + _apply_modifier(context, lod, dec.name) + if _face_count(lod) == 0: + raise RockGenError("LOD%d hat keine Faces mehr (lod_ratio_step zu niedrig)." % lvl) + # Normalen + Lightmap fuer die reduzierte Topologie neu + context.view_layer.objects.active = lod + 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') + if settings.generate_lightmap_uv: + _make_lightmap_uv(context, lod) + created.append(lod) + + # Empty als LOD-Group-Parent + empty = bpy.data.objects.new(base_name + "_LODGroup", None) + empty.empty_display_type = 'PLAIN_AXES' + empty.location = location + empty["fbx_type"] = "LodGroup" # von Unreal beim FBX-Import ausgewertet + context.collection.objects.link(empty) + for lod in created: + _parent_keep_transform(lod, empty) + created.append(empty) + return created + + +# --------------------------------------------------------------------------- +# Ein Rock (bzw. eine LOD-Gruppe) erzeugen +# --------------------------------------------------------------------------- + +def make_one_rock(context, settings, index): + """Erzeugt genau einen Rock / eine LOD-Gruppe. Liefert Liste der Objekte.""" + rng = random.Random(settings.seed + index * 9973) + location = (index * settings.spacing, 0.0, 0.0) + + if settings.generate_lods: + return _build_lod_group(context, settings, index, location, rng) + + obj = _build_rock_mesh(context, settings, index, location, rng) + _apply_hardsurface(context, obj, settings) + _apply_random_transform(context, obj, settings, rng) + if settings.assign_material: + _assign_material(obj, index) + return [obj] + + +def _cleanup_partial(context, names_before): + """Entfernt Objekte, die seit dem Snapshot neu entstanden sind (fehlerhafter Rock).""" + _ensure_object_mode(context) + for name in list(bpy.data.objects.keys()): + if name not in names_before: + obj = bpy.data.objects.get(name) + if obj is not None: + bpy.data.objects.remove(obj, do_unlink=True) + + +def _report_result(op, created, failed, cancelled=False): + n_mesh = sum(1 for o in created if getattr(o, "type", None) == 'MESH') + if failed: + op.report( + {'WARNING'}, + "%d Rock(s) erzeugt, %d fehlgeschlagen (Details in der System-Konsole)." % (n_mesh, len(failed)), + ) + elif cancelled: + op.report({'INFO'}, "Abgebrochen - %d Rock(s) erzeugt." % n_mesh) + else: + op.report({'INFO'}, "%d Rock(s) erzeugt." % n_mesh) + + +# --------------------------------------------------------------------------- +# Property Group +# --------------------------------------------------------------------------- + +class RockGenSettings(PropertyGroup): + # --- Basis --- + base_shape: EnumProperty( + name="Basis-Form", + items=[('CUBE', "Cube", ""), ('ICOSPHERE', "Ico-Sphere", "")], + default='CUBE', + ) + subsurf_levels: IntProperty(name="Subsurf Levels", default=5, min=1, max=6) + + # --- Noise / Displace --- + texture_type: EnumProperty( + name="Noise-Typ", + items=[ + ('VORONOI', "Voronoi", "Facettiert / kristallin"), + ('CLOUDS', "Clouds", "Weich / organisch"), + ('DISTORTED_NOISE', "Distorted Noise", "Unregelmaessig"), + ], + default='VORONOI', + ) + noise_scale: FloatProperty(name="Noise Scale", default=1.0, min=0.01, max=10.0) + displace_strength: FloatProperty(name="Displace Staerke", default=0.3, min=0.0, max=2.0) + seed: IntProperty(name="Seed", default=0, min=0) + + # --- 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) + dissolve_angle: FloatProperty( + name="Dissolve Winkel (Grad)", default=5.0, min=0.0, max=45.0, + description="Groesserer Winkel = mehr Faces werden zusammengefasst", + ) + + # --- UV (Unreal) --- + generate_uv0: BoolProperty( + name="UV0 (Smart Project)", default=True, + description="Fuer Material-Maker-Texturen (Albedo/Normal/Roughness/AO)", + ) + generate_lightmap_uv: BoolProperty( + name="UV1 Lightmap-UV", default=True, + description="Separates, nicht ueberlappendes UV fuer Unreal Static-Mesh-Lightmaps", + ) + + # --- Hard-Surface (optional) --- + add_bevel: BoolProperty( + name="Bevel", default=False, + description="Optionaler Bevel-Modifier fuer einen 'clean' Kantenlook", + ) + bevel_width: FloatProperty(name="Bevel Breite", default=0.02, min=0.0, max=0.5) + bevel_segments: IntProperty(name="Bevel Segmente", default=2, min=1, max=8) + add_weighted_normal: BoolProperty( + name="Weighted Normal", default=False, + description="Weighted-Normal-Modifier fuer sauberere Shading-Uebergaenge", + ) + + # --- Material --- + assign_material: BoolProperty( + name="Material zuweisen", default=False, + description="Weist pro Rock ein Platzhalter-Material zu (rotiert durch eine Liste)", + ) + + # --- LOD --- + generate_lods: BoolProperty( + name="LOD-Set erzeugen", default=False, + description="Erzeugt LOD0-LODn als LOD-Gruppe (Empty mit fbx_type=LodGroup) fuer Unreal", + ) + lod_count: IntProperty( + name="LOD-Stufen", default=4, min=2, max=4, + description="Anzahl LOD-Stufen inkl. LOD0", + ) + lod_ratio_step: FloatProperty( + name="LOD Reduktion/Stufe", default=0.5, min=0.1, max=0.9, + description="Decimate-Faktor je zusaetzlicher LOD-Stufe (0.5 = halbe Faces pro Stufe)", + ) + + # --- Transform --- + randomize_transform: BoolProperty(name="Skalierung/Rotation randomisieren", default=True) + scale_min: FloatProperty(name="Scale Min", default=0.8, min=0.1, max=5.0) + scale_max: FloatProperty(name="Scale Max", default=1.2, min=0.1, max=5.0) + + # --- Batch --- + batch_count: IntProperty(name="Anzahl Rocks", default=5, min=1, max=500) + spacing: FloatProperty(name="Abstand", default=3.0, min=0.0, max=20.0) + use_modal: BoolProperty( + name="Modal (Progress-Bar)", default=True, + description="Grosse Batches nicht-blockierend mit Fortschrittsanzeige erzeugen", + ) + modal_threshold: IntProperty( + name="Modal ab", default=25, min=1, max=1000, + description="Ab dieser Rock-Anzahl wird der modale (nicht-blockierende) Modus genutzt", + ) + + # --- Presets --- + active_preset: EnumProperty(name="Preset", items=_preset_items) + new_preset_name: StringProperty(name="Preset-Name", default="") + + +# --------------------------------------------------------------------------- +# Operator: Batch erzeugen (synchron ODER modal) +# --------------------------------------------------------------------------- + +class OBJECT_OT_generate_rocks(Operator): + bl_idname = "object.generate_rocks_batch" + bl_label = "Generate Rocks" + bl_description = "Erzeugt N stylized Rocks mit randomisierten Parametern" + bl_options = {'REGISTER', 'UNDO'} + + _timer = None + _index = 0 + _created = None + _failed = None + + # --- synchroner Pfad (kleine Batches, Hintergrund, Tests) --- + def execute(self, context): + settings = context.scene.rock_gen_settings + created, failed = [], [] + for i in range(settings.batch_count): + names_before = set(bpy.data.objects.keys()) + try: + created.extend(make_one_rock(context, settings, i)) + except Exception as exc: # noqa: BLE001 - bewusst breit, ein Rock darf nicht den Batch killen + failed.append((i, str(exc))) + print("[RockGen] Rock %d fehlgeschlagen: %s" % (i, exc)) + traceback.print_exc() + _cleanup_partial(context, names_before) + _report_result(self, created, failed) + return {'FINISHED'} + + # --- Einstiegspunkt: entscheidet synchron vs. modal --- + def invoke(self, context, event): + settings = context.scene.rock_gen_settings + if bpy.app.background or not settings.use_modal or settings.batch_count < settings.modal_threshold: + return self.execute(context) + + self._index = 0 + self._created = [] + self._failed = [] + wm = context.window_manager + wm.progress_begin(0, settings.batch_count) + self._timer = wm.event_timer_add(0.01, window=context.window) + wm.modal_handler_add(self) + context.workspace.status_text_set( + "Rock Gen: 0/%d (ESC = Abbrechen)" % settings.batch_count + ) + return {'RUNNING_MODAL'} + + def modal(self, context, event): + settings = context.scene.rock_gen_settings + + if event.type == 'ESC': + self._finish(context) + _report_result(self, self._created, self._failed, cancelled=True) + return {'CANCELLED'} + + if event.type == 'TIMER': + if self._index >= settings.batch_count: + self._finish(context) + _report_result(self, self._created, self._failed) + return {'FINISHED'} + + names_before = set(bpy.data.objects.keys()) + try: + self._created.extend(make_one_rock(context, settings, self._index)) + except Exception as exc: # noqa: BLE001 + self._failed.append((self._index, str(exc))) + print("[RockGen] Rock %d fehlgeschlagen: %s" % (self._index, exc)) + traceback.print_exc() + _cleanup_partial(context, names_before) + + self._index += 1 + context.window_manager.progress_update(self._index) + context.workspace.status_text_set( + "Rock Gen: %d/%d (ESC = Abbrechen)" % (self._index, settings.batch_count) + ) + return {'RUNNING_MODAL'} + + return {'PASS_THROUGH'} + + def _finish(self, context): + wm = context.window_manager + if self._timer is not None: + wm.event_timer_remove(self._timer) + self._timer = None + wm.progress_end() + context.workspace.status_text_set(None) + + +# --------------------------------------------------------------------------- +# Operatoren: Presets +# --------------------------------------------------------------------------- + +class ROCKGEN_OT_preset_save(Operator): + bl_idname = "rockgen.preset_save" + bl_label = "Preset speichern" + bl_description = "Speichert die aktuellen Einstellungen als benanntes Preset (JSON)" + + def execute(self, context): + settings = context.scene.rock_gen_settings + name = settings.new_preset_name.strip() + if not name: + self.report({'ERROR'}, "Bitte einen Preset-Namen eingeben.") + return {'CANCELLED'} + if name in BUILTIN_PRESETS: + self.report({'ERROR'}, "Name kollidiert mit einem eingebauten Preset.") + return {'CANCELLED'} + presets = _load_user_presets() + presets[name] = _settings_to_dict(settings) + ok, info = _save_user_presets(presets) + if not ok: + self.report({'ERROR'}, "Speichern fehlgeschlagen: %s" % info) + return {'CANCELLED'} + settings.active_preset = "user::" + name + self.report({'INFO'}, "Preset '%s' gespeichert." % name) + return {'FINISHED'} + + +class ROCKGEN_OT_preset_load(Operator): + bl_idname = "rockgen.preset_load" + bl_label = "Preset laden" + bl_description = "Laedt das gewaehlte Preset in die Einstellungen" + + def execute(self, context): + settings = context.scene.rock_gen_settings + sel = settings.active_preset + if sel == '__NONE__': + self.report({'WARNING'}, "Kein Preset gewaehlt.") + return {'CANCELLED'} + kind, _, name = sel.partition("::") + data = BUILTIN_PRESETS.get(name) if kind == 'builtin' else _load_user_presets().get(name) + if not data: + self.report({'ERROR'}, "Preset nicht gefunden.") + return {'CANCELLED'} + _apply_dict_to_settings(settings, data) + self.report({'INFO'}, "Preset '%s' geladen." % name) + return {'FINISHED'} + + +class ROCKGEN_OT_preset_delete(Operator): + bl_idname = "rockgen.preset_delete" + bl_label = "Preset loeschen" + bl_description = "Loescht das gewaehlte eigene Preset" + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def execute(self, context): + settings = context.scene.rock_gen_settings + kind, _, name = settings.active_preset.partition("::") + if kind != 'user': + self.report({'WARNING'}, "Nur eigene Presets koennen geloescht werden.") + return {'CANCELLED'} + presets = _load_user_presets() + if name in presets: + del presets[name] + ok, info = _save_user_presets(presets) + if not ok: + self.report({'ERROR'}, "Loeschen fehlgeschlagen: %s" % info) + return {'CANCELLED'} + settings.active_preset = '__NONE__' + self.report({'INFO'}, "Preset '%s' geloescht." % name) + return {'FINISHED'} + + +# --------------------------------------------------------------------------- +# Panel +# --------------------------------------------------------------------------- + +class VIEW3D_PT_rock_generator(Panel): + bl_label = "Rock Generator" + bl_idname = "VIEW3D_PT_rock_generator" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Rock Gen" + + def draw(self, context): + layout = self.layout + settings = context.scene.rock_gen_settings + + box = layout.box() + box.label(text="Presets", icon='PRESET') + box.prop(settings, "active_preset", text="") + row = box.row(align=True) + row.operator("rockgen.preset_load", text="Laden", icon='IMPORT') + row.operator("rockgen.preset_delete", text="Loeschen", icon='TRASH') + row = box.row(align=True) + row.prop(settings, "new_preset_name", text="") + row.operator("rockgen.preset_save", text="Speichern", icon='ADD') + + box = layout.box() + box.label(text="Basis") + box.prop(settings, "base_shape") + box.prop(settings, "subsurf_levels") + + box = layout.box() + box.label(text="Noise / Displace") + box.prop(settings, "texture_type") + box.prop(settings, "noise_scale") + box.prop(settings, "displace_strength") + box.prop(settings, "seed") + + box = layout.box() + box.label(text="Decimate / Cleanup") + box.prop(settings, "decimate_ratio_1") + box.prop(settings, "decimate_ratio_2") + box.prop(settings, "dissolve_angle") + + box = layout.box() + box.label(text="Hard-Surface (optional)") + box.prop(settings, "add_bevel") + sub = box.column(align=True) + sub.enabled = settings.add_bevel + sub.prop(settings, "bevel_width") + sub.prop(settings, "bevel_segments") + box.prop(settings, "add_weighted_normal") + + box = layout.box() + box.label(text="UV (Unreal Export)") + box.prop(settings, "generate_uv0") + box.prop(settings, "generate_lightmap_uv") + + box = layout.box() + box.label(text="LOD") + box.prop(settings, "generate_lods") + sub = box.column(align=True) + sub.enabled = settings.generate_lods + sub.prop(settings, "lod_count") + sub.prop(settings, "lod_ratio_step") + + box = layout.box() + box.label(text="Material") + box.prop(settings, "assign_material") + + box = layout.box() + box.label(text="Transform") + box.prop(settings, "randomize_transform") + row = box.row(align=True) + row.prop(settings, "scale_min") + row.prop(settings, "scale_max") + + box = layout.box() + box.label(text="Batch") + box.prop(settings, "batch_count") + box.prop(settings, "spacing") + box.prop(settings, "use_modal") + sub = box.row() + sub.enabled = settings.use_modal + sub.prop(settings, "modal_threshold") + + layout.separator() + layout.operator("object.generate_rocks_batch", icon='MESH_ICOSPHERE') + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +classes = ( + RockGenSettings, + OBJECT_OT_generate_rocks, + ROCKGEN_OT_preset_save, + ROCKGEN_OT_preset_load, + ROCKGEN_OT_preset_delete, + VIEW3D_PT_rock_generator, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.rock_gen_settings = PointerProperty(type=RockGenSettings) + + +def unregister(): + del bpy.types.Scene.rock_gen_settings + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() diff --git a/tests/test_rock_gen.py b/tests/test_rock_gen.py new file mode 100644 index 0000000..91bfe3b --- /dev/null +++ b/tests/test_rock_gen.py @@ -0,0 +1,135 @@ +"""Headless-Test fuer den Stylized Rock Generator. + +Aufruf (aus dem Repo-Root oder beliebig): + + blender --background --python tests/test_rock_gen.py + +Exit-Code 0 = alle Checks bestanden, 1 = mindestens ein Check fehlgeschlagen. + +Der Test: + * registriert das Addon + * erzeugt einen Batch von 3 Rocks mit Standard-Settings (Subsurf reduziert + fuer Speed) + * prueft je Rock: keine losen Vertices, manifold, Normalen nach aussen + (signed volume > 0), UV0 + UV1 vorhanden + * gibt am Ende eine Zusammenfassung (Face-/Vertex-Count) aus +""" + +import os +import sys +import importlib.util + +import bpy +import bmesh + + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +ADDON_FILE = os.path.normpath(os.path.join(THIS_DIR, "..", "stylized_rock_generator.py")) + +BATCH = 3 + + +def load_addon(): + spec = importlib.util.spec_from_file_location("stylized_rock_generator", ADDON_FILE) + mod = importlib.util.module_from_spec(spec) + sys.modules["stylized_rock_generator"] = mod + spec.loader.exec_module(mod) + try: + mod.unregister() + except Exception: + pass + mod.register() + return mod + + +def mesh_stats(obj): + bm = bmesh.new() + bm.from_mesh(obj.data) + bm.normal_update() + loose = sum(1 for v in bm.verts if not v.link_faces) + nonmanifold = sum(1 for e in bm.edges if not e.is_manifold) + volume = bm.calc_volume(signed=True) + stats = dict( + name=obj.name, + verts=len(bm.verts), + faces=len(bm.faces), + loose=loose, + nonmanifold=nonmanifold, + volume=volume, + uv_layers=[layer.name for layer in obj.data.uv_layers], + ) + bm.free() + return stats + + +def main(): + failures = [] + + # 1. Saubere, leere Szene + bpy.ops.wm.read_factory_settings(use_empty=True) + + # 2. Addon registrieren (Scene-Property lebt am Typ -> nach Reset ok) + load_addon() + + settings = bpy.context.scene.rock_gen_settings + settings.batch_count = BATCH + settings.subsurf_levels = 4 # Test-Speed + settings.use_modal = False # synchron -> deterministisch + settings.generate_uv0 = True + settings.generate_lightmap_uv = True + settings.generate_lods = False + settings.assign_material = False + + # 3. Batch erzeugen + result = bpy.ops.object.generate_rocks_batch() + if 'FINISHED' not in result: + failures.append("Operator lieferte nicht {'FINISHED'}: %s" % (result,)) + + rocks = [o for o in bpy.context.scene.objects + if o.type == 'MESH' and o.name.startswith("Rock_")] + + if len(rocks) != BATCH: + failures.append("Erwartet %d Rocks, gefunden %d" % (BATCH, len(rocks))) + + # 4. Validierung + Zusammenfassung + print("\n" + "=" * 64) + print(" Rock-Generator Test - Zusammenfassung") + print("=" * 64) + print(" %-16s %7s %7s %6s %6s %9s %s" % + ("Name", "Verts", "Faces", "Loose", "NonMf", "Volume", "UVs")) + print("-" * 64) + + for obj in sorted(rocks, key=lambda o: o.name): + st = mesh_stats(obj) + print(" %-16s %7d %7d %6d %6d %9.3f %s" % ( + st["name"], st["verts"], st["faces"], st["loose"], + st["nonmanifold"], st["volume"], ",".join(st["uv_layers"]))) + + if st["faces"] == 0: + failures.append("%s: keine Faces" % st["name"]) + if st["loose"] != 0: + failures.append("%s: %d lose Vertices" % (st["name"], st["loose"])) + if st["nonmanifold"] != 0: + failures.append("%s: %d non-manifold Kanten" % (st["name"], st["nonmanifold"])) + if st["volume"] <= 0.0: + failures.append("%s: signed volume <= 0 (Normalen invertiert?)" % st["name"]) + if "Lightmap" not in st["uv_layers"]: + failures.append("%s: kein Lightmap-UV (UV1)" % st["name"]) + if len(st["uv_layers"]) < 2: + failures.append("%s: weniger als 2 UV-Layer" % st["name"]) + + print("=" * 64) + + if failures: + print("\nFEHLGESCHLAGEN (%d):" % len(failures)) + for f in failures: + print(" - " + f) + print("") + sys.exit(1) + + print("\nAlle Checks bestanden.\n") + sys.exit(0) + + +if __name__ == "__main__": + main()