bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", "version": (2, 19, 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. # Neutraler Slot fuer das Erzfels-Set: EIN Set fuer alle Erzsorten, welche # Sorte es ist (Kupfer/Eisen/Gold) macht die Material-Instanz in UE. ORE_MATERIAL = ("M_Rock_Ore", (0.30, 0.28, 0.26, 1.0)) # Unterhalb dieser Vertex-Zahl verschmieren die Erz-Nuggets zu Schlieren - die # Maske haengt an der Vertex-Dichte. Gemessen: bei ~150 Verts ist jedes dritte # ein Nugget-Zentrum, bei ~1300 liest es sich als einzelne Einschluesse. ORE_MIN_VERTS = 800 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)), ) # Schnellstart: fertige Einstellungs-Saetze fuer die Steinsorten, die in # EcoGame tatsaechlich vorkommen. Deckt den Alltag ab, ohne dass man sich durch # neun Unter-Panels klickt - die bleiben fuer den Feinschliff. # # Der Limited-Dissolve-Winkel ist bewusst ein BEREICH: ein fester Wert passt nie # fuer alle Steine einer Sorte, man stellt ihn sonst bei jedem Stein nach. ROCK_KINDS = { 'FINDLING': { "label": "Findling", "desc": "Runder Brocken, weiche Kanten - der Allerwelts-Stein", "values": { "base_shape": 'ICOSPHERE', "subsurf_levels": 6, # Leichter als der Erz-Satz: ein normaler Stein braucht die hohe # Dichte nicht und bleibt so im Tri-Fenster 1500-3000. Nur die # Erz-Maske haengt an der Vertex-Dichte. "decimate_ratio_1": 0.40, "decimate_ratio_2": 0.80, "dissolve_angle": 17.0, "dissolve_angle_max": 25.0, "shading_mode": 'AUTO', "add_bevel": False, "flatten_bottom": True, "randomize_shape": True, }, }, 'PLATTE': { "label": "Platte", "desc": "Flacher Brocken, liegt satt auf dem Boden", "values": { "base_shape": 'BOX', "box_proportions": (2.2, 1.6, 0.5), "box_variation": 0.25, "subsurf_levels": 6, # Hoeher als bei den runden Sorten: eine flache Grundform hat # weniger Oberflaeche, mit 0.40/0.80 landete sie bei 930 Tris. "decimate_ratio_1": 0.60, "decimate_ratio_2": 0.90, # Flache Flaechen verschmelzen beim Dissolve staerker als gewoelbte: # mit 20-28 Grad fiel die Platte auf 848 Tris. Gleicher Bereich wie # die anderen Sorten haelt sie im Fenster. "dissolve_angle": 17.0, "dissolve_angle_max": 25.0, "shading_mode": 'AUTO', "add_bevel": False, "flatten_bottom": True, "randomize_shape": True, }, }, 'KANTIG': { "label": "Kantig", "desc": "Gebrochener Fels mit harten Kanten (Bevel + Weighted Normal)", "values": { "base_shape": 'CUBE', "subsurf_levels": 4, "decimate_ratio_1": 0.35, "decimate_ratio_2": 0.70, "dissolve_angle": 8.0, "dissolve_angle_max": 14.0, "shading_mode": 'FLAT', "add_bevel": True, "add_weighted_normal": True, "flatten_bottom": True, "randomize_shape": True, }, }, 'ERZ': { "label": "Erzbrocken", "desc": ("Dicht genug fuer eine saubere Erz-Maske, Slot M_Rock_Ore. " "Fuer das komplette S+M-Set den Knopf im Vertex-Farben-Panel"), "values": { "base_shape": 'ICOSPHERE', "subsurf_levels": 6, # Knapper als der Satz hinter dem S+M-SET: der einzelne Brocken hier # ist M-gross, das Set enthaelt auch kleine S-Brocken und braucht # dort mehr Reserve, um ORE_MIN_VERTS zu halten. Gemessen ueber # fuenf Seeds: min 1078 Verts, max 2368 Tris. "decimate_ratio_1": 0.45, "decimate_ratio_2": 0.82, "dissolve_angle": 17.0, "dissolve_angle_max": 25.0, "shading_mode": 'AUTO', "add_bevel": False, "flatten_bottom": True, "randomize_shape": True, "bake_vertex_cavity": True, "bake_ore": True, }, }, } # Basis-Groesse je Groessen-Klasse (Meter-Faktor auf das Grundmesh). # WICHTIG - Unterschied zur Zufalls-Streuung: DIESE Groesse ist gewollt und # unterscheidet die Klassen. Die Streuung (scale_min/max) variiert dagegen nur # 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. # Der Dissolve-Winkel gehoert MIT hier hinein. Er frisst deutlich Geometrie: # derselbe Satz liefert bei niedrigem Standardwinkel ~2700 Tris, bei 17-25 Grad # nur noch ~1030 und faellt damit unter ORE_MIN_VERTS - das Erz-Set haette seine # eigene Warnung ausgeloest, sobald jemand vorher eine Sorte mit hohem Winkel # gewaehlt hatte. Gemessen ueber drei Seeds bei 17-25 Grad: 1162 Verts / 2320 Tris. # Decimate bewusst hoch: ueber vier Seeds und beide Groessenklassen gemessen # liefert 0.40/0.80 im schlechtesten Fall nur 708 Verts und faellt damit unter # ORE_MIN_VERTS. 0.70/0.92 haelt den Worst Case bei 847 Verts und bleibt mit # maximal 2806 Tris im Zielfenster. DENSE_PRESET = { "subsurf_levels": 6, "decimate_ratio_1": 0.70, "decimate_ratio_2": 0.92, "dissolve_angle": 17.0, "dissolve_angle_max": 25.0, "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, fixed=None): """Setzt den Material-Slot. Die UE-Skripte mappen ueber den SLOT-NAMEN, deshalb ist die Benennung Teil des Vertrags und kein Kosmetik-Thema. `fixed` erzwingt einen bestimmten Slot - das Erzfels-Set nutzt das fuer seinen neutralen `M_Rock_Ore`, statt durch die Sorten zu rotieren. """ name, color = fixed if fixed is not None else 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') # Der Winkel wird je Stein aus dem Bereich gezogen. Ein fester Wert passt # nie fuer alle Steine einer Sorte - sonst stellt man ihn bei jedem Stein # nach. Bereich zu = fester Wert wie vorher. lo_a, hi_a = sorted((settings.dissolve_angle, settings.dissolve_angle_max)) eff_angle = rng.uniform(lo_a, hi_a) if hi_a > lo_a else lo_a bpy.ops.mesh.dissolve_limited(angle_limit=math.radians(eff_angle)) bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.normals_make_consistent(inside=False) bpy.ops.object.mode_set(mode='OBJECT') 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, INVERTIERT geschrieben - 1.0 = kein Erz, 0.0 = Erz. # # Der Master rechnet `(1 - A) * ErzMenge`, wobei ErzMenge ein # MI-Regler mit Standard 0 ist. Damit ignorieren normale Stein-MIs # den Kanal vollstaendig, und nur die Erz-MIs drehen ihn auf. Ein # Stein ohne Erz-Bake behaelt seine 1.0 und bleibt unveraendert - # deshalb muss das bestehende 25er-Set nicht neu exportiert werden. # # Wichtig: die Nuggets muessen deshalb Richtung 0 laufen, nicht # Richtung 1. Andersherum waere das Erz unsichtbar und der ganze # RESTLICHE Stein wuerde als Erz gelesen. a = 1.0 if ore_a is None else 1.0 - 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, fixed=ORE_MATERIAL if settings.bake_ore else None) 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, fixed=ORE_MATERIAL if settings.bake_ore else None) 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, settings=None): n_mesh = sum(1 for o in created if getattr(o, "type", None) == 'MESH') # Erz-Haken an, aber das Mesh ist zu grob? Dann verschmieren die Nuggets. # Das faellt sonst erst in UE auf - und dort sieht es nach einem # Material-Fehler aus, obwohl es an der Vertex-Dichte liegt. if settings is not None and settings.bake_ore and not failed: duenn = [o for o in created if getattr(o, "type", None) == 'MESH' and len(o.data.vertices) < ORE_MIN_VERTS] if duenn: op.report( {'WARNING'}, "%d Rock(s) erzeugt - aber %d davon haben unter %d Verts. Die " "Erz-Nuggets verschmieren dabei zu Schlieren. Knopf " "'Smooth & dicht' druecken und neu erzeugen." % (n_mesh, len(duenn), ORE_MIN_VERTS), ) return 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) rock_kind: EnumProperty( name="Sorte", items=[(k, v["label"], v["desc"]) for k, v in ROCK_KINDS.items()], default='FINDLING', description="Schnellstart-Sorte - der Knopf daneben uebernimmt die Werte", ) dissolve_angle_max: FloatProperty( name="Winkel bis", default=25.0, min=0.0, max=80.0, description=("Obergrenze fuer den Limited-Dissolve-Winkel. Je Stein wird " "ein Wert aus dem Bereich gezogen - gleich gross wie der " "untere Wert heisst fester Winkel"), ) dissolve_angle: FloatProperty( name="Dissolve Winkel (Grad)", default=5.0, min=0.0, max=45.0, description="Groesserer Winkel = mehr Faces werden zusammengefasst", ) # --- 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, und zwar Richtung 0: " "1.0 = kein Erz, 0.0 = Erz. Der Master rechnet " "(1 - A) x ErzMenge, ErzMenge ist ein MI-Regler mit " "Standard 0 - normale Stein-MIs ignorieren den Kanal damit " "komplett. EIN neutrales Set fuer ALLE Erzsorten"), ) 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", ) # --- Bruchstuecke --- piece_count: IntProperty( name="Bruchstuecke", default=3, min=2, max=4, description="In wie viele Teile ein Stein zerfaellt", ) piece_with_ore: BoolProperty( name="Ein Stueck mit Erz", default=False, description=("Ein zufaelliges Bruchstueck heisst _Piece_Erz und " "traegt Erz im A-Kanal. Ob es gespawnt wird, entscheidet " "die Loot-Logik im Spiel"), ) piece_seed: IntProperty( name="Bruch-Seed", default=0, min=0, max=9999, description="Andere Bruchlinien bei gleicher Form", ) piece_hide_source: BoolProperty( name="Mutterstein ausblenden", default=True, description="Blendet den ungebrochenen Stein aus, statt ihn zu loeschen", ) 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=True, description=("Legt den Material-Slot an (M_Rock_, beim Erz-Set " "neutral M_Rock_Ore). Die UE-Skripte mappen ueber den " "Slot-NAMEN - ohne Slot erzeugt Unreal nur einen Default " "und die Konvention greift nicht mehr"), ) # --- 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="Rock", 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, settings=settings) 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, settings=context.scene.rock_gen_settings) return {'CANCELLED'} if event.type == 'TIMER': if self._index >= settings.batch_count: self._finish(context) _report_result(self, self._created, self._failed, settings=context.scene.rock_gen_settings) 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 - berechnet aus LOD0, NICHT aus der # gemeinsamen Bbox aller Stufen. Eine stark dezimierte Stufe kann tiefer # reichen als LOD0; nimmt man die gemeinsame Box, sitzt LOD0 danach ueber # dem Pivot (gemessen 7 cm) und der Stein schwebt. Unreal platziert nach # LOD0, also gibt LOD0 den Ursprung vor. referenz = max(meshes, key=lambda o: len(o.data.vertices)) if meshes else None lo, hi = _bbox_of([referenz] if referenz is not None else []) if lo is not None: shift = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z)) off = Matrix.Translation(shift) 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" def _voronoi_cells(me, punkte): """Zerlegt ein Mesh in Voronoi-Zellen. Liefert je Zelle ein neues Mesh. Verfahren: eine Zelle ist der Schnitt aller Halbraeume. Fuer jedes andere Zentrum wird an der Mittelsenkrechten zwischen beiden Zentren geschnitten und die abgewandte Seite verworfen. Der Clou: benachbarte Zellen schneiden an GENAU DERSELBEN Ebene - dadurch passen die Bruchflaechen zusammen, ohne dass man sie aufeinander abstimmen muesste. Bewusst ohne das Cell-Fracture-Addon: das muesste beim User aktiviert sein und arbeitet ueber Operatoren mit Kontext-Anforderungen. bmesh laeuft immer. Wichtig ist, JEDEN Schnitt sofort zu schliessen und dafuer genau die Kanten zu nehmen, die `bisect_plane` als `geom_cut` zurueckgibt. Der Umweg ueber "am Ende alle Randkanten suchen und fuellen" war nicht verlaesslich: er hat Kanten mit vier Flaechen erzeugt, und wenn man die abgeraeumt hat, blieben Loecher. Ein offenes Bruchstueck ist als Physik-Actor unbrauchbar. """ zellen = [] for i, pi in enumerate(punkte): bm = bmesh.new() bm.from_mesh(me) for j, pj in enumerate(punkte): if i == j: continue normale = pj - pi laenge = normale.length if laenge < 1e-6: continue normale = normale / laenge mitte = (pi + pj) * 0.5 ergebnis = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], plane_co=mitte, plane_no=normale, clear_outer=True, # alles jenseits der Ebene verwerfen use_snap_center=False, ) if not bm.faces: break # Genau die Schnittkanten schliessen - das ist die Bruchflaeche. schnitt = [g for g in ergebnis.get("geom_cut", []) if isinstance(g, bmesh.types.BMEdge) and g.is_valid] if schnitt: try: bmesh.ops.edgeloop_fill(bm, edges=schnitt) except (TypeError, ValueError, RuntimeError): bmesh.ops.holes_fill(bm, edges=schnitt, sides=0) if not bm.faces: bm.free() zellen.append(None) continue # Sicherheitsnetz, falls ein Schnitt doch etwas offen gelassen hat. rand = [e for e in bm.edges if len(e.link_faces) == 1] if rand: bmesh.ops.holes_fill(bm, edges=rand, sides=0) gross = [f for f in bm.faces if len(f.verts) > 4] if gross: bmesh.ops.triangulate(bm, faces=gross) bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) bm.normal_update() neu = bpy.data.meshes.new(me.name + "_cell%d" % i) bm.to_mesh(neu) bm.free() zellen.append(neu) return zellen class ROCKGEN_OT_fracture(Operator): """Zerlegt die ausgewaehlten Steine in Bruchstuecke. Kein Laufzeit-Fracturing im Spiel: die Teile werden beim Anschlagen als Physik-Actors gespawnt. Deshalb entstehen sie hier als vollwertige Assets - Vertex-Layout v2, Ursprung unten-mittig, gleiche Material-Slots, und beim Export bekommt jedes Teil seine eigene UCX-Huelle. """ bl_idname = "rockgen.fracture" bl_label = "In Bruchstuecke zerlegen" bl_description = ("Erzeugt _Piece_01..NN mit zusammenpassenden " "Bruchkanten. Optional ein Stueck mit Erz-Adern") bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.rock_gen_settings _ensure_object_mode(context) quellen = [o for o in context.selected_objects if o.type == 'MESH'] if not quellen: self.report({'ERROR'}, "Keine Mesh-Objekte ausgewaehlt.") return {'CANCELLED'} rng = random.Random(s.piece_seed) gesamt, leer = 0, 0 for src in quellen: me = src.data co = [v.co for v in me.vertices] if not co: continue lo = Vector((min(c.x for c in co), min(c.y for c in co), min(c.z for c in co))) hi = Vector((max(c.x for c in co), max(c.y for c in co), max(c.z for c in co))) # Zentren im Inneren streuen, mit Abstand zum Rand: sonst entstehen # hauchduenne Splitter statt brauchbarer Brocken. n = s.piece_count punkte = [Vector(( lo.x + (hi.x - lo.x) * (0.2 + 0.6 * rng.random()), lo.y + (hi.y - lo.y) * (0.2 + 0.6 * rng.random()), lo.z + (hi.z - lo.z) * (0.2 + 0.6 * rng.random()), )) for _ in range(n)] zellen = _voronoi_cells(me, punkte) # Punkt 4a: ein Stueck traegt optional das Erz. Ob es gespawnt wird, # entscheidet die Loot-Logik im Spiel. erz_idx = rng.randrange(n) if s.piece_with_ore else -1 for i, zelle in enumerate(zellen): if zelle is None or len(zelle.polygons) == 0: leer += 1 if zelle is not None: bpy.data.meshes.remove(zelle) continue ist_erz = (i == erz_idx) name = "%s_Piece_%s" % (src.name, "Erz" if ist_erz else "%02d" % (i + 1)) zelle.name = name ob = bpy.data.objects.new(name, zelle) context.collection.objects.link(ob) ob.matrix_world = src.matrix_world.copy() # Gleiche Material-Slots wie der Mutterstein - die UE-Skripte # mappen ueber die Slot-Namen. for mat in me.materials: zelle.materials.append(mat) _apply_shading(context, ob, s) if s.bake_vertex_cavity: merker = s.bake_ore s.bake_ore = ist_erz try: _bake_vertex_cavity(context, ob, s) except RockGenError as exc: print("[RockGen] Cavity auf %s: %s" % (name, exc)) finally: s.bake_ore = merker _set_origin_bottom(context, ob) gesamt += 1 if s.piece_hide_source: src.hide_set(True) if leer: self.report({'WARNING'}, "%d Bruchstueck(e), %d Zelle(n) leer " "(Zentren lagen zu dicht)." % (gesamt, leer)) else: self.report({'INFO'}, "%d Bruchstueck(e) erzeugt." % gesamt) return {'FINISHED'} class ROCKGEN_OT_apply_kind(Operator): """Uebernimmt die Werte einer Schnellstart-Sorte.""" bl_idname = "rockgen.apply_kind" bl_label = "Sorte uebernehmen" bl_description = "Setzt Form, Dichte, Dissolve-Bereich und Shading der gewaehlten Sorte" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.rock_gen_settings eintrag = ROCK_KINDS.get(s.rock_kind) if eintrag is None: self.report({'ERROR'}, "Unbekannte Sorte: %s" % s.rock_kind) return {'CANCELLED'} for k, v in eintrag["values"].items(): setattr(s, k, v) self.report({'INFO'}, "Sorte '%s' uebernommen - Dissolve %.0f-%.0f Grad." % (eintrag["label"], s.dissolve_angle, s.dissolve_angle_max)) return {'FINISHED'} class ROCKGEN_OT_dense_preset(Operator): """Stellt auf smooth und dicht um (~2700 Tris statt ~620).""" bl_idname = "rockgen.dense_preset" 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, "assign_material": s.assign_material, } 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.assign_material = True # Slot M_Rock_Ore - die UE-Skripte mappen darueber 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 # Schnellstart zuerst: deckt den Alltag ab, ohne durch neun # Unter-Panels zu klicken. row = layout.row(align=True) row.prop(s, "rock_kind", text="") row.operator("rockgen.apply_kind", text="", icon='CHECKMARK') layout.separator() row = layout.row(align=True) row.prop(s, "active_preset", text="") row.operator("rockgen.preset_load", text="", icon='IMPORT') 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") row = layout.row(align=True) row.prop(s, "dissolve_angle", text="Dissolve von") row.prop(s, "dissolve_angle_max", text="bis") layout.separator() layout.prop(s, "add_bevel") sub = layout.column(align=True) 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') layout.separator() box = layout.box() box.label(text="Bruchstuecke", icon='MOD_BUILD') row = box.row(align=True) row.prop(s, "piece_count") row.prop(s, "piece_seed", text="") box.prop(s, "piece_with_ore") box.prop(s, "piece_hide_source") box.operator("rockgen.fracture", icon='MOD_BUILD') col.label(text="R=Cavity G=%s B=Hoehe A=%s" % ("Teil-Maske" if s.part_mask_from_shells else "Moos", "Erz (0=Erz)" if s.bake_ore else "1.0"), icon='INFO') if s.bake_ore: col.label(text="Master rechnet (1-A) x ErzMenge", icon='NODE_MATERIAL') # 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_apply_kind, ROCKGEN_OT_dense_preset, ROCKGEN_OT_fracture, 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()