bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", "version": (2, 4, 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, FloatVectorProperty, 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", "shading_mode", "autosmooth_angle", "randomize_shape", "noise_scale_max", "displace_strength_max", "nonuniform_scale", "flatten_bottom", "flatten_ratio", "set_origin_bottom", ) # 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 _asset_name(settings, index, batch_count): """EcoGame-Schema: Fels_
_, bei Batches mit laufender Nummer.""" base = "%s_%s_%s" % (settings.name_prefix.strip() or "Fels", (settings.name_form.strip() or "A").upper(), settings.name_class) if settings.name_numbered and batch_count > 1: return "%s_%02d" % (base, index + 1) return base def _face_count(obj): return len(obj.data.polygons) def _deselect_all(context): for o in list(context.view_layer.objects): try: o.select_set(False) except (ReferenceError, AttributeError, RuntimeError): # Kann nach dem Entfernen eines Objekts kurzzeitig ungueltig sein pass 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, name=None): """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 = name or ("Rock_%03d" % index) # Effektive Form-Parameter (optional pro Rock variiert) eff_noise = settings.noise_scale eff_displace = settings.displace_strength if settings.randomize_shape: lo_n, hi_n = sorted((settings.noise_scale, settings.noise_scale_max)) lo_d, hi_d = sorted((settings.displace_strength, settings.displace_strength_max)) eff_noise = rng.uniform(lo_n, hi_n) eff_displace = rng.uniform(lo_d, hi_d) # 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 = eff_noise elif settings.texture_type == 'CLOUDS': tex.noise_scale = eff_noise tex.noise_depth = 2 elif settings.texture_type == 'DISTORTED_NOISE': tex.noise_scale = eff_noise tex.distortion = 1.0 displace = obj.modifiers.new(name="Displace", type='DISPLACE') displace.texture = tex displace.texture_coords = 'GLOBAL' displace.strength = eff_displace 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.") # 5c. Boden abflachen (optional) - vor den UVs, damit die Unterseite UVs bekommt if settings.flatten_bottom: _flatten_bottom(context, obj, settings) # 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) 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 lo, hi = sorted((settings.scale_min, settings.scale_max)) if settings.nonuniform_scale: obj.scale = (rng.uniform(lo, hi), rng.uniform(lo, hi), rng.uniform(lo, hi)) else: s = rng.uniform(lo, hi) obj.scale = (s, s, s) 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) def _apply_shading(context, obj, settings): """Setzt den Shading-Look: FLAT (facettiert), SMOOTH oder AUTO (nach Winkel).""" _deselect_all(context) context.view_layer.objects.active = obj obj.select_set(True) mode = settings.shading_mode if mode == 'FLAT': bpy.ops.object.shade_flat() elif mode == 'SMOOTH': bpy.ops.object.shade_smooth() else: # AUTO try: bpy.ops.object.shade_auto_smooth(angle=math.radians(settings.autosmooth_angle)) except (RuntimeError, TypeError): bpy.ops.object.shade_smooth() # Scharfe Kanten nach Winkel markieren -> FBX-EDGE-Export traegt sie mit. bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.mark_sharp(clear=True) bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(settings.autosmooth_angle)) bpy.ops.mesh.mark_sharp() bpy.ops.mesh.select_all(action='SELECT') bpy.ops.object.mode_set(mode='OBJECT') def _flatten_bottom(context, obj, settings): """Schneidet eine flache Unterseite (Bisect), damit der Rock plan aufsteht.""" zs = [v.co.z for v in obj.data.vertices] if not zs: return zmin, zmax = min(zs), max(zs) cut_z = zmin + (zmax - zmin) * settings.flatten_ratio context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') try: bpy.ops.mesh.bisect( plane_co=(0.0, 0.0, cut_z), plane_no=(0.0, 0.0, 1.0), clear_inner=True, use_fill=True, ) except RuntimeError as exc: bpy.ops.object.mode_set(mode='OBJECT') raise RockGenError("Boden abflachen (bisect) fehlgeschlagen: %s" % exc) 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 Boden-Abflachen keine Faces mehr uebrig.") def _set_origin_bottom(context, obj): """Setzt den Origin auf die Unterseiten-Mitte (fuer sauberes Aufstellen in UE).""" mw = obj.matrix_world coords = [mw @ v.co for v in obj.data.vertices] if not coords: return cx = sum(c.x for c in coords) / len(coords) cy = sum(c.y for c in coords) / len(coords) zmin = min(c.z for c in coords) scene = context.scene prev_cursor = scene.cursor.location.copy() scene.cursor.location = (cx, cy, zmin) _deselect_all(context) context.view_layer.objects.active = obj obj.select_set(True) bpy.ops.object.origin_set(type='ORIGIN_CURSOR') scene.cursor.location = prev_cursor # --------------------------------------------------------------------------- # 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=None): base_name = base_name or ("Rock_%03d" % index) lod0 = _build_rock_mesh(context, settings, index, location, rng, name=base_name) _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) # Shading pro LOD (nach dem Decimate, damit scharfe Kanten zur Topologie passen) for lod in created: _apply_shading(context, lod, settings) # 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, batch_count=1): """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) name = _asset_name(settings, index, batch_count) if settings.generate_lods: return _build_lod_group(context, settings, index, location, rng, base_name=name) obj = _build_rock_mesh(context, settings, index, location, rng, name=name) _apply_hardsurface(context, obj, settings) _apply_random_transform(context, obj, settings, rng) _apply_shading(context, obj, settings) if settings.set_origin_bottom: _set_origin_bottom(context, obj) 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) randomize_shape: BoolProperty( name="Form pro Rock variieren", default=False, description="Noise-Scale und Displace-Staerke pro Rock zufaellig zwischen Min und Max", ) noise_scale_max: FloatProperty(name="Noise Scale Max", default=2.0, min=0.01, max=10.0) displace_strength_max: FloatProperty(name="Displace Max", default=0.5, min=0.0, max=2.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=False, description=("Separates, nicht ueberlappendes UV fuer Static-Mesh-Lightmaps. " "EcoGame beleuchtet dynamisch - dort nicht noetig"), ) # --- 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", ) # --- Shading --- shading_mode: EnumProperty( name="Shading", items=[ ('FLAT', "Faceted", "Harte Flaechen - klassischer stylized Low-Poly-Look"), ('SMOOTH', "Smooth", "Voll geglaettet"), ('AUTO', "Auto-Smooth", "Glatt, aber scharfe Kanten oberhalb des Winkels"), ], default='AUTO', ) autosmooth_angle: FloatProperty( name="Auto-Smooth Winkel (Grad)", default=30.0, min=0.0, max=180.0, description="Kanten steiler als dieser Winkel bleiben hart", ) # --- Form / Boden --- flatten_bottom: BoolProperty( name="Boden abflachen", default=False, description="Schneidet eine flache Unterseite, damit der Rock plan aufsteht", ) flatten_ratio: FloatProperty( name="Schnitthoehe", default=0.1, min=0.0, max=0.45, description="Wie weit von unten abgeschnitten wird (Anteil der Hoehe)", ) set_origin_bottom: BoolProperty( name="Origin unten-mittig", default=False, description="Setzt den Origin auf die Unterseiten-Mitte (sauberes Aufstellen in UE)", ) # --- 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) nonuniform_scale: BoolProperty( name="Nicht-uniforme Skalierung", default=False, description="Skaliert jede Achse einzeln (flache Platten vs. runde Brocken)", ) # --- 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", ) # --- Textur-Bake (tileable, fuer Triplanar in UE) --- bake_dir: StringProperty( name="Bake-Ordner", default="", subtype='DIR_PATH', description="Zielordner fuer die gebackenen Textur-PNGs", ) # --- Benennung (EcoGame-Schema: Fels__) --- name_prefix: StringProperty( name="Praefix", default="Fels", description="Basisname, z.B. 'Fels' oder 'Felsblock'", ) name_form: StringProperty( name="Form", default="A", description="Form-Variante als Buchstabe (A, B, C ...)", ) name_class: EnumProperty( name="Groessen-Klasse", items=[('S', "S", "klein"), ('M', "M", "mittel"), ('L', "L", "gross"), ('XL', "XL", "sehr gross")], default='M', description=("Groessen-KLASSE mit eigener Geometrie. Reines Groesser/Kleiner " "macht die Engine ueber ScaleMin/Max - eine eigene Klasse nur, " "wenn sich die Silhouette aendert"), ) name_numbered: BoolProperty( name="Bei Batch durchnummerieren", default=True, description="Haengt bei mehreren Rocks _01, _02 ... an (sonst Blender-Suffix)", ) bake_prefix: StringProperty( name="Textur-Name", default="fels_a", description="Basisname; es entstehen _albedo.png und _normal.png", ) bake_resolution: EnumProperty( name="Aufloesung", items=[('512', "512", ""), ('1024', "1024", ""), ('2048', "2048", "")], default='1024', # reicht fuer stylized/flaechige Formen ) bake_color: BoolProperty(name="Albedo", default=True) bake_normal: BoolProperty(name="Normal", default=True) bake_roughness: BoolProperty( name="Roughness", default=False, description="In EcoGame ist Roughness eine ZAHL im Master-Material, keine Textur", ) bake_ao: BoolProperty( name="AO separat", default=False, description=("Eigene AO-Textur. In EcoGame nicht noetig - dort wird AO ins " "Albedo eingerechnet (siehe unten)"), ) bake_ao_into_albedo: BoolProperty( name="AO ins Albedo einrechnen", default=True, description="Cavity/AO multiplikativ ins Farbbild backen (EcoGame-Konvention)", ) bake_ao_strength: FloatProperty( name="AO-Staerke", default=1.0, min=0.0, max=1.0, description="0 = kein AO im Albedo, 1 = voll", ) bake_base_color: FloatVectorProperty( name="Grundfarbe", subtype='COLOR', size=4, default=(0.52, 0.47, 0.40, 1.0), min=0.0, max=1.0, description="Grundton des Steins; Outline/Highlight werden davon abgeleitet", ) bake_cell_scale: FloatProperty( name="Zellgroesse", default=2.5, min=0.5, max=12.0, description="Kleiner = groessere Zellen (weniger Bruchstuecke)", ) # --- FBX-Export --- export_dir: StringProperty( name="Export-Ordner", default="", subtype='DIR_PATH', description="Zielordner fuer die FBX-Dateien (je Rock/LOD-Gruppe eine Datei)", ) export_selected_only: BoolProperty( name="Nur Auswahl exportieren", default=False, description="Exportiert nur die aktuell selektierten Rocks statt aller", ) # --- 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, settings.batch_count)) 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, settings.batch_count)) 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) # --------------------------------------------------------------------------- # FBX-Export (UE-ready) # --------------------------------------------------------------------------- def _collect_export_targets(context, settings): """Top-Level-Rocks: LOD-Group-Empties + freistehende Rock-Meshes ohne Parent.""" tops = [] for o in context.scene.objects: is_group = (o.type == 'EMPTY' and o.get("fbx_type") == "LodGroup") is_standalone = (o.type == 'MESH' and o.parent is None and (o.name.startswith(settings.name_prefix.strip() or "Fels") or o.name.startswith("Rock_"))) if is_group or is_standalone: tops.append(o) if settings.export_selected_only: sel = set(context.selected_objects) tops = [t for t in tops if t in sel or any(c in sel for c in t.children_recursive)] return tops def _safe_filename(name): keep = "-_.() " return "".join(c for c in name if c.isalnum() or c in keep).strip() or "Rock" class OBJECT_OT_export_rocks_fbx(Operator): bl_idname = "object.export_rocks_fbx" bl_label = "FBX exportieren" bl_description = "Exportiert jeden Rock / jede LOD-Gruppe als eigene FBX (UE-ready)" def execute(self, context): settings = context.scene.rock_gen_settings directory = bpy.path.abspath(settings.export_dir) if settings.export_dir else "" if not directory or not os.path.isdir(directory): self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.") return {'CANCELLED'} targets = _collect_export_targets(context, settings) if not targets: self.report({'WARNING'}, "Keine Rocks zum Exportieren gefunden.") return {'CANCELLED'} # AUTO -> Edge-Smoothing (traegt scharfe Kanten), sonst Face-Smoothing. smooth_type = 'EDGE' if settings.shading_mode == 'AUTO' else 'FACE' _ensure_object_mode(context) exported, failed = 0, 0 for top in targets: objs = list(top.children_recursive) + [top] _deselect_all(context) for o in objs: o.select_set(True) context.view_layer.objects.active = top filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx") try: bpy.ops.export_scene.fbx( filepath=filepath, use_selection=True, object_types={'MESH', 'EMPTY'}, use_mesh_modifiers=True, mesh_smooth_type=smooth_type, use_triangles=True, use_custom_props=True, # noetig fuer fbx_type=LodGroup add_leaf_bones=False, bake_anim=False, path_mode='AUTO', ) exported += 1 except RuntimeError as exc: failed += 1 print("[RockGen] FBX-Export '%s' fehlgeschlagen: %s" % (top.name, exc)) if failed: self.report({'WARNING'}, "%d FBX exportiert, %d fehlgeschlagen (Konsole)." % (exported, failed)) else: self.report({'INFO'}, "%d FBX-Datei(en) nach %s exportiert." % (exported, directory)) return {'FINISHED'} # --------------------------------------------------------------------------- # Textur-Bake (nahtlos tileable via 4D-Torus-Projektion, fuer Triplanar in UE) # --------------------------------------------------------------------------- def _build_bake_material(base_color=(0.52, 0.47, 0.40), cell_scale=2.5): """Baut ein prozedurales, nahtlos tileables Stylized-Rock-Material (Zell-Look). Trick: Die 2D-UV wird als (cos u, sin u, cos v, sin v) auf einen Torus projiziert und 4D gesampelt -> tilet in U und V ohne Naht. Voronoi liefert hand-painted Zellen mit dunklen Outlines; base_color/cell_scale steuern Ton und Zellgroesse. Liefert (mat, sockets) fuer die Bake-Passes. """ br, bg, bb = base_color[0], base_color[1], base_color[2] def _tone(factor): return (min(br * factor, 1.0), min(bg * factor, 1.0), min(bb * factor, 1.0), 1.0) mat = bpy.data.materials.new("__RockBakeMat") mat.use_nodes = True nt = mat.node_tree nodes, links = nt.nodes, nt.links nodes.clear() out = nodes.new("ShaderNodeOutputMaterial") bsdf = nodes.new("ShaderNodeBsdfPrincipled") emit = nodes.new("ShaderNodeEmission") texco = nodes.new("ShaderNodeTexCoord") sep = nodes.new("ShaderNodeSeparateXYZ") links.new(texco.outputs["UV"], sep.inputs[0]) def _circle(coord_socket): mul = nodes.new("ShaderNodeMath"); mul.operation = 'MULTIPLY' mul.inputs[1].default_value = TWO_PI links.new(coord_socket, mul.inputs[0]) cos = nodes.new("ShaderNodeMath"); cos.operation = 'COSINE' links.new(mul.outputs[0], cos.inputs[0]) sin = nodes.new("ShaderNodeMath"); sin.operation = 'SINE' links.new(mul.outputs[0], sin.inputs[0]) return cos.outputs[0], sin.outputs[0] cu, su = _circle(sep.outputs["X"]) cv, sv = _circle(sep.outputs["Y"]) comb = nodes.new("ShaderNodeCombineXYZ") links.new(cu, comb.inputs[0]) links.new(su, comb.inputs[1]) links.new(cv, comb.inputs[2]) # Domain-Warp: verzerrt die Zellen leicht organisch (tileable, da gleiche # Torus-Coords -> ueber die Naht hinweg stetig). warp_noise = nodes.new("ShaderNodeTexNoise") warp_noise.noise_dimensions = '4D' warp_noise.inputs["Scale"].default_value = 3.0 warp_noise.inputs["Detail"].default_value = 2.0 links.new(comb.outputs[0], warp_noise.inputs["Vector"]) links.new(sv, warp_noise.inputs["W"]) warp_scale = nodes.new("ShaderNodeVectorMath"); warp_scale.operation = 'SCALE' links.new(warp_noise.outputs["Color"], warp_scale.inputs[0]) warp_scale.inputs[3].default_value = 0.15 comb_w = nodes.new("ShaderNodeVectorMath"); comb_w.operation = 'ADD' links.new(comb.outputs[0], comb_w.inputs[0]) links.new(warp_scale.outputs[0], comb_w.inputs[1]) # Zwei Voronoi-Nodes mit identischen Coords: Rand-Distanz + Per-Zell-Farbe. vor_edge = nodes.new("ShaderNodeTexVoronoi") vor_edge.voronoi_dimensions = '4D' vor_edge.feature = 'DISTANCE_TO_EDGE' vor_edge.inputs["Scale"].default_value = cell_scale links.new(comb_w.outputs[0], vor_edge.inputs["Vector"]) links.new(sv, vor_edge.inputs["W"]) vor_cell = nodes.new("ShaderNodeTexVoronoi") vor_cell.voronoi_dimensions = '4D' vor_cell.feature = 'F1' vor_cell.inputs["Scale"].default_value = cell_scale links.new(comb_w.outputs[0], vor_cell.inputs["Vector"]) links.new(sv, vor_cell.inputs["W"]) # cell_fac: 0 an den Zellraendern (dunkle Outlines), 1 in der Zellmitte. # Breiter Bereich -> jede Zelle bekommt einen weichen Woelbungs-Gradient. cell_fac = nodes.new("ShaderNodeMapRange") links.new(vor_edge.outputs["Distance"], cell_fac.inputs["Value"]) cell_fac.inputs["From Min"].default_value = 0.0 cell_fac.inputs["From Max"].default_value = 0.32 cell_fac.inputs["To Min"].default_value = 0.0 cell_fac.inputs["To Max"].default_value = 1.0 cell_fac.clamp = True # Per-Zell-Helligkeit (leichte Variation Zelle zu Zelle) cell_bw = nodes.new("ShaderNodeRGBToBW") links.new(vor_cell.outputs["Color"], cell_bw.inputs[0]) cell_var = nodes.new("ShaderNodeMapRange") links.new(cell_bw.outputs[0], cell_var.inputs["Value"]) cell_var.inputs["To Min"].default_value = 0.78 cell_var.inputs["To Max"].default_value = 1.08 cell_var.clamp = True combined = nodes.new("ShaderNodeMath"); combined.operation = 'MULTIPLY' links.new(cell_fac.outputs[0], combined.inputs[0]) links.new(cell_var.outputs[0], combined.inputs[1]) # BaseColor: dunkle Outline -> Zell-Ton -> Highlight (hand-painted Look), # alle Toene aus base_color abgeleitet. ramp = nodes.new("ShaderNodeValToRGB") cr = ramp.color_ramp cr.elements[0].position = 0.0 cr.elements[0].color = _tone(0.12) # Riss / Outline cr.elements[1].position = 0.85 cr.elements[1].color = _tone(1.22) # Zell-Highlight mid = cr.elements.new(0.32) mid.color = _tone(0.72) # Zell-Grundton links.new(combined.outputs[0], ramp.inputs["Fac"]) # Roughness: an den Rissen etwas rauer rough = nodes.new("ShaderNodeMapRange") links.new(cell_fac.outputs[0], rough.inputs["Value"]) rough.inputs["To Min"].default_value = 0.95 rough.inputs["To Max"].default_value = 0.72 rough.clamp = True # AO: Zellraender abgedunkelt ao = nodes.new("ShaderNodeMapRange") links.new(cell_fac.outputs[0], ao.inputs["Value"]) ao.inputs["To Min"].default_value = 0.35 ao.inputs["To Max"].default_value = 1.0 ao.clamp = True # Bump: Risse werden zu Rillen (cell_fac niedrig am Rand -> vertieft) bump = nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = 0.7 bump.inputs["Distance"].default_value = 0.4 links.new(cell_fac.outputs[0], bump.inputs["Height"]) # EcoGame-Konvention: AO/Cavity wird NICHT als eigene Textur exportiert, # sondern direkt ins Albedo multipliziert - dunkle Ritzen und helle Platten # sind im Farbbild eingebacken. Das traegt den Stylized-Look. ao_mix = nodes.new("ShaderNodeMixRGB") ao_mix.blend_type = 'MULTIPLY' ao_mix.inputs["Fac"].default_value = 1.0 links.new(ramp.outputs["Color"], ao_mix.inputs["Color1"]) ao_rgb = nodes.new("ShaderNodeCombineXYZ") links.new(ao.outputs[0], ao_rgb.inputs["X"]) links.new(ao.outputs[0], ao_rgb.inputs["Y"]) links.new(ao.outputs[0], ao_rgb.inputs["Z"]) links.new(ao_rgb.outputs[0], ao_mix.inputs["Color2"]) links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) links.new(rough.outputs[0], bsdf.inputs["Roughness"]) links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) links.new(bsdf.outputs[0], out.inputs["Surface"]) sockets = { "out": out, "bsdf": bsdf, "emit": emit, "color": ramp.outputs["Color"], "color_ao": ao_mix.outputs["Color"], # Albedo mit eingebackenem AO "ao_mix": ao_mix, "roughness": rough.outputs[0], "ao": ao.outputs[0], } return mat, sockets def _bake_one(context, mat, img_node, name, folder, res, bake_type, non_color): img = bpy.data.images.new(name, res, res, alpha=False, float_buffer=False) if non_color: img.colorspace_settings.name = 'Non-Color' img_node.image = img mat.node_tree.nodes.active = img_node if bake_type == 'NORMAL': bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT', use_clear=True) else: bpy.ops.object.bake(type='EMIT', use_clear=True) path = os.path.join(folder, name + ".png") img.filepath_raw = path img.file_format = 'PNG' img.save() bpy.data.images.remove(img) return path class OBJECT_OT_bake_rock_textures(Operator): bl_idname = "object.bake_rock_textures" bl_label = "Textur backen" bl_description = "Backt ein nahtlos tileables Stylized-Rock-Textur-Set (fuer Triplanar in UE)" def execute(self, context): settings = context.scene.rock_gen_settings folder = bpy.path.abspath(settings.bake_dir) if settings.bake_dir else "" if not folder or not os.path.isdir(folder): self.report({'ERROR'}, "Bitte einen gueltigen Bake-Ordner waehlen.") return {'CANCELLED'} res = int(settings.bake_resolution) prefix = settings.bake_prefix.strip() or "T_Rock_Stylized" scene = context.scene # Zustand sichern prev_engine = scene.render.engine prev_active = context.view_layer.objects.active prev_selected = [o for o in context.selected_objects] prev_samples = getattr(scene.cycles, "samples", None) if hasattr(scene, "cycles") else None _ensure_object_mode(context) saved = [] plane = None mat = None try: bpy.ops.mesh.primitive_plane_add(size=2.0) plane = context.active_object plane.name = "__RockBakePlane" mat, sk = _build_bake_material( base_color=tuple(settings.bake_base_color), cell_scale=settings.bake_cell_scale, ) # AO-Staerke ins Albedo (Fac 0 = aus, 1 = voll) sk["ao_mix"].inputs["Fac"].default_value = ( settings.bake_ao_strength if settings.bake_ao_into_albedo else 0.0 ) plane.data.materials.clear() plane.data.materials.append(mat) scene.render.engine = 'CYCLES' if hasattr(scene, "cycles"): scene.cycles.samples = 1 # Emit/Normal sind deterministisch scene.render.bake.margin = max(2, res // 128) nodes = mat.node_tree.nodes links = mat.node_tree.links img_node = nodes.new("ShaderNodeTexImage") _deselect_all(context) plane.select_set(True) context.view_layer.objects.active = plane if settings.bake_normal: links.new(sk["bsdf"].outputs[0], sk["out"].inputs["Surface"]) saved.append(_bake_one(context, mat, img_node, prefix + "_normal", folder, res, 'NORMAL', non_color=True)) def _emit_pass(source_socket, suffix, non_color): links.new(source_socket, sk["emit"].inputs["Color"]) links.new(sk["emit"].outputs[0], sk["out"].inputs["Surface"]) saved.append(_bake_one(context, mat, img_node, prefix + suffix, folder, res, 'EMIT', non_color)) if settings.bake_color: # EcoGame braucht nur _albedo und _normal. Das AO steckt bereits # im Farbbild, sofern "AO ins Albedo einrechnen" aktiv ist. src = sk["color_ao"] if settings.bake_ao_into_albedo else sk["color"] _emit_pass(src, "_albedo", non_color=False) if settings.bake_roughness: _emit_pass(sk["roughness"], "_roughness", non_color=True) if settings.bake_ao: _emit_pass(sk["ao"], "_ao", non_color=True) except Exception as exc: # noqa: BLE001 print("[RockGen] Bake fehlgeschlagen: %s" % exc) traceback.print_exc() self.report({'ERROR'}, "Bake fehlgeschlagen: %s (Details in der Konsole)" % exc) finally: if plane is not None: bpy.data.objects.remove(plane, do_unlink=True) if mat is not None and mat.users == 0: bpy.data.materials.remove(mat) scene.render.engine = prev_engine if prev_samples is not None: scene.cycles.samples = prev_samples _deselect_all(context) for o in prev_selected: try: o.select_set(True) except ReferenceError: pass context.view_layer.objects.active = prev_active if saved: self.report({'INFO'}, "%d Textur(en) gebacken nach %s" % (len(saved), folder)) return {'FINISHED'} return {'CANCELLED'} # --------------------------------------------------------------------------- # 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="Benennung", icon='SORTALPHA') row = box.row(align=True) row.prop(settings, "name_prefix", text="") row.prop(settings, "name_form", text="") row.prop(settings, "name_class", text="") box.prop(settings, "name_numbered") box.label(text="Ergebnis: %s" % _asset_name(settings, 0, settings.batch_count), icon='INFO') 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.prop(settings, "randomize_shape") sub = box.column(align=True) sub.enabled = settings.randomize_shape sub.prop(settings, "noise_scale_max") sub.prop(settings, "displace_strength_max") 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="Shading / Form") box.prop(settings, "shading_mode") row = box.row() row.enabled = settings.shading_mode == 'AUTO' row.prop(settings, "autosmooth_angle") box.prop(settings, "flatten_bottom") row = box.row() row.enabled = settings.flatten_bottom row.prop(settings, "flatten_ratio") box.prop(settings, "set_origin_bottom") 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.prop(settings, "nonuniform_scale") 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') box = layout.box() box.label(text="FBX-Export (Unreal)") box.prop(settings, "export_dir") box.prop(settings, "export_selected_only") box.operator("object.export_rocks_fbx", icon='EXPORT') box = layout.box() box.label(text="Textur-Bake (tileable / Triplanar)") box.prop(settings, "bake_dir") box.prop(settings, "bake_prefix") box.prop(settings, "bake_resolution") box.prop(settings, "bake_base_color") box.prop(settings, "bake_cell_scale") row = box.row(align=True) row.prop(settings, "bake_color", toggle=True) row.prop(settings, "bake_normal", toggle=True) box.prop(settings, "bake_ao_into_albedo") sub = box.row() sub.enabled = settings.bake_ao_into_albedo sub.prop(settings, "bake_ao_strength") row = box.row(align=True) row.prop(settings, "bake_roughness", toggle=True) row.prop(settings, "bake_ao", toggle=True) box.operator("object.bake_rock_textures", icon='TEXTURE') # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- classes = ( RockGenSettings, OBJECT_OT_generate_rocks, OBJECT_OT_export_rocks_fbx, OBJECT_OT_bake_rock_textures, 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()