Files
stylized-rock-generator/stylized_rock_generator.py
T
D4rkst3randClaude Opus 4.8 c5bb3e2612 Stylized Rock Generator v2.0.0: Haertung, Presets, LODs, Material, Tests
- Fix: smart_project/dissolve_limited angle_limit jetzt in Radiant (Blender 4.x/5.x)
- Fix: Lightmap-UV-Kanal vor lightmap_pack aktiv setzen (UV0 nicht mehr ueberschrieben)
- Fehlerbehandlung pro bpy.ops-Call; einzelner Rock-Fehler bricht Batch nicht ab
- Modaler Batch-Modus mit Progress-Bar (ab modal_threshold)
- Bevel + Weighted Normal Toggle
- LOD-Set als fbx_type=LodGroup-Gruppe fuer Unreal
- Preset-System (JSON) + eingebaute Presets
- Platzhalter-Material-Zuweisung
- Headless-Testskript (tests/test_rock_gen.py)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 06:24:06 +02:00

883 lines
32 KiB
Python

bl_info = {
"name": "Stylized Rock Generator",
"author": "D4rkst3r",
"version": (2, 0, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Rock Gen",
"description": (
"Batch-Generator fuer stylized Rocks "
"(Subsurf -> Displace -> Decimate -> Limited Dissolve) "
"mit Presets, LOD-Gruppen, Material- und UV-Setup fuer Unreal Engine."
),
"category": "Object",
}
import bpy
import os
import json
import math
import random
import traceback
from bpy.props import (
IntProperty, FloatProperty, EnumProperty, BoolProperty,
StringProperty, PointerProperty,
)
from bpy.types import Operator, Panel, PropertyGroup
# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------
TWO_PI = math.pi * 2.0
# Platzhalter-Materialien: (Name, Base-Color RGBA). Werden pro Rock rotiert.
MATERIAL_PALETTE = (
("M_Rock_Granite", (0.32, 0.30, 0.28, 1.0)),
("M_Rock_Basalt", (0.11, 0.11, 0.13, 1.0)),
("M_Rock_Sandstone", (0.55, 0.42, 0.30, 1.0)),
("M_Rock_Mossy", (0.24, 0.30, 0.18, 1.0)),
)
# Property-Keys, die von Presets gespeichert/geladen werden.
PRESET_KEYS = (
"base_shape", "subsurf_levels", "texture_type", "noise_scale",
"displace_strength", "seed", "decimate_ratio_1", "decimate_ratio_2",
"dissolve_angle", "generate_uv0", "generate_lightmap_uv",
"randomize_transform", "scale_min", "scale_max",
"add_bevel", "bevel_width", "bevel_segments", "add_weighted_normal",
"assign_material", "generate_lods", "lod_count", "lod_ratio_step",
"batch_count", "spacing",
)
# Eingebaute Presets (nicht ueberschreibbar/loeschbar).
BUILTIN_PRESETS = {
"Kleiner Kiesel": {
"base_shape": "ICOSPHERE", "subsurf_levels": 3, "texture_type": "CLOUDS",
"noise_scale": 0.6, "displace_strength": 0.18, "decimate_ratio_1": 0.3,
"decimate_ratio_2": 0.6, "dissolve_angle": 6.0, "scale_min": 0.4,
"scale_max": 0.7, "spacing": 1.5,
},
"Grosser Bruchfels": {
"base_shape": "CUBE", "subsurf_levels": 5, "texture_type": "VORONOI",
"noise_scale": 1.4, "displace_strength": 0.5, "decimate_ratio_1": 0.2,
"decimate_ratio_2": 0.45, "dissolve_angle": 4.0, "scale_min": 1.5,
"scale_max": 2.5, "spacing": 5.0,
},
"Lava-Rock": {
"base_shape": "ICOSPHERE", "subsurf_levels": 4,
"texture_type": "DISTORTED_NOISE", "noise_scale": 1.0,
"displace_strength": 0.35, "decimate_ratio_1": 0.25,
"decimate_ratio_2": 0.5, "dissolve_angle": 5.0, "scale_min": 0.9,
"scale_max": 1.4, "spacing": 3.0,
},
}
class RockGenError(Exception):
"""Kontrollierter Fehler innerhalb der Rock-Generierung.
Wird pro Rock gefangen, damit der Batch weiterlaeuft statt komplett
abzustuerzen.
"""
pass
# ---------------------------------------------------------------------------
# Kleine Helfer (Fehlerbehandlung / Kontext)
# ---------------------------------------------------------------------------
def _apply_modifier(context, obj, mod_name):
"""Wendet einen Modifier an und wirft bei Fehlern RockGenError."""
context.view_layer.objects.active = obj
try:
res = bpy.ops.object.modifier_apply(modifier=mod_name)
except RuntimeError as exc:
raise RockGenError("Modifier '%s' Apply fehlgeschlagen: %s" % (mod_name, exc))
if 'CANCELLED' in res:
raise RockGenError("Modifier '%s' Apply wurde abgebrochen." % mod_name)
def _face_count(obj):
return len(obj.data.polygons)
def _deselect_all(context):
for o in context.view_layer.objects:
if o.select_get():
o.select_set(False)
def _ensure_object_mode(context):
if context.mode != 'OBJECT':
try:
bpy.ops.object.mode_set(mode='OBJECT')
except RuntimeError:
pass
# ---------------------------------------------------------------------------
# Preset-Speicher (JSON neben dem Addon, Fallback: User-Config)
# ---------------------------------------------------------------------------
def _addon_dir():
try:
return os.path.dirname(os.path.abspath(__file__))
except NameError:
# z.B. wenn aus dem Text-Editor ausgefuehrt
return bpy.utils.user_resource('CONFIG', path="stylized_rock_gen", create=True)
def _preset_path():
directory = _addon_dir()
if not os.access(directory, os.W_OK):
directory = bpy.utils.user_resource('CONFIG', path="stylized_rock_gen", create=True)
return os.path.join(directory, "rock_presets.json")
def _load_user_presets():
path = _preset_path()
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
return data
except (OSError, ValueError):
print("[RockGen] Konnte Presets nicht lesen:", path)
return {}
def _save_user_presets(data):
path = _preset_path()
try:
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
return True, path
except OSError as exc:
return False, str(exc)
def _settings_to_dict(settings):
out = {}
for key in PRESET_KEYS:
out[key] = getattr(settings, key)
return out
def _apply_dict_to_settings(settings, data):
for key in PRESET_KEYS:
if key in data:
try:
setattr(settings, key, data[key])
except (TypeError, ValueError):
print("[RockGen] Preset-Wert ignoriert:", key, data[key])
# Referenz-Cache: dynamische EnumProperty-Items muessen am Leben gehalten
# werden, sonst kann Blender abstuerzen (bekannter bpy-Fallstrick).
_PRESET_ENUM_CACHE = []
def _preset_items(self, context):
_PRESET_ENUM_CACHE.clear()
_PRESET_ENUM_CACHE.append(('__NONE__', "- Preset waehlen -", ""))
for name in BUILTIN_PRESETS:
_PRESET_ENUM_CACHE.append(("builtin::" + name, name, "Eingebautes Preset"))
for name in sorted(_load_user_presets().keys()):
_PRESET_ENUM_CACHE.append(("user::" + name, name, "Eigenes Preset"))
return _PRESET_ENUM_CACHE
# ---------------------------------------------------------------------------
# Material-Zuweisung (Platzhalter)
# ---------------------------------------------------------------------------
def _get_or_create_material(name, color):
mat = bpy.data.materials.get(name)
if mat is None:
mat = bpy.data.materials.new(name=name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
if "Base Color" in bsdf.inputs:
bsdf.inputs["Base Color"].default_value = color
if "Roughness" in bsdf.inputs:
bsdf.inputs["Roughness"].default_value = 0.85
return mat
def _assign_material(obj, index):
name, color = MATERIAL_PALETTE[index % len(MATERIAL_PALETTE)]
mat = _get_or_create_material(name, color)
obj.data.materials.clear()
obj.data.materials.append(mat)
# ---------------------------------------------------------------------------
# UV-Setup (UV0 = Smart Project, UV1 = Lightmap)
# ---------------------------------------------------------------------------
def _make_uv0(context, obj):
"""Smart UV Project auf Kanal 0 (angle_limit ist in Blender 4.x/5.x rad!)."""
# Primitive bringen bereits eine "UVMap" mit -> Kanal 0 nutzen, sonst neu.
if not obj.data.uv_layers:
obj.data.uv_layers.new(name="UV0")
obj.data.uv_layers.active_index = 0
context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
try:
bpy.ops.uv.smart_project(
angle_limit=math.radians(66.0),
island_margin=0.02,
)
except RuntimeError as exc:
_ensure_object_mode(context)
raise RockGenError("Smart UV Project fehlgeschlagen: %s" % exc)
bpy.ops.object.mode_set(mode='OBJECT')
# Kanal 0 einheitlich benennen (Unreal nutzt die Reihenfolge, nicht den Namen)
if obj.data.uv_layers and obj.data.uv_layers[0].name != "Lightmap":
obj.data.uv_layers[0].name = "UV0"
def _make_lightmap_uv(context, obj):
"""Separates Lightmap-UV auf Kanal 1, danach UV0 wieder aktiv/Render."""
_deselect_all(context)
context.view_layer.objects.active = obj
obj.select_set(True)
lm = obj.data.uv_layers.get("Lightmap")
if lm is None:
lm = obj.data.uv_layers.new(name="Lightmap")
# WICHTIG: aktiven UV-Layer explizit auf Lightmap setzen, sonst wuerde
# lightmap_pack UV0 ueberschreiben.
obj.data.uv_layers.active = lm
try:
bpy.ops.uv.lightmap_pack(
PREF_CONTEXT='ALL_FACES',
PREF_PACK_IN_ONE=True,
PREF_NEW_UVLAYER=False,
PREF_BOX_DIV=12,
PREF_MARGIN_DIV=0.2,
)
except RuntimeError as exc:
raise RockGenError("Lightmap Pack fehlgeschlagen: %s" % exc)
# UV0 wieder als Kanal 0 / Render-UV setzen (Unreal nutzt Kanal-Reihenfolge)
if obj.data.uv_layers:
obj.data.uv_layers.active_index = 0
obj.data.uv_layers[0].active_render = True
# ---------------------------------------------------------------------------
# Mesh-Aufbau
# ---------------------------------------------------------------------------
def _build_rock_mesh(context, settings, index, location, rng):
"""Baut das reine Rock-Mesh (ohne Transform/Material). Kann RockGenError werfen."""
# 1. Basis-Mesh
if settings.base_shape == 'CUBE':
bpy.ops.mesh.primitive_cube_add(size=2, location=location)
else:
bpy.ops.mesh.primitive_ico_sphere_add(
subdivisions=1, radius=1.0, location=location
)
obj = context.active_object
obj.name = "Rock_%03d" % index
# 2. Subdivision Surface -> Apply
subsurf = obj.modifiers.new(name="Subsurf", type='SUBSURF')
subsurf.levels = settings.subsurf_levels
subsurf.render_levels = settings.subsurf_levels
_apply_modifier(context, obj, subsurf.name)
# 3. Displace + Noise-Textur -> Apply
tex = bpy.data.textures.new(name="RockTex_%03d" % index, type=settings.texture_type)
if settings.texture_type == 'VORONOI':
tex.noise_scale = settings.noise_scale
elif settings.texture_type == 'CLOUDS':
tex.noise_scale = settings.noise_scale
tex.noise_depth = 2
elif settings.texture_type == 'DISTORTED_NOISE':
tex.noise_scale = settings.noise_scale
tex.distortion = 1.0
displace = obj.modifiers.new(name="Displace", type='DISPLACE')
displace.texture = tex
displace.texture_coords = 'GLOBAL'
displace.strength = settings.displace_strength
displace.mid_level = 0.5
# Variation pro Instanz: Objekt vor dem Apply an eine zufaellige (moderate!)
# Weltposition schieben, damit ein anderer Ausschnitt der Noise-Textur
# gesampelt wird. Offset klein halten -> keine Float-Praezisionsprobleme.
original_loc = obj.location.copy()
obj.location = (
original_loc.x + rng.uniform(-25.0, 25.0),
original_loc.y + rng.uniform(-25.0, 25.0),
original_loc.z + rng.uniform(-25.0, 25.0),
)
context.view_layer.update()
try:
_apply_modifier(context, obj, displace.name)
finally:
obj.location = original_loc
if tex.users == 0:
bpy.data.textures.remove(tex)
# 4. Decimate, zweistufig
dec1 = obj.modifiers.new(name="Decimate1", type='DECIMATE')
dec1.ratio = settings.decimate_ratio_1
_apply_modifier(context, obj, dec1.name)
if _face_count(obj) == 0:
raise RockGenError("Nach Decimate 1 keine Faces mehr (ratio zu niedrig).")
dec2 = obj.modifiers.new(name="Decimate2", type='DECIMATE')
dec2.ratio = settings.decimate_ratio_2
_apply_modifier(context, obj, dec2.name)
if _face_count(obj) == 0:
raise RockGenError("Nach Decimate 2 keine Faces mehr (ratio zu niedrig).")
# 5. Limited Dissolve + Normalen neu berechnen (angle_limit ist rad!)
context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.dissolve_limited(angle_limit=math.radians(settings.dissolve_angle))
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
if _face_count(obj) == 0:
raise RockGenError("Nach Limited Dissolve keine Faces mehr uebrig.")
# 6. UVs
if settings.generate_uv0:
_make_uv0(context, obj)
if settings.generate_lightmap_uv:
_make_lightmap_uv(context, obj)
return obj
def _apply_hardsurface(context, obj, settings):
"""Optional: Bevel + Weighted Normal fuer einen 'clean' Kantenlook."""
if settings.add_bevel:
bev = obj.modifiers.new(name="Bevel", type='BEVEL')
bev.width = settings.bevel_width
bev.segments = settings.bevel_segments
bev.limit_method = 'ANGLE'
bev.angle_limit = math.radians(30.0)
_apply_modifier(context, obj, bev.name)
if settings.add_weighted_normal:
_deselect_all(context)
context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.shade_smooth()
wn = obj.modifiers.new(name="WeightedNormal", type='WEIGHTED_NORMAL')
wn.keep_sharp = True
_apply_modifier(context, obj, wn.name)
def _apply_random_transform(context, obj, settings, rng):
if not settings.randomize_transform:
return
scale_factor = rng.uniform(settings.scale_min, settings.scale_max)
obj.scale = (scale_factor, scale_factor, scale_factor)
obj.rotation_euler = (
rng.uniform(0.0, TWO_PI),
rng.uniform(0.0, TWO_PI),
rng.uniform(0.0, TWO_PI),
)
_deselect_all(context)
context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
# ---------------------------------------------------------------------------
# LOD-Gruppen (fbx_type = LodGroup -> von Unreal beim FBX-Import erkannt)
# ---------------------------------------------------------------------------
def _duplicate_object(context, src, new_name):
new_obj = src.copy()
new_obj.data = src.data.copy()
new_obj.name = new_name
new_obj.data.name = new_name
context.collection.objects.link(new_obj)
return new_obj
def _parent_keep_transform(child, parent):
child.parent = parent
child.matrix_parent_inverse = parent.matrix_world.inverted()
def _build_lod_group(context, settings, index, location, rng):
base_name = "Rock_%03d" % index
lod0 = _build_rock_mesh(context, settings, index, location, rng)
_apply_hardsurface(context, lod0, settings)
_apply_random_transform(context, lod0, settings, rng)
if settings.assign_material:
_assign_material(lod0, index)
lod0.name = base_name + "_LOD0"
lod0.data.name = base_name + "_LOD0"
created = [lod0]
ratio = 1.0
for lvl in range(1, settings.lod_count):
ratio *= settings.lod_ratio_step
lod = _duplicate_object(context, lod0, "%s_LOD%d" % (base_name, lvl))
dec = lod.modifiers.new(name="LODdec%d" % lvl, type='DECIMATE')
dec.ratio = max(0.01, ratio)
_apply_modifier(context, lod, dec.name)
if _face_count(lod) == 0:
raise RockGenError("LOD%d hat keine Faces mehr (lod_ratio_step zu niedrig)." % lvl)
# Normalen + Lightmap fuer die reduzierte Topologie neu
context.view_layer.objects.active = lod
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
if settings.generate_lightmap_uv:
_make_lightmap_uv(context, lod)
created.append(lod)
# Empty als LOD-Group-Parent
empty = bpy.data.objects.new(base_name + "_LODGroup", None)
empty.empty_display_type = 'PLAIN_AXES'
empty.location = location
empty["fbx_type"] = "LodGroup" # von Unreal beim FBX-Import ausgewertet
context.collection.objects.link(empty)
for lod in created:
_parent_keep_transform(lod, empty)
created.append(empty)
return created
# ---------------------------------------------------------------------------
# Ein Rock (bzw. eine LOD-Gruppe) erzeugen
# ---------------------------------------------------------------------------
def make_one_rock(context, settings, index):
"""Erzeugt genau einen Rock / eine LOD-Gruppe. Liefert Liste der Objekte."""
rng = random.Random(settings.seed + index * 9973)
location = (index * settings.spacing, 0.0, 0.0)
if settings.generate_lods:
return _build_lod_group(context, settings, index, location, rng)
obj = _build_rock_mesh(context, settings, index, location, rng)
_apply_hardsurface(context, obj, settings)
_apply_random_transform(context, obj, settings, rng)
if settings.assign_material:
_assign_material(obj, index)
return [obj]
def _cleanup_partial(context, names_before):
"""Entfernt Objekte, die seit dem Snapshot neu entstanden sind (fehlerhafter Rock)."""
_ensure_object_mode(context)
for name in list(bpy.data.objects.keys()):
if name not in names_before:
obj = bpy.data.objects.get(name)
if obj is not None:
bpy.data.objects.remove(obj, do_unlink=True)
def _report_result(op, created, failed, cancelled=False):
n_mesh = sum(1 for o in created if getattr(o, "type", None) == 'MESH')
if failed:
op.report(
{'WARNING'},
"%d Rock(s) erzeugt, %d fehlgeschlagen (Details in der System-Konsole)." % (n_mesh, len(failed)),
)
elif cancelled:
op.report({'INFO'}, "Abgebrochen - %d Rock(s) erzeugt." % n_mesh)
else:
op.report({'INFO'}, "%d Rock(s) erzeugt." % n_mesh)
# ---------------------------------------------------------------------------
# Property Group
# ---------------------------------------------------------------------------
class RockGenSettings(PropertyGroup):
# --- Basis ---
base_shape: EnumProperty(
name="Basis-Form",
items=[('CUBE', "Cube", ""), ('ICOSPHERE', "Ico-Sphere", "")],
default='CUBE',
)
subsurf_levels: IntProperty(name="Subsurf Levels", default=5, min=1, max=6)
# --- Noise / Displace ---
texture_type: EnumProperty(
name="Noise-Typ",
items=[
('VORONOI', "Voronoi", "Facettiert / kristallin"),
('CLOUDS', "Clouds", "Weich / organisch"),
('DISTORTED_NOISE', "Distorted Noise", "Unregelmaessig"),
],
default='VORONOI',
)
noise_scale: FloatProperty(name="Noise Scale", default=1.0, min=0.01, max=10.0)
displace_strength: FloatProperty(name="Displace Staerke", default=0.3, min=0.0, max=2.0)
seed: IntProperty(name="Seed", default=0, min=0)
# --- Decimate / Cleanup ---
decimate_ratio_1: FloatProperty(name="Decimate 1", default=0.2, min=0.01, max=1.0)
decimate_ratio_2: FloatProperty(name="Decimate 2", default=0.5, min=0.01, max=1.0)
dissolve_angle: FloatProperty(
name="Dissolve Winkel (Grad)", default=5.0, min=0.0, max=45.0,
description="Groesserer Winkel = mehr Faces werden zusammengefasst",
)
# --- UV (Unreal) ---
generate_uv0: BoolProperty(
name="UV0 (Smart Project)", default=True,
description="Fuer Material-Maker-Texturen (Albedo/Normal/Roughness/AO)",
)
generate_lightmap_uv: BoolProperty(
name="UV1 Lightmap-UV", default=True,
description="Separates, nicht ueberlappendes UV fuer Unreal Static-Mesh-Lightmaps",
)
# --- Hard-Surface (optional) ---
add_bevel: BoolProperty(
name="Bevel", default=False,
description="Optionaler Bevel-Modifier fuer einen 'clean' Kantenlook",
)
bevel_width: FloatProperty(name="Bevel Breite", default=0.02, min=0.0, max=0.5)
bevel_segments: IntProperty(name="Bevel Segmente", default=2, min=1, max=8)
add_weighted_normal: BoolProperty(
name="Weighted Normal", default=False,
description="Weighted-Normal-Modifier fuer sauberere Shading-Uebergaenge",
)
# --- Material ---
assign_material: BoolProperty(
name="Material zuweisen", default=False,
description="Weist pro Rock ein Platzhalter-Material zu (rotiert durch eine Liste)",
)
# --- LOD ---
generate_lods: BoolProperty(
name="LOD-Set erzeugen", default=False,
description="Erzeugt LOD0-LODn als LOD-Gruppe (Empty mit fbx_type=LodGroup) fuer Unreal",
)
lod_count: IntProperty(
name="LOD-Stufen", default=4, min=2, max=4,
description="Anzahl LOD-Stufen inkl. LOD0",
)
lod_ratio_step: FloatProperty(
name="LOD Reduktion/Stufe", default=0.5, min=0.1, max=0.9,
description="Decimate-Faktor je zusaetzlicher LOD-Stufe (0.5 = halbe Faces pro Stufe)",
)
# --- Transform ---
randomize_transform: BoolProperty(name="Skalierung/Rotation randomisieren", default=True)
scale_min: FloatProperty(name="Scale Min", default=0.8, min=0.1, max=5.0)
scale_max: FloatProperty(name="Scale Max", default=1.2, min=0.1, max=5.0)
# --- Batch ---
batch_count: IntProperty(name="Anzahl Rocks", default=5, min=1, max=500)
spacing: FloatProperty(name="Abstand", default=3.0, min=0.0, max=20.0)
use_modal: BoolProperty(
name="Modal (Progress-Bar)", default=True,
description="Grosse Batches nicht-blockierend mit Fortschrittsanzeige erzeugen",
)
modal_threshold: IntProperty(
name="Modal ab", default=25, min=1, max=1000,
description="Ab dieser Rock-Anzahl wird der modale (nicht-blockierende) Modus genutzt",
)
# --- Presets ---
active_preset: EnumProperty(name="Preset", items=_preset_items)
new_preset_name: StringProperty(name="Preset-Name", default="")
# ---------------------------------------------------------------------------
# Operator: Batch erzeugen (synchron ODER modal)
# ---------------------------------------------------------------------------
class OBJECT_OT_generate_rocks(Operator):
bl_idname = "object.generate_rocks_batch"
bl_label = "Generate Rocks"
bl_description = "Erzeugt N stylized Rocks mit randomisierten Parametern"
bl_options = {'REGISTER', 'UNDO'}
_timer = None
_index = 0
_created = None
_failed = None
# --- synchroner Pfad (kleine Batches, Hintergrund, Tests) ---
def execute(self, context):
settings = context.scene.rock_gen_settings
created, failed = [], []
for i in range(settings.batch_count):
names_before = set(bpy.data.objects.keys())
try:
created.extend(make_one_rock(context, settings, i))
except Exception as exc: # noqa: BLE001 - bewusst breit, ein Rock darf nicht den Batch killen
failed.append((i, str(exc)))
print("[RockGen] Rock %d fehlgeschlagen: %s" % (i, exc))
traceback.print_exc()
_cleanup_partial(context, names_before)
_report_result(self, created, failed)
return {'FINISHED'}
# --- Einstiegspunkt: entscheidet synchron vs. modal ---
def invoke(self, context, event):
settings = context.scene.rock_gen_settings
if bpy.app.background or not settings.use_modal or settings.batch_count < settings.modal_threshold:
return self.execute(context)
self._index = 0
self._created = []
self._failed = []
wm = context.window_manager
wm.progress_begin(0, settings.batch_count)
self._timer = wm.event_timer_add(0.01, window=context.window)
wm.modal_handler_add(self)
context.workspace.status_text_set(
"Rock Gen: 0/%d (ESC = Abbrechen)" % settings.batch_count
)
return {'RUNNING_MODAL'}
def modal(self, context, event):
settings = context.scene.rock_gen_settings
if event.type == 'ESC':
self._finish(context)
_report_result(self, self._created, self._failed, cancelled=True)
return {'CANCELLED'}
if event.type == 'TIMER':
if self._index >= settings.batch_count:
self._finish(context)
_report_result(self, self._created, self._failed)
return {'FINISHED'}
names_before = set(bpy.data.objects.keys())
try:
self._created.extend(make_one_rock(context, settings, self._index))
except Exception as exc: # noqa: BLE001
self._failed.append((self._index, str(exc)))
print("[RockGen] Rock %d fehlgeschlagen: %s" % (self._index, exc))
traceback.print_exc()
_cleanup_partial(context, names_before)
self._index += 1
context.window_manager.progress_update(self._index)
context.workspace.status_text_set(
"Rock Gen: %d/%d (ESC = Abbrechen)" % (self._index, settings.batch_count)
)
return {'RUNNING_MODAL'}
return {'PASS_THROUGH'}
def _finish(self, context):
wm = context.window_manager
if self._timer is not None:
wm.event_timer_remove(self._timer)
self._timer = None
wm.progress_end()
context.workspace.status_text_set(None)
# ---------------------------------------------------------------------------
# Operatoren: Presets
# ---------------------------------------------------------------------------
class ROCKGEN_OT_preset_save(Operator):
bl_idname = "rockgen.preset_save"
bl_label = "Preset speichern"
bl_description = "Speichert die aktuellen Einstellungen als benanntes Preset (JSON)"
def execute(self, context):
settings = context.scene.rock_gen_settings
name = settings.new_preset_name.strip()
if not name:
self.report({'ERROR'}, "Bitte einen Preset-Namen eingeben.")
return {'CANCELLED'}
if name in BUILTIN_PRESETS:
self.report({'ERROR'}, "Name kollidiert mit einem eingebauten Preset.")
return {'CANCELLED'}
presets = _load_user_presets()
presets[name] = _settings_to_dict(settings)
ok, info = _save_user_presets(presets)
if not ok:
self.report({'ERROR'}, "Speichern fehlgeschlagen: %s" % info)
return {'CANCELLED'}
settings.active_preset = "user::" + name
self.report({'INFO'}, "Preset '%s' gespeichert." % name)
return {'FINISHED'}
class ROCKGEN_OT_preset_load(Operator):
bl_idname = "rockgen.preset_load"
bl_label = "Preset laden"
bl_description = "Laedt das gewaehlte Preset in die Einstellungen"
def execute(self, context):
settings = context.scene.rock_gen_settings
sel = settings.active_preset
if sel == '__NONE__':
self.report({'WARNING'}, "Kein Preset gewaehlt.")
return {'CANCELLED'}
kind, _, name = sel.partition("::")
data = BUILTIN_PRESETS.get(name) if kind == 'builtin' else _load_user_presets().get(name)
if not data:
self.report({'ERROR'}, "Preset nicht gefunden.")
return {'CANCELLED'}
_apply_dict_to_settings(settings, data)
self.report({'INFO'}, "Preset '%s' geladen." % name)
return {'FINISHED'}
class ROCKGEN_OT_preset_delete(Operator):
bl_idname = "rockgen.preset_delete"
bl_label = "Preset loeschen"
bl_description = "Loescht das gewaehlte eigene Preset"
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
def execute(self, context):
settings = context.scene.rock_gen_settings
kind, _, name = settings.active_preset.partition("::")
if kind != 'user':
self.report({'WARNING'}, "Nur eigene Presets koennen geloescht werden.")
return {'CANCELLED'}
presets = _load_user_presets()
if name in presets:
del presets[name]
ok, info = _save_user_presets(presets)
if not ok:
self.report({'ERROR'}, "Loeschen fehlgeschlagen: %s" % info)
return {'CANCELLED'}
settings.active_preset = '__NONE__'
self.report({'INFO'}, "Preset '%s' geloescht." % name)
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Panel
# ---------------------------------------------------------------------------
class VIEW3D_PT_rock_generator(Panel):
bl_label = "Rock Generator"
bl_idname = "VIEW3D_PT_rock_generator"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Rock Gen"
def draw(self, context):
layout = self.layout
settings = context.scene.rock_gen_settings
box = layout.box()
box.label(text="Presets", icon='PRESET')
box.prop(settings, "active_preset", text="")
row = box.row(align=True)
row.operator("rockgen.preset_load", text="Laden", icon='IMPORT')
row.operator("rockgen.preset_delete", text="Loeschen", icon='TRASH')
row = box.row(align=True)
row.prop(settings, "new_preset_name", text="")
row.operator("rockgen.preset_save", text="Speichern", icon='ADD')
box = layout.box()
box.label(text="Basis")
box.prop(settings, "base_shape")
box.prop(settings, "subsurf_levels")
box = layout.box()
box.label(text="Noise / Displace")
box.prop(settings, "texture_type")
box.prop(settings, "noise_scale")
box.prop(settings, "displace_strength")
box.prop(settings, "seed")
box = layout.box()
box.label(text="Decimate / Cleanup")
box.prop(settings, "decimate_ratio_1")
box.prop(settings, "decimate_ratio_2")
box.prop(settings, "dissolve_angle")
box = layout.box()
box.label(text="Hard-Surface (optional)")
box.prop(settings, "add_bevel")
sub = box.column(align=True)
sub.enabled = settings.add_bevel
sub.prop(settings, "bevel_width")
sub.prop(settings, "bevel_segments")
box.prop(settings, "add_weighted_normal")
box = layout.box()
box.label(text="UV (Unreal Export)")
box.prop(settings, "generate_uv0")
box.prop(settings, "generate_lightmap_uv")
box = layout.box()
box.label(text="LOD")
box.prop(settings, "generate_lods")
sub = box.column(align=True)
sub.enabled = settings.generate_lods
sub.prop(settings, "lod_count")
sub.prop(settings, "lod_ratio_step")
box = layout.box()
box.label(text="Material")
box.prop(settings, "assign_material")
box = layout.box()
box.label(text="Transform")
box.prop(settings, "randomize_transform")
row = box.row(align=True)
row.prop(settings, "scale_min")
row.prop(settings, "scale_max")
box = layout.box()
box.label(text="Batch")
box.prop(settings, "batch_count")
box.prop(settings, "spacing")
box.prop(settings, "use_modal")
sub = box.row()
sub.enabled = settings.use_modal
sub.prop(settings, "modal_threshold")
layout.separator()
layout.operator("object.generate_rocks_batch", icon='MESH_ICOSPHERE')
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
classes = (
RockGenSettings,
OBJECT_OT_generate_rocks,
ROCKGEN_OT_preset_save,
ROCKGEN_OT_preset_load,
ROCKGEN_OT_preset_delete,
VIEW3D_PT_rock_generator,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.rock_gen_settings = PointerProperty(type=RockGenSettings)
def unregister():
del bpy.types.Scene.rock_gen_settings
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()