Files
stylized-rock-generator/stylized_rock_generator.py
T
D4rkst3randClaude Opus 4.8 f5b528a874 Rock Gen 2.15.0: Erzfels-Set (Punkt 5) + Smooth/dicht (Punkt 6)
Punkt 5 - EIN neutrales Erzfels-Set fuer ALLE Erzsorten
  A-Kanal traegt die Erz-Maske: sie sagt nur WO Erz sitzt. WELCHES Erz
  (Kupfer/Eisen/Gold = Tint + Gluehen) macht die Material-Instanz in UE.
  Knopf "Erzfels-Set erzeugen (S + M)" liefert Rock_Ore_S_NN und Rock_Ore_M_NN
  und setzt alle Einstellungen danach zurueck. Regler: Erz-Anteil,
  Nugget-Groesse (Radius in m), Erz-Seed.

  Nuggets statt Noise: gestreute Zentren mit weichem Abfall. Ein Noise-Feld
  gaebe wolkige Schlieren, Erz sitzt aber in abgegrenzten Einschluessen. Die
  Zentren bevorzugen Fugen - Adern folgen Rissen, und das Gluehen liest sich
  in einer Vertiefung besser.

  Ohne Erz-Bake bleibt A auf 1.0 - wie im eingefrorenen Layout v2 und wie das
  bestehende 25er-Set es traegt.

Punkt 6 vorgezogen, weil Punkt 5 davon abhaengt
  Die Maskenaufloesung haengt an der Vertex-Dichte. Bei den bisherigen
  ~150-Vert-Steinen war jedes dritte Vertex ein Nugget-Zentrum: im Render
  liefen die Nuggets zu grossen Schlieren zusammen statt einzelne Einschluesse
  zu bleiben. Bei ~1350 Verts liest es sich richtig. Deshalb gibt es jetzt
  "Smooth & dicht" (Unterteilung 5, Decimate 0.40/0.80, Auto-Smooth) und das
  Erz-Set stellt selbst darauf um. Gemessen ueber drei Seeds: 2708/2658/2716
  Tris - im Zielfenster 1500-3000. Zusaetzlich ist die Nuggetzahl auf ein
  Zentrum je ~10 Verts gedeckelt.

Nebenbei: _asset_name schrieb die Form-Bezeichnung pauschal gross, daraus wurde
"Rock_ORE_S_01" statt des vereinbarten "Rock_Ore_S_01". Einzelne Buchstaben
(Form A, B, C) werden weiter gross geschrieben, laengere Bezeichner behalten
ihre Schreibweise.

Gemessen: Erz-Anteil 0.08 -> 4 %, 0.18 -> 9 %, 0.35 -> 15 % der Oberflaeche,
A 0.00..1.00, S bis 0.80 m / M ab 1.71 m, Maske ueberlebt den FBX-Roundtrip.
Neu: tests/test_erz.py. 16 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 08:13:31 +02:00

2540 lines
100 KiB
Python

bl_info = {
"name": "Stylized Rock Generator",
"author": "D4rkst3r",
"version": (2, 15, 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 bmesh
import os
import json
import math
import random
import traceback
from bpy.props import (
IntProperty, FloatProperty, FloatVectorProperty, EnumProperty, BoolProperty,
StringProperty, PointerProperty,
)
from bpy.types import Operator, Panel, PropertyGroup
from mathutils import Matrix, Vector
# ---------------------------------------------------------------------------
# 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)),
)
# Basis-Groesse je Groessen-Klasse (Meter-Faktor auf das Grundmesh).
# WICHTIG - Unterschied zur Zufalls-Streuung: DIESE Groesse ist gewollt und
# unterscheidet die Klassen. Die Streuung (scale_min/max) variiert dagegen nur
# die FORM innerhalb einer Klasse und wird von "keep_size" auf konstantes
# Volumen normiert, damit sie sich nicht mit den ScaleMin/Max der Engine
# multipliziert.
# "Smooth & dicht": kein Low-Poly-Look, und die Vertex-Masken (Cavity, Moos,
# Erz) haengen direkt an der Vertex-Dichte. Gemessen ueber drei Seeds:
# ~1350 Verts / ~2694 Tris - im Zielfenster 1500-3000 Tris.
DENSE_PRESET = {
"subsurf_levels": 5,
"decimate_ratio_1": 0.40,
"decimate_ratio_2": 0.80,
"shading_mode": 'AUTO',
}
CLASS_SIZES = {'S': 0.45, 'M': 1.0, 'L': 1.9, 'XL': 3.2}
# Property-Keys, die von Presets gespeichert/geladen werden.
PRESET_KEYS = (
"base_shape", "box_proportions", "box_variation", "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",
"shading_mode", "autosmooth_angle",
"randomize_shape", "noise_scale_max", "displace_strength_max", "nonuniform_scale",
"flatten_bottom", "flatten_ratio", "set_origin_bottom", "keep_size",
"bake_vertex_cavity", "cavity_strength", "cavity_blur",
"moss_amount", "moss_noise", "moss_seed", "part_mask_from_shells",
"use_class_size", "base_size",
)
# 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 _asset_name(settings, index, batch_count):
"""EcoGame-Schema: Fels_<Form>_<Klasse>, bei Batches mit laufender Nummer."""
# Einzelne Buchstaben werden gross geschrieben (Form A, B, C ...), laengere
# Bezeichner behalten ihre Schreibweise - sonst wuerde aus dem vereinbarten
# "Rock_Ore_S_01" ein "Rock_ORE_S_01".
form = settings.name_form.strip() or "A"
form = form.upper() if len(form) == 1 else form
base = "%s_%s_%s" % (settings.name_prefix.strip() or "Fels",
form, settings.name_class)
if settings.name_numbered and batch_count > 1:
return "%s_%02d" % (base, index + 1)
return base
def _face_count(obj):
return len(obj.data.polygons)
def _deselect_all(context):
for o in list(context.view_layer.objects):
try:
o.select_set(False)
except (ReferenceError, AttributeError, RuntimeError):
# Kann nach dem Entfernen eines Objekts kurzzeitig ungueltig sein
pass
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, name=None):
"""Baut das reine Rock-Mesh (ohne Transform/Material). Kann RockGenError werfen."""
# 1. Basis-Mesh
if settings.base_shape in ('CUBE', 'BOX'):
bpy.ops.mesh.primitive_cube_add(size=2, location=location)
if settings.base_shape == 'BOX':
# Proportionen auf die MESH-DATEN anwenden, nicht auf das Objekt:
# der Displace nutzt globale Texturkoordinaten, eine Objekt-Skalierung
# wuerde das Rauschen mitverzerren statt die Form zu aendern.
px, py, pz = settings.box_proportions
# Auf gleiches Volumen normieren: sonst waere ein Quader bei gleicher
# Groessen-Klasse deutlich groesser als ein Wuerfel, nur weil eine
# Achse gestreckt ist. So bedeutet "M" bei jeder Form dasselbe -
# die Platte ist dann flacher UND breiter, aber nicht schwerer.
gmean = (abs(px) * abs(py) * abs(pz)) ** (1.0 / 3.0)
if gmean > 1e-6:
px, py, pz = px / gmean, py / gmean, pz / gmean
v = settings.box_variation
if v > 0.0:
px *= 1.0 + rng.uniform(-v, v) * 0.5
py *= 1.0 + rng.uniform(-v, v) * 0.5
pz *= 1.0 + rng.uniform(-v, v) * 0.5
ob_tmp = context.active_object
ob_tmp.scale = (px, py, pz)
_deselect_all(context)
context.view_layer.objects.active = ob_tmp
ob_tmp.select_set(True)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
else:
bpy.ops.mesh.primitive_ico_sphere_add(
subdivisions=1, radius=1.0, location=location
)
obj = context.active_object
obj.name = name or ("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
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 = eff_noise
elif settings.texture_type == 'CLOUDS':
tex.noise_scale = eff_noise
tex.noise_depth = 2
elif settings.texture_type == 'DISTORTED_NOISE':
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 = eff_displace
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.")
# 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)
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)
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:
# Klassengroesse gilt trotzdem - sie ist keine Zufalls-Streuung.
base = (CLASS_SIZES.get(settings.name_class, 1.0)
if settings.use_class_size else settings.base_size)
if abs(base - 1.0) > 1e-6:
obj.scale = (base, base, base)
_deselect_all(context)
context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return
base = (CLASS_SIZES.get(settings.name_class, 1.0)
if settings.use_class_size else settings.base_size)
lo, hi = sorted((settings.scale_min, settings.scale_max))
if settings.nonuniform_scale:
sx, sy, sz = (rng.uniform(lo, hi), rng.uniform(lo, hi), rng.uniform(lo, hi))
if settings.keep_size:
# Geometrisches Mittel auf 1.0 normieren: die Proportionen streuen,
# das Volumen bleibt. Sonst multipliziert sich die Streuung mit den
# ScaleMin/Max der Engine.
g = (abs(sx) * abs(sy) * abs(sz)) ** (1.0 / 3.0)
if g > 1e-6:
sx, sy, sz = sx / g, sy / g, sz / g
obj.scale = (sx * base, sy * base, sz * base)
else:
s = 1.0 if settings.keep_size else rng.uniform(lo, hi)
obj.scale = (s * base, s * base, s * base)
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)
def _ore_mask(me, settings):
"""Erz-Maske je VERTEX: gestreute Nuggets mit weichem Abfall.
Warum Nuggets und kein Noise: ein Noise-Feld ergibt wolkige Schlieren ueber
den ganzen Stein. Erz sitzt aber in abgegrenzten Einschluessen. Deshalb
werden Zentren gestreut und der Abfall darum herum gerechnet - dadurch sind
Menge und Korngroesse unabhaengig voneinander regelbar.
Die Zentren bevorzugen Fugen (niedrige Cavity): Adern folgen Rissen, und
das Gluehen liest sich in einer Vertiefung deutlich besser als auf einer
flachen Platte.
"""
import random as _rnd
from mathutils import kdtree
n_verts = len(me.vertices)
if n_verts == 0:
return None
radius = max(settings.ore_nugget_size, 1e-4)
# Anzahl aus der Oberflaeche ableiten, nicht fest: sonst haette ein
# S-Brocken dieselbe Nuggetzahl wie ein M-Brocken und waere uebersaet.
area = sum(p.area for p in me.polygons)
pro_nugget = math.pi * radius * radius
n_nuggets = int(round(settings.ore_amount * area / max(pro_nugget, 1e-6)))
# Deckel an der AUFLOESUNG, nicht nur an einer festen Zahl: die Maske haengt
# an der Vertex-Dichte. Bei einem 150-Vert-Stein waren 46 Zentren jeder
# dritte Vertex - die Nuggets liefen zu grossen Schlieren zusammen statt
# einzelne Einschluesse zu bleiben. Ein Zentrum je ~10 Verts haelt sie
# getrennt. Wer feinere Nuggets will, braucht ein dichteres Mesh.
n_nuggets = max(1, min(n_nuggets, 96, max(1, n_verts // 10)))
# Fugen-Gewicht je Vertex aus der bereits gebackenen Cavity im R-Kanal.
layer = me.color_attributes.get("Cavity")
gewicht = [1.0] * n_verts
if layer is not None:
summe = [0.0] * n_verts
zahl = [0] * n_verts
for li, loop in enumerate(me.loops):
vi = loop.vertex_index
summe[vi] += layer.data[li].color[0]
zahl[vi] += 1
for vi in range(n_verts):
if zahl[vi]:
# 1 - Cavity -> in der Fuge gross
gewicht[vi] = 0.25 + 0.75 * (1.0 - summe[vi] / zahl[vi])
rng = _rnd.Random(settings.ore_seed)
gesamt = sum(gewicht) or 1.0
zentren = []
for _ in range(n_nuggets):
# gewichtetes Ziehen ohne cumsum-Tabelle (Meshes sind klein)
ziel = rng.random() * gesamt
lauf = 0.0
pick = n_verts - 1
for vi in range(n_verts):
lauf += gewicht[vi]
if lauf >= ziel:
pick = vi
break
zentren.append(me.vertices[pick].co.copy())
baum = kdtree.KDTree(len(zentren))
for i, co in enumerate(zentren):
baum.insert(co, i)
baum.balance()
maske = [0.0] * n_verts
for vi, v in enumerate(me.vertices):
_, _, dist = baum.find(v.co)
if dist is None or dist >= radius:
continue
t = 1.0 - dist / radius
maske[vi] = t * t * (3.0 - 2.0 * t) # Smoothstep: weiche Raender
return maske
def _bake_vertex_cavity(context, obj, settings):
"""Vertex-Farb-Layout v2 - KANAL-GETRENNT im Attribut "Cavity".
R = Cavity pur 1 = offene Flaeche, 0 = tiefe Fuge (ohne Verlauf!)
G = Moos/Schmutz 1 = hier waechst was (Oberseite + Fugennaehe + Noise)
B = Hoehen-Verlauf 0 = Unterseite, 1 = oben
A = 1 (Reserve)
Getrennt statt eingebacken, damit das Material selbst entscheidet: Boden-Saum
und Moosfarbe werden pro Material-Instanz geregelt, nicht im Mesh festgelegt.
"""
import random as _rnd
me = obj.data
layer = me.color_attributes.get("Cavity")
if layer is None:
layer = me.color_attributes.new(name="Cavity", type='BYTE_COLOR', domain='CORNER')
me.color_attributes.active_color = layer
idx = me.color_attributes.find("Cavity")
if idx >= 0:
me.color_attributes.render_color_index = idx
# --- R: Cavity ueber Blenders Dirty-Vertex-Colors ---
_deselect_all(context)
context.view_layer.objects.active = obj
obj.select_set(True)
try:
bpy.ops.paint.vertex_color_set(color=(1.0, 1.0, 1.0, 1.0))
except RuntimeError:
pass
try:
bpy.ops.paint.vertex_color_dirt(
blur_strength=1.0, blur_iterations=settings.cavity_blur,
clean_angle=3.14159, dirt_angle=0.0, dirt_only=False, normalize=True,
)
except RuntimeError as exc:
raise RockGenError("Vertex-Cavity fehlgeschlagen: %s" % exc)
data = layer.data
cav_raw = [data[i].color[0] for i in range(len(data))]
zs = [v.co.z for v in me.vertices]
if not zs:
return
zmin, zmax = min(zs), max(zs)
span = max(zmax - zmin, 1e-6)
# Normalen je Loop fuer den Oberseiten-Anteil
me.calc_normals_split() if hasattr(me, "calc_normals_split") else None
rng = _rnd.Random(settings.moss_seed)
# Noise-Wert je VERTEX (nicht je Loop), damit er nicht flackert
vnoise = [rng.random() for _ in range(len(me.vertices))]
amt = settings.cavity_strength
moss = settings.moss_amount
mnoise = settings.moss_noise
# --- A: Erz-Maske (Nuggets) ---------------------------------------------
# EIN neutrales Set fuer ALLE Erzsorten: die Maske sagt nur WO Erz sitzt.
# WELCHES Erz (Kupfer/Eisen/Gold = Tint + Gluehen) entscheidet die
# Material-Instanz in UE.
#
# Nuggets statt Rauschen: gestreute Zentren mit weichem Abfall. Ein
# Noise-Feld gaebe wolkige Schlieren, keine Einschluesse.
ore_a = None
if getattr(settings, "bake_ore", False) and settings.ore_amount > 0.0:
ore_a = _ore_mask(me, settings)
for poly in me.polygons:
nz = poly.normal.z # 1 = zeigt nach oben
for li in poly.loop_indices:
vi = me.loops[li].vertex_index
c_raw = cav_raw[li]
# R: reine Cavity, Staerke geregelt, KEIN Hoehen-Verlauf
r = max(0.0, min(1.0, 1.0 - (1.0 - c_raw) * amt))
# B: Hoehen-Verlauf 0..1
b = max(0.0, min(1.0, (me.vertices[vi].co.z - zmin) / span))
# G: Moos - Oberseite UND Fugennaehe, mit Noise aufgebrochen
up = max(0.0, nz) # 0 unten, 1 oben
crev = 1.0 - c_raw # 1 in der Fuge
base = up * 0.7 + crev * 0.5
n = 1.0 - mnoise + mnoise * vnoise[vi]
g = max(0.0, min(1.0, base * n * moss * 1.6))
# A: Erz-Maske. Ohne Erz bleibt der Kanal auf 1.0 - so wie im
# eingefrorenen Layout v2 festgelegt und wie das bestehende
# 25er-Set es bereits traegt.
a = 1.0 if ore_a is None else ore_a[vi]
data[li].color = (r, g, b, a)
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)
# ---------------------------------------------------------------------------
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=None):
base_name = base_name or ("Rock_%03d" % index)
lod0 = _build_rock_mesh(context, settings, index, location, rng, name=base_name)
_apply_hardsurface(context, lod0, settings)
_apply_random_transform(context, lod0, settings, rng)
# Vertex-Farben JETZT backen, vor dem Duplizieren. Zwei Gruende:
# 1. Der LOD-Zweig sprang vorher komplett am Bake vorbei - LOD-Gruppen kamen
# OHNE Vertex-Farben aus dem Export (gemessen: kein Farbattribut auf einer
# der vier Stufen). Genau der offene Punkt der Spielseite.
# 2. Das Decimate interpoliert die Farben mit. Wuerde je Stufe neu gebacken,
# haette jede Stufe ihre eigene Cavity und der LOD-Wechsel wuerde poppen.
if settings.bake_vertex_cavity:
_bake_vertex_cavity(context, lod0, settings)
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)
# 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'
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, batch_count=1):
"""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)
name = _asset_name(settings, index, batch_count)
if settings.generate_lods:
return _build_lod_group(context, settings, index, location, rng, base_name=name)
obj = _build_rock_mesh(context, settings, index, location, rng, name=name)
_apply_hardsurface(context, obj, settings)
_apply_random_transform(context, obj, settings, rng)
_apply_shading(context, obj, settings)
if settings.bake_vertex_cavity:
_bake_vertex_cavity(context, obj, settings)
if settings.set_origin_bottom:
_set_origin_bottom(context, obj)
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', "Wuerfel", "Gleichmaessiger Brocken"),
('BOX', "Quader", "Laengliche oder flache Grundform - Proportionen unten"),
('ICOSPHERE', "Ico-Sphere", "Rundlicher Brocken"),
],
default='CUBE',
)
box_proportions: FloatVectorProperty(
name="Quader-Proportionen", size=3, default=(1.6, 1.1, 0.55),
min=0.15, max=4.0, subtype='XYZ',
description=("Seitenverhaeltnis der Grundform bei 'Quader'. Flach (kleines Z) "
"ergibt Platten und Findlinge, langgestreckt ergibt Brocken. "
"Wird VOR dem Displace angewendet, damit die Verformung mitgeht"),
)
box_variation: FloatProperty(
name="Proportions-Streuung", default=0.25, min=0.0, max=1.0,
description="Wuerfelt die Proportionen je Stein etwas anders (0 = alle gleich)",
)
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)
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)
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=False,
description=("Separates, nicht ueberlappendes UV fuer Static-Mesh-Lightmaps. "
"EcoGame beleuchtet dynamisch - dort nicht noetig"),
)
# --- 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",
)
# --- 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)",
)
# --- Vertex-Cavity (texturfreier Weg) ---
bake_vertex_cavity: BoolProperty(
name="Cavity in Vertex-Farben", default=False,
description=("Backt Cavity/AO in die Vertex-Farben (Attribut 'Cavity'). "
"Das Material braucht dann KEINE Textur: Base Color = Farbton "
"x Vertex-Farbe, Rest ueber Kantenlicht und Schattentint"),
)
cavity_strength: FloatProperty(
name="Cavity-Staerke", default=0.7, min=0.0, max=1.0,
description="0 = alles weiss, 1 = volle Abdunklung in den Vertiefungen",
)
cavity_blur: IntProperty(
name="Weichzeichnen", default=1, min=0, max=5,
description="Glaettet den Cavity-Verlauf ueber benachbarte Vertices",
)
# --- Erz (A-Kanal) ---
bake_ore: BoolProperty(
name="Erz-Maske (A-Kanal)", default=False,
description=("Schreibt Erz-Nuggets in den A-Kanal. EIN neutrales Set fuer "
"ALLE Erzsorten - welches Erz es ist (Tint + Gluehen), "
"entscheidet die Material-Instanz in UE. Aus = A bleibt 1.0"),
)
ore_amount: FloatProperty(
name="Erz-Anteil", default=0.18, min=0.0, max=1.0,
description=("Wie viel der Oberflaeche Erz traegt. Die Nuggetzahl wird aus "
"der Flaeche abgeleitet, damit ein S-Brocken nicht uebersaet ist"),
)
ore_nugget_size: FloatProperty(
name="Nugget-Groesse", default=0.10, min=0.01, max=1.0, unit='LENGTH',
description="Radius eines einzelnen Erz-Einschlusses in Metern",
)
ore_seed: IntProperty(
name="Erz-Seed", default=0, min=0, max=9999,
description="Andere Verteilung der Nuggets bei gleicher Form",
)
part_mask_from_shells: BoolProperty(
name="G = Teil-Maske (Shells)", default=False,
description=("Statt der Moos-Maske: getrennte Mesh-Teile markieren. "
"Der oberste/breiteste Teil bekommt G=1 (z.B. Pilzhut), "
"der Rest 0 (Stiel). Nutzt Strg+J-verbundene Objekte aus - "
"damit tont EIN Material beide Teile unterschiedlich"),
)
moss_amount: FloatProperty(
name="Moos-Menge", default=0.55, min=0.0, max=1.0,
description=("Wie viel der Oberflaeche die Moos-/Schmutzmaske (Kanal G) "
"markiert. Die FARBE bestimmt das Material, nicht das Mesh"),
)
moss_noise: FloatProperty(
name="Moos-Aufbruch", default=0.55, min=0.0, max=1.0,
description="Bricht die Maske mit Rauschen auf, damit sie nicht flaechig wirkt",
)
moss_seed: IntProperty(
name="Moos-Seed", default=0, min=0, max=9999,
description="Anderer Wert = andere Moos-Verteilung bei gleicher Form",
)
# --- 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)
nonuniform_scale: BoolProperty(
name="Nicht-uniforme Skalierung", default=True,
description="Skaliert jede Achse einzeln (flache Platten vs. runde Brocken)",
)
keep_size: BoolProperty(
name="Gesamtgroesse konstant halten", default=True,
description=("Normiert die Achsen-Faktoren, sodass nur die FORM streut und nicht "
"die Groesse. Wichtig fuer EcoGame: die Engine streut die Groesse "
"zusaetzlich ueber ScaleMin/Max - beides zusammen multipliziert sich "
"und erzeugt sonst Mini- und Riesen-Exemplare"),
)
# --- 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",
)
# --- Textur-Bake (tileable, fuer Triplanar in UE) ---
bake_dir: StringProperty(
name="Bake-Ordner", default="", subtype='DIR_PATH',
description="Zielordner fuer die gebackenen Textur-PNGs",
)
# --- Benennung (EcoGame-Schema: Fels_<Form>_<Klasse>) ---
name_prefix: StringProperty(
name="Praefix", default="Fels",
description="Basisname, z.B. 'Fels' oder 'Felsblock'",
)
name_form: StringProperty(
name="Form", default="A",
description="Form-Variante als Buchstabe (A, B, C ...)",
)
name_class: EnumProperty(
name="Groessen-Klasse",
items=[('S', "S", "klein"), ('M', "M", "mittel"),
('L', "L", "gross"), ('XL', "XL", "sehr gross")],
default='M',
description=("Groessen-KLASSE mit eigener Geometrie. Reines Groesser/Kleiner "
"macht die Engine ueber ScaleMin/Max - eine eigene Klasse nur, "
"wenn sich die Silhouette aendert"),
)
use_class_size: BoolProperty(
name="Groesse aus Klasse", default=True,
description=("Leitet die Basisgroesse aus der Klasse ab "
"(S=0.45, M=1.0, L=1.9, XL=3.2). Ausschalten, um "
"'Basis-Groesse' frei zu setzen"),
)
base_size: FloatProperty(
name="Basis-Groesse", default=1.0, min=0.05, max=20.0,
description=("Absolute Groesse des Steins (Faktor). Getrennt von der "
"Zufalls-Streuung, die nur die Form variiert"),
)
name_numbered: BoolProperty(
name="Bei Batch durchnummerieren", default=True,
description="Haengt bei mehreren Rocks _01, _02 ... an (sonst Blender-Suffix)",
)
bake_prefix: StringProperty(
name="Textur-Name", default="fels_a",
description="Basisname; es entstehen <name>_albedo.png und <name>_normal.png",
)
bake_resolution: EnumProperty(
name="Aufloesung",
items=[('512', "512", ""), ('1024', "1024", ""), ('2048', "2048", "")],
default='1024', # reicht fuer stylized/flaechige Formen
)
bake_color: BoolProperty(name="Albedo", default=True)
bake_normal: BoolProperty(name="Normal", default=True)
bake_roughness: BoolProperty(
name="Roughness", default=False,
description="In EcoGame ist Roughness eine ZAHL im Master-Material, keine Textur",
)
bake_ao: BoolProperty(
name="AO separat", default=False,
description=("Eigene AO-Textur. In EcoGame nicht noetig - dort wird AO ins "
"Albedo eingerechnet (siehe unten)"),
)
bake_ao_into_albedo: BoolProperty(
name="AO ins Albedo einrechnen", default=True,
description="Cavity/AO multiplikativ ins Farbbild backen (EcoGame-Konvention)",
)
bake_ao_strength: FloatProperty(
name="AO-Staerke", default=0.55, min=0.0, max=1.0,
description="0 = kein AO im Albedo, 1 = voll",
)
bake_base_color: FloatVectorProperty(
name="Grundfarbe", subtype='COLOR', size=4,
default=(0.62, 0.60, 0.56, 1.0), min=0.0, max=1.0,
description="Grundton des Steins; Outline/Highlight werden davon abgeleitet",
)
bake_seam_width: FloatProperty(
name="Fugenbreite", default=0.05, min=0.01, max=0.5,
description=("Wie breit der Uebergang von der Fuge zur Plattenflaeche ist. "
"Klein = schmale harte Fuge und flache Platte (Genshin), "
"gross = weicher Verlauf ueber die ganze Zelle"),
)
bake_plate_flat: FloatProperty(
name="Platten flach", default=1.0, min=0.0, max=1.0,
description=("1 = Plattenflaeche nahezu einfarbig, Detail steckt nur in der "
"dunklen Fuge. 0 = klassischer Verlauf ueber die Zelle"),
)
bake_cell_scale: FloatProperty(
name="Plattengroesse", default=0.7, min=0.3, max=12.0,
description=("Kleiner = GROESSERE Platten. Genshin-Referenz: nur wenige grosse "
"Platten pro Kachel, nicht viele kleine Zellen"),
)
# --- 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",
)
# Ursprung unten-mittig + Nullpunkt ist KEINE Option, sondern passiert immer -
# ein Stein, der seine Szenenposition mit ins Asset nimmt, ist schlicht kaputt.
export_lay_flat: BoolProperty(
name="Flache Steine hinlegen", default=True,
description=("Dreht vor dem Export die kuerzeste Bbox-Achse nach oben, "
"damit plattige Steine liegen statt zu stehen. Im Spiel gibt "
"es nur zufaelligen Yaw - die Ausrichtung ist endgueltig"),
)
export_ucx: BoolProperty(
name="UCX-Kollision mitexportieren", default=True,
description=("Legt je Stein eine konvexe Huelle 'UCX_<Name>' ins FBX. "
"Unreal nimmt sie automatisch als Simple Collision"),
)
ucx_max_verts: IntProperty(
name="UCX max. Verts", default=32, min=8, max=128,
description=("Obergrenze fuer die Kollisionshuelle. Simple Collision soll "
"wenige Ebenen haben - eine volle Huelle waere keine "
"Vereinfachung"),
)
# --- Zufall ---
random_amount: FloatProperty(
name="Streuung", default=0.35, min=0.0, max=1.0,
description=("Wie stark 'Zufaellig' die Werte veraendert. 0 = nur neuer Seed, "
"1 = volle Bandbreite"),
)
random_shape: BoolProperty(
name="Auch Basis-Form/Noise-Typ", default=False,
description="Wuerfelt zusaetzlich Cube/Ico-Sphere und den Noise-Typ",
)
# --- 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, settings.batch_count))
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, settings.batch_count))
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)
# ---------------------------------------------------------------------------
# FBX-Export (UE-ready)
# ---------------------------------------------------------------------------
def _lay_flat_matrix(dims, tolerance=0.9):
"""Rotation, die die KUERZESTE Bbox-Achse nach oben dreht (flache Seite unten).
Im Spiel gibt es nur zufaelligen Yaw - die gebackene Ausrichtung ist also
endgueltig. Plattige Steine muessen deshalb schon im FBX liegen.
Gedreht wird nur, wenn die kuerzeste Achse deutlich kuerzer ist als die
aktuelle Hoehe (Toleranz). Sonst wuerde ein annaehernd wuerfeliger Stein
willkuerlich gekippt, obwohl er richtig steht.
"""
dx, dy, dz = dims
shortest = min(dx, dy, dz)
if shortest >= dz * tolerance:
return None # steht schon richtig (oder ist rundlich)
if shortest == dx:
return Matrix.Rotation(math.radians(90.0), 4, 'Y')
return Matrix.Rotation(math.radians(90.0), 4, 'X')
def _bake_transform(ob, world, extra=None):
"""Backt die uebergebene Weltmatrix in die Mesh-Daten und nullt das Objekt.
Noetig, weil der FBX-Export die Objekt-Transformation MITSCHREIBT: das
25er-Set trug seine Szenen-Raster-Positionen (bis 20 m) im Asset und schwebte
im Spiel neben dem Platzierungspunkt.
FALLE: `ob.matrix_world = Identity` loescht die Transformation NICHT, wenn das
Objekt ein Eltern-Objekt hat - Blender rechnet dann eine lokale Gegen-Matrix
aus, damit die Welt-Position stimmt. Bei einer LOD-Gruppe an Position
(13/-7.5/2.2) landete das Mesh dadurch bei MINUS derselben Position. Deshalb
werden matrix_basis UND matrix_parent_inverse direkt genullt.
"""
mat = extra @ world if extra is not None else world
ob.data.transform(mat)
# Bei gespiegelter Skalierung dreht sich die Flaechen-Orientierung um.
if mat.determinant() < 0.0:
ob.data.flip_normals()
ob.matrix_basis = Matrix.Identity(4)
ob.matrix_parent_inverse = Matrix.Identity(4)
def _bbox_of(objs):
"""Gemeinsame Bounding-Box mehrerer Meshes in deren (bereits gebackenen) Koordinaten."""
lo = [float("inf")] * 3
hi = [float("-inf")] * 3
for o in objs:
if o.type != 'MESH':
continue
for v in o.data.vertices:
for i in range(3):
lo[i] = min(lo[i], v.co[i])
hi[i] = max(hi[i], v.co[i])
if lo[0] == float("inf"):
return None, None
return Vector(lo), Vector(hi)
def _make_ucx(context, src_objs, mesh_name, max_verts=32):
"""Konvexe Huelle als `UCX_<Name>` - Unreal nimmt sie als Simple Collision.
Wichtig fuer die Physik-Bruchstuecke: "Complex as Simple" taugt dafuer nicht.
Die Huelle wird auf max_verts eingedampft (planares Aufloesen), weil UE fuer
Simple Collision wenige Ebenen will - eine 150-Vert-Huelle ist keine
Vereinfachung mehr.
"""
# FALLE: nur die PUNKTE einlesen, nicht das ganze Mesh. Mit from_mesh kommen
# die Originalflaechen mit, und convex_hull legt seine Huelle dann obendrauf -
# das Ergebnis hatte gemessen 16-21 offene Kanten und war keine geschlossene
# Huelle mehr.
bm = bmesh.new()
for o in src_objs:
if o.type == 'MESH':
for v in o.data.vertices:
bm.verts.new(v.co)
if not bm.verts:
bm.free()
return None
bm.verts.ensure_lookup_table()
res = bmesh.ops.convex_hull(bm, input=bm.verts[:], use_existing_faces=False)
# Punkte im Inneren und uebrig gebliebene Geometrie gehoeren nicht zur Huelle.
weg = list(set(res.get("geom_interior", []) + res.get("geom_unused", [])))
if weg:
bmesh.ops.delete(bm, geom=weg, context='VERTS')
bm.verts.ensure_lookup_table()
bm.normal_update()
# Alle Quellpunkte merken - die Huelle muss sie danach noch umschliessen.
quelle = [v.co.copy() for v in bm.verts]
# Aufs Budget eindampfen. Jede Vereinfachung schneidet zwangslaeufig Ecken ab,
# die Huelle wird dadurch KLEINER als der Stein - eine Kollision, die kleiner
# ist als das Mesh, laesst den Spieler einsinken. Deshalb wird sie danach
# exakt so weit aufgeblasen, dass wieder alles hineinpasst: fuer Kollision ist
# minimal zu gross unkritisch, zu klein nicht.
angle = math.radians(1.0)
while len(bm.verts) > max_verts and angle < math.radians(60.0):
bmesh.ops.dissolve_limit(
bm, angle_limit=angle, verts=bm.verts[:], edges=bm.edges[:])
bmesh.ops.delete(
bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS')
bm.verts.ensure_lookup_table()
if not bm.faces:
break
angle += math.radians(3.0)
bm.normal_update()
if bm.faces and quelle:
mitte = Vector((0.0, 0.0, 0.0))
for v in bm.verts:
mitte += v.co
mitte /= max(len(bm.verts), 1)
# Kleinster Faktor, mit dem jede Ebene alle Quellpunkte einschliesst.
faktor = 1.0
for f in bm.faces:
n = f.normal
if n.length_squared < 1e-12:
continue
d = n.dot(f.verts[0].co)
basis = d - n.dot(mitte)
if basis <= 1e-9:
continue
for co in quelle:
noetig = (n.dot(co) - n.dot(mitte)) / basis
if noetig > faktor:
faktor = noetig
if faktor > 1.0:
for v in bm.verts:
v.co = mitte + (v.co - mitte) * faktor
bmesh.ops.triangulate(bm, faces=bm.faces[:])
me = bpy.data.meshes.new("UCX_" + mesh_name)
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new("UCX_" + mesh_name, me)
context.scene.collection.objects.link(ob)
return ob
def _prepare_export_copies(context, top, lay_flat=True, want_ucx=True,
ucx_max_verts=32):
"""Baut eine export-fertige Kopie der Hierarchie. Gibt (objekte, aufraeumen).
Auf KOPIEN, damit die Szene des Users unangetastet bleibt - Punkt 1 und 2
veraendern die Geometrie ja dauerhaft.
"""
made = []
renamed = [] # (Original, urspruenglicher Name) zum Zuruecksetzen
# JETZT merken: gleich traegt das Original einen Hilfsnamen, und die
# Kollisionshuelle haette sonst "UCX_<Name>.__src" geheissen.
export_name = _safe_filename(top.name)
def _copy(src):
# FALLE: Blender vergibt Namen eindeutig - eine Kopie von "Rock_A_S_01"
# heisst "Rock_A_S_01.001". Unreal ordnet die Kollision aber ueber den
# EXAKTEN Mesh-Namen zu (UCX_<Name>), und der Asset-Name haengt auch
# daran. Deshalb bekommt das Original fuer die Dauer des Exports einen
# Hilfsnamen und die Kopie den echten.
name = src.name
src.name = name + ".__src"
renamed.append((src, name))
cp = src.copy()
if src.data is not None:
cp.data = src.data.copy()
cp.data.name = name
cp.name = name
context.scene.collection.objects.link(cp)
made.append(cp)
return cp
src_children = [c for c in top.children_recursive if c.type == 'MESH']
top_copy = _copy(top)
child_copies = []
for c in src_children:
cc = _copy(c)
cc.parent = top_copy
cc.matrix_parent_inverse = c.matrix_parent_inverse.copy()
child_copies.append(cc)
# FALLE: matrix_world wird faul berechnet. Direkt nach dem Setzen von parent
# und matrix_parent_inverse liefert es noch alte Werte - bei LOD-Gruppen wurde
# dadurch die Gruppenposition (13/-7.5/2.2) NICHT herausgerechnet und landete
# im Asset. Ein view_layer-Update erzwingt die Neuberechnung.
context.view_layer.update()
meshes = [o for o in ([top_copy] + child_copies) if o.type == 'MESH']
# Weltmatrizen EINSAMMELN, bevor irgendetwas genullt wird - jedes Nullen
# veraendert die Matrizen der Kinder.
welt = {o: o.matrix_world.copy() for o in meshes}
# 2) Hinlegen: EINE Drehung fuer alle LOD-Stufen desselben Steins, sonst
# liegen die Stufen unterschiedlich herum.
extra = None
if lay_flat and meshes:
ref = meshes[0]
co = [welt[ref] @ v.co for v in ref.data.vertices]
if co:
dims = [max(c[i] for c in co) - min(c[i] for c in co) for i in range(3)]
extra = _lay_flat_matrix(dims)
# 1) Transform backen -> alle Objekte stehen auf Identitaet, das Mesh traegt alles.
for o in meshes:
_bake_transform(o, welt[o], extra)
if top_copy.type != 'MESH':
top_copy.matrix_basis = Matrix.Identity(4)
top_copy.matrix_parent_inverse = Matrix.Identity(4)
context.view_layer.update()
# 1) Ursprung auf unten-mittig der gemeinsamen Bbox.
lo, hi = _bbox_of(meshes)
if lo is not None:
shift = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z))
off = Matrix.Translation(shift)
for o in meshes:
o.data.transform(off)
# 3) UCX. Bei einer LOD-Gruppe darf die Huelle NICHT unter das LodGroup-Empty:
# Unreal wuerde jedes Kind als LOD-Stufe lesen. Also als Geschwister.
if want_ucx and meshes:
ucx = _make_ucx(context, [meshes[0]], export_name,
max_verts=ucx_max_verts)
if ucx is not None:
made.append(ucx)
def cleanup():
for o in reversed(made):
data = o.data
bpy.data.objects.remove(o, do_unlink=True)
if data is not None and data.users == 0:
if isinstance(data, bpy.types.Mesh):
bpy.data.meshes.remove(data)
# Erst die Kopien weg, DANN die Namen zurueck - sonst kollidieren sie.
for ob, name in renamed:
ob.name = name
return made, cleanup
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.parent is None
and (o.name.startswith(settings.name_prefix.strip() or "Fels")
or o.name.startswith("Rock_")))
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 ROCKGEN_OT_dense_preset(Operator):
"""Stellt auf smooth und dicht um (~2700 Tris statt ~620)."""
bl_idname = "rockgen.dense_preset"
bl_label = "Smooth & dicht"
bl_description = ("Setzt Unterteilung, Decimate und Shading auf den dichten "
"Smooth-Look (~1500-3000 Tris). Feinere Cavity- und "
"Erz-Aufloesung, kein Low-Poly-Look")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.rock_gen_settings
for k, v in DENSE_PRESET.items():
setattr(s, k, v)
self.report({'INFO'}, "Smooth & dicht: Unterteilung %d, Decimate %.2f/%.2f, "
"Shading %s" % (DENSE_PRESET["subsurf_levels"],
DENSE_PRESET["decimate_ratio_1"],
DENSE_PRESET["decimate_ratio_2"],
DENSE_PRESET["shading_mode"]))
return {'FINISHED'}
class ROCKGEN_OT_ore_set(Operator):
"""Erzeugt das Erzfels-Set: EIN neutrales Set in S und M.
Bewusst kein Set je Erzsorte - die A-Kanal-Maske sagt nur, WO Erz sitzt.
Welches Erz es ist (Kupfer/Eisen/Gold = Tint + Gluehen), entscheidet die
Material-Instanz in UE. S und M, weil die Brocken in Hoehlengaenge passen
muessen.
"""
bl_idname = "rockgen.ore_set"
bl_label = "Erzfels-Set erzeugen (S + M)"
bl_description = ("Erzeugt Rock_Ore_S_NN und Rock_Ore_M_NN mit Erz-Maske im "
"A-Kanal - ein Set fuer alle Erzsorten")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.rock_gen_settings
vorher = {
"name_prefix": s.name_prefix, "name_form": s.name_form,
"name_class": s.name_class, "name_numbered": s.name_numbered,
"bake_ore": s.bake_ore, "bake_vertex_cavity": s.bake_vertex_cavity,
"use_class_size": s.use_class_size, "seed": s.seed,
}
vorher.update({k: getattr(s, k) for k in DENSE_PRESET})
# Erz MUSS dicht: die Maske haengt an der Vertex-Dichte. Bei ~150 Verts
# sind die Nugget-Zentren jeder dritte Vertex und laufen zu Schlieren
# zusammen - gemessen und im Render bestaetigt. Bei ~1350 Verts liest es
# sich als einzelne Einschluesse.
for k, v in DENSE_PRESET.items():
setattr(s, k, v)
s.name_prefix = "Rock"
s.name_form = "Ore"
s.name_numbered = True
s.bake_ore = True
s.bake_vertex_cavity = True # A haengt am selben Attribut
s.use_class_size = True # S und M sollen sich in der Groesse unterscheiden
gesamt = 0
try:
for i, klasse in enumerate(("S", "M")):
s.name_class = klasse
# Anderer Seed je Klasse, sonst ist der M-Brocken nur ein
# aufgeblasener S-Brocken.
s.seed = vorher["seed"] + i * 4177
res = bpy.ops.object.generate_rocks_batch()
if 'FINISHED' not in res:
self.report({'WARNING'}, "Klasse %s abgebrochen." % klasse)
break
gesamt += s.batch_count
finally:
for k, v in vorher.items():
setattr(s, k, v)
self.report({'INFO'}, "Erzfels-Set: %d Brocken (Rock_Ore_S/M_NN), "
"Erz im A-Kanal." % gesamt)
return {'FINISHED'}
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:
# Dateiname VOR dem Kopieren merken: waehrend des Exports traegt das
# Original einen Hilfsnamen (siehe _prepare_export_copies).
filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx")
# Punkt 1-3: Ursprung erzwingen, flache Steine hinlegen, UCX bauen.
# Laeuft auf Kopien, die Szene des Users bleibt unveraendert.
try:
objs, cleanup = _prepare_export_copies(
context, top,
lay_flat=settings.export_lay_flat,
want_ucx=settings.export_ucx,
ucx_max_verts=settings.ucx_max_verts,
)
except Exception as exc: # ein Stein darf den Batch nicht killen
failed += 1
print("[RockGen] Export-Vorbereitung '%s' fehlgeschlagen: %s"
% (top.name, exc))
traceback.print_exc()
continue
_deselect_all(context)
for o in objs:
o.select_set(True)
context.view_layer.objects.active = objs[0]
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
colors_type='SRGB', # Vertex-Cavity mit exportieren
# (in UE: Vertex Color Import = Replace)
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"
% (os.path.basename(filepath), exc))
finally:
# MUSS auch bei Fehlern laufen: sonst bleiben die Kopien in der
# Szene stehen und die Originale behalten ihren Hilfsnamen.
cleanup()
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'}
# ---------------------------------------------------------------------------
# Textur-Bake (nahtlos tileable via 4D-Torus-Projektion, fuer Triplanar in UE)
# ---------------------------------------------------------------------------
def _build_bake_material(base_color=(0.52, 0.47, 0.40), cell_scale=2.5,
seam_width=0.32, plate_flat=0.0):
"""Baut ein prozedurales, nahtlos tileables Stylized-Rock-Material (Zell-Look).
Trick: Die 2D-UV wird als (cos u, sin u, cos v, sin v) auf einen Torus
projiziert und 4D gesampelt -> tilet in U und V ohne Naht. Voronoi liefert
hand-painted Zellen mit dunklen Outlines; base_color/cell_scale steuern Ton
und Zellgroesse. Liefert (mat, sockets) fuer die Bake-Passes.
"""
br, bg, bb = base_color[0], base_color[1], base_color[2]
def _tone(factor):
return (min(br * factor, 1.0), min(bg * factor, 1.0), min(bb * factor, 1.0), 1.0)
mat = bpy.data.materials.new("__RockBakeMat")
mat.use_nodes = True
nt = mat.node_tree
nodes, links = nt.nodes, nt.links
nodes.clear()
out = nodes.new("ShaderNodeOutputMaterial")
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
emit = nodes.new("ShaderNodeEmission")
texco = nodes.new("ShaderNodeTexCoord")
sep = nodes.new("ShaderNodeSeparateXYZ")
links.new(texco.outputs["UV"], sep.inputs[0])
def _circle(coord_socket):
mul = nodes.new("ShaderNodeMath"); mul.operation = 'MULTIPLY'
mul.inputs[1].default_value = TWO_PI
links.new(coord_socket, mul.inputs[0])
cos = nodes.new("ShaderNodeMath"); cos.operation = 'COSINE'
links.new(mul.outputs[0], cos.inputs[0])
sin = nodes.new("ShaderNodeMath"); sin.operation = 'SINE'
links.new(mul.outputs[0], sin.inputs[0])
return cos.outputs[0], sin.outputs[0]
cu, su = _circle(sep.outputs["X"])
cv, sv = _circle(sep.outputs["Y"])
comb = nodes.new("ShaderNodeCombineXYZ")
links.new(cu, comb.inputs[0])
links.new(su, comb.inputs[1])
links.new(cv, comb.inputs[2])
# Domain-Warp: verzerrt die Zellen leicht organisch (tileable, da gleiche
# Torus-Coords -> ueber die Naht hinweg stetig).
warp_noise = nodes.new("ShaderNodeTexNoise")
warp_noise.noise_dimensions = '4D'
warp_noise.inputs["Scale"].default_value = 3.0
warp_noise.inputs["Detail"].default_value = 2.0
links.new(comb.outputs[0], warp_noise.inputs["Vector"])
links.new(sv, warp_noise.inputs["W"])
warp_scale = nodes.new("ShaderNodeVectorMath"); warp_scale.operation = 'SCALE'
links.new(warp_noise.outputs["Color"], warp_scale.inputs[0])
warp_scale.inputs[3].default_value = 0.15
comb_w = nodes.new("ShaderNodeVectorMath"); comb_w.operation = 'ADD'
links.new(comb.outputs[0], comb_w.inputs[0])
links.new(warp_scale.outputs[0], comb_w.inputs[1])
# Zwei Voronoi-Nodes mit identischen Coords: Rand-Distanz + Per-Zell-Farbe.
vor_edge = nodes.new("ShaderNodeTexVoronoi")
vor_edge.voronoi_dimensions = '4D'
vor_edge.feature = 'DISTANCE_TO_EDGE'
vor_edge.inputs["Scale"].default_value = cell_scale
links.new(comb_w.outputs[0], vor_edge.inputs["Vector"])
links.new(sv, vor_edge.inputs["W"])
vor_cell = nodes.new("ShaderNodeTexVoronoi")
vor_cell.voronoi_dimensions = '4D'
vor_cell.feature = 'F1'
vor_cell.inputs["Scale"].default_value = cell_scale
links.new(comb_w.outputs[0], vor_cell.inputs["Vector"])
links.new(sv, vor_cell.inputs["W"])
# cell_fac: 0 an den Zellraendern (dunkle Outlines), 1 in der Zellmitte.
# Breiter Bereich -> jede Zelle bekommt einen weichen Woelbungs-Gradient.
cell_fac = nodes.new("ShaderNodeMapRange")
links.new(vor_edge.outputs["Distance"], cell_fac.inputs["Value"])
cell_fac.inputs["From Min"].default_value = 0.0
# Fugenbreite: klein = schmale, harte Fuge und flache Platte (Genshin-Look),
# gross = breiter Verlauf ueber die ganze Zelle ("puffy", Golfball).
cell_fac.inputs["From Max"].default_value = seam_width
cell_fac.inputs["To Min"].default_value = 0.0
cell_fac.inputs["To Max"].default_value = 1.0
cell_fac.clamp = True
# Per-Zell-Helligkeit (leichte Variation Zelle zu Zelle)
cell_bw = nodes.new("ShaderNodeRGBToBW")
links.new(vor_cell.outputs["Color"], cell_bw.inputs[0])
cell_var = nodes.new("ShaderNodeMapRange")
links.new(cell_bw.outputs[0], cell_var.inputs["Value"])
cell_var.inputs["To Min"].default_value = 0.78
cell_var.inputs["To Max"].default_value = 1.08
cell_var.clamp = True
combined = nodes.new("ShaderNodeMath"); combined.operation = 'MULTIPLY'
links.new(cell_fac.outputs[0], combined.inputs[0])
links.new(cell_var.outputs[0], combined.inputs[1])
# BaseColor: dunkle Outline -> Zell-Ton -> Highlight (hand-painted Look),
# alle Toene aus base_color abgeleitet.
ramp = nodes.new("ShaderNodeValToRGB")
cr = ramp.color_ramp
# plate_flat 0 = klassischer Verlauf, 1 = Plattenflaeche nahezu einfarbig
# mit dunkler Fuge (Referenz Genshin: leere Flaeche, Detail nur in der Fuge).
f = max(0.0, min(1.0, plate_flat))
cr.elements[0].position = 0.0
cr.elements[0].color = _tone(0.12 - 0.05 * f) # Fuge: dunkler bei flach
cr.elements[1].position = 0.85 - 0.6 * f # Plateau frueher erreicht
cr.elements[1].color = _tone(1.22 - 0.24 * f) # weniger Highlight
mid = cr.elements.new(max(0.04, 0.32 - 0.26 * f))
mid.color = _tone(0.72 + 0.22 * f) # Platte heller/gleichmaessiger
links.new(combined.outputs[0], ramp.inputs["Fac"])
# Roughness: an den Rissen etwas rauer
rough = nodes.new("ShaderNodeMapRange")
links.new(cell_fac.outputs[0], rough.inputs["Value"])
rough.inputs["To Min"].default_value = 0.95
rough.inputs["To Max"].default_value = 0.72
rough.clamp = True
# AO: Zellraender abgedunkelt
ao = nodes.new("ShaderNodeMapRange")
links.new(cell_fac.outputs[0], ao.inputs["Value"])
ao.inputs["To Min"].default_value = 0.35
ao.inputs["To Max"].default_value = 1.0
ao.clamp = True
# Bump: Risse werden zu Rillen (cell_fac niedrig am Rand -> vertieft)
bump = nodes.new("ShaderNodeBump")
bump.inputs["Strength"].default_value = 0.7
bump.inputs["Distance"].default_value = 0.4
links.new(cell_fac.outputs[0], bump.inputs["Height"])
# EcoGame-Konvention: AO/Cavity wird NICHT als eigene Textur exportiert,
# sondern direkt ins Albedo multipliziert - dunkle Ritzen und helle Platten
# sind im Farbbild eingebacken. Das traegt den Stylized-Look.
ao_mix = nodes.new("ShaderNodeMixRGB")
ao_mix.blend_type = 'MULTIPLY'
ao_mix.inputs["Fac"].default_value = 1.0
links.new(ramp.outputs["Color"], ao_mix.inputs["Color1"])
ao_rgb = nodes.new("ShaderNodeCombineXYZ")
links.new(ao.outputs[0], ao_rgb.inputs["X"])
links.new(ao.outputs[0], ao_rgb.inputs["Y"])
links.new(ao.outputs[0], ao_rgb.inputs["Z"])
links.new(ao_rgb.outputs[0], ao_mix.inputs["Color2"])
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
links.new(rough.outputs[0], bsdf.inputs["Roughness"])
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
links.new(bsdf.outputs[0], out.inputs["Surface"])
sockets = {
"out": out, "bsdf": bsdf, "emit": emit,
"color": ramp.outputs["Color"],
"color_ao": ao_mix.outputs["Color"], # Albedo mit eingebackenem AO
"ao_mix": ao_mix,
"roughness": rough.outputs[0],
"ao": ao.outputs[0],
}
return mat, sockets
def _bake_one(context, mat, img_node, name, folder, res, bake_type, non_color):
img = bpy.data.images.new(name, res, res, alpha=False, float_buffer=False)
if non_color:
img.colorspace_settings.name = 'Non-Color'
img_node.image = img
mat.node_tree.nodes.active = img_node
if bake_type == 'NORMAL':
bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT', use_clear=True)
else:
bpy.ops.object.bake(type='EMIT', use_clear=True)
path = os.path.join(folder, name + ".png")
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
bpy.data.images.remove(img)
return path
class OBJECT_OT_bake_rock_textures(Operator):
bl_idname = "object.bake_rock_textures"
bl_label = "Textur backen"
bl_description = "Backt ein nahtlos tileables Stylized-Rock-Textur-Set (fuer Triplanar in UE)"
def execute(self, context):
settings = context.scene.rock_gen_settings
folder = bpy.path.abspath(settings.bake_dir) if settings.bake_dir else ""
if not folder or not os.path.isdir(folder):
self.report({'ERROR'}, "Bitte einen gueltigen Bake-Ordner waehlen.")
return {'CANCELLED'}
res = int(settings.bake_resolution)
prefix = settings.bake_prefix.strip() or "T_Rock_Stylized"
scene = context.scene
# Zustand sichern
prev_engine = scene.render.engine
prev_active = context.view_layer.objects.active
prev_selected = [o for o in context.selected_objects]
prev_samples = getattr(scene.cycles, "samples", None) if hasattr(scene, "cycles") else None
_ensure_object_mode(context)
saved = []
plane = None
mat = None
try:
bpy.ops.mesh.primitive_plane_add(size=2.0)
plane = context.active_object
plane.name = "__RockBakePlane"
mat, sk = _build_bake_material(
base_color=tuple(settings.bake_base_color),
cell_scale=settings.bake_cell_scale,
seam_width=settings.bake_seam_width,
plate_flat=settings.bake_plate_flat,
)
# AO-Staerke ins Albedo (Fac 0 = aus, 1 = voll)
sk["ao_mix"].inputs["Fac"].default_value = (
settings.bake_ao_strength if settings.bake_ao_into_albedo else 0.0
)
plane.data.materials.clear()
plane.data.materials.append(mat)
scene.render.engine = 'CYCLES'
if hasattr(scene, "cycles"):
scene.cycles.samples = 1 # Emit/Normal sind deterministisch
scene.render.bake.margin = max(2, res // 128)
nodes = mat.node_tree.nodes
links = mat.node_tree.links
img_node = nodes.new("ShaderNodeTexImage")
_deselect_all(context)
plane.select_set(True)
context.view_layer.objects.active = plane
if settings.bake_normal:
links.new(sk["bsdf"].outputs[0], sk["out"].inputs["Surface"])
saved.append(_bake_one(context, mat, img_node, prefix + "_normal",
folder, res, 'NORMAL', non_color=True))
def _emit_pass(source_socket, suffix, non_color):
links.new(source_socket, sk["emit"].inputs["Color"])
links.new(sk["emit"].outputs[0], sk["out"].inputs["Surface"])
saved.append(_bake_one(context, mat, img_node, prefix + suffix,
folder, res, 'EMIT', non_color))
if settings.bake_color:
# EcoGame braucht nur _albedo und _normal. Das AO steckt bereits
# im Farbbild, sofern "AO ins Albedo einrechnen" aktiv ist.
src = sk["color_ao"] if settings.bake_ao_into_albedo else sk["color"]
_emit_pass(src, "_albedo", non_color=False)
if settings.bake_roughness:
_emit_pass(sk["roughness"], "_roughness", non_color=True)
if settings.bake_ao:
_emit_pass(sk["ao"], "_ao", non_color=True)
except Exception as exc: # noqa: BLE001
print("[RockGen] Bake fehlgeschlagen: %s" % exc)
traceback.print_exc()
self.report({'ERROR'}, "Bake fehlgeschlagen: %s (Details in der Konsole)" % exc)
finally:
if plane is not None:
bpy.data.objects.remove(plane, do_unlink=True)
if mat is not None and mat.users == 0:
bpy.data.materials.remove(mat)
scene.render.engine = prev_engine
if prev_samples is not None:
scene.cycles.samples = prev_samples
_deselect_all(context)
for o in prev_selected:
try:
o.select_set(True)
except ReferenceError:
pass
context.view_layer.objects.active = prev_active
if saved:
self.report({'INFO'}, "%d Textur(en) gebacken nach %s" % (len(saved), folder))
return {'FINISHED'}
return {'CANCELLED'}
# ---------------------------------------------------------------------------
# Operatoren: Presets
# ---------------------------------------------------------------------------
def _mesh_shells(me):
"""Zusammenhaengende Teile (Shells) als Listen von Polygon-Indizes.
Bei Assets, die aus mehreren Objekten mit Strg+J verbunden wurden (z.B.
Pilz = Hut + Stiel), bleiben die Teile geometrisch getrennt. Damit laesst
sich die Teil-Maske EXAKT bilden statt sie ueber Normalen zu schaetzen.
"""
adj = {}
for poly in me.polygons:
for ek in poly.edge_keys:
adj.setdefault(ek, []).append(poly.index)
seen = set()
shells = []
for poly in me.polygons:
if poly.index in seen:
continue
stack = [poly.index]
comp = set()
while stack:
pi = stack.pop()
if pi in comp:
continue
comp.add(pi)
for ek in me.polygons[pi].edge_keys:
for nb in adj.get(ek, ()):
if nb not in comp:
stack.append(nb)
seen |= comp
shells.append(comp)
return shells
def _shell_part_mask(me):
"""G-Wert je Polygon: 1 fuer den OBEREN/breiteren Teil (z.B. Hut), 0 sonst.
Bei nur einer Shell gibt es nichts zu trennen -> alles 0, damit die Maske
nicht faelschlich etwas markiert.
"""
shells = _mesh_shells(me)
if len(shells) < 2:
return {p.index: 0.0 for p in me.polygons}, len(shells)
infos = []
for sh in shells:
vs = {vi for pi in sh for vi in me.polygons[pi].vertices}
zs = [me.vertices[i].co.z for i in vs]
xs = [me.vertices[i].co.x for i in vs]
ys = [me.vertices[i].co.y for i in vs]
infos.append((sh, max(zs), max(max(xs) - min(xs), max(ys) - min(ys))))
# Oberster Teil, bei Gleichstand der breitere -> das ist der Hut/Deckel
top = max(infos, key=lambda t: (t[1], t[2]))
out = {}
for sh, _, _ in infos:
val = 1.0 if sh is top[0] else 0.0
for pi in sh:
out[pi] = val
return out, len(shells)
class ROCKGEN_OT_bake_cavity_selection(Operator):
bl_idname = "rockgen.bake_cavity_selection"
bl_label = "Vertex-Cavity auf Auswahl"
bl_description = ("Backt das Layout R=Cavity / G=Teil-Maske / B=Hoehe auf ALLE "
"ausgewaehlten Meshes - auch auf handgebaute Assets")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.rock_gen_settings
targets = [o for o in context.selected_objects if o.type == 'MESH']
if not targets:
self.report({'ERROR'}, "Kein Mesh ausgewaehlt.")
return {'CANCELLED'}
_ensure_object_mode(context)
done, notes = 0, []
for ob in targets:
try:
_bake_vertex_cavity(context, ob, s)
if s.part_mask_from_shells:
mask, n = _shell_part_mask(ob.data)
layer = ob.data.color_attributes.get("Cavity")
if layer is not None:
for poly in ob.data.polygons:
g = mask.get(poly.index, 0.0)
for li in poly.loop_indices:
c = layer.data[li].color
layer.data[li].color = (c[0], g, c[2], 1.0)
notes.append("%s: %d Teil(e)" % (ob.name, n))
done += 1
except Exception as exc: # noqa: BLE001
self.report({'WARNING'}, "%s: %s" % (ob.name, exc))
msg = "Cavity auf %d Mesh(es) gebacken." % done
if notes:
msg += " " + ", ".join(notes[:4])
self.report({'INFO'}, msg)
return {'FINISHED'}
class ROCKGEN_OT_randomize(Operator):
bl_idname = "rockgen.randomize"
bl_label = "Zufaellig"
bl_description = ("Wuerfelt Seed und Form-Parameter neu. 'Streuung' bestimmt, wie "
"weit von den aktuellen Werten abgewichen wird")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.rock_gen_settings
rng = random.Random()
a = s.random_amount
def jitter(prop, lo, hi):
"""Wert um +-a in Richtung der Grenzen ziehen, dann clampen.
Int-Properties brauchen einen int - ein float wirft sonst
TypeError und der ganze Operator bricht ab.
"""
cur = getattr(s, prop)
span = (hi - lo) * a
val = max(lo, min(hi, cur + rng.uniform(-span, span)))
setattr(s, prop, int(round(val)) if isinstance(cur, int) else val)
s.seed = rng.randint(0, 9999)
jitter("noise_scale", 0.4, 3.0)
jitter("displace_strength", 0.12, 0.7)
jitter("decimate_ratio_1", 0.12, 0.45)
jitter("decimate_ratio_2", 0.3, 0.7)
jitter("dissolve_angle", 2.0, 10.0)
jitter("subsurf_levels", 3, 5)
if s.randomize_shape:
jitter("noise_scale_max", 0.6, 4.0)
jitter("displace_strength_max", 0.2, 0.9)
if s.random_shape:
s.base_shape = rng.choice(['CUBE', 'BOX', 'ICOSPHERE'])
s.texture_type = rng.choice(['VORONOI', 'CLOUDS', 'DISTORTED_NOISE'])
self.report({'INFO'}, "Neue Zufallswerte (Seed %d)." % s.seed)
return {'FINISHED'}
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):
"""Haupt-Panel: nur was man bei JEDEM Stein anfasst.
Alles andere sitzt in eingeklappten Unter-Panels (bl_parent_id). Vorher war
das eine Kette aus 15 dauerhaft offenen Boxen - man musste scrollen, um an
den Erzeugen-Knopf zu kommen.
"""
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
s = context.scene.rock_gen_settings
row = layout.row(align=True)
row.prop(s, "active_preset", text="")
row.operator("rockgen.preset_load", text="", icon='IMPORT')
row.operator("rockgen.preset_delete", text="", icon='TRASH')
row = layout.row(align=True)
row.prop(s, "new_preset_name", text="")
row.operator("rockgen.preset_save", text="", icon='ADD')
layout.separator()
col = layout.column(align=True)
col.scale_y = 1.4
col.operator("object.generate_rocks_batch",
text="%d Stein(e) erzeugen" % s.batch_count,
icon='MESH_ICOSPHERE')
layout.label(text=_asset_name(s, 0, s.batch_count), icon='INFO')
row = layout.row(align=True)
row.prop(s, "random_amount", text="Zufall")
row.prop(s, "random_shape", text="", icon='MOD_NOISE')
row.operator("rockgen.randomize", text="", icon='FILE_REFRESH')
class _RockSub(Panel):
"""Basis aller Unter-Panels - spart neunmal dieselben vier Zeilen."""
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Rock Gen"
bl_parent_id = "VIEW3D_PT_rock_generator"
bl_options = {'DEFAULT_CLOSED'}
class VIEW3D_PT_rock_naming(_RockSub):
bl_label = "1 - Benennung & Groesse"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
row = layout.row(align=True)
row.prop(s, "name_prefix", text="")
row.prop(s, "name_form", text="")
row.prop(s, "name_class", text="")
layout.prop(s, "name_numbered")
layout.separator()
layout.prop(s, "use_class_size")
sub = layout.row()
sub.active = not s.use_class_size
sub.prop(s, "base_size")
if s.use_class_size:
layout.label(text="Klasse %s: x%.2f"
% (s.name_class, CLASS_SIZES.get(s.name_class, 1.0)),
icon='FIXED_SIZE')
class VIEW3D_PT_rock_shape(_RockSub):
bl_label = "2 - Form"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
layout.prop(s, "base_shape")
if s.base_shape == 'BOX':
layout.prop(s, "box_proportions")
layout.prop(s, "box_variation")
layout.prop(s, "subsurf_levels")
layout.operator("rockgen.dense_preset", icon='MOD_SMOOTH')
layout.separator()
layout.prop(s, "texture_type")
col = layout.column(align=True)
col.prop(s, "noise_scale")
col.prop(s, "displace_strength")
layout.prop(s, "seed")
layout.separator()
layout.prop(s, "randomize_shape")
sub = layout.column(align=True)
sub.active = s.randomize_shape
sub.prop(s, "noise_scale_max")
sub.prop(s, "displace_strength_max")
class VIEW3D_PT_rock_detail(_RockSub):
bl_label = "3 - Detail & Cleanup"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
col = layout.column(align=True)
col.prop(s, "decimate_ratio_1")
col.prop(s, "decimate_ratio_2")
layout.prop(s, "dissolve_angle")
layout.separator()
layout.prop(s, "add_bevel")
sub = layout.column(align=True)
sub.active = s.add_bevel
sub.prop(s, "bevel_width")
sub.prop(s, "bevel_segments")
layout.prop(s, "add_weighted_normal")
class VIEW3D_PT_rock_shading(_RockSub):
bl_label = "4 - Shading & Ausrichtung"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
layout.prop(s, "shading_mode")
row = layout.row()
row.active = s.shading_mode == 'AUTO'
row.prop(s, "autosmooth_angle")
layout.separator()
layout.prop(s, "flatten_bottom")
row = layout.row()
row.active = s.flatten_bottom
row.prop(s, "flatten_ratio")
layout.prop(s, "set_origin_bottom")
class VIEW3D_PT_rock_vcol(_RockSub):
bl_label = "5 - Vertex-Farben"
def draw_header(self, context):
# Schalter im Panel-Kopf: man sieht auch eingeklappt, ob gebacken wird.
self.layout.prop(context.scene.rock_gen_settings,
"bake_vertex_cavity", text="")
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
col = layout.column()
col.active = s.bake_vertex_cavity
sub = col.column(align=True)
sub.prop(s, "cavity_strength")
sub.prop(s, "cavity_blur")
col.separator()
sub = col.column(align=True)
sub.prop(s, "moss_amount")
sub.prop(s, "moss_noise")
sub.prop(s, "moss_seed")
col.prop(s, "part_mask_from_shells")
col.separator()
col.prop(s, "bake_ore")
sub = col.column(align=True)
sub.active = s.bake_ore
sub.prop(s, "ore_amount")
sub.prop(s, "ore_nugget_size")
sub.prop(s, "ore_seed")
sub.separator()
sub.operator("rockgen.ore_set", icon='MOD_EXPLODE')
col.label(text="R=Cavity G=%s B=Hoehe A=%s"
% ("Teil-Maske" if s.part_mask_from_shells else "Moos",
"Erz" if s.bake_ore else "1.0"),
icon='INFO')
# Nicht in der ausgegrauten Spalte: der Knopf arbeitet auf der Auswahl
# und ist auch dann sinnvoll, wenn beim Erzeugen nicht gebacken wird.
layout.separator()
layout.operator("rockgen.bake_cavity_selection", icon='BRUSH_DATA')
class VIEW3D_PT_rock_uv(_RockSub):
bl_label = "6 - UV, LOD & Material"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
layout.prop(s, "generate_uv0")
layout.prop(s, "generate_lightmap_uv")
layout.separator()
layout.prop(s, "generate_lods")
sub = layout.column(align=True)
sub.active = s.generate_lods
sub.prop(s, "lod_count")
sub.prop(s, "lod_ratio_step")
layout.separator()
layout.prop(s, "assign_material")
class VIEW3D_PT_rock_batch(_RockSub):
bl_label = "7 - Batch & Transform"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
col = layout.column(align=True)
col.prop(s, "batch_count")
col.prop(s, "spacing")
layout.separator()
layout.prop(s, "use_modal")
row = layout.row()
row.active = s.use_modal
row.prop(s, "modal_threshold")
layout.separator()
layout.prop(s, "randomize_transform")
sub = layout.column(align=True)
sub.active = s.randomize_transform
row = sub.row(align=True)
row.prop(s, "scale_min")
row.prop(s, "scale_max")
sub.prop(s, "nonuniform_scale")
sub.prop(s, "keep_size")
class VIEW3D_PT_rock_export(_RockSub):
bl_label = "8 - FBX-Export (Unreal)"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
layout.prop(s, "export_dir")
layout.prop(s, "export_selected_only")
layout.separator()
layout.prop(s, "export_lay_flat")
layout.prop(s, "export_ucx")
row = layout.row()
row.active = s.export_ucx
row.prop(s, "ucx_max_verts")
layout.label(text="Ursprung unten-mittig: immer", icon='CHECKMARK')
layout.separator()
layout.operator("object.export_rocks_fbx", icon='EXPORT')
class VIEW3D_PT_rock_bake(_RockSub):
bl_label = "9 - Textur-Bake (tileable)"
def draw(self, context):
layout = self.layout
s = context.scene.rock_gen_settings
layout.prop(s, "bake_dir")
row = layout.row(align=True)
row.prop(s, "bake_prefix", text="")
row.prop(s, "bake_resolution", text="")
layout.separator()
col = layout.column(align=True)
col.prop(s, "bake_base_color")
col.prop(s, "bake_cell_scale")
col.prop(s, "bake_seam_width")
col.prop(s, "bake_plate_flat")
layout.separator()
row = layout.row(align=True)
row.prop(s, "bake_color", toggle=True)
row.prop(s, "bake_normal", toggle=True)
row = layout.row(align=True)
row.prop(s, "bake_roughness", toggle=True)
row.prop(s, "bake_ao", toggle=True)
layout.prop(s, "bake_ao_into_albedo")
row = layout.row()
row.active = s.bake_ao_into_albedo
row.prop(s, "bake_ao_strength")
layout.separator()
layout.operator("object.bake_rock_textures", icon='TEXTURE')
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
classes = (
RockGenSettings,
OBJECT_OT_generate_rocks,
OBJECT_OT_export_rocks_fbx,
OBJECT_OT_bake_rock_textures,
ROCKGEN_OT_bake_cavity_selection,
ROCKGEN_OT_dense_preset,
ROCKGEN_OT_ore_set,
ROCKGEN_OT_randomize,
ROCKGEN_OT_preset_save,
ROCKGEN_OT_preset_load,
ROCKGEN_OT_preset_delete,
# Das Eltern-Panel MUSS vor seinen Unter-Panels stehen, sonst findet Blender
# die bl_parent_id nicht. Die Reihenfolge hier ist die Reihenfolge im N-Panel.
VIEW3D_PT_rock_generator,
VIEW3D_PT_rock_naming,
VIEW3D_PT_rock_shape,
VIEW3D_PT_rock_detail,
VIEW3D_PT_rock_shading,
VIEW3D_PT_rock_vcol,
VIEW3D_PT_rock_uv,
VIEW3D_PT_rock_batch,
VIEW3D_PT_rock_export,
VIEW3D_PT_rock_bake,
)
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()