Rock Generator 2.6.0: Groessen-Klassen wirken jetzt + Randomize-Bug gefixt

1) KLASSEN-GROESSE (vom User gemeldet): S und M kamen gleich gross raus.
   Ursache war eine Luecke im Design, nicht in der Bedienung: "keep_size"
   normiert die Zufalls-Streuung auf konstantes Volumen - damit wurde alles,
   was ueber scale_min/max eingestellt war, wieder herausgerechnet. Fuer die
   Groesse der KLASSE gab es schlicht keinen Regler.
   Neu: CLASS_SIZES (S=0.45, M=1.0, L=1.9, XL=3.2) plus "Groesse aus Klasse"
   und eine freie "Basis-Groesse". Die Klassengroesse ist bewusst gesetzt und
   von der Streuung getrennt; sie greift auch bei abgeschalteter Randomisierung.
   Gemessen: S 0.85 m | M 1.9 m | L 3.5 m | XL 6.0 m, Volumen-Streuung
   INNERHALB jeder Klasse bleibt bei 1.11x (nur Form).

2) BUG im Zufalls-Button (kam mit 2.5.0): jitter() schrieb einen float in die
   int-Property subsurf_levels -> TypeError, der Operator brach komplett ab.
   jitter() rundet jetzt fuer Int-Properties. Von tests/test_randomize.py
   gefangen.

Neuer Test tests/test_rock_classes.py deckt die Klassengroessen ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 02:42:17 +02:00
co-authored by Claude Opus 4.8
parent 833e929f82
commit d58bfe05e8
7 changed files with 93 additions and 14 deletions
+7 -2
View File
@@ -18,8 +18,12 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung:
**Benennung**
- Felsen: `Fels_<Form>_<Klasse>` — Form = Buchstabe (A, B, …), Klasse = `S/M/L/XL`.
Die Klasse meint eine eigene **Silhouette**, nicht blosse Groesse: rein
groesser/kleiner macht die Engine ueber ScaleMin/Max.
Die Klasse setzt eine **Basis-Groesse** (S=0.45, M=1.0, L=1.9, XL=3.2) und ist
damit ein bewusst anderes Asset — gemessen: S 0.85 m, M 1.9 m, L 3.5 m, XL 6.0 m.
Davon strikt getrennt ist die **Zufalls-Streuung** (`scale_min/max`): die variiert
nur die FORM und wird von „Gesamtgroesse konstant halten" auf konstantes Volumen
normiert (1.11× statt 1.92×), damit sie sich nicht mit den ScaleMin/Max der Engine
multipliziert.
- Baeume: `<Art>_<Variante>_S<Stufe>`, z. B. `Birke_01_S1``Birke_01_S4`.
Zusatz-Ebenen haengen hinten an: `_Leaf`, `_Frucht`.
- Texturen: `<name>_albedo` und `<name>_normal` — mehr nicht.
@@ -197,6 +201,7 @@ Weitere Suiten (jeweils `blender --background --python <datei>`):
| `tests/test_ecogame_tree.py` | Baum-Benennung `<Art>_<Variante>_S<Stufe>`, Ursprung, Lightmap-Default |
| `tests/test_randomize.py` | Zufalls-Funktion beider Addons, `_Leaf`/`_Frucht` mit gleichem Ursprung |
| `tests/test_leaves.py` | Blattwerk: Streuung, Ursprung, Tri-Budget, leeres `_Leaf` ohne Card |
| `tests/test_rock_classes.py` | Groessen-Klassen S/M/L/XL unterscheiden sich, Streuung bleibt formbezogen |
Die Suiten erwarten die gebauten Zips in `dist/` bzw. lesen die `.py` direkt.
+1 -1
View File
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "stylized_rock_generator"
version = "2.5.0"
version = "2.6.0"
name = "Stylized Rock Generator"
tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export"
maintainer = "D4rkst3r"
+4 -4
View File
@@ -7,7 +7,7 @@
"id": "stylized_rock_generator",
"name": "Stylized Rock Generator",
"tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export",
"version": "2.5.0",
"version": "2.6.0",
"type": "add-on",
"maintainer": "D4rkst3r",
"license": [
@@ -23,9 +23,9 @@
"Mesh",
"Modeling"
],
"archive_url": "./stylized_rock_generator-2.5.0.zip",
"archive_size": 16991,
"archive_hash": "sha256:f89c740398afb7550ccc109980be6d9ea9d785ccc9383e70aa8c7d7cb89006bd"
"archive_url": "./stylized_rock_generator-2.6.0.zip",
"archive_size": 17621,
"archive_hash": "sha256:f20343bd1aae3c03585e7621816b8934719e8855d7d4c411a0973df3d21ac397"
},
{
"schema_version": "1.0.0",
Binary file not shown.
Binary file not shown.
+50 -7
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Rock Generator",
"author": "D4rkst3r",
"version": (2, 5, 0),
"version": (2, 6, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Rock Gen",
"description": (
@@ -40,6 +40,14 @@ MATERIAL_PALETTE = (
("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.
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", "subsurf_levels", "texture_type", "noise_scale",
@@ -52,6 +60,7 @@ PRESET_KEYS = (
"shading_mode", "autosmooth_angle",
"randomize_shape", "noise_scale_max", "displace_strength_max", "nonuniform_scale",
"flatten_bottom", "flatten_ratio", "set_origin_bottom", "keep_size",
"use_class_size", "base_size",
)
# Eingebaute Presets (nicht ueberschreibbar/loeschbar).
@@ -407,7 +416,18 @@ def _apply_hardsurface(context, obj, settings):
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))
@@ -418,10 +438,10 @@ def _apply_random_transform(context, obj, settings, rng):
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, sy, sz)
obj.scale = (sx * base, sy * base, sz * base)
else:
s = 1.0 if settings.keep_size else rng.uniform(lo, hi)
obj.scale = (s, s, s)
obj.scale = (s * base, s * base, s * base)
obj.rotation_euler = (
rng.uniform(0.0, TWO_PI),
rng.uniform(0.0, TWO_PI),
@@ -781,6 +801,17 @@ class RockGenSettings(PropertyGroup):
"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)",
@@ -1316,11 +1347,15 @@ class ROCKGEN_OT_randomize(Operator):
a = s.random_amount
def jitter(prop, lo, hi):
"""Wert um +-a in Richtung der Grenzen ziehen, dann clampen."""
"""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 = cur + rng.uniform(-span, span)
setattr(s, prop, max(lo, min(hi, val)))
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)
@@ -1329,7 +1364,6 @@ class ROCKGEN_OT_randomize(Operator):
jitter("decimate_ratio_2", 0.3, 0.7)
jitter("dissolve_angle", 2.0, 10.0)
jitter("subsurf_levels", 3, 5)
s.subsurf_levels = int(round(s.subsurf_levels))
if s.randomize_shape:
jitter("noise_scale_max", 0.6, 4.0)
jitter("displace_strength_max", 0.2, 0.9)
@@ -1444,6 +1478,15 @@ class VIEW3D_PT_rock_generator(Panel):
row.prop(settings, "name_form", text="")
row.prop(settings, "name_class", text="")
box.prop(settings, "name_numbered")
row = box.row(align=True)
row.prop(settings, "use_class_size")
sub = row.row()
sub.enabled = not settings.use_class_size
sub.prop(settings, "base_size", text="")
if settings.use_class_size:
box.label(text="Basis-Groesse aus Klasse %s: x%.2f"
% (settings.name_class, CLASS_SIZES.get(settings.name_class, 1.0)),
icon='FIXED_SIZE')
box.label(text="Ergebnis: %s" % _asset_name(settings, 0, settings.batch_count),
icon='INFO')
+31
View File
@@ -0,0 +1,31 @@
import bpy, sys, importlib.util
spec=importlib.util.spec_from_file_location("srg", r"A:\eco\stylized_rock_generator\stylized_rock_generator.py")
r=importlib.util.module_from_spec(spec); sys.modules["srg"]=r; spec.loader.exec_module(r)
try: r.unregister()
except Exception: pass
r.register()
s=bpy.context.scene.rock_gen_settings
s.batch_count=5; s.subsurf_levels=3; s.use_modal=False; s.spacing=3
s.scale_min=0.75; s.scale_max=1.25; s.keep_size=True
fails=[]; means={}
print("Klasse | Groesse min..max (m) | Volumen min..max | Streuung")
for kl in ('S','M','L','XL'):
for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True)
s.name_class=kl
bpy.ops.object.generate_rocks_batch()
vols=[]; dims=[]
for o in bpy.context.scene.objects:
if o.type!='MESH': continue
bb=o.bound_box
d=[(max(v[i] for v in bb)-min(v[i] for v in bb))*o.scale[i] for i in range(3)]
dims.append(max(d)); vols.append(d[0]*d[1]*d[2])
means[kl]=sum(vols)/len(vols)
print(" %-3s | %.2f .. %.2f | %.2f .. %.2f | %.2fx"
% (kl, min(dims), max(dims), min(vols), max(vols), max(vols)/min(vols)))
if max(vols)/min(vols) > 1.6: fails.append("streuung_%s"%kl)
print("Volumen-Verhaeltnis M/S = %.1fx | L/M = %.1fx | XL/L = %.1fx"
% (means['M']/means['S'], means['L']/means['M'], means['XL']/means['L']))
if not (means['S'] < means['M'] < means['L'] < means['XL']): fails.append("reihenfolge")
if means['M']/means['S'] < 3: fails.append("S_zu_gross")
r.unregister()
print("ERGEBNIS:", "OK" if not fails else "FEHLER: "+", ".join(fails))