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>
This commit is contained in:
2026-07-29 06:24:06 +02:00
co-authored by Claude Opus 4.8
commit c5bb3e2612
5 changed files with 1159 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
# Addon-Laufzeitdaten (eigene Presets bleiben lokal)
rock_presets.json
# Blender
*.blend1
*.blend2
# OS / Editor
.DS_Store
Thumbs.db
.vscode/
.idea/
+18
View File
@@ -0,0 +1,18 @@
Stylized Rock Generator - Blender Addon
Copyright (C) 2026 D4rkst3r
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>.
Note: Blender-Addons, die das `bpy`-Modul nutzen, werden ueblicherweise unter
GPL lizenziert. Bei Bedarf hier den vollstaendigen GPLv3-Text ergaenzen
(https://www.gnu.org/licenses/gpl-3.0.txt) oder auf eine andere Lizenz wechseln.
+106
View File
@@ -0,0 +1,106 @@
# Stylized Rock Generator (Blender Addon)
Batch-Generator fuer stylized Rocks in Blender, ausgelegt auf den Export nach
**Unreal Engine 5** ueber FBX.
Pipeline pro Rock: Basis-Mesh (Cube / Ico-Sphere) → Subdivision Surface →
Displace mit prozeduraler Noise-Textur (Voronoi / Clouds / Distorted Noise) →
zweistufiges Decimate → Limited Dissolve → Normalen-Reparatur →
UV0 (Smart Project) + UV1 (Lightmap) → optional Bevel/Weighted Normal, Material,
LOD-Set, randomisierte Skalierung/Rotation.
Getestet mit **Blender 5.1.2** (Minimum laut `bl_info`: 4.2).
---
## Installation
1. `stylized_rock_generator.py` herunterladen.
2. Blender → *Edit → Preferences → Add-ons → Install…* → die `.py` waehlen.
3. Haken bei **Stylized Rock Generator** setzen.
4. Panel: *View3D → Sidebar (N) → Reiter "Rock Gen"*.
Update: einfach die neue `.py` erneut installieren (gleicher Modulname) oder das
Repo lokal ziehen und den Addon-Pfad in den Preferences auf den Repo-Ordner
zeigen lassen.
---
## Features
- **Basis-Formen:** Cube oder Ico-Sphere, einstellbare Subsurf-Stufen.
- **Noise/Displace:** Voronoi / Clouds / Distorted Noise, Scale + Staerke, Seed.
Seed-Variation ueber einen moderaten Welt-Offset (±25) vor dem Displace-Apply
Blenders prozedurale Texturen haben keinen echten Seed-Parameter.
- **Cleanup:** zweistufiges Decimate + Limited Dissolve, danach
`normals_make_consistent` (verhindert eingefallene/dunkle Flaechen).
- **UV fuer Unreal:** UV0 (Smart Project) fuer Material-Texturen, UV1 (Lightmap)
als separater, nicht ueberlappender Kanal fuer Static-Mesh-Lightmaps.
- **Hard-Surface (optional):** Bevel + Weighted Normal fuer einen "clean"
Kantenlook.
- **Material (Platzhalter):** weist pro Rock ein Material zu und rotiert durch
eine kleine Palette (Granite/Basalt/Sandstone/Mossy), damit nichts materiallos
exportiert wird.
- **LOD-Set:** erzeugt LOD0LODn als LOD-Gruppe fuer Unreal (siehe unten).
- **Presets:** eingebaute Presets (Kleiner Kiesel, Grosser Bruchfels, Lava-Rock)
plus eigene Presets als JSON (`rock_presets.json` neben dem Addon).
- **Batch:** synchron fuer kleine Mengen, ab `modal_threshold` (Default 25)
automatisch **modal mit Progress-Bar** die UI bleibt bedienbar, ESC bricht ab.
- **Robustheit:** Faellt ein einzelner Rock aus (z. B. 0 Faces nach zu
aggressivem Decimate), wird nur dieser uebersprungen und aufgeraeumt, der Rest
des Batches bleibt erhalten. Fehler landen in der System-Konsole.
---
## LOD-Export nach Unreal
Blenders FBX-Exporter kennt **keine** eigene LOD-Option. Unreal erkennt eine
LOD-Gruppe aber, wenn das Eltern-**Empty** die Custom Property
`fbx_type = "LodGroup"` traegt und die LOD-Meshes in Reihenfolge (LOD0…LODn)
darunter geparentet sind. Genau das erzeugt der Generator.
Beim FBX-Export darauf achten:
- **Custom Properties** aktivieren (sonst geht `fbx_type` verloren).
- Nur die LOD-Gruppe (Empty + Kinder) selektieren, `use_selection` an.
Beim Import in Unreal:
- Im FBX-Import-Dialog **Import LODs** aktivieren.
Referenz:
[Importing Static Mesh LODs Using FBX (UE Docs)](https://dev.epicgames.com/documentation/en-us/unreal-engine/importing-static-mesh-lods-using-fbx-in-unreal-engine).
---
## Tests
```bash
blender --background --python tests/test_rock_gen.py
```
Erzeugt 3 Rocks, prueft je Rock: keine losen Vertices, manifold, Normalen nach
aussen (signed volume > 0), UV0 + UV1 vorhanden, und gibt eine Zusammenfassung
(Vertex-/Face-Count) aus. Exit-Code 0 = alles bestanden, 1 = Fehler.
---
## Changelog v2.0.0
Gehaertet & erweitert gegenueber v1.0.0:
- **Bugfix:** `smart_project(angle_limit=…)` ist in Blender 4.x/5.x in **Radiant**
(`[0, 1.5708]`) der alte Wert `66.0` war ungueltig. Jetzt `radians(66°)`.
- **Bugfix:** `dissolve_limited(angle_limit=…)` erwartet **Radiant**; der
Grad-Wert wurde ungewandelt durchgereicht → uebermaessiges Dissolve. Jetzt
`radians(dissolve_angle)`.
- **Bugfix:** Der Lightmap-Kanal wurde vor `lightmap_pack` nicht als aktiver
UV-Layer gesetzt → UV0 konnte ueberschrieben werden. Jetzt explizit gesetzt,
UV0 danach wieder als Render-Kanal.
- Fehlerbehandlung um jeden `bpy.ops.*`-Aufruf; einzelne Rock-Fehler brechen den
Batch nicht mehr ab.
- Nicht-blockierender modaler Batch-Modus mit Progress-Bar.
- Bevel + Weighted Normal als Toggle.
- LOD-Set als `fbx_type=LodGroup`-Gruppe.
- Preset-System (JSON) mit Panel-Dropdown.
- Platzhalter-Material-Zuweisung.
+882
View File
@@ -0,0 +1,882 @@
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()
+135
View File
@@ -0,0 +1,135 @@
"""Headless-Test fuer den Stylized Rock Generator.
Aufruf (aus dem Repo-Root oder beliebig):
blender --background --python tests/test_rock_gen.py
Exit-Code 0 = alle Checks bestanden, 1 = mindestens ein Check fehlgeschlagen.
Der Test:
* registriert das Addon
* erzeugt einen Batch von 3 Rocks mit Standard-Settings (Subsurf reduziert
fuer Speed)
* prueft je Rock: keine losen Vertices, manifold, Normalen nach aussen
(signed volume > 0), UV0 + UV1 vorhanden
* gibt am Ende eine Zusammenfassung (Face-/Vertex-Count) aus
"""
import os
import sys
import importlib.util
import bpy
import bmesh
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
ADDON_FILE = os.path.normpath(os.path.join(THIS_DIR, "..", "stylized_rock_generator.py"))
BATCH = 3
def load_addon():
spec = importlib.util.spec_from_file_location("stylized_rock_generator", ADDON_FILE)
mod = importlib.util.module_from_spec(spec)
sys.modules["stylized_rock_generator"] = mod
spec.loader.exec_module(mod)
try:
mod.unregister()
except Exception:
pass
mod.register()
return mod
def mesh_stats(obj):
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.normal_update()
loose = sum(1 for v in bm.verts if not v.link_faces)
nonmanifold = sum(1 for e in bm.edges if not e.is_manifold)
volume = bm.calc_volume(signed=True)
stats = dict(
name=obj.name,
verts=len(bm.verts),
faces=len(bm.faces),
loose=loose,
nonmanifold=nonmanifold,
volume=volume,
uv_layers=[layer.name for layer in obj.data.uv_layers],
)
bm.free()
return stats
def main():
failures = []
# 1. Saubere, leere Szene
bpy.ops.wm.read_factory_settings(use_empty=True)
# 2. Addon registrieren (Scene-Property lebt am Typ -> nach Reset ok)
load_addon()
settings = bpy.context.scene.rock_gen_settings
settings.batch_count = BATCH
settings.subsurf_levels = 4 # Test-Speed
settings.use_modal = False # synchron -> deterministisch
settings.generate_uv0 = True
settings.generate_lightmap_uv = True
settings.generate_lods = False
settings.assign_material = False
# 3. Batch erzeugen
result = bpy.ops.object.generate_rocks_batch()
if 'FINISHED' not in result:
failures.append("Operator lieferte nicht {'FINISHED'}: %s" % (result,))
rocks = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and o.name.startswith("Rock_")]
if len(rocks) != BATCH:
failures.append("Erwartet %d Rocks, gefunden %d" % (BATCH, len(rocks)))
# 4. Validierung + Zusammenfassung
print("\n" + "=" * 64)
print(" Rock-Generator Test - Zusammenfassung")
print("=" * 64)
print(" %-16s %7s %7s %6s %6s %9s %s" %
("Name", "Verts", "Faces", "Loose", "NonMf", "Volume", "UVs"))
print("-" * 64)
for obj in sorted(rocks, key=lambda o: o.name):
st = mesh_stats(obj)
print(" %-16s %7d %7d %6d %6d %9.3f %s" % (
st["name"], st["verts"], st["faces"], st["loose"],
st["nonmanifold"], st["volume"], ",".join(st["uv_layers"])))
if st["faces"] == 0:
failures.append("%s: keine Faces" % st["name"])
if st["loose"] != 0:
failures.append("%s: %d lose Vertices" % (st["name"], st["loose"]))
if st["nonmanifold"] != 0:
failures.append("%s: %d non-manifold Kanten" % (st["name"], st["nonmanifold"]))
if st["volume"] <= 0.0:
failures.append("%s: signed volume <= 0 (Normalen invertiert?)" % st["name"])
if "Lightmap" not in st["uv_layers"]:
failures.append("%s: kein Lightmap-UV (UV1)" % st["name"])
if len(st["uv_layers"]) < 2:
failures.append("%s: weniger als 2 UV-Layer" % st["name"])
print("=" * 64)
if failures:
print("\nFEHLGESCHLAGEN (%d):" % len(failures))
for f in failures:
print(" - " + f)
print("")
sys.exit(1)
print("\nAlle Checks bestanden.\n")
sys.exit(0)
if __name__ == "__main__":
main()