bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", "version": (2, 15, 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 bmesh 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 from mathutils import Matrix, Vector # --------------------------------------------------------------------------- # 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. # "Smooth & dicht": kein Low-Poly-Look, und die Vertex-Masken (Cavity, Moos, # Erz) haengen direkt an der Vertex-Dichte. Gemessen ueber drei Seeds: # ~1350 Verts / ~2694 Tris - im Zielfenster 1500-3000 Tris. DENSE_PRESET = { "subsurf_levels": 5, "decimate_ratio_1": 0.40, "decimate_ratio_2": 0.80, "shading_mode": 'AUTO', } 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.""" # Einzelne Buchstaben werden gross geschrieben (Form A, B, C ...), laengere # Bezeichner behalten ihre Schreibweise - sonst wuerde aus dem vereinbarten # "Rock_Ore_S_01" ein "Rock_ORE_S_01". form = settings.name_form.strip() or "A" form = form.upper() if len(form) == 1 else form base = "%s_%s_%s" % (settings.name_prefix.strip() or "Fels", form, 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 _ore_mask(me, settings): """Erz-Maske je VERTEX: gestreute Nuggets mit weichem Abfall. Warum Nuggets und kein Noise: ein Noise-Feld ergibt wolkige Schlieren ueber den ganzen Stein. Erz sitzt aber in abgegrenzten Einschluessen. Deshalb werden Zentren gestreut und der Abfall darum herum gerechnet - dadurch sind Menge und Korngroesse unabhaengig voneinander regelbar. Die Zentren bevorzugen Fugen (niedrige Cavity): Adern folgen Rissen, und das Gluehen liest sich in einer Vertiefung deutlich besser als auf einer flachen Platte. """ import random as _rnd from mathutils import kdtree n_verts = len(me.vertices) if n_verts == 0: return None radius = max(settings.ore_nugget_size, 1e-4) # Anzahl aus der Oberflaeche ableiten, nicht fest: sonst haette ein # S-Brocken dieselbe Nuggetzahl wie ein M-Brocken und waere uebersaet. area = sum(p.area for p in me.polygons) pro_nugget = math.pi * radius * radius n_nuggets = int(round(settings.ore_amount * area / max(pro_nugget, 1e-6))) # Deckel an der AUFLOESUNG, nicht nur an einer festen Zahl: die Maske haengt # an der Vertex-Dichte. Bei einem 150-Vert-Stein waren 46 Zentren jeder # dritte Vertex - die Nuggets liefen zu grossen Schlieren zusammen statt # einzelne Einschluesse zu bleiben. Ein Zentrum je ~10 Verts haelt sie # getrennt. Wer feinere Nuggets will, braucht ein dichteres Mesh. n_nuggets = max(1, min(n_nuggets, 96, max(1, n_verts // 10))) # Fugen-Gewicht je Vertex aus der bereits gebackenen Cavity im R-Kanal. layer = me.color_attributes.get("Cavity") gewicht = [1.0] * n_verts if layer is not None: summe = [0.0] * n_verts zahl = [0] * n_verts for li, loop in enumerate(me.loops): vi = loop.vertex_index summe[vi] += layer.data[li].color[0] zahl[vi] += 1 for vi in range(n_verts): if zahl[vi]: # 1 - Cavity -> in der Fuge gross gewicht[vi] = 0.25 + 0.75 * (1.0 - summe[vi] / zahl[vi]) rng = _rnd.Random(settings.ore_seed) gesamt = sum(gewicht) or 1.0 zentren = [] for _ in range(n_nuggets): # gewichtetes Ziehen ohne cumsum-Tabelle (Meshes sind klein) ziel = rng.random() * gesamt lauf = 0.0 pick = n_verts - 1 for vi in range(n_verts): lauf += gewicht[vi] if lauf >= ziel: pick = vi break zentren.append(me.vertices[pick].co.copy()) baum = kdtree.KDTree(len(zentren)) for i, co in enumerate(zentren): baum.insert(co, i) baum.balance() maske = [0.0] * n_verts for vi, v in enumerate(me.vertices): _, _, dist = baum.find(v.co) if dist is None or dist >= radius: continue t = 1.0 - dist / radius maske[vi] = t * t * (3.0 - 2.0 * t) # Smoothstep: weiche Raender return maske 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 # --- A: Erz-Maske (Nuggets) --------------------------------------------- # EIN neutrales Set fuer ALLE Erzsorten: die Maske sagt nur WO Erz sitzt. # WELCHES Erz (Kupfer/Eisen/Gold = Tint + Gluehen) entscheidet die # Material-Instanz in UE. # # Nuggets statt Rauschen: gestreute Zentren mit weichem Abfall. Ein # Noise-Feld gaebe wolkige Schlieren, keine Einschluesse. ore_a = None if getattr(settings, "bake_ore", False) and settings.ore_amount > 0.0: ore_a = _ore_mask(me, settings) 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)) # A: Erz-Maske. Ohne Erz bleibt der Kanal auf 1.0 - so wie im # eingefrorenen Layout v2 festgelegt und wie das bestehende # 25er-Set es bereits traegt. a = 1.0 if ore_a is None else ore_a[vi] data[li].color = (r, g, b, a) 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) # Vertex-Farben JETZT backen, vor dem Duplizieren. Zwei Gruende: # 1. Der LOD-Zweig sprang vorher komplett am Bake vorbei - LOD-Gruppen kamen # OHNE Vertex-Farben aus dem Export (gemessen: kein Farbattribut auf einer # der vier Stufen). Genau der offene Punkt der Spielseite. # 2. Das Decimate interpoliert die Farben mit. Wuerde je Stufe neu gebacken, # haette jede Stufe ihre eigene Cavity und der LOD-Wechsel wuerde poppen. if settings.bake_vertex_cavity: _bake_vertex_cavity(context, lod0, settings) 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", ) # --- Erz (A-Kanal) --- bake_ore: BoolProperty( name="Erz-Maske (A-Kanal)", default=False, description=("Schreibt Erz-Nuggets in den A-Kanal. EIN neutrales Set fuer " "ALLE Erzsorten - welches Erz es ist (Tint + Gluehen), " "entscheidet die Material-Instanz in UE. Aus = A bleibt 1.0"), ) ore_amount: FloatProperty( name="Erz-Anteil", default=0.18, min=0.0, max=1.0, description=("Wie viel der Oberflaeche Erz traegt. Die Nuggetzahl wird aus " "der Flaeche abgeleitet, damit ein S-Brocken nicht uebersaet ist"), ) ore_nugget_size: FloatProperty( name="Nugget-Groesse", default=0.10, min=0.01, max=1.0, unit='LENGTH', description="Radius eines einzelnen Erz-Einschlusses in Metern", ) ore_seed: IntProperty( name="Erz-Seed", default=0, min=0, max=9999, description="Andere Verteilung der Nuggets bei gleicher Form", ) 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", ) # Ursprung unten-mittig + Nullpunkt ist KEINE Option, sondern passiert immer - # ein Stein, der seine Szenenposition mit ins Asset nimmt, ist schlicht kaputt. export_lay_flat: BoolProperty( name="Flache Steine hinlegen", default=True, description=("Dreht vor dem Export die kuerzeste Bbox-Achse nach oben, " "damit plattige Steine liegen statt zu stehen. Im Spiel gibt " "es nur zufaelligen Yaw - die Ausrichtung ist endgueltig"), ) export_ucx: BoolProperty( name="UCX-Kollision mitexportieren", default=True, description=("Legt je Stein eine konvexe Huelle 'UCX_' ins FBX. " "Unreal nimmt sie automatisch als Simple Collision"), ) ucx_max_verts: IntProperty( name="UCX max. Verts", default=32, min=8, max=128, description=("Obergrenze fuer die Kollisionshuelle. Simple Collision soll " "wenige Ebenen haben - eine volle Huelle waere keine " "Vereinfachung"), ) # --- 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 _lay_flat_matrix(dims, tolerance=0.9): """Rotation, die die KUERZESTE Bbox-Achse nach oben dreht (flache Seite unten). Im Spiel gibt es nur zufaelligen Yaw - die gebackene Ausrichtung ist also endgueltig. Plattige Steine muessen deshalb schon im FBX liegen. Gedreht wird nur, wenn die kuerzeste Achse deutlich kuerzer ist als die aktuelle Hoehe (Toleranz). Sonst wuerde ein annaehernd wuerfeliger Stein willkuerlich gekippt, obwohl er richtig steht. """ dx, dy, dz = dims shortest = min(dx, dy, dz) if shortest >= dz * tolerance: return None # steht schon richtig (oder ist rundlich) if shortest == dx: return Matrix.Rotation(math.radians(90.0), 4, 'Y') return Matrix.Rotation(math.radians(90.0), 4, 'X') def _bake_transform(ob, world, extra=None): """Backt die uebergebene Weltmatrix in die Mesh-Daten und nullt das Objekt. Noetig, weil der FBX-Export die Objekt-Transformation MITSCHREIBT: das 25er-Set trug seine Szenen-Raster-Positionen (bis 20 m) im Asset und schwebte im Spiel neben dem Platzierungspunkt. FALLE: `ob.matrix_world = Identity` loescht die Transformation NICHT, wenn das Objekt ein Eltern-Objekt hat - Blender rechnet dann eine lokale Gegen-Matrix aus, damit die Welt-Position stimmt. Bei einer LOD-Gruppe an Position (13/-7.5/2.2) landete das Mesh dadurch bei MINUS derselben Position. Deshalb werden matrix_basis UND matrix_parent_inverse direkt genullt. """ mat = extra @ world if extra is not None else world ob.data.transform(mat) # Bei gespiegelter Skalierung dreht sich die Flaechen-Orientierung um. if mat.determinant() < 0.0: ob.data.flip_normals() ob.matrix_basis = Matrix.Identity(4) ob.matrix_parent_inverse = Matrix.Identity(4) def _bbox_of(objs): """Gemeinsame Bounding-Box mehrerer Meshes in deren (bereits gebackenen) Koordinaten.""" lo = [float("inf")] * 3 hi = [float("-inf")] * 3 for o in objs: if o.type != 'MESH': continue for v in o.data.vertices: for i in range(3): lo[i] = min(lo[i], v.co[i]) hi[i] = max(hi[i], v.co[i]) if lo[0] == float("inf"): return None, None return Vector(lo), Vector(hi) def _make_ucx(context, src_objs, mesh_name, max_verts=32): """Konvexe Huelle als `UCX_` - Unreal nimmt sie als Simple Collision. Wichtig fuer die Physik-Bruchstuecke: "Complex as Simple" taugt dafuer nicht. Die Huelle wird auf max_verts eingedampft (planares Aufloesen), weil UE fuer Simple Collision wenige Ebenen will - eine 150-Vert-Huelle ist keine Vereinfachung mehr. """ # FALLE: nur die PUNKTE einlesen, nicht das ganze Mesh. Mit from_mesh kommen # die Originalflaechen mit, und convex_hull legt seine Huelle dann obendrauf - # das Ergebnis hatte gemessen 16-21 offene Kanten und war keine geschlossene # Huelle mehr. bm = bmesh.new() for o in src_objs: if o.type == 'MESH': for v in o.data.vertices: bm.verts.new(v.co) if not bm.verts: bm.free() return None bm.verts.ensure_lookup_table() res = bmesh.ops.convex_hull(bm, input=bm.verts[:], use_existing_faces=False) # Punkte im Inneren und uebrig gebliebene Geometrie gehoeren nicht zur Huelle. weg = list(set(res.get("geom_interior", []) + res.get("geom_unused", []))) if weg: bmesh.ops.delete(bm, geom=weg, context='VERTS') bm.verts.ensure_lookup_table() bm.normal_update() # Alle Quellpunkte merken - die Huelle muss sie danach noch umschliessen. quelle = [v.co.copy() for v in bm.verts] # Aufs Budget eindampfen. Jede Vereinfachung schneidet zwangslaeufig Ecken ab, # die Huelle wird dadurch KLEINER als der Stein - eine Kollision, die kleiner # ist als das Mesh, laesst den Spieler einsinken. Deshalb wird sie danach # exakt so weit aufgeblasen, dass wieder alles hineinpasst: fuer Kollision ist # minimal zu gross unkritisch, zu klein nicht. angle = math.radians(1.0) while len(bm.verts) > max_verts and angle < math.radians(60.0): bmesh.ops.dissolve_limit( bm, angle_limit=angle, verts=bm.verts[:], edges=bm.edges[:]) bmesh.ops.delete( bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS') bm.verts.ensure_lookup_table() if not bm.faces: break angle += math.radians(3.0) bm.normal_update() if bm.faces and quelle: mitte = Vector((0.0, 0.0, 0.0)) for v in bm.verts: mitte += v.co mitte /= max(len(bm.verts), 1) # Kleinster Faktor, mit dem jede Ebene alle Quellpunkte einschliesst. faktor = 1.0 for f in bm.faces: n = f.normal if n.length_squared < 1e-12: continue d = n.dot(f.verts[0].co) basis = d - n.dot(mitte) if basis <= 1e-9: continue for co in quelle: noetig = (n.dot(co) - n.dot(mitte)) / basis if noetig > faktor: faktor = noetig if faktor > 1.0: for v in bm.verts: v.co = mitte + (v.co - mitte) * faktor bmesh.ops.triangulate(bm, faces=bm.faces[:]) me = bpy.data.meshes.new("UCX_" + mesh_name) bm.to_mesh(me) bm.free() ob = bpy.data.objects.new("UCX_" + mesh_name, me) context.scene.collection.objects.link(ob) return ob def _prepare_export_copies(context, top, lay_flat=True, want_ucx=True, ucx_max_verts=32): """Baut eine export-fertige Kopie der Hierarchie. Gibt (objekte, aufraeumen). Auf KOPIEN, damit die Szene des Users unangetastet bleibt - Punkt 1 und 2 veraendern die Geometrie ja dauerhaft. """ made = [] renamed = [] # (Original, urspruenglicher Name) zum Zuruecksetzen # JETZT merken: gleich traegt das Original einen Hilfsnamen, und die # Kollisionshuelle haette sonst "UCX_.__src" geheissen. export_name = _safe_filename(top.name) def _copy(src): # FALLE: Blender vergibt Namen eindeutig - eine Kopie von "Rock_A_S_01" # heisst "Rock_A_S_01.001". Unreal ordnet die Kollision aber ueber den # EXAKTEN Mesh-Namen zu (UCX_), und der Asset-Name haengt auch # daran. Deshalb bekommt das Original fuer die Dauer des Exports einen # Hilfsnamen und die Kopie den echten. name = src.name src.name = name + ".__src" renamed.append((src, name)) cp = src.copy() if src.data is not None: cp.data = src.data.copy() cp.data.name = name cp.name = name context.scene.collection.objects.link(cp) made.append(cp) return cp src_children = [c for c in top.children_recursive if c.type == 'MESH'] top_copy = _copy(top) child_copies = [] for c in src_children: cc = _copy(c) cc.parent = top_copy cc.matrix_parent_inverse = c.matrix_parent_inverse.copy() child_copies.append(cc) # FALLE: matrix_world wird faul berechnet. Direkt nach dem Setzen von parent # und matrix_parent_inverse liefert es noch alte Werte - bei LOD-Gruppen wurde # dadurch die Gruppenposition (13/-7.5/2.2) NICHT herausgerechnet und landete # im Asset. Ein view_layer-Update erzwingt die Neuberechnung. context.view_layer.update() meshes = [o for o in ([top_copy] + child_copies) if o.type == 'MESH'] # Weltmatrizen EINSAMMELN, bevor irgendetwas genullt wird - jedes Nullen # veraendert die Matrizen der Kinder. welt = {o: o.matrix_world.copy() for o in meshes} # 2) Hinlegen: EINE Drehung fuer alle LOD-Stufen desselben Steins, sonst # liegen die Stufen unterschiedlich herum. extra = None if lay_flat and meshes: ref = meshes[0] co = [welt[ref] @ v.co for v in ref.data.vertices] if co: dims = [max(c[i] for c in co) - min(c[i] for c in co) for i in range(3)] extra = _lay_flat_matrix(dims) # 1) Transform backen -> alle Objekte stehen auf Identitaet, das Mesh traegt alles. for o in meshes: _bake_transform(o, welt[o], extra) if top_copy.type != 'MESH': top_copy.matrix_basis = Matrix.Identity(4) top_copy.matrix_parent_inverse = Matrix.Identity(4) context.view_layer.update() # 1) Ursprung auf unten-mittig der gemeinsamen Bbox. lo, hi = _bbox_of(meshes) if lo is not None: shift = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z)) off = Matrix.Translation(shift) for o in meshes: o.data.transform(off) # 3) UCX. Bei einer LOD-Gruppe darf die Huelle NICHT unter das LodGroup-Empty: # Unreal wuerde jedes Kind als LOD-Stufe lesen. Also als Geschwister. if want_ucx and meshes: ucx = _make_ucx(context, [meshes[0]], export_name, max_verts=ucx_max_verts) if ucx is not None: made.append(ucx) def cleanup(): for o in reversed(made): data = o.data bpy.data.objects.remove(o, do_unlink=True) if data is not None and data.users == 0: if isinstance(data, bpy.types.Mesh): bpy.data.meshes.remove(data) # Erst die Kopien weg, DANN die Namen zurueck - sonst kollidieren sie. for ob, name in renamed: ob.name = name return made, cleanup 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 ROCKGEN_OT_dense_preset(Operator): """Stellt auf smooth und dicht um (~2700 Tris statt ~620).""" bl_idname = "rockgen.dense_preset" bl_label = "Smooth & dicht" bl_description = ("Setzt Unterteilung, Decimate und Shading auf den dichten " "Smooth-Look (~1500-3000 Tris). Feinere Cavity- und " "Erz-Aufloesung, kein Low-Poly-Look") bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.rock_gen_settings for k, v in DENSE_PRESET.items(): setattr(s, k, v) self.report({'INFO'}, "Smooth & dicht: Unterteilung %d, Decimate %.2f/%.2f, " "Shading %s" % (DENSE_PRESET["subsurf_levels"], DENSE_PRESET["decimate_ratio_1"], DENSE_PRESET["decimate_ratio_2"], DENSE_PRESET["shading_mode"])) return {'FINISHED'} class ROCKGEN_OT_ore_set(Operator): """Erzeugt das Erzfels-Set: EIN neutrales Set in S und M. Bewusst kein Set je Erzsorte - die A-Kanal-Maske sagt nur, WO Erz sitzt. Welches Erz es ist (Kupfer/Eisen/Gold = Tint + Gluehen), entscheidet die Material-Instanz in UE. S und M, weil die Brocken in Hoehlengaenge passen muessen. """ bl_idname = "rockgen.ore_set" bl_label = "Erzfels-Set erzeugen (S + M)" bl_description = ("Erzeugt Rock_Ore_S_NN und Rock_Ore_M_NN mit Erz-Maske im " "A-Kanal - ein Set fuer alle Erzsorten") bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.rock_gen_settings vorher = { "name_prefix": s.name_prefix, "name_form": s.name_form, "name_class": s.name_class, "name_numbered": s.name_numbered, "bake_ore": s.bake_ore, "bake_vertex_cavity": s.bake_vertex_cavity, "use_class_size": s.use_class_size, "seed": s.seed, } vorher.update({k: getattr(s, k) for k in DENSE_PRESET}) # Erz MUSS dicht: die Maske haengt an der Vertex-Dichte. Bei ~150 Verts # sind die Nugget-Zentren jeder dritte Vertex und laufen zu Schlieren # zusammen - gemessen und im Render bestaetigt. Bei ~1350 Verts liest es # sich als einzelne Einschluesse. for k, v in DENSE_PRESET.items(): setattr(s, k, v) s.name_prefix = "Rock" s.name_form = "Ore" s.name_numbered = True s.bake_ore = True s.bake_vertex_cavity = True # A haengt am selben Attribut s.use_class_size = True # S und M sollen sich in der Groesse unterscheiden gesamt = 0 try: for i, klasse in enumerate(("S", "M")): s.name_class = klasse # Anderer Seed je Klasse, sonst ist der M-Brocken nur ein # aufgeblasener S-Brocken. s.seed = vorher["seed"] + i * 4177 res = bpy.ops.object.generate_rocks_batch() if 'FINISHED' not in res: self.report({'WARNING'}, "Klasse %s abgebrochen." % klasse) break gesamt += s.batch_count finally: for k, v in vorher.items(): setattr(s, k, v) self.report({'INFO'}, "Erzfels-Set: %d Brocken (Rock_Ore_S/M_NN), " "Erz im A-Kanal." % gesamt) return {'FINISHED'} 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: # Dateiname VOR dem Kopieren merken: waehrend des Exports traegt das # Original einen Hilfsnamen (siehe _prepare_export_copies). filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx") # Punkt 1-3: Ursprung erzwingen, flache Steine hinlegen, UCX bauen. # Laeuft auf Kopien, die Szene des Users bleibt unveraendert. try: objs, cleanup = _prepare_export_copies( context, top, lay_flat=settings.export_lay_flat, want_ucx=settings.export_ucx, ucx_max_verts=settings.ucx_max_verts, ) except Exception as exc: # ein Stein darf den Batch nicht killen failed += 1 print("[RockGen] Export-Vorbereitung '%s' fehlgeschlagen: %s" % (top.name, exc)) traceback.print_exc() continue _deselect_all(context) for o in objs: o.select_set(True) context.view_layer.objects.active = objs[0] 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" % (os.path.basename(filepath), exc)) finally: # MUSS auch bei Fehlern laufen: sonst bleiben die Kopien in der # Szene stehen und die Originale behalten ihren Hilfsnamen. cleanup() 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): """Haupt-Panel: nur was man bei JEDEM Stein anfasst. Alles andere sitzt in eingeklappten Unter-Panels (bl_parent_id). Vorher war das eine Kette aus 15 dauerhaft offenen Boxen - man musste scrollen, um an den Erzeugen-Knopf zu kommen. """ 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 s = context.scene.rock_gen_settings row = layout.row(align=True) row.prop(s, "active_preset", text="") row.operator("rockgen.preset_load", text="", icon='IMPORT') row.operator("rockgen.preset_delete", text="", icon='TRASH') row = layout.row(align=True) row.prop(s, "new_preset_name", text="") row.operator("rockgen.preset_save", text="", icon='ADD') layout.separator() col = layout.column(align=True) col.scale_y = 1.4 col.operator("object.generate_rocks_batch", text="%d Stein(e) erzeugen" % s.batch_count, icon='MESH_ICOSPHERE') layout.label(text=_asset_name(s, 0, s.batch_count), icon='INFO') row = layout.row(align=True) row.prop(s, "random_amount", text="Zufall") row.prop(s, "random_shape", text="", icon='MOD_NOISE') row.operator("rockgen.randomize", text="", icon='FILE_REFRESH') class _RockSub(Panel): """Basis aller Unter-Panels - spart neunmal dieselben vier Zeilen.""" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "Rock Gen" bl_parent_id = "VIEW3D_PT_rock_generator" bl_options = {'DEFAULT_CLOSED'} class VIEW3D_PT_rock_naming(_RockSub): bl_label = "1 - Benennung & Groesse" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings row = layout.row(align=True) row.prop(s, "name_prefix", text="") row.prop(s, "name_form", text="") row.prop(s, "name_class", text="") layout.prop(s, "name_numbered") layout.separator() layout.prop(s, "use_class_size") sub = layout.row() sub.active = not s.use_class_size sub.prop(s, "base_size") if s.use_class_size: layout.label(text="Klasse %s: x%.2f" % (s.name_class, CLASS_SIZES.get(s.name_class, 1.0)), icon='FIXED_SIZE') class VIEW3D_PT_rock_shape(_RockSub): bl_label = "2 - Form" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings layout.prop(s, "base_shape") if s.base_shape == 'BOX': layout.prop(s, "box_proportions") layout.prop(s, "box_variation") layout.prop(s, "subsurf_levels") layout.operator("rockgen.dense_preset", icon='MOD_SMOOTH') layout.separator() layout.prop(s, "texture_type") col = layout.column(align=True) col.prop(s, "noise_scale") col.prop(s, "displace_strength") layout.prop(s, "seed") layout.separator() layout.prop(s, "randomize_shape") sub = layout.column(align=True) sub.active = s.randomize_shape sub.prop(s, "noise_scale_max") sub.prop(s, "displace_strength_max") class VIEW3D_PT_rock_detail(_RockSub): bl_label = "3 - Detail & Cleanup" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings col = layout.column(align=True) col.prop(s, "decimate_ratio_1") col.prop(s, "decimate_ratio_2") layout.prop(s, "dissolve_angle") layout.separator() layout.prop(s, "add_bevel") sub = layout.column(align=True) sub.active = s.add_bevel sub.prop(s, "bevel_width") sub.prop(s, "bevel_segments") layout.prop(s, "add_weighted_normal") class VIEW3D_PT_rock_shading(_RockSub): bl_label = "4 - Shading & Ausrichtung" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings layout.prop(s, "shading_mode") row = layout.row() row.active = s.shading_mode == 'AUTO' row.prop(s, "autosmooth_angle") layout.separator() layout.prop(s, "flatten_bottom") row = layout.row() row.active = s.flatten_bottom row.prop(s, "flatten_ratio") layout.prop(s, "set_origin_bottom") class VIEW3D_PT_rock_vcol(_RockSub): bl_label = "5 - Vertex-Farben" def draw_header(self, context): # Schalter im Panel-Kopf: man sieht auch eingeklappt, ob gebacken wird. self.layout.prop(context.scene.rock_gen_settings, "bake_vertex_cavity", text="") def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings col = layout.column() col.active = s.bake_vertex_cavity sub = col.column(align=True) sub.prop(s, "cavity_strength") sub.prop(s, "cavity_blur") col.separator() sub = col.column(align=True) sub.prop(s, "moss_amount") sub.prop(s, "moss_noise") sub.prop(s, "moss_seed") col.prop(s, "part_mask_from_shells") col.separator() col.prop(s, "bake_ore") sub = col.column(align=True) sub.active = s.bake_ore sub.prop(s, "ore_amount") sub.prop(s, "ore_nugget_size") sub.prop(s, "ore_seed") sub.separator() sub.operator("rockgen.ore_set", icon='MOD_EXPLODE') col.label(text="R=Cavity G=%s B=Hoehe A=%s" % ("Teil-Maske" if s.part_mask_from_shells else "Moos", "Erz" if s.bake_ore else "1.0"), icon='INFO') # Nicht in der ausgegrauten Spalte: der Knopf arbeitet auf der Auswahl # und ist auch dann sinnvoll, wenn beim Erzeugen nicht gebacken wird. layout.separator() layout.operator("rockgen.bake_cavity_selection", icon='BRUSH_DATA') class VIEW3D_PT_rock_uv(_RockSub): bl_label = "6 - UV, LOD & Material" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings layout.prop(s, "generate_uv0") layout.prop(s, "generate_lightmap_uv") layout.separator() layout.prop(s, "generate_lods") sub = layout.column(align=True) sub.active = s.generate_lods sub.prop(s, "lod_count") sub.prop(s, "lod_ratio_step") layout.separator() layout.prop(s, "assign_material") class VIEW3D_PT_rock_batch(_RockSub): bl_label = "7 - Batch & Transform" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings col = layout.column(align=True) col.prop(s, "batch_count") col.prop(s, "spacing") layout.separator() layout.prop(s, "use_modal") row = layout.row() row.active = s.use_modal row.prop(s, "modal_threshold") layout.separator() layout.prop(s, "randomize_transform") sub = layout.column(align=True) sub.active = s.randomize_transform row = sub.row(align=True) row.prop(s, "scale_min") row.prop(s, "scale_max") sub.prop(s, "nonuniform_scale") sub.prop(s, "keep_size") class VIEW3D_PT_rock_export(_RockSub): bl_label = "8 - FBX-Export (Unreal)" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings layout.prop(s, "export_dir") layout.prop(s, "export_selected_only") layout.separator() layout.prop(s, "export_lay_flat") layout.prop(s, "export_ucx") row = layout.row() row.active = s.export_ucx row.prop(s, "ucx_max_verts") layout.label(text="Ursprung unten-mittig: immer", icon='CHECKMARK') layout.separator() layout.operator("object.export_rocks_fbx", icon='EXPORT') class VIEW3D_PT_rock_bake(_RockSub): bl_label = "9 - Textur-Bake (tileable)" def draw(self, context): layout = self.layout s = context.scene.rock_gen_settings layout.prop(s, "bake_dir") row = layout.row(align=True) row.prop(s, "bake_prefix", text="") row.prop(s, "bake_resolution", text="") layout.separator() col = layout.column(align=True) col.prop(s, "bake_base_color") col.prop(s, "bake_cell_scale") col.prop(s, "bake_seam_width") col.prop(s, "bake_plate_flat") layout.separator() row = layout.row(align=True) row.prop(s, "bake_color", toggle=True) row.prop(s, "bake_normal", toggle=True) row = layout.row(align=True) row.prop(s, "bake_roughness", toggle=True) row.prop(s, "bake_ao", toggle=True) layout.prop(s, "bake_ao_into_albedo") row = layout.row() row.active = s.bake_ao_into_albedo row.prop(s, "bake_ao_strength") layout.separator() layout.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_dense_preset, ROCKGEN_OT_ore_set, ROCKGEN_OT_randomize, ROCKGEN_OT_preset_save, ROCKGEN_OT_preset_load, ROCKGEN_OT_preset_delete, # Das Eltern-Panel MUSS vor seinen Unter-Panels stehen, sonst findet Blender # die bl_parent_id nicht. Die Reihenfolge hier ist die Reihenfolge im N-Panel. VIEW3D_PT_rock_generator, VIEW3D_PT_rock_naming, VIEW3D_PT_rock_shape, VIEW3D_PT_rock_detail, VIEW3D_PT_rock_shading, VIEW3D_PT_rock_vcol, VIEW3D_PT_rock_uv, VIEW3D_PT_rock_batch, VIEW3D_PT_rock_export, VIEW3D_PT_rock_bake, ) 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()