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:
+257
-8
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user