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:
+18
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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 LOD0–LODn 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.
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user