- 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>
883 lines
32 KiB
Python
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()
|