diff --git a/README.md b/README.md index 7d19af3..1e51f1d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,17 @@ aussen (signed volume > 0), UV0 + UV1 vorhanden, und gibt eine Zusammenfassung --- +## Changelog v2.1.0 + +- **FBX-Export-Button (UE-ready):** exportiert jeden Rock / jede LOD-Gruppe als + eigene FBX mit `use_custom_props=True` (fuer `fbx_type=LodGroup`), Triangulate + und passendem Smoothing (`EDGE` bei Auto-Smooth, sonst `FACE`). + *UE-Import-Tipp:* Normal Import Method auf „Import Normals" stellen, „Import LODs" an. +- **Shading-Toggle:** Faceted / Smooth / Auto-Smooth-nach-Winkel. +- **Pro-Rock-Varianz:** Noise-Scale & Displace-Staerke zwischen Min/Max plus + optionale nicht-uniforme Skalierung → ein Batch wirkt variantenreich. +- **Boden abflachen (Bisect) + Origin unten-mittig** → Rocks stehen in UE plan auf. + ## Changelog v2.0.0 Gehaertet & erweitert gegenueber v1.0.0: diff --git a/blender_manifest.toml b/blender_manifest.toml index d12d621..2e5414b 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.0.0" +version = "2.1.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/dist/index.json b/dist/index.json index 98b9bcf..8853a70 100644 --- a/dist/index.json +++ b/dist/index.json @@ -7,7 +7,7 @@ "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.0.0", + "version": "2.1.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -23,9 +23,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.0.0.zip", - "archive_size": 9183, - "archive_hash": "sha256:7bcf0d9cc56015157dfa17264d9ea51e624e8c0013f84b59069bcd3386815ad6" + "archive_url": "./stylized_rock_generator-2.1.0.zip", + "archive_size": 11477, + "archive_hash": "sha256:76911905d9e283ce2cbe85ab341194e3fcfcb431eb7cf3816b96e6d2358214ae" } ] } \ No newline at end of file diff --git a/dist/stylized_rock_generator-2.0.0.zip b/dist/stylized_rock_generator-2.0.0.zip deleted file mode 100644 index 93781ed..0000000 Binary files a/dist/stylized_rock_generator-2.0.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.1.0.zip b/dist/stylized_rock_generator-2.1.0.zip new file mode 100644 index 0000000..67625a9 Binary files /dev/null and b/dist/stylized_rock_generator-2.1.0.zip differ diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index 994467a..f2e1a4a 100644 --- a/stylized_rock_generator.py +++ b/stylized_rock_generator.py @@ -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,