diff --git a/README.md b/README.md index 7dde1c5..488c4d7 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: | **R** | Cavity pur — 1 = offene Flaeche, 0 = tiefe Fuge (**ohne** Hoehen-Verlauf) | | **G** | Moos-/Schmutzmaske — 1 = hier waechst was (Oberseite + Fugennaehe + Noise) | | **B** | Hoehen-Verlauf — 0 = Unterseite, 1 = oben | - | **A** | 1 (Reserve) | + | **A** | Erz-Maske — 1 = Erz, 0 = kein Erz. Ohne Erz-Bake bleibt der Kanal auf 1.0 | Getrennt statt eingebacken: der erdige **Boden-Saum** am Stein-Fuss und die **Moosfarbe** werden pro Material-Instanz geregelt, nicht im Mesh festgelegt. @@ -118,6 +118,39 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: --- +## Erzfels-Set (A-Kanal) + +**EIN neutrales Set fuer ALLE Erzsorten.** Die Maske im A-Kanal sagt nur, *wo* +Erz sitzt. *Welches* Erz es ist (Kupfer/Eisen/Gold = Tint + Gluehen), entscheidet +die Material-Instanz in UE. Knopf: **Erzfels-Set erzeugen (S + M)** erzeugt +`Rock_Ore_S_NN` und `Rock_Ore_M_NN` und stellt danach alle Einstellungen zurueck. + +Regler: `Erz-Anteil`, `Nugget-Groesse` (Radius in m), `Erz-Seed`. + +Nuggets statt Rauschen: gestreute Zentren mit weichem Abfall. Ein Noise-Feld +gaebe wolkige Schlieren ueber den ganzen Stein, Erz sitzt aber in abgegrenzten +Einschluessen. Die Zentren bevorzugen Fugen — Adern folgen Rissen, und das +Gluehen liest sich in einer Vertiefung besser als auf einer flachen Platte. + +> **Die Maskenaufloesung haengt an der Vertex-Dichte.** Bei den bisherigen +> ~150-Vert-Steinen war jedes dritte Vertex ein Nugget-Zentrum: die Nuggets +> liefen zu grossen Schlieren zusammen. Deshalb erzeugt das Erz-Set **dicht** +> (siehe unten) — dort liest es sich als einzelne Einschluesse. Die Nuggetzahl +> ist zusaetzlich auf ein Zentrum je ~10 Verts gedeckelt. + +Gemessen (dicht, ~2700 Tris): Erz-Anteil 0.08 → 4 %, 0.18 → 9 %, 0.35 → 15 % +der Oberflaeche. A faellt auf 0.00 und erreicht 1.00, und die Maske ueberlebt +den FBX-Roundtrip. + +## Smooth & dicht + +Knopf im Form-Panel: setzt Unterteilung 5, Decimate 0.40/0.80 und Auto-Smooth. +Gemessen ueber drei Seeds: **2708/2658/2716 Tris** (Schnitt 2694) — im +Zielfenster 1500–3000. Kein Low-Poly-Look, und alle Vertex-Masken (Cavity, Moos, +Erz) werden feiner. + +--- + ## Export-Haertung (Rock Gen) Drei Dinge passieren beim FBX-Export jetzt automatisch. Alles laeuft auf @@ -451,6 +484,7 @@ gefiltert `.\run_tests.ps1 -Filter panels`. | Datei | prueft | |---|---| | `tests/test_proportionen.py` | Baum-Proportionen: Palmenkrone, Strauch-Breite, Setzling-Schlankheit, Branch Count 0 | +| `tests/test_erz.py` | Erz-Maske: A=1.0 ohne Erz, Anteil monoton, Set-Namen/Groessen, Einstellungen zurueckgesetzt, FBX-Roundtrip, Tri-Fenster | | `tests/test_export_robust.py` | Export am **reimportierten** FBX: Ursprung erzwungen, flach = liegend, UCX geschlossen/umschliessend/im Budget, LOD+Vertex-Farben | | `tests/test_panels.py` | Alle vier Addons: registrieren, jedes Panel zeichnet, Unter-Panels im richtigen Tab, **jede Property irgendwo erreichbar** | | `tests/test_tree_addon.py` | Tree-Addon: Presets, Wachstums-Stufen, UVs, Modifier-Apply | diff --git a/blender_manifest.toml b/blender_manifest.toml index a811693..12a6276 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.14.0" +version = "2.15.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/dist/index.html b/dist/index.html index bd963b4..474edf9 100644 --- a/dist/index.html +++ b/dist/index.html @@ -40,14 +40,14 @@ 4.9KB - stylized_rock_generator-2.14.0 + stylized_rock_generator-2.15.0 Stylized Rock Generator Batch-Generator fuer stylized Rocks mit UE5-Export link 4.2.0 - ~ all all - 26.1KB + 28.9KB stylized_tree_generator-1.25.0 @@ -61,6 +61,6 @@ -

Built 2026-08-03, 06:02

+

Built 2026-08-03, 06:13

diff --git a/dist/index.json b/dist/index.json index adc6c38..d2d3988 100644 --- a/dist/index.json +++ b/dist/index.json @@ -57,7 +57,7 @@ "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.14.0", + "version": "2.15.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -73,9 +73,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.14.0.zip", - "archive_size": 26683, - "archive_hash": "sha256:f60aa4d5c8d73cabfccbccf19020ea30d31e77ad22dca5f8a7151e46c7ec1c42" + "archive_url": "./stylized_rock_generator-2.15.0.zip", + "archive_size": 29580, + "archive_hash": "sha256:f57604a8176506626352d61e79a5a449f8d1aedced3aeed3a288a1abe5efcd51" }, { "schema_version": "1.0.0", diff --git a/dist/stylized_rock_generator-2.14.0.zip b/dist/stylized_rock_generator-2.14.0.zip deleted file mode 100644 index 6af1f84..0000000 Binary files a/dist/stylized_rock_generator-2.14.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.15.0.zip b/dist/stylized_rock_generator-2.15.0.zip new file mode 100644 index 0000000..a1106a8 Binary files /dev/null and b/dist/stylized_rock_generator-2.15.0.zip differ diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index b2f62df..d3b818b 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, 14, 0), + "version": (2, 15, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -48,6 +48,16 @@ MATERIAL_PALETTE = ( # 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. @@ -117,9 +127,13 @@ def _apply_modifier(context, obj, mod_name): def _asset_name(settings, index, batch_count): """EcoGame-Schema: Fels_
_, 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", - (settings.name_form.strip() or "A").upper(), - settings.name_class) + form, settings.name_class) if settings.name_numbered and batch_count > 1: return "%s_%02d" % (base, index + 1) return base @@ -480,6 +494,85 @@ def _apply_random_transform(context, obj, settings, rng): 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". @@ -536,6 +629,17 @@ def _bake_vertex_cavity(context, obj, settings): 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: @@ -555,7 +659,12 @@ def _bake_vertex_cavity(context, obj, settings): n = 1.0 - mnoise + mnoise * vnoise[vi] g = max(0.0, min(1.0, base * n * moss * 1.6)) - data[li].color = (r, g, b, 1.0) + # 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): @@ -877,6 +986,27 @@ class RockGenSettings(PropertyGroup): 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. " @@ -1435,6 +1565,84 @@ def _safe_filename(name): 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" @@ -2098,6 +2306,7 @@ class VIEW3D_PT_rock_shape(_RockSub): 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) @@ -2171,8 +2380,18 @@ class VIEW3D_PT_rock_vcol(_RockSub): sub.prop(s, "moss_noise") sub.prop(s, "moss_seed") col.prop(s, "part_mask_from_shells") - col.label(text="R=Cavity G=%s B=Hoehe" - % ("Teil-Maske" if s.part_mask_from_shells else "Moos"), + 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. @@ -2283,6 +2502,8 @@ classes = ( 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, diff --git a/tests/test_erz.py b/tests/test_erz.py new file mode 100644 index 0000000..11132a6 --- /dev/null +++ b/tests/test_erz.py @@ -0,0 +1,166 @@ +"""Erzfels-Set (Wunschliste Punkt 5) + Smooth/dicht (Punkt 6). + +Kernaussagen, die hier festgenagelt werden: +* EIN neutrales Set - die A-Maske sagt nur WO Erz sitzt, nicht welches. +* Ohne Erz bleibt A auf 1.0 (Layout v2, wie das bestehende 25er-Set). +* Die Maske ueberlebt den FBX-Roundtrip. +* Die Aufloesung der Maske haengt an der Vertex-Dichte - deshalb erzeugt das + Erz-Set dicht. Bei ~150 Verts waren die Nugget-Zentren jeder dritte Vertex + und liefen zu Schlieren zusammen statt einzelne Einschluesse zu bleiben. + +Aufruf: blender --background --factory-startup --python tests/test_erz.py +""" +import os +import sys +import tempfile +import importlib.util + +import bpy + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +spec = importlib.util.spec_from_file_location( + "rg", os.path.join(ROOT, "stylized_rock_generator.py")) +rg = importlib.util.module_from_spec(spec) +sys.modules["rg"] = rg +spec.loader.exec_module(rg) +rg.register() + +fails = [] +for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + +s = bpy.context.scene.rock_gen_settings +s.use_modal = False +s.assign_material = False +s.generate_lods = False +s.bake_vertex_cavity = True + + +def einer(**kw): + for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + s.batch_count = 1 + for k, v in kw.items(): + setattr(s, k, v) + bpy.ops.object.generate_rocks_batch() + ob = [o for o in bpy.context.scene.objects if o.type == 'MESH'][0] + a = [c.color[3] for c in ob.data.color_attributes["Cavity"].data] + tris = sum(len(p.vertices) - 2 for p in ob.data.polygons) + return ob, a, tris + + +# --- 1) Ohne Erz bleibt A auf 1.0 ---------------------------------------- +_, a, _ = einer(bake_ore=False, subsurf_levels=4) +if min(a) < 0.999: + fails.append("Ohne Erz ist A nicht 1.0 (min %.3f) - das bricht das " + "eingefrorene Layout v2 und das bestehende 25er-Set" % min(a)) + +# --- 2) Mit Erz: Maske vorhanden, Anteil steigt monoton ------------------- +print("%-24s %7s %7s %9s %7s" % ("Variante", "A min", "A max", "A>0.5", "Tris")) +anteile = [] +for amt in (0.08, 0.18, 0.35): + ob, a, tris = einer(bake_ore=True, ore_amount=amt, ore_nugget_size=0.10, + subsurf_levels=5, decimate_ratio_1=0.40, + decimate_ratio_2=0.80) + anteil = sum(1 for x in a if x > 0.5) / len(a) + anteile.append(anteil) + print("%-24s %7.2f %7.2f %8.0f%% %7d" + % ("Erz-Anteil %.2f" % amt, min(a), max(a), 100 * anteil, tris)) + if max(a) < 0.9: + fails.append("Erz-Anteil %.2f: A erreicht nur %.2f - keine Nuggets" + % (amt, max(a))) + if min(a) > 0.01: + fails.append("Erz-Anteil %.2f: A faellt nie auf 0 - der ganze Stein " + "waere Erz" % amt) +if not (anteile[0] < anteile[1] < anteile[2]): + fails.append("Erz-Anteil wirkt nicht monoton: %s" + % ", ".join("%.0f%%" % (100 * x) for x in anteile)) + +# --- 3) Smooth & dicht landet im Tri-Fenster 1500-3000 ------------------- +bpy.ops.rockgen.dense_preset() +tris_liste = [] +for seed in (1, 14, 27): + _, _, tris = einer(seed=seed, bake_ore=False) + tris_liste.append(tris) +schnitt = sum(tris_liste) // len(tris_liste) +print("") +print("Smooth & dicht: %s Tris (Schnitt %d)" + % ("/".join(str(t) for t in tris_liste), schnitt)) +if not (1500 <= schnitt <= 3000): + fails.append("Smooth & dicht liefert %d Tris, Zielfenster 1500-3000" % schnitt) +if s.shading_mode != 'AUTO': + fails.append("Smooth & dicht setzt kein Auto-Smooth (shading_mode=%s)" + % s.shading_mode) + +# --- 4) Das Set: Namen, Groessen, Einstellungen wieder wie vorher --------- +for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) +s.batch_count = 2 +s.subsurf_levels = 4 # absichtlich duenn: das Set muss selbst dicht stellen +s.name_prefix = "Fels" +s.name_form = "A" +s.name_class = 'M' +s.bake_ore = False +merker = (s.name_prefix, s.name_form, s.name_class, s.bake_ore, s.subsurf_levels) + +OUT = tempfile.mkdtemp() +s.export_dir = OUT +bpy.ops.rockgen.ore_set() + +namen = sorted(o.name for o in bpy.context.scene.objects if o.type == 'MESH') +erwartet = ["Rock_Ore_M_01", "Rock_Ore_M_02", "Rock_Ore_S_01", "Rock_Ore_S_02"] +print("") +print("Set: %s" % ", ".join(namen)) +if namen != erwartet: + fails.append("Set-Namen %s, erwartet %s" % (namen, erwartet)) + +hoehen = {} +for ob in bpy.context.scene.objects: + if ob.type != 'MESH': + continue + co = [ob.matrix_world @ v.co for v in ob.data.vertices] + hoehen[ob.name] = max(c.z for c in co) - min(c.z for c in co) + if len(ob.data.vertices) < 800: + fails.append("%s hat nur %d Verts - zu grob fuer eine Nugget-Maske" + % (ob.name, len(ob.data.vertices))) + a = [c.color[3] for c in ob.data.color_attributes["Cavity"].data] + if max(a) < 0.9: + fails.append("%s traegt keine Erz-Maske (A max %.2f)" % (ob.name, max(a))) + +s_hoch = max(v for k, v in hoehen.items() if "_S_" in k) +m_hoch = min(v for k, v in hoehen.items() if "_M_" in k) +print("Groessen: S bis %.2f m, M ab %.2f m" % (s_hoch, m_hoch)) +if s_hoch >= m_hoch: + fails.append("S (%.2f m) ist nicht kleiner als M (%.2f m) - die Brocken " + "muessen in Hoehlengaenge passen" % (s_hoch, m_hoch)) + +if (s.name_prefix, s.name_form, s.name_class, s.bake_ore, s.subsurf_levels) != merker: + fails.append("Das Set hat die Einstellungen nicht zurueckgesetzt: %s" + % str((s.name_prefix, s.name_form, s.name_class, s.bake_ore, + s.subsurf_levels))) + +# --- 5) A ueberlebt den FBX-Roundtrip ------------------------------------ +bpy.ops.object.export_rocks_fbx() +dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx")) +if not dateien: + fails.append("Erz-Set: kein FBX exportiert") +else: + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, dateien[0])) + ob = [o for o in bpy.context.scene.objects + if o.type == 'MESH' and not o.name.startswith("UCX_")][0] + a = [c.color[3] for c in ob.data.color_attributes[0].data] + anteil = sum(1 for x in a if x > 0.5) / len(a) + print("Nach FBX (%s): A %.2f..%.2f, Erz auf %.0f%%" + % (dateien[0], min(a), max(a), 100 * anteil)) + if max(a) < 0.9 or min(a) > 0.01: + fails.append("Erz-Maske ueberlebt den FBX-Roundtrip nicht " + "(A %.2f..%.2f)" % (min(a), max(a))) + +print("") +if fails: + print("ERGEBNIS: %d FEHLER" % len(fails)) + for x in fails: + print(" - " + x) + sys.exit(1) +print("ERGEBNIS: ALLE CHECKS OK")