v2.1.0: FBX-Export, Shading-Toggle, Pro-Rock-Varianz, Boden abflachen + Origin

- FBX-Export-Button (UE-ready): je Rock/LOD-Gruppe eine FBX, use_custom_props fuer LodGroup, Triangulate, EDGE/FACE-Smoothing je nach Shading
- Shading-Modus: Faceted / Smooth / Auto-Smooth-nach-Winkel
- Pro-Rock-Varianz: Noise/Displace Min-Max + nicht-uniforme Skalierung
- Boden abflachen (Bisect) + Origin unten-mittig
- Live gegen Blender 5.1.2 getestet, Headless-Test gruen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 07:01:10 +02:00
co-authored by Claude Opus 4.8
parent 32317c446c
commit 46fc05eec4
6 changed files with 273 additions and 13 deletions
+257 -8
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Rock Generator",
"author": "D4rkst3r",
"version": (2, 0, 0),
"version": (2, 1, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Rock Gen",
"description": (
@@ -49,6 +49,9 @@ PRESET_KEYS = (
"add_bevel", "bevel_width", "bevel_segments", "add_weighted_normal",
"assign_material", "generate_lods", "lod_count", "lod_ratio_step",
"batch_count", "spacing",
"shading_mode", "autosmooth_angle",
"randomize_shape", "noise_scale_max", "displace_strength_max", "nonuniform_scale",
"flatten_bottom", "flatten_ratio", "set_origin_bottom",
)
# Eingebaute Presets (nicht ueberschreibbar/loeschbar).
@@ -285,6 +288,15 @@ def _build_rock_mesh(context, settings, index, location, rng):
obj = context.active_object
obj.name = "Rock_%03d" % index
# Effektive Form-Parameter (optional pro Rock variiert)
eff_noise = settings.noise_scale
eff_displace = settings.displace_strength
if settings.randomize_shape:
lo_n, hi_n = sorted((settings.noise_scale, settings.noise_scale_max))
lo_d, hi_d = sorted((settings.displace_strength, settings.displace_strength_max))
eff_noise = rng.uniform(lo_n, hi_n)
eff_displace = rng.uniform(lo_d, hi_d)
# 2. Subdivision Surface -> Apply
subsurf = obj.modifiers.new(name="Subsurf", type='SUBSURF')
subsurf.levels = settings.subsurf_levels
@@ -294,18 +306,18 @@ def _build_rock_mesh(context, settings, index, location, rng):
# 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
tex.noise_scale = eff_noise
elif settings.texture_type == 'CLOUDS':
tex.noise_scale = settings.noise_scale
tex.noise_scale = eff_noise
tex.noise_depth = 2
elif settings.texture_type == 'DISTORTED_NOISE':
tex.noise_scale = settings.noise_scale
tex.noise_scale = eff_noise
tex.distortion = 1.0
displace = obj.modifiers.new(name="Displace", type='DISPLACE')
displace.texture = tex
displace.texture_coords = 'GLOBAL'
displace.strength = settings.displace_strength
displace.strength = eff_displace
displace.mid_level = 0.5
# Variation pro Instanz: Objekt vor dem Apply an eine zufaellige (moderate!)
@@ -349,6 +361,10 @@ def _build_rock_mesh(context, settings, index, location, rng):
if _face_count(obj) == 0:
raise RockGenError("Nach Limited Dissolve keine Faces mehr uebrig.")
# 5c. Boden abflachen (optional) - vor den UVs, damit die Unterseite UVs bekommt
if settings.flatten_bottom:
_flatten_bottom(context, obj, settings)
# 6. UVs
if settings.generate_uv0:
_make_uv0(context, obj)
@@ -371,7 +387,6 @@ def _apply_hardsurface(context, obj, settings):
_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)
@@ -380,8 +395,12 @@ def _apply_hardsurface(context, obj, settings):
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)
lo, hi = sorted((settings.scale_min, settings.scale_max))
if settings.nonuniform_scale:
obj.scale = (rng.uniform(lo, hi), rng.uniform(lo, hi), rng.uniform(lo, hi))
else:
s = rng.uniform(lo, hi)
obj.scale = (s, s, s)
obj.rotation_euler = (
rng.uniform(0.0, TWO_PI),
rng.uniform(0.0, TWO_PI),
@@ -393,6 +412,77 @@ def _apply_random_transform(context, obj, settings, rng):
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
def _apply_shading(context, obj, settings):
"""Setzt den Shading-Look: FLAT (facettiert), SMOOTH oder AUTO (nach Winkel)."""
_deselect_all(context)
context.view_layer.objects.active = obj
obj.select_set(True)
mode = settings.shading_mode
if mode == 'FLAT':
bpy.ops.object.shade_flat()
elif mode == 'SMOOTH':
bpy.ops.object.shade_smooth()
else: # AUTO
try:
bpy.ops.object.shade_auto_smooth(angle=math.radians(settings.autosmooth_angle))
except (RuntimeError, TypeError):
bpy.ops.object.shade_smooth()
# Scharfe Kanten nach Winkel markieren -> FBX-EDGE-Export traegt sie mit.
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.mark_sharp(clear=True)
bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(settings.autosmooth_angle))
bpy.ops.mesh.mark_sharp()
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.object.mode_set(mode='OBJECT')
def _flatten_bottom(context, obj, settings):
"""Schneidet eine flache Unterseite (Bisect), damit der Rock plan aufsteht."""
zs = [v.co.z for v in obj.data.vertices]
if not zs:
return
zmin, zmax = min(zs), max(zs)
cut_z = zmin + (zmax - zmin) * settings.flatten_ratio
context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
try:
bpy.ops.mesh.bisect(
plane_co=(0.0, 0.0, cut_z),
plane_no=(0.0, 0.0, 1.0),
clear_inner=True,
use_fill=True,
)
except RuntimeError as exc:
bpy.ops.object.mode_set(mode='OBJECT')
raise RockGenError("Boden abflachen (bisect) fehlgeschlagen: %s" % exc)
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
if _face_count(obj) == 0:
raise RockGenError("Nach Boden-Abflachen keine Faces mehr uebrig.")
def _set_origin_bottom(context, obj):
"""Setzt den Origin auf die Unterseiten-Mitte (fuer sauberes Aufstellen in UE)."""
mw = obj.matrix_world
coords = [mw @ v.co for v in obj.data.vertices]
if not coords:
return
cx = sum(c.x for c in coords) / len(coords)
cy = sum(c.y for c in coords) / len(coords)
zmin = min(c.z for c in coords)
scene = context.scene
prev_cursor = scene.cursor.location.copy()
scene.cursor.location = (cx, cy, zmin)
_deselect_all(context)
context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
scene.cursor.location = prev_cursor
# ---------------------------------------------------------------------------
# LOD-Gruppen (fbx_type = LodGroup -> von Unreal beim FBX-Import erkannt)
# ---------------------------------------------------------------------------
@@ -443,6 +533,10 @@ def _build_lod_group(context, settings, index, location, rng):
_make_lightmap_uv(context, lod)
created.append(lod)
# Shading pro LOD (nach dem Decimate, damit scharfe Kanten zur Topologie passen)
for lod in created:
_apply_shading(context, lod, settings)
# Empty als LOD-Group-Parent
empty = bpy.data.objects.new(base_name + "_LODGroup", None)
empty.empty_display_type = 'PLAIN_AXES'
@@ -470,6 +564,9 @@ def make_one_rock(context, settings, index):
obj = _build_rock_mesh(context, settings, index, location, rng)
_apply_hardsurface(context, obj, settings)
_apply_random_transform(context, obj, settings, rng)
_apply_shading(context, obj, settings)
if settings.set_origin_bottom:
_set_origin_bottom(context, obj)
if settings.assign_material:
_assign_material(obj, index)
return [obj]
@@ -524,6 +621,12 @@ class RockGenSettings(PropertyGroup):
noise_scale: FloatProperty(name="Noise Scale", default=1.0, min=0.01, max=10.0)
displace_strength: FloatProperty(name="Displace Staerke", default=0.3, min=0.0, max=2.0)
seed: IntProperty(name="Seed", default=0, min=0)
randomize_shape: BoolProperty(
name="Form pro Rock variieren", default=False,
description="Noise-Scale und Displace-Staerke pro Rock zufaellig zwischen Min und Max",
)
noise_scale_max: FloatProperty(name="Noise Scale Max", default=2.0, min=0.01, max=10.0)
displace_strength_max: FloatProperty(name="Displace Max", default=0.5, min=0.0, max=2.0)
# --- Decimate / Cleanup ---
decimate_ratio_1: FloatProperty(name="Decimate 1", default=0.2, min=0.01, max=1.0)
@@ -555,6 +658,35 @@ class RockGenSettings(PropertyGroup):
description="Weighted-Normal-Modifier fuer sauberere Shading-Uebergaenge",
)
# --- Shading ---
shading_mode: EnumProperty(
name="Shading",
items=[
('FLAT', "Faceted", "Harte Flaechen - klassischer stylized Low-Poly-Look"),
('SMOOTH', "Smooth", "Voll geglaettet"),
('AUTO', "Auto-Smooth", "Glatt, aber scharfe Kanten oberhalb des Winkels"),
],
default='AUTO',
)
autosmooth_angle: FloatProperty(
name="Auto-Smooth Winkel (Grad)", default=30.0, min=0.0, max=180.0,
description="Kanten steiler als dieser Winkel bleiben hart",
)
# --- Form / Boden ---
flatten_bottom: BoolProperty(
name="Boden abflachen", default=False,
description="Schneidet eine flache Unterseite, damit der Rock plan aufsteht",
)
flatten_ratio: FloatProperty(
name="Schnitthoehe", default=0.1, min=0.0, max=0.45,
description="Wie weit von unten abgeschnitten wird (Anteil der Hoehe)",
)
set_origin_bottom: BoolProperty(
name="Origin unten-mittig", default=False,
description="Setzt den Origin auf die Unterseiten-Mitte (sauberes Aufstellen in UE)",
)
# --- Material ---
assign_material: BoolProperty(
name="Material zuweisen", default=False,
@@ -579,6 +711,10 @@ class RockGenSettings(PropertyGroup):
randomize_transform: BoolProperty(name="Skalierung/Rotation randomisieren", default=True)
scale_min: FloatProperty(name="Scale Min", default=0.8, min=0.1, max=5.0)
scale_max: FloatProperty(name="Scale Max", default=1.2, min=0.1, max=5.0)
nonuniform_scale: BoolProperty(
name="Nicht-uniforme Skalierung", default=False,
description="Skaliert jede Achse einzeln (flache Platten vs. runde Brocken)",
)
# --- Batch ---
batch_count: IntProperty(name="Anzahl Rocks", default=5, min=1, max=500)
@@ -592,6 +728,16 @@ class RockGenSettings(PropertyGroup):
description="Ab dieser Rock-Anzahl wird der modale (nicht-blockierende) Modus genutzt",
)
# --- FBX-Export ---
export_dir: StringProperty(
name="Export-Ordner", default="", subtype='DIR_PATH',
description="Zielordner fuer die FBX-Dateien (je Rock/LOD-Gruppe eine Datei)",
)
export_selected_only: BoolProperty(
name="Nur Auswahl exportieren", default=False,
description="Exportiert nur die aktuell selektierten Rocks statt aller",
)
# --- Presets ---
active_preset: EnumProperty(name="Preset", items=_preset_items)
new_preset_name: StringProperty(name="Preset-Name", default="")
@@ -687,6 +833,84 @@ class OBJECT_OT_generate_rocks(Operator):
context.workspace.status_text_set(None)
# ---------------------------------------------------------------------------
# FBX-Export (UE-ready)
# ---------------------------------------------------------------------------
def _collect_export_targets(context, settings):
"""Top-Level-Rocks: LOD-Group-Empties + freistehende Rock-Meshes ohne Parent."""
tops = []
for o in context.scene.objects:
is_group = (o.type == 'EMPTY' and o.get("fbx_type") == "LodGroup")
is_standalone = (o.type == 'MESH' and o.name.startswith("Rock_") and o.parent is None)
if is_group or is_standalone:
tops.append(o)
if settings.export_selected_only:
sel = set(context.selected_objects)
tops = [t for t in tops
if t in sel or any(c in sel for c in t.children_recursive)]
return tops
def _safe_filename(name):
keep = "-_.() "
return "".join(c for c in name if c.isalnum() or c in keep).strip() or "Rock"
class OBJECT_OT_export_rocks_fbx(Operator):
bl_idname = "object.export_rocks_fbx"
bl_label = "FBX exportieren"
bl_description = "Exportiert jeden Rock / jede LOD-Gruppe als eigene FBX (UE-ready)"
def execute(self, context):
settings = context.scene.rock_gen_settings
directory = bpy.path.abspath(settings.export_dir) if settings.export_dir else ""
if not directory or not os.path.isdir(directory):
self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.")
return {'CANCELLED'}
targets = _collect_export_targets(context, settings)
if not targets:
self.report({'WARNING'}, "Keine Rocks zum Exportieren gefunden.")
return {'CANCELLED'}
# AUTO -> Edge-Smoothing (traegt scharfe Kanten), sonst Face-Smoothing.
smooth_type = 'EDGE' if settings.shading_mode == 'AUTO' else 'FACE'
_ensure_object_mode(context)
exported, failed = 0, 0
for top in targets:
objs = list(top.children_recursive) + [top]
_deselect_all(context)
for o in objs:
o.select_set(True)
context.view_layer.objects.active = top
filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx")
try:
bpy.ops.export_scene.fbx(
filepath=filepath,
use_selection=True,
object_types={'MESH', 'EMPTY'},
use_mesh_modifiers=True,
mesh_smooth_type=smooth_type,
use_triangles=True,
use_custom_props=True, # noetig fuer fbx_type=LodGroup
add_leaf_bones=False,
bake_anim=False,
path_mode='AUTO',
)
exported += 1
except RuntimeError as exc:
failed += 1
print("[RockGen] FBX-Export '%s' fehlgeschlagen: %s" % (top.name, exc))
if failed:
self.report({'WARNING'}, "%d FBX exportiert, %d fehlgeschlagen (Konsole)." % (exported, failed))
else:
self.report({'INFO'}, "%d FBX-Datei(en) nach %s exportiert." % (exported, directory))
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Operatoren: Presets
# ---------------------------------------------------------------------------
@@ -799,6 +1023,11 @@ class VIEW3D_PT_rock_generator(Panel):
box.prop(settings, "noise_scale")
box.prop(settings, "displace_strength")
box.prop(settings, "seed")
box.prop(settings, "randomize_shape")
sub = box.column(align=True)
sub.enabled = settings.randomize_shape
sub.prop(settings, "noise_scale_max")
sub.prop(settings, "displace_strength_max")
box = layout.box()
box.label(text="Decimate / Cleanup")
@@ -815,6 +1044,18 @@ class VIEW3D_PT_rock_generator(Panel):
sub.prop(settings, "bevel_segments")
box.prop(settings, "add_weighted_normal")
box = layout.box()
box.label(text="Shading / Form")
box.prop(settings, "shading_mode")
row = box.row()
row.enabled = settings.shading_mode == 'AUTO'
row.prop(settings, "autosmooth_angle")
box.prop(settings, "flatten_bottom")
row = box.row()
row.enabled = settings.flatten_bottom
row.prop(settings, "flatten_ratio")
box.prop(settings, "set_origin_bottom")
box = layout.box()
box.label(text="UV (Unreal Export)")
box.prop(settings, "generate_uv0")
@@ -838,6 +1079,7 @@ class VIEW3D_PT_rock_generator(Panel):
row = box.row(align=True)
row.prop(settings, "scale_min")
row.prop(settings, "scale_max")
box.prop(settings, "nonuniform_scale")
box = layout.box()
box.label(text="Batch")
@@ -851,6 +1093,12 @@ class VIEW3D_PT_rock_generator(Panel):
layout.separator()
layout.operator("object.generate_rocks_batch", icon='MESH_ICOSPHERE')
box = layout.box()
box.label(text="FBX-Export (Unreal)")
box.prop(settings, "export_dir")
box.prop(settings, "export_selected_only")
box.operator("object.export_rocks_fbx", icon='EXPORT')
# ---------------------------------------------------------------------------
# Registration
@@ -859,6 +1107,7 @@ class VIEW3D_PT_rock_generator(Panel):
classes = (
RockGenSettings,
OBJECT_OT_generate_rocks,
OBJECT_OT_export_rocks_fbx,
ROCKGEN_OT_preset_save,
ROCKGEN_OT_preset_load,
ROCKGEN_OT_preset_delete,