bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", "version": (2, 12, 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)), ) # 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 # die FORM innerhalb einer Klasse und wird von "keep_size" auf konstantes # Volumen normiert, damit sie sich nicht mit den ScaleMin/Max der Engine # multipliziert. CLASS_SIZES = {'S': 0.45, 'M': 1.0, 'L': 1.9, 'XL': 3.2} # Property-Keys, die von Presets gespeichert/geladen werden. PRESET_KEYS = ( "base_shape", "box_proportions", "box_variation", "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", "keep_size", "bake_vertex_cavity", "cavity_strength", "cavity_blur", "moss_amount", "moss_noise", "moss_seed", "part_mask_from_shells", "use_class_size", "base_size", ) # 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 in ('CUBE', 'BOX'): bpy.ops.mesh.primitive_cube_add(size=2, location=location) if settings.base_shape == 'BOX': # Proportionen auf die MESH-DATEN anwenden, nicht auf das Objekt: # der Displace nutzt globale Texturkoordinaten, eine Objekt-Skalierung # wuerde das Rauschen mitverzerren statt die Form zu aendern. px, py, pz = settings.box_proportions # Auf gleiches Volumen normieren: sonst waere ein Quader bei gleicher # Groessen-Klasse deutlich groesser als ein Wuerfel, nur weil eine # Achse gestreckt ist. So bedeutet "M" bei jeder Form dasselbe - # die Platte ist dann flacher UND breiter, aber nicht schwerer. gmean = (abs(px) * abs(py) * abs(pz)) ** (1.0 / 3.0) if gmean > 1e-6: px, py, pz = px / gmean, py / gmean, pz / gmean v = settings.box_variation if v > 0.0: px *= 1.0 + rng.uniform(-v, v) * 0.5 py *= 1.0 + rng.uniform(-v, v) * 0.5 pz *= 1.0 + rng.uniform(-v, v) * 0.5 ob_tmp = context.active_object ob_tmp.scale = (px, py, pz) _deselect_all(context) context.view_layer.objects.active = ob_tmp ob_tmp.select_set(True) bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) 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: # Klassengroesse gilt trotzdem - sie ist keine Zufalls-Streuung. base = (CLASS_SIZES.get(settings.name_class, 1.0) if settings.use_class_size else settings.base_size) if abs(base - 1.0) > 1e-6: obj.scale = (base, base, base) _deselect_all(context) context.view_layer.objects.active = obj obj.select_set(True) bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) return base = (CLASS_SIZES.get(settings.name_class, 1.0) if settings.use_class_size else settings.base_size) lo, hi = sorted((settings.scale_min, settings.scale_max)) if settings.nonuniform_scale: sx, sy, sz = (rng.uniform(lo, hi), rng.uniform(lo, hi), rng.uniform(lo, hi)) if settings.keep_size: # Geometrisches Mittel auf 1.0 normieren: die Proportionen streuen, # das Volumen bleibt. Sonst multipliziert sich die Streuung mit den # ScaleMin/Max der Engine. g = (abs(sx) * abs(sy) * abs(sz)) ** (1.0 / 3.0) if g > 1e-6: sx, sy, sz = sx / g, sy / g, sz / g obj.scale = (sx * base, sy * base, sz * base) else: s = 1.0 if settings.keep_size else rng.uniform(lo, hi) obj.scale = (s * base, s * base, s * base) 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 _bake_vertex_cavity(context, obj, settings): """Vertex-Farb-Layout v2 - KANAL-GETRENNT im Attribut "Cavity". R = Cavity pur 1 = offene Flaeche, 0 = tiefe Fuge (ohne Verlauf!) G = Moos/Schmutz 1 = hier waechst was (Oberseite + Fugennaehe + Noise) B = Hoehen-Verlauf 0 = Unterseite, 1 = oben A = 1 (Reserve) Getrennt statt eingebacken, damit das Material selbst entscheidet: Boden-Saum und Moosfarbe werden pro Material-Instanz geregelt, nicht im Mesh festgelegt. """ import random as _rnd me = obj.data layer = me.color_attributes.get("Cavity") if layer is None: layer = me.color_attributes.new(name="Cavity", type='BYTE_COLOR', domain='CORNER') me.color_attributes.active_color = layer idx = me.color_attributes.find("Cavity") if idx >= 0: me.color_attributes.render_color_index = idx # --- R: Cavity ueber Blenders Dirty-Vertex-Colors --- _deselect_all(context) context.view_layer.objects.active = obj obj.select_set(True) try: bpy.ops.paint.vertex_color_set(color=(1.0, 1.0, 1.0, 1.0)) except RuntimeError: pass try: bpy.ops.paint.vertex_color_dirt( blur_strength=1.0, blur_iterations=settings.cavity_blur, clean_angle=3.14159, dirt_angle=0.0, dirt_only=False, normalize=True, ) except RuntimeError as exc: raise RockGenError("Vertex-Cavity fehlgeschlagen: %s" % exc) data = layer.data cav_raw = [data[i].color[0] for i in range(len(data))] zs = [v.co.z for v in me.vertices] if not zs: return zmin, zmax = min(zs), max(zs) span = max(zmax - zmin, 1e-6) # Normalen je Loop fuer den Oberseiten-Anteil me.calc_normals_split() if hasattr(me, "calc_normals_split") else None rng = _rnd.Random(settings.moss_seed) # Noise-Wert je VERTEX (nicht je Loop), damit er nicht flackert vnoise = [rng.random() for _ in range(len(me.vertices))] amt = settings.cavity_strength moss = settings.moss_amount mnoise = settings.moss_noise for poly in me.polygons: nz = poly.normal.z # 1 = zeigt nach oben for li in poly.loop_indices: vi = me.loops[li].vertex_index c_raw = cav_raw[li] # R: reine Cavity, Staerke geregelt, KEIN Hoehen-Verlauf r = max(0.0, min(1.0, 1.0 - (1.0 - c_raw) * amt)) # B: Hoehen-Verlauf 0..1 b = max(0.0, min(1.0, (me.vertices[vi].co.z - zmin) / span)) # G: Moos - Oberseite UND Fugennaehe, mit Noise aufgebrochen up = max(0.0, nz) # 0 unten, 1 oben crev = 1.0 - c_raw # 1 in der Fuge base = up * 0.7 + crev * 0.5 n = 1.0 - mnoise + mnoise * vnoise[vi] g = max(0.0, min(1.0, base * n * moss * 1.6)) data[li].color = (r, g, b, 1.0) 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.bake_vertex_cavity: _bake_vertex_cavity(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', "Wuerfel", "Gleichmaessiger Brocken"), ('BOX', "Quader", "Laengliche oder flache Grundform - Proportionen unten"), ('ICOSPHERE', "Ico-Sphere", "Rundlicher Brocken"), ], default='CUBE', ) box_proportions: FloatVectorProperty( name="Quader-Proportionen", size=3, default=(1.6, 1.1, 0.55), min=0.15, max=4.0, subtype='XYZ', description=("Seitenverhaeltnis der Grundform bei 'Quader'. Flach (kleines Z) " "ergibt Platten und Findlinge, langgestreckt ergibt Brocken. " "Wird VOR dem Displace angewendet, damit die Verformung mitgeht"), ) box_variation: FloatProperty( name="Proportions-Streuung", default=0.25, min=0.0, max=1.0, description="Wuerfelt die Proportionen je Stein etwas anders (0 = alle gleich)", ) 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)", ) # --- Vertex-Cavity (texturfreier Weg) --- bake_vertex_cavity: BoolProperty( name="Cavity in Vertex-Farben", default=False, description=("Backt Cavity/AO in die Vertex-Farben (Attribut 'Cavity'). " "Das Material braucht dann KEINE Textur: Base Color = Farbton " "x Vertex-Farbe, Rest ueber Kantenlicht und Schattentint"), ) cavity_strength: FloatProperty( name="Cavity-Staerke", default=0.7, min=0.0, max=1.0, description="0 = alles weiss, 1 = volle Abdunklung in den Vertiefungen", ) cavity_blur: IntProperty( name="Weichzeichnen", default=1, min=0, max=5, description="Glaettet den Cavity-Verlauf ueber benachbarte Vertices", ) part_mask_from_shells: BoolProperty( name="G = Teil-Maske (Shells)", default=False, description=("Statt der Moos-Maske: getrennte Mesh-Teile markieren. " "Der oberste/breiteste Teil bekommt G=1 (z.B. Pilzhut), " "der Rest 0 (Stiel). Nutzt Strg+J-verbundene Objekte aus - " "damit tont EIN Material beide Teile unterschiedlich"), ) moss_amount: FloatProperty( name="Moos-Menge", default=0.55, min=0.0, max=1.0, description=("Wie viel der Oberflaeche die Moos-/Schmutzmaske (Kanal G) " "markiert. Die FARBE bestimmt das Material, nicht das Mesh"), ) moss_noise: FloatProperty( name="Moos-Aufbruch", default=0.55, min=0.0, max=1.0, description="Bricht die Maske mit Rauschen auf, damit sie nicht flaechig wirkt", ) moss_seed: IntProperty( name="Moos-Seed", default=0, min=0, max=9999, description="Anderer Wert = andere Moos-Verteilung bei gleicher Form", ) # --- 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=True, description="Skaliert jede Achse einzeln (flache Platten vs. runde Brocken)", ) keep_size: BoolProperty( name="Gesamtgroesse konstant halten", default=True, description=("Normiert die Achsen-Faktoren, sodass nur die FORM streut und nicht " "die Groesse. Wichtig fuer EcoGame: die Engine streut die Groesse " "zusaetzlich ueber ScaleMin/Max - beides zusammen multipliziert sich " "und erzeugt sonst Mini- und Riesen-Exemplare"), ) # --- 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"), ) use_class_size: BoolProperty( name="Groesse aus Klasse", default=True, description=("Leitet die Basisgroesse aus der Klasse ab " "(S=0.45, M=1.0, L=1.9, XL=3.2). Ausschalten, um " "'Basis-Groesse' frei zu setzen"), ) base_size: FloatProperty( name="Basis-Groesse", default=1.0, min=0.05, max=20.0, description=("Absolute Groesse des Steins (Faktor). Getrennt von der " "Zufalls-Streuung, die nur die Form variiert"), ) 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=0.55, 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.62, 0.60, 0.56, 1.0), min=0.0, max=1.0, description="Grundton des Steins; Outline/Highlight werden davon abgeleitet", ) bake_seam_width: FloatProperty( name="Fugenbreite", default=0.05, min=0.01, max=0.5, description=("Wie breit der Uebergang von der Fuge zur Plattenflaeche ist. " "Klein = schmale harte Fuge und flache Platte (Genshin), " "gross = weicher Verlauf ueber die ganze Zelle"), ) bake_plate_flat: FloatProperty( name="Platten flach", default=1.0, min=0.0, max=1.0, description=("1 = Plattenflaeche nahezu einfarbig, Detail steckt nur in der " "dunklen Fuge. 0 = klassischer Verlauf ueber die Zelle"), ) bake_cell_scale: FloatProperty( name="Plattengroesse", default=0.7, min=0.3, max=12.0, description=("Kleiner = GROESSERE Platten. Genshin-Referenz: nur wenige grosse " "Platten pro Kachel, nicht viele kleine Zellen"), ) # --- 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", ) # --- Zufall --- random_amount: FloatProperty( name="Streuung", default=0.35, min=0.0, max=1.0, description=("Wie stark 'Zufaellig' die Werte veraendert. 0 = nur neuer Seed, " "1 = volle Bandbreite"), ) random_shape: BoolProperty( name="Auch Basis-Form/Noise-Typ", default=False, description="Wuerfelt zusaetzlich Cube/Ico-Sphere und den Noise-Typ", ) # --- 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 colors_type='SRGB', # Vertex-Cavity mit exportieren # (in UE: Vertex Color Import = Replace) 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, seam_width=0.32, plate_flat=0.0): """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 # Fugenbreite: klein = schmale, harte Fuge und flache Platte (Genshin-Look), # gross = breiter Verlauf ueber die ganze Zelle ("puffy", Golfball). cell_fac.inputs["From Max"].default_value = seam_width 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 # plate_flat 0 = klassischer Verlauf, 1 = Plattenflaeche nahezu einfarbig # mit dunkler Fuge (Referenz Genshin: leere Flaeche, Detail nur in der Fuge). f = max(0.0, min(1.0, plate_flat)) cr.elements[0].position = 0.0 cr.elements[0].color = _tone(0.12 - 0.05 * f) # Fuge: dunkler bei flach cr.elements[1].position = 0.85 - 0.6 * f # Plateau frueher erreicht cr.elements[1].color = _tone(1.22 - 0.24 * f) # weniger Highlight mid = cr.elements.new(max(0.04, 0.32 - 0.26 * f)) mid.color = _tone(0.72 + 0.22 * f) # Platte heller/gleichmaessiger 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, seam_width=settings.bake_seam_width, plate_flat=settings.bake_plate_flat, ) # 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 # --------------------------------------------------------------------------- def _mesh_shells(me): """Zusammenhaengende Teile (Shells) als Listen von Polygon-Indizes. Bei Assets, die aus mehreren Objekten mit Strg+J verbunden wurden (z.B. Pilz = Hut + Stiel), bleiben die Teile geometrisch getrennt. Damit laesst sich die Teil-Maske EXAKT bilden statt sie ueber Normalen zu schaetzen. """ adj = {} for poly in me.polygons: for ek in poly.edge_keys: adj.setdefault(ek, []).append(poly.index) seen = set() shells = [] for poly in me.polygons: if poly.index in seen: continue stack = [poly.index] comp = set() while stack: pi = stack.pop() if pi in comp: continue comp.add(pi) for ek in me.polygons[pi].edge_keys: for nb in adj.get(ek, ()): if nb not in comp: stack.append(nb) seen |= comp shells.append(comp) return shells def _shell_part_mask(me): """G-Wert je Polygon: 1 fuer den OBEREN/breiteren Teil (z.B. Hut), 0 sonst. Bei nur einer Shell gibt es nichts zu trennen -> alles 0, damit die Maske nicht faelschlich etwas markiert. """ shells = _mesh_shells(me) if len(shells) < 2: return {p.index: 0.0 for p in me.polygons}, len(shells) infos = [] for sh in shells: vs = {vi for pi in sh for vi in me.polygons[pi].vertices} zs = [me.vertices[i].co.z for i in vs] xs = [me.vertices[i].co.x for i in vs] ys = [me.vertices[i].co.y for i in vs] infos.append((sh, max(zs), max(max(xs) - min(xs), max(ys) - min(ys)))) # Oberster Teil, bei Gleichstand der breitere -> das ist der Hut/Deckel top = max(infos, key=lambda t: (t[1], t[2])) out = {} for sh, _, _ in infos: val = 1.0 if sh is top[0] else 0.0 for pi in sh: out[pi] = val return out, len(shells) class ROCKGEN_OT_bake_cavity_selection(Operator): bl_idname = "rockgen.bake_cavity_selection" bl_label = "Vertex-Cavity auf Auswahl" bl_description = ("Backt das Layout R=Cavity / G=Teil-Maske / B=Hoehe auf ALLE " "ausgewaehlten Meshes - auch auf handgebaute Assets") bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.rock_gen_settings targets = [o for o in context.selected_objects if o.type == 'MESH'] if not targets: self.report({'ERROR'}, "Kein Mesh ausgewaehlt.") return {'CANCELLED'} _ensure_object_mode(context) done, notes = 0, [] for ob in targets: try: _bake_vertex_cavity(context, ob, s) if s.part_mask_from_shells: mask, n = _shell_part_mask(ob.data) layer = ob.data.color_attributes.get("Cavity") if layer is not None: for poly in ob.data.polygons: g = mask.get(poly.index, 0.0) for li in poly.loop_indices: c = layer.data[li].color layer.data[li].color = (c[0], g, c[2], 1.0) notes.append("%s: %d Teil(e)" % (ob.name, n)) done += 1 except Exception as exc: # noqa: BLE001 self.report({'WARNING'}, "%s: %s" % (ob.name, exc)) msg = "Cavity auf %d Mesh(es) gebacken." % done if notes: msg += " " + ", ".join(notes[:4]) self.report({'INFO'}, msg) return {'FINISHED'} class ROCKGEN_OT_randomize(Operator): bl_idname = "rockgen.randomize" bl_label = "Zufaellig" bl_description = ("Wuerfelt Seed und Form-Parameter neu. 'Streuung' bestimmt, wie " "weit von den aktuellen Werten abgewichen wird") bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.rock_gen_settings rng = random.Random() a = s.random_amount def jitter(prop, lo, hi): """Wert um +-a in Richtung der Grenzen ziehen, dann clampen. Int-Properties brauchen einen int - ein float wirft sonst TypeError und der ganze Operator bricht ab. """ cur = getattr(s, prop) span = (hi - lo) * a val = max(lo, min(hi, cur + rng.uniform(-span, span))) setattr(s, prop, int(round(val)) if isinstance(cur, int) else val) s.seed = rng.randint(0, 9999) jitter("noise_scale", 0.4, 3.0) jitter("displace_strength", 0.12, 0.7) jitter("decimate_ratio_1", 0.12, 0.45) jitter("decimate_ratio_2", 0.3, 0.7) jitter("dissolve_angle", 2.0, 10.0) jitter("subsurf_levels", 3, 5) if s.randomize_shape: jitter("noise_scale_max", 0.6, 4.0) jitter("displace_strength_max", 0.2, 0.9) if s.random_shape: s.base_shape = rng.choice(['CUBE', 'BOX', 'ICOSPHERE']) s.texture_type = rng.choice(['VORONOI', 'CLOUDS', 'DISTORTED_NOISE']) self.report({'INFO'}, "Neue Zufallswerte (Seed %d)." % s.seed) return {'FINISHED'} 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") row = box.row(align=True) row.prop(settings, "use_class_size") sub = row.row() sub.enabled = not settings.use_class_size sub.prop(settings, "base_size", text="") if settings.use_class_size: box.label(text="Basis-Groesse aus Klasse %s: x%.2f" % (settings.name_class, CLASS_SIZES.get(settings.name_class, 1.0)), icon='FIXED_SIZE') 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") if settings.base_shape == 'BOX': box.prop(settings, "box_proportions") box.prop(settings, "box_variation") 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="Vertex-Cavity (ohne Textur)") box.prop(settings, "bake_vertex_cavity") sub = box.column(align=True) sub.enabled = settings.bake_vertex_cavity sub.prop(settings, "cavity_strength") sub.prop(settings, "cavity_blur") sub.separator() sub.prop(settings, "moss_amount") sub.prop(settings, "moss_noise") sub.prop(settings, "moss_seed") sub.prop(settings, "part_mask_from_shells") sub.label(text="R=Cavity G=%s B=Hoehe" % ("Teil-Maske" if settings.part_mask_from_shells else "Moos"), icon='INFO') box.separator() box.operator("rockgen.bake_cavity_selection", icon='BRUSH_DATA') box = layout.box() box.label(text="Material") 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.prop(settings, "keep_size") 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") box = layout.box() box.label(text="Zufall", icon='FILE_REFRESH') row = box.row(align=True) row.prop(settings, "random_amount") row.prop(settings, "random_shape", text="", icon='MOD_NOISE') box.operator("rockgen.randomize", icon='FILE_REFRESH') 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") box.prop(settings, "bake_seam_width") box.prop(settings, "bake_plate_flat") 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_bake_cavity_selection, ROCKGEN_OT_randomize, 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()