diff --git a/README.md b/README.md index 0d7d09a..f9e20c9 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,17 @@ Referenz: blender --background --python tests/test_rock_gen.py ``` +Weitere Suiten (jeweils `blender --background --python `): + +| Datei | prueft | +|---|---| +| `tests/test_tree_addon.py` | Tree-Addon: Presets, Wachstums-Stufen, UVs, Modifier-Apply | +| `tests/test_ecogame_rock.py` | Fels-Benennung, Bake liefert nur `_albedo`/`_normal`, AO im Albedo | +| `tests/test_ecogame_tree.py` | Baum-Benennung `__S`, Ursprung, Lightmap-Default | +| `tests/test_randomize.py` | Zufalls-Funktion beider Addons, `_Leaf`/`_Frucht` mit gleichem Ursprung | + +Die Suiten erwarten die gebauten Zips in `dist/` bzw. lesen die `.py` direkt. + Erzeugt 3 Rocks, prueft je Rock: keine losen Vertices, manifold, Normalen nach aussen (signed volume > 0), UV0 + UV1 vorhanden, und gibt eine Zusammenfassung (Vertex-/Face-Count) aus. Exit-Code 0 = alles bestanden, 1 = Fehler. diff --git a/blender_manifest.toml b/blender_manifest.toml index d78c793..806bc1a 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.4.0" +version = "2.5.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 6e64508..8dbd292 100644 --- a/blender_manifest_tree.toml +++ b/blender_manifest_tree.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_tree_generator" -version = "1.18.0" +version = "1.19.0" name = "Stylized Tree Generator" tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" maintainer = "D4rkst3r" diff --git a/dist/index.json b/dist/index.json index 52c751c..4466481 100644 --- a/dist/index.json +++ b/dist/index.json @@ -7,7 +7,7 @@ "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.4.0", + "version": "2.5.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -23,16 +23,16 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.4.0.zip", - "archive_size": 16036, - "archive_hash": "sha256:e7163f6f0c53731f897b8a1ba63db1cdab94aa182dd720ca1a5653a2ca3df9ec" + "archive_url": "./stylized_rock_generator-2.5.0.zip", + "archive_size": 16991, + "archive_hash": "sha256:f89c740398afb7550ccc109980be6d9ea9d785ccc9383e70aa8c7d7cb89006bd" }, { "schema_version": "1.0.0", "id": "stylized_tree_generator", "name": "Stylized Tree Generator", "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", - "version": "1.18.0", + "version": "1.19.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -48,9 +48,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.18.0.zip", - "archive_size": 16777, - "archive_hash": "sha256:9ea5b7a9c01f3c1b382436d25539ab9ec41ffb8b051d26c17c82783c594680a0" + "archive_url": "./stylized_tree_generator-1.19.0.zip", + "archive_size": 17827, + "archive_hash": "sha256:37f6a0be381178144b36a731b68f8d307c18c714e0d9b2cd9f1c109e4b4de8e6" } ] } \ No newline at end of file diff --git a/dist/stylized_rock_generator-2.4.0.zip b/dist/stylized_rock_generator-2.4.0.zip deleted file mode 100644 index 264d821..0000000 Binary files a/dist/stylized_rock_generator-2.4.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.5.0.zip b/dist/stylized_rock_generator-2.5.0.zip new file mode 100644 index 0000000..a8d5240 Binary files /dev/null and b/dist/stylized_rock_generator-2.5.0.zip differ diff --git a/dist/stylized_tree_generator-1.18.0.zip b/dist/stylized_tree_generator-1.18.0.zip deleted file mode 100644 index f8b8bd1..0000000 Binary files a/dist/stylized_tree_generator-1.18.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.19.0.zip b/dist/stylized_tree_generator-1.19.0.zip new file mode 100644 index 0000000..112c62c Binary files /dev/null and b/dist/stylized_tree_generator-1.19.0.zip differ diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index a72060e..86adb1f 100644 --- a/stylized_rock_generator.py +++ b/stylized_rock_generator.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", - "version": (2, 4, 0), + "version": (2, 5, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -51,7 +51,7 @@ PRESET_KEYS = ( "batch_count", "spacing", "shading_mode", "autosmooth_angle", "randomize_shape", "noise_scale_max", "displace_strength_max", "nonuniform_scale", - "flatten_bottom", "flatten_ratio", "set_origin_bottom", + "flatten_bottom", "flatten_ratio", "set_origin_bottom", "keep_size", ) # Eingebaute Presets (nicht ueberschreibbar/loeschbar). @@ -410,9 +410,17 @@ def _apply_random_transform(context, obj, settings, rng): return lo, hi = sorted((settings.scale_min, settings.scale_max)) if settings.nonuniform_scale: - obj.scale = (rng.uniform(lo, hi), rng.uniform(lo, hi), rng.uniform(lo, hi)) + 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, sy, sz) else: - s = rng.uniform(lo, hi) + s = 1.0 if settings.keep_size else rng.uniform(lo, hi) obj.scale = (s, s, s) obj.rotation_euler = ( rng.uniform(0.0, TWO_PI), @@ -727,9 +735,16 @@ class RockGenSettings(PropertyGroup): scale_min: FloatProperty(name="Scale Min", default=0.8, min=0.1, max=5.0) scale_max: FloatProperty(name="Scale Max", default=1.2, min=0.1, max=5.0) nonuniform_scale: BoolProperty( - name="Nicht-uniforme Skalierung", default=False, + 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) @@ -819,6 +834,17 @@ class RockGenSettings(PropertyGroup): description="Exportiert nur die aktuell selektierten Rocks statt aller", ) + # --- 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="") @@ -1277,6 +1303,43 @@ class OBJECT_OT_bake_rock_textures(Operator): # Operatoren: Presets # --------------------------------------------------------------------------- +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.""" + cur = getattr(s, prop) + span = (hi - lo) * a + val = cur + rng.uniform(-span, span) + setattr(s, prop, max(lo, min(hi, 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) + 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) + if s.random_shape: + s.base_shape = rng.choice(['CUBE', '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" @@ -1452,6 +1515,7 @@ class VIEW3D_PT_rock_generator(Panel): row.prop(settings, "scale_min") row.prop(settings, "scale_max") box.prop(settings, "nonuniform_scale") + box.prop(settings, "keep_size") box = layout.box() box.label(text="Batch") @@ -1462,6 +1526,13 @@ class VIEW3D_PT_rock_generator(Panel): sub.enabled = settings.use_modal sub.prop(settings, "modal_threshold") + box = layout.box() + box.label(text="Zufall", icon='FILE_REFRESH') + row = box.row(align=True) + row.prop(settings, "random_amount") + row.prop(settings, "random_shape", text="", icon='MOD_NOISE') + box.operator("rockgen.randomize", icon='FILE_REFRESH') + layout.separator() layout.operator("object.generate_rocks_batch", icon='MESH_ICOSPHERE') @@ -1500,6 +1571,7 @@ classes = ( OBJECT_OT_generate_rocks, OBJECT_OT_export_rocks_fbx, OBJECT_OT_bake_rock_textures, + ROCKGEN_OT_randomize, ROCKGEN_OT_preset_save, ROCKGEN_OT_preset_load, ROCKGEN_OT_preset_delete, diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index 9353768..43a9176 100644 --- a/stylized_tree_generator.py +++ b/stylized_tree_generator.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Tree Generator", "author": "D4rkst3r", - "version": (1, 18, 0), + "version": (1, 19, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", @@ -992,6 +992,21 @@ class TreeGenSettings(PropertyGroup): description="Laufende Variante derselben Art (Birke_01, Birke_02 ...)", ) spacing: FloatProperty(name="Abstand", default=4.0, min=0.0, max=20.0) + random_amount: FloatProperty( + name="Streuung", default=0.3, min=0.0, max=1.0, + description=("Wie stark 'Zufaellig' die Werte veraendert. 0 = nur neuer Seed, " + "1 = volle Bandbreite. Der Preset-Charakter bleibt erhalten"), + ) + make_leaf: BoolProperty( + name="_Leaf-Ebene anlegen", default=False, + description=("Legt pro Baum ein leeres Objekt _Leaf mit GLEICHEM Ursprung " + "an. Dort modellierst du die Krone - in UE sitzen beide Ebenen " + "dadurch deckungsgleich"), + ) + make_frucht: BoolProperty( + name="_Frucht-Ebene anlegen", default=False, + description="Zusaetzlich _Frucht (gleicher Ursprung) fuer die Frucht-Ebene", + ) apply_modifier: BoolProperty( name="Modifier anwenden", default=False, description="Ergebnis als normales Mesh einfrieren (fuer den Export)", @@ -1038,6 +1053,45 @@ def _add_lightmap_uv(context, ob, op): ob.data.uv_layers[0].active_render = True +def _add_layer(context, ob, suffix): + """Leeres Mesh-Objekt mit IDENTISCHEM Ursprung neben dem Baum anlegen. + + Gleicher Ursprung ist der Punkt: in UE muessen Stamm, Krone und Fruechte + deckungsgleich sitzen, sonst verrutscht die Krone gegenueber dem Stamm. + """ + name = ob.name + suffix + old = bpy.data.objects.get(name) + if old: + bpy.data.objects.remove(old, do_unlink=True) + me = bpy.data.meshes.new(name) + new = bpy.data.objects.new(name, me) + context.collection.objects.link(new) + new.location = ob.location.copy() + new.rotation_euler = ob.rotation_euler.copy() + new.scale = ob.scale.copy() + return new + + +class TREEGEN_OT_randomize(Operator): + bl_idname = "object.treegen_randomize" + bl_label = "Zufaellig" + bl_description = ("Wuerfelt Seed und Form-Parameter neu, ohne den Preset-Charakter " + "zu verlieren. 'Streuung' bestimmt die Abweichung") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.tree_gen_settings + import random as _r + rng = _r.Random() + s.seed = rng.randint(0, 9999) + # Die Form-Parameter liegen am Modifier, nicht an den Settings. Deshalb + # merken wir uns die Streuung und wenden sie beim Erzeugen an. + s["_rand_jitter"] = s.random_amount + self.report({'INFO'}, "Neuer Seed %d, Streuung %.0f%%." + % (s.seed, s.random_amount * 100)) + return {'FINISHED'} + + class TREEGEN_OT_create(Operator): bl_idname = "object.treegen_create" bl_label = "Baum erzeugen" @@ -1075,6 +1129,30 @@ class TREEGEN_OT_create(Operator): self.report({'ERROR'}, "Erzeugen fehlgeschlagen: %s" % exc) return {'CANCELLED'} + # Streuung auf die Form-Regler anwenden (Preset-Charakter bleibt) + amt = float(s.get("_rand_jitter", 0.0)) + if amt > 0.0: + import random as _r + rng = _r.Random(s.seed) + ids = socket_ids(ng) + JITTER = {"Height": 0.35, "Trunk Radius": 0.25, "Bend": 0.5, + "Branch Count": 0.3, "Branch Length": 0.35, + "Branch Up": 0.3, "Branch Bend": 0.4, + "Crown Taper": 0.3, "Crown Bulge": 0.3, + "Root Flare": 0.4, "Root Lobes": 0.3} + for ob in created: + md = ob.modifiers.get("GN_Tree") + if md is None: + continue + for key, rel in JITTER.items(): + sid = ids.get(key) + if sid is None: + continue + cur = md[sid] + f = 1.0 + rng.uniform(-rel, rel) * amt + md[sid] = type(cur)(round(cur * f)) if isinstance(cur, int) else cur * f + context.view_layer.update() + if s.apply_modifier: for ob in created: context.view_layer.objects.active = ob @@ -1087,6 +1165,15 @@ class TREEGEN_OT_create(Operator): if s.generate_lightmap_uv: _add_lightmap_uv(context, ob, self) + if s.make_leaf or s.make_frucht: + extra = [] + for ob in created: + if s.make_leaf: + extra.append(_add_layer(context, ob, "_Leaf")) + if s.make_frucht: + extra.append(_add_layer(context, ob, "_Frucht")) + created.extend(extra) + context.view_layer.update() total = 0 dg = context.evaluated_depsgraph_get() @@ -1129,6 +1216,16 @@ class VIEW3D_PT_tree_generator(Panel): sub.prop(s, "stages") sub.prop(s, "spacing") + box = layout.box() + box.label(text="Zufall", icon='FILE_REFRESH') + box.prop(s, "random_amount") + box.operator("object.treegen_randomize", icon='FILE_REFRESH') + + box = layout.box() + box.label(text="Zusatz-Ebenen") + box.prop(s, "make_leaf") + box.prop(s, "make_frucht") + box = layout.box() box.label(text="Export") box.prop(s, "apply_modifier") @@ -1143,6 +1240,7 @@ class VIEW3D_PT_tree_generator(Panel): classes = ( TreeGenSettings, + TREEGEN_OT_randomize, TREEGEN_OT_create, VIEW3D_PT_tree_generator, ) diff --git a/tests/test_ecogame_rock.py b/tests/test_ecogame_rock.py new file mode 100644 index 0000000..e22ae5c --- /dev/null +++ b/tests/test_ecogame_rock.py @@ -0,0 +1,51 @@ +import bpy, sys, importlib.util, os, glob, tempfile, numpy as np +ADDON=r"A:\eco\stylized_rock_generator\stylized_rock_generator.py" +spec=importlib.util.spec_from_file_location("srg",ADDON) +m=importlib.util.module_from_spec(spec); sys.modules["srg"]=m; spec.loader.exec_module(m) +try: m.unregister() +except Exception: pass +m.register() +fails=[] +s=bpy.context.scene.rock_gen_settings +for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) + +# --- Namensschema --- +s.batch_count=3; s.subsurf_levels=3; s.use_modal=False; s.generate_lods=False +s.name_prefix="Fels"; s.name_form="a"; s.name_class='L' +bpy.ops.object.generate_rocks_batch() +names=sorted(o.name for o in bpy.context.scene.objects if o.type=='MESH') +print("NAMEN:", names) +if not all(n.startswith("Fels_A_L") for n in names): fails.append("naming") + +# Einzeln (ohne Nummer) +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) +s.batch_count=1 +bpy.ops.object.generate_rocks_batch() +single=[o.name for o in bpy.context.scene.objects if o.type=='MESH'] +print("EINZELN:", single) +if single and single[0]!="Fels_A_L": fails.append("naming_single") +print("LIGHTMAP-DEFAULT aus:", not m.RockGenSettings.bl_rna.properties['generate_lightmap_uv'].default) + +# --- Bake: nur _albedo + _normal, AO drin --- +out=tempfile.mkdtemp() +s.bake_dir=out; s.bake_prefix="fels_a"; s.bake_resolution='512' +s.bake_color=True; s.bake_normal=True; s.bake_roughness=False; s.bake_ao=False +def run(ao_on, strength=1.0): + for f in glob.glob(os.path.join(out,"*.png")): os.remove(f) + s.bake_ao_into_albedo=ao_on; s.bake_ao_strength=strength + bpy.ops.object.bake_rock_textures() + files=sorted(os.path.basename(f) for f in glob.glob(os.path.join(out,"*.png"))) + img=bpy.data.images.load(os.path.join(out,"fels_a_albedo.png")) + px=np.array(img.pixels[:],dtype=np.float32).reshape(-1,4)[:,:3] + bpy.data.images.remove(img) + return files, float(px.mean()), float(px.std()) +f_on, mean_on, std_on = run(True) +f_off, mean_off, std_off = run(False) +print("DATEIEN:", f_on) +print("AO an : mean=%.4f std=%.4f" % (mean_on, std_on)) +print("AO aus: mean=%.4f std=%.4f" % (mean_off, std_off)) +if f_on != ["fels_a_albedo.png","fels_a_normal.png"]: fails.append("dateinamen") +if not (mean_on < mean_off): fails.append("ao_nicht_eingerechnet") +m.unregister() +print("ERGEBNIS:", "ALLE CHECKS OK" if not fails else "FEHLER: "+", ".join(fails)) +sys.exit(1 if fails else 0) diff --git a/tests/test_ecogame_tree.py b/tests/test_ecogame_tree.py new file mode 100644 index 0000000..5811890 --- /dev/null +++ b/tests/test_ecogame_tree.py @@ -0,0 +1,39 @@ +import bpy, sys, importlib.util +ADDON=r"A:\eco\stylized_rock_generator\stylized_tree_generator.py" +spec=importlib.util.spec_from_file_location("stg",ADDON) +m=importlib.util.module_from_spec(spec); sys.modules["stg"]=m; spec.loader.exec_module(m) +try: m.unregister() +except Exception: pass +m.register() +fails=[] +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) +s=bpy.context.scene.tree_gen_settings + +print("LIGHTMAP-Default aus:", not m.TreeGenSettings.bl_rna.properties['generate_lightmap_uv'].default) +if m.TreeGenSettings.bl_rna.properties['generate_lightmap_uv'].default: fails.append("lightmap_default") + +# Wachstums-Stufen mit Art-Name +s.preset='birke'; s.art_name="Birke"; s.variant=1 +s.use_growth=True; s.stages=4; s.apply_modifier=False +bpy.ops.object.treegen_create() +names=sorted(o.name for o in bpy.context.scene.objects if o.type=='MESH') +print("STUFEN:", names) +if names != ["Birke_01_S1","Birke_01_S2","Birke_01_S3","Birke_01_S4"]: fails.append("stufen_namen") + +# Einzelbaum, Art aus Preset abgeleitet +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) +s.use_growth=False; s.art_name=""; s.preset='eiche'; s.variant=2 +bpy.ops.object.treegen_create() +single=[o.name for o in bpy.context.scene.objects if o.type=='MESH'] +print("EINZELN:", single) +if single != ["Eiche_02"]: fails.append("einzel_name") + +# Ursprung unten-mittig + echte Meter +ob=bpy.context.scene.objects[single[0]] +ev=ob.evaluated_get(bpy.context.evaluated_depsgraph_get()) +zs=[v.co.z for v in ev.data.vertices] +print("URSPRUNG z=%.3f min_z=%.3f Hoehe=%.2f m" % (ob.location.z, min(zs), max(zs)-min(zs))) +if abs(min(zs)) > 0.02: fails.append("ursprung") +m.unregister() +print("ERGEBNIS:", "ALLE CHECKS OK" if not fails else "FEHLER: "+", ".join(fails)) +sys.exit(1 if fails else 0) diff --git a/tests/test_randomize.py b/tests/test_randomize.py new file mode 100644 index 0000000..d64d96c --- /dev/null +++ b/tests/test_randomize.py @@ -0,0 +1,64 @@ +import bpy, sys, importlib.util +fails=[] + +# ---------- ROCK ---------- +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 +before=(s.seed, round(s.noise_scale,3), round(s.displace_strength,3), round(s.decimate_ratio_1,3)) +s.random_amount=0.5 +bpy.ops.rockgen.randomize() +after=(s.seed, round(s.noise_scale,3), round(s.displace_strength,3), round(s.decimate_ratio_1,3)) +print("ROCK vorher:", before) +print("ROCK nachher:", after) +if before==after: fails.append("rock_randomize_ohne_wirkung") +# Grenzen eingehalten? +if not (0.01<=s.noise_scale<=10 and 0<=s.displace_strength<=2 and 0.01<=s.decimate_ratio_1<=1): + fails.append("rock_grenzen") +# Erzeugt der Rock danach noch gueltige Geometrie? +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) +s.batch_count=1; s.use_modal=False; s.subsurf_levels=3 +res=bpy.ops.object.generate_rocks_batch() +meshes=[o for o in bpy.context.scene.objects if o.type=='MESH'] +print("ROCK nach Randomize erzeugt:", [o.name for o in meshes], "faces=", len(meshes[0].data.polygons) if meshes else 0) +if not meshes or len(meshes[0].data.polygons)==0: fails.append("rock_leer") +r.unregister() + +# ---------- TREE ---------- +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) +spec2=importlib.util.spec_from_file_location("stg", r"A:\eco\stylized_rock_generator\stylized_tree_generator.py") +t=importlib.util.module_from_spec(spec2); sys.modules["stg"]=t; spec2.loader.exec_module(t) +try: t.unregister() +except Exception: pass +t.register() +ts=bpy.context.scene.tree_gen_settings +ts.preset='birke'; ts.art_name="Birke"; ts.variant=1; ts.use_growth=False +ts.make_leaf=True; ts.make_frucht=True; ts.random_amount=0.5 +ts.seed=0 +bpy.ops.object.treegen_randomize() +print("TREE Seed nach Randomize:", ts.seed) +if ts.seed==0: fails.append("tree_seed") +bpy.ops.object.treegen_create() +names=sorted(o.name for o in bpy.context.scene.objects) +print("TREE Objekte:", names) +if not {"Birke_01","Birke_01_Leaf","Birke_01_Frucht"} <= set(names): fails.append("zusatz_ebenen") +# Ursprung identisch? +base=bpy.context.scene.objects["Birke_01"]; leaf=bpy.context.scene.objects["Birke_01_Leaf"] +d=(base.location-leaf.location).length +print("URSPRUNG-Abstand Stamm<->Leaf: %.6f" % d) +if d>1e-6: fails.append("ursprung_leaf") +# Zwei Randomize-Durchlaeufe -> unterschiedliche Geometrie? +def height(): + ev=bpy.context.scene.objects["Birke_01"].evaluated_get(bpy.context.evaluated_depsgraph_get()) + zs=[v.co.z for v in ev.data.vertices]; return round(max(zs)-min(zs),3) +h1=height() +bpy.ops.object.treegen_randomize(); bpy.ops.object.treegen_create() +h2=height() +print("TREE Hoehe Lauf1=%.3f Lauf2=%.3f" % (h1,h2)) +if abs(h1-h2)<1e-4: fails.append("tree_keine_variation") +t.unregister() +print("ERGEBNIS:", "ALLE CHECKS OK" if not fails else "FEHLER: "+", ".join(fails)) +sys.exit(1 if fails else 0) diff --git a/tests/test_tree_addon.py b/tests/test_tree_addon.py new file mode 100644 index 0000000..8d2885f --- /dev/null +++ b/tests/test_tree_addon.py @@ -0,0 +1,80 @@ +"""Prueft das gebaute Tree-Addon: registrieren, Operator ausfuehren, Ergebnis messen.""" +import bpy, sys, zipfile, os, tempfile, importlib.util, traceback + +ZIP = r"A:\eco\stylized_rock_generator\dist\stylized_tree_generator-1.19.0.zip" +fails = [] + +tmp = tempfile.mkdtemp() +with zipfile.ZipFile(ZIP) as z: + z.extractall(tmp) +init = None +for root, _, files in os.walk(tmp): + if "__init__.py" in files: + init = os.path.join(root, "__init__.py"); break +if not init: + print("FAIL: __init__.py nicht im Zip"); sys.exit(1) +print("Zip-Inhalt ok:", os.path.relpath(init, tmp)) + +spec = importlib.util.spec_from_file_location("stylized_tree_generator", init) +m = importlib.util.module_from_spec(spec) +sys.modules["stylized_tree_generator"] = m +try: + spec.loader.exec_module(m) + m.register() + print("registriert ok") +except Exception: + traceback.print_exc(); print("FAIL: register"); sys.exit(1) + +s = bpy.context.scene.tree_gen_settings + +# 1) einzelner Baum je Preset +for p in ("baum", "palme", "busch", "kaktus"): + for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + s.preset = p; s.use_growth = False; s.seed = 3 + r = bpy.ops.object.treegen_create() + obs = [o for o in bpy.data.objects if o.type == 'MESH'] + bpy.context.view_layer.update() + dg = bpy.context.evaluated_depsgraph_get() + v = len(obs[0].evaluated_get(dg).data.vertices) if obs else 0 + ok = ('FINISHED' in r) and v > 0 + print("preset %-7s -> %s verts=%d" % (p, "ok" if ok else "FAIL", v)) + if not ok: fails.append(p) + +# 2) Wachstums-Stufen +for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) +s.preset = "baum"; s.use_growth = True; s.stages = 4 +r = bpy.ops.object.treegen_create() +obs = sorted([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: o.name) +bpy.context.view_layer.update() +dg = bpy.context.evaluated_depsgraph_get() +counts = [len(o.evaluated_get(dg).data.vertices) for o in obs] +growing = all(counts[i] < counts[i+1] for i in range(len(counts)-1)) +print("stufen: %d Objekte verts=%s aufsteigend=%s" % (len(obs), counts, growing)) +if len(obs) != 4 or not growing: fails.append("stages") + +# 3) Modifier anwenden (Export-Pfad) +for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) +s.use_growth = False; s.apply_modifier = True +s.generate_lightmap_uv = True # Default ist AUS (EcoGame: dynamisches Licht) +bpy.ops.object.treegen_create() +obs = [o for o in bpy.data.objects if o.type == 'MESH'] +real = len(obs[0].data.vertices) if obs else 0 # echte Mesh-Daten, nicht evaluated +uvs = [l.name for l in obs[0].data.uv_layers] if obs else [] +print("apply_modifier -> echte Verts=%d mods=%d uvs=%s" % (real, len(obs[0].modifiers) if obs else -1, uvs)) +if real == 0: fails.append("apply") +if "UVMap" not in uvs: fails.append("uv0") +if "Lightmap" not in uvs: fails.append("uv1") +# UV muss world-space sein: V-Bereich ~ Baumhoehe +if obs and obs[0].data.uv_layers.get("UVMap"): + d = obs[0].data.uv_layers["UVMap"].data + vs = [x.uv[1] for x in d] + h = max(v.co.z for v in obs[0].data.vertices) + print("UV world-space check: V-max=%.2f Baumhoehe=%.2f" % (max(vs), h)) + if max(vs) < h * 0.5: fails.append("uv_scale") + +m.unregister() +print("ERGEBNIS:", "ALLE TESTS BESTANDEN" if not fails else ("FEHLER: " + ", ".join(fails))) +sys.exit(1 if fails else 0)