From c7154ca6904088ce9e46c5f675cfa6a6d50fbb3b Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 31 Jul 2026 10:46:01 +0200 Subject: [PATCH] Neu: Stylized Grass Generator 1.0.0 (drittes Addon im Repo) Gras-, Blumen- und Farn-Bueschel. Kern ist die WIND-UV nach Vorgabe des Users: UV0.V = 0 an der Wurzel, 1 an der Spitze - genau die Maske, die UEs "SimpleGrassWind" als WindWeight braucht. Am Zielmaterial geprueft (M_Grass.uasset): es nutzt SimpleGrassWind + WorldPositionOffset + TextureCoordinate, aber KEINE VertexColor. Die Windmaske kommt deshalb primaer ueber die UV - die Assets laufen damit ohne Materialaenderung. Vertex-Farben zusaetzlich, nicht als einzige Quelle: R = Kopie der Windmaske (Fallback) G = Variation je HALM - Ebene unter dem Biom-Tint, den die Engine bereits ueber PerInstanceCustomData 0/1 pro Instanz setzt; im Material multiplizieren B = AO (unten dunkel) Presets gras / gras_hoch / steppengras / farn / blume, Zufalls-Button, Tri-Budget-Warnung (400 laut ASSETS.md). Gemessen: UV.V Wurzel 0.000 -> Spitze 1.000, 112 Tris bei 14 Halmen, G hat exakt 14 verschiedene Werte bei 14 Halmen (also wirklich pro Halm, nicht pro Vertex), min_z = 0.0000, UV und VCol ueberleben den FBX-Roundtrip. build.ps1 baut jetzt drei Extensions - dieselbe URL liefert alle. Co-Authored-By: Claude Opus 4.8 --- README.md | 24 ++ blender_manifest_grass.toml | 22 ++ build.ps1 | 3 +- dist/index.json | 25 ++ dist/stylized_grass_generator-1.0.0.zip | Bin 0 -> 4752 bytes stylized_grass_generator.py | 347 ++++++++++++++++++++++++ tests/test_grass.py | 66 +++++ 7 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 blender_manifest_grass.toml create mode 100644 dist/stylized_grass_generator-1.0.0.zip create mode 100644 stylized_grass_generator.py create mode 100644 tests/test_grass.py diff --git a/README.md b/README.md index e374c52..0bda2ec 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ > |---|---|---| > | **Stylized Rock Generator** | *Rock Gen* | Batch-Rocks + Textur-Bake + FBX/LOD-Export | > | **Stylized Tree Generator** | *Tree Gen* | Baeume/Palmen/Bueschen/Kakteen (Geometry Nodes) mit Wachstums-Stufen | +> | **Stylized Grass Generator** | *Grass Gen* | Gras/Blumen/Farne mit Wind-UV fuer `SimpleGrassWind` | > > Ein Blender-Extension-Repo kann mehrere Extensions ausliefern — `server-generate` > listet jedes Zip in `dist/`. Ein „Check for Updates" aktualisiert also beide. @@ -79,6 +80,29 @@ Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: --- +## Stylized Grass Generator + +Gras-, Blumen- und Farn-Bueschel. Der Kern ist die **Wind-UV**: + +- **UV0.V = 0 an der Wurzel, 1 an der Spitze** — genau die Maske, die UEs + `SimpleGrassWind` als *WindWeight* braucht. Ohne sie rutscht der Halm beim + Wehen vom Boden weg oder bewegt sich gar nicht. + Am Zielmaterial `M_Grass` geprueft: es liest `TextureCoordinate`, aber **keine** + `VertexColor` — deshalb liefert der Generator die Maske PRIMAER ueber die UV. + Die Assets funktionieren damit **ohne Materialaenderung**. +- **Vertex-Farben zusaetzlich** (nicht als einzige Quelle): + `R` = Kopie der Windmaske · `G` = Variation je HALM · `B` = AO (unten dunkel). + `G` ist die Ebene **unter** dem Biom-Tint, den die Engine ueber + `PerInstanceCustomData 0/1` pro Instanz setzt — im Material **multiplizieren**, + nicht ersetzen. +- Presets: `gras`, `gras_hoch`, `steppengras`, `farn`, `blume`. +- Tri-Budget-Warnung (Default 400 laut `ASSETS.md` fuer Gras/Blume). + Gemessen: ein Standard-Bueschel mit 14 Halmen = **112 Tris**. +- Ursprung unten-mittig (`min_z = 0.0000`), UV und Vertex-Farben ueberleben den + FBX-Roundtrip. + +--- + ## Stylized Tree Generator Parametrischer **Geaest**-Generator (Stamm + Aeste + Sub-Aeste) als Geometry-Nodes- diff --git a/blender_manifest_grass.toml b/blender_manifest_grass.toml new file mode 100644 index 0000000..bd6496f --- /dev/null +++ b/blender_manifest_grass.toml @@ -0,0 +1,22 @@ +schema_version = "1.0.0" + +id = "stylized_grass_generator" +version = "1.0.0" +name = "Stylized Grass Generator" +tagline = "Gras, Blumen und Farne mit Wind-UV fuer SimpleGrassWind" +maintainer = "D4rkst3r" +type = "add-on" + +website = "https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator" + +tags = ["Object", "Mesh", "Modeling"] + +blender_version_min = "4.2.0" + +license = [ + "SPDX:GPL-3.0-or-later", +] + +copyright = [ + "2026 D4rkst3r", +] diff --git a/build.ps1 b/build.ps1 index ccddb37..a2adef7 100644 --- a/build.ps1 +++ b/build.ps1 @@ -26,7 +26,8 @@ New-Item -ItemType Directory -Force -Path $dist | Out-Null # name = Staging-Ordner, manifest = Quelldatei, source = Addon-Code $extensions = @( @{ name = "rock"; manifest = "blender_manifest.toml"; source = "stylized_rock_generator.py" }, - @{ name = "tree"; manifest = "blender_manifest_tree.toml"; source = "stylized_tree_generator.py" } + @{ name = "tree"; manifest = "blender_manifest_tree.toml"; source = "stylized_tree_generator.py" }, + @{ name = "grass"; manifest = "blender_manifest_grass.toml"; source = "stylized_grass_generator.py" } ) # Alte Zips weg, damit geloeschte Versionen nicht im Listing haengen bleiben. diff --git a/dist/index.json b/dist/index.json index 732dfe3..15dd3c3 100644 --- a/dist/index.json +++ b/dist/index.json @@ -2,6 +2,31 @@ "version": "v1", "blocklist": [], "data": [ + { + "schema_version": "1.0.0", + "id": "stylized_grass_generator", + "name": "Stylized Grass Generator", + "tagline": "Gras, Blumen und Farne mit Wind-UV fuer SimpleGrassWind", + "version": "1.0.0", + "type": "add-on", + "maintainer": "D4rkst3r", + "license": [ + "SPDX:GPL-3.0-or-later" + ], + "blender_version_min": "4.2.0", + "website": "https://git.d4rkst3r.de/D4rkst3r/stylized-rock-generator", + "copyright": [ + "2026 D4rkst3r" + ], + "tags": [ + "Object", + "Mesh", + "Modeling" + ], + "archive_url": "./stylized_grass_generator-1.0.0.zip", + "archive_size": 4752, + "archive_hash": "sha256:ed8d7dda2f090f93c4ecad3b81af46c007578701e0851035366404399d13e14f" + }, { "schema_version": "1.0.0", "id": "stylized_rock_generator", diff --git a/dist/stylized_grass_generator-1.0.0.zip b/dist/stylized_grass_generator-1.0.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..08e4389a10c4a381fbdd3fdd86e17d1259ac0bca GIT binary patch literal 4752 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 Sidebar > Grass Gen", + "description": ( + "Gras-, Blumen- und Farn-Bueschel mit sauberer Wind-UV " + "(V = 0 an der Wurzel, 1 an der Spitze) fuer SimpleGrassWind." + ), + "category": "Add Mesh", +} + +# --------------------------------------------------------------------------- +# WARUM DIE UV DER KERN IST +# +# Das Zielmaterial (EcoGame M_Grass) nutzt UEs "SimpleGrassWind" mit +# WorldPositionOffset. Dessen WindWeight-Eingang braucht eine Maske, die an der +# WURZEL 0 und an der SPITZE 1 ist - sonst rutscht der Halm beim Wehen vom Boden +# weg oder bewegt sich gar nicht. Geprueft am Material: es liest +# TextureCoordinate, aber KEINE VertexColor. Deshalb liefert dieser Generator +# die Windmaske PRIMAER ueber UV0.V. +# +# Vertex-Farben kommen zusaetzlich (nicht als einzige Quelle): +# R = Kopie der Windmaske (Fallback / andere Materialien) +# G = Variation je HALM - Ebene UNTER dem Biom-Tint, den die Engine bereits +# ueber PerInstanceCustomData 0/1 pro Instanz setzt. Im Material +# multiplizieren, nicht ersetzen. +# B = AO (unten dunkel) +# A = 1 (Reserve) +# --------------------------------------------------------------------------- + +import bpy +import bmesh +import math +import random + +from bpy.props import ( + IntProperty, FloatProperty, EnumProperty, BoolProperty, StringProperty, + FloatVectorProperty, PointerProperty, +) +from bpy.types import Operator, Panel, PropertyGroup + + +PRESETS = { + "gras": dict(blades=14, height=0.42, width=0.030, segments=4, + bend=0.42, spread=0.16, tilt=0.55, taper=0.85, + curl=0.0, height_var=0.45), + "gras_hoch": dict(blades=11, height=0.85, width=0.026, segments=5, + bend=0.60, spread=0.20, tilt=0.45, taper=0.90, + curl=0.0, height_var=0.40), + "steppengras": dict(blades=16, height=0.55, width=0.022, segments=4, + bend=0.75, spread=0.26, tilt=0.75, taper=0.95, + curl=0.0, height_var=0.55), + "farn": dict(blades=7, height=0.50, width=0.075, segments=5, + bend=0.70, spread=0.30, tilt=0.85, taper=0.55, + curl=0.25, height_var=0.30), + "blume": dict(blades=9, height=0.30, width=0.028, segments=3, + bend=0.25, spread=0.12, tilt=0.35, taper=0.80, + curl=0.0, height_var=0.35), +} + + +class GrassGenError(Exception): + pass + + +# --------------------------------------------------------------------------- +# Geometrie +# --------------------------------------------------------------------------- + +def _add_blade(bm, uv_layer, col_layer, cfg, rng): + """Einen Halm als Streifen bauen - inkl. UV und Vertex-Farben. + + Der Halm wird als Kette von Quads gebaut. Pro Ring: + t = 0 an der Wurzel .. 1 an der Spitze + UV.V = t -> genau die Windmaske + Breite = width * (1 - taper * t) + """ + seg = max(2, cfg["segments"]) + h = cfg["height"] * (1.0 + rng.uniform(-1.0, 1.0) * cfg["height_var"] * 0.5) + w = cfg["width"] * (1.0 + rng.uniform(-0.25, 0.25)) + + # Halm-Ausrichtung: Richtung + Neigung aus der Mitte heraus + yaw = rng.uniform(0.0, math.tau) + lean = cfg["tilt"] * rng.uniform(0.35, 1.0) + root_r = cfg["spread"] * math.sqrt(rng.random()) + rx, ry = math.cos(yaw) * root_r, math.sin(yaw) * root_r + + # Variation je HALM (Kanal G) - eine Zahl fuer den ganzen Halm + g = rng.uniform(0.0, 1.0) + + rows = [] + for i in range(seg + 1): + t = i / float(seg) + # Biegung: quadratisch, damit die Wurzel senkrecht bleibt + off = (cfg["bend"] * t * t) * h + # optionales Einrollen (Farn) + curl = cfg["curl"] * t * t * h + px = rx + math.cos(yaw) * (off * lean) + py = ry + math.sin(yaw) * (off * lean) + pz = h * t - curl + hw = 0.5 * w * max(0.02, 1.0 - cfg["taper"] * t) + # Breitenachse senkrecht zur Halm-Richtung + nx, ny = -math.sin(yaw), math.cos(yaw) + v_l = bm.verts.new((px - nx * hw, py - ny * hw, pz)) + v_r = bm.verts.new((px + nx * hw, py + ny * hw, pz)) + rows.append((v_l, v_r, t, g)) + + for i in range(seg): + a_l, a_r, t0, _ = rows[i] + b_l, b_r, t1, _ = rows[i + 1] + f = bm.faces.new((a_l, a_r, b_r, b_l)) + for loop, (u, t) in zip(f.loops, ((0.0, t0), (1.0, t0), (1.0, t1), (0.0, t1))): + loop[uv_layer].uv = (u, t) # V = Wurzel 0 -> Spitze 1 + ao = 0.35 + 0.65 * min(1.0, t * 1.6) # unten dunkler + loop[col_layer] = (t, g, ao, 1.0) # R=Wind, G=Halm, B=AO + + +def build_tuft(cfg, seed=0, name="Gras_01"): + rng = random.Random(seed) + me = bpy.data.meshes.new(name) + bm = bmesh.new() + uv_layer = bm.loops.layers.uv.new("UVMap") + col_layer = bm.loops.layers.float_color.new("Cavity") + for _ in range(max(1, cfg["blades"])): + _add_blade(bm, uv_layer, col_layer, cfg, rng) + bm.to_mesh(me) + bm.free() + me.update() + return me + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + +def _preset_items(self, context): + return [(k, k.replace("_", " ").capitalize(), "") for k in PRESETS] + + +class GrassGenSettings(PropertyGroup): + preset: EnumProperty(name="Preset", items=_preset_items) + seed: IntProperty(name="Seed", default=0, min=0, max=9999) + + art_name: StringProperty( + name="Art", default="", + description="Name fuer den Export. Leer = Preset-Name", + ) + variant: IntProperty(name="Variante", default=1, min=1, max=99) + + blades: IntProperty(name="Halme", default=14, min=1, max=80) + height: FloatProperty(name="Hoehe (m)", default=0.42, min=0.02, max=3.0) + height_var: FloatProperty(name="Hoehen-Streuung", default=0.45, min=0.0, max=1.0) + width: FloatProperty(name="Halmbreite (m)", default=0.030, min=0.002, max=0.3) + segments: IntProperty( + name="Segmente", default=4, min=2, max=10, + description="Unterteilungen je Halm. Mehr = weichere Biegung im Wind", + ) + bend: FloatProperty(name="Biegung", default=0.42, min=0.0, max=2.0) + spread: FloatProperty(name="Streuung am Boden (m)", default=0.16, min=0.0, max=1.5) + tilt: FloatProperty(name="Neigung", default=0.55, min=0.0, max=2.0) + taper: FloatProperty( + name="Verjuengung", default=0.85, min=0.0, max=1.0, + description="1 = laeuft spitz zu, 0 = gleich breit bis oben", + ) + curl: FloatProperty(name="Einrollen", default=0.0, min=0.0, max=1.0) + + tri_budget: IntProperty( + name="Tri-Budget", default=400, min=0, max=20000, + description="Warnung bei Ueberschreitung (Gras/Blume laut ASSETS.md: 400)", + ) + batch_count: IntProperty(name="Anzahl Varianten", default=1, min=1, max=32) + spacing: FloatProperty(name="Abstand", default=0.8, min=0.0, max=10.0) + + +def _cfg_from(settings): + return dict( + blades=settings.blades, height=settings.height, width=settings.width, + segments=settings.segments, bend=settings.bend, spread=settings.spread, + tilt=settings.tilt, taper=settings.taper, curl=settings.curl, + height_var=settings.height_var, + ) + + +def _art(settings): + return (settings.art_name.strip() or settings.preset).replace("_", " ").title().replace(" ", "") + + +# --------------------------------------------------------------------------- +# Operatoren +# --------------------------------------------------------------------------- + +class GRASSGEN_OT_apply_preset(Operator): + bl_idname = "grassgen.apply_preset" + bl_label = "Preset laden" + bl_description = "Uebernimmt die Werte des gewaehlten Presets in die Regler" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.grass_gen_settings + cfg = PRESETS.get(s.preset) + if not cfg: + self.report({'ERROR'}, "Preset unbekannt.") + return {'CANCELLED'} + for k, v in cfg.items(): + if hasattr(s, k): + setattr(s, k, v) + self.report({'INFO'}, "Preset '%s' geladen." % s.preset) + return {'FINISHED'} + + +class GRASSGEN_OT_randomize(Operator): + bl_idname = "grassgen.randomize" + bl_label = "Zufaellig" + bl_description = "Neuer Seed - gleiche Art, andere Halmverteilung" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.grass_gen_settings + s.seed = random.randint(0, 9999) + self.report({'INFO'}, "Seed %d" % s.seed) + return {'FINISHED'} + + +class GRASSGEN_OT_create(Operator): + bl_idname = "object.grassgen_create" + bl_label = "Gras erzeugen" + bl_description = "Erzeugt Gras-Bueschel mit Wind-UV (V = Wurzel 0 -> Spitze 1)" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.grass_gen_settings + cfg = _cfg_from(s) + created, total = [], 0 + try: + for i in range(s.batch_count): + name = "%s_%02d" % (_art(s), s.variant + i) + me = build_tuft(cfg, seed=s.seed + i * 733, name=name) + ob = bpy.data.objects.new(name, me) + context.collection.objects.link(ob) + ob.location.x = i * s.spacing + created.append(ob) + total += sum(len(p.vertices) - 2 for p in me.polygons) + except Exception as exc: # noqa: BLE001 + self.report({'ERROR'}, "Erzeugen fehlgeschlagen: %s" % exc) + return {'CANCELLED'} + + for o in context.view_layer.objects: + try: + o.select_set(False) + except (ReferenceError, RuntimeError): + pass + for ob in created: + ob.select_set(True) + if created: + context.view_layer.objects.active = created[0] + + per = total // max(1, len(created)) + msg = "%d Bueschel, %d Tris (je %d)" % (len(created), total, per) + if s.tri_budget and per > s.tri_budget: + self.report({'WARNING'}, msg + " - UEBER Budget (%d)" % s.tri_budget) + else: + self.report({'INFO'}, msg) + return {'FINISHED'} + + +# --------------------------------------------------------------------------- +# Panel +# --------------------------------------------------------------------------- + +class VIEW3D_PT_grass_generator(Panel): + bl_label = "Grass Generator" + bl_idname = "VIEW3D_PT_grass_generator" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Grass Gen" + + def draw(self, context): + layout = self.layout + s = context.scene.grass_gen_settings + + box = layout.box() + box.label(text="Vorlage", icon='PRESET') + row = box.row(align=True) + row.prop(s, "preset", text="") + row.operator("grassgen.apply_preset", text="", icon='IMPORT') + row = box.row(align=True) + row.prop(s, "art_name", text="") + row.prop(s, "variant") + box.label(text="Name: %s_%02d" % (_art(s), s.variant), icon='INFO') + + box = layout.box() + box.label(text="Form") + box.prop(s, "blades") + box.prop(s, "height") + box.prop(s, "height_var") + box.prop(s, "width") + box.prop(s, "taper") + box.prop(s, "segments") + + box = layout.box() + box.label(text="Haltung") + box.prop(s, "bend") + box.prop(s, "tilt") + box.prop(s, "spread") + box.prop(s, "curl") + + box = layout.box() + box.label(text="Zufall", icon='FILE_REFRESH') + row = box.row(align=True) + row.prop(s, "seed") + row.operator("grassgen.randomize", text="", icon='FILE_REFRESH') + box.prop(s, "batch_count") + box.prop(s, "spacing") + box.prop(s, "tri_budget") + + layout.separator() + layout.operator("object.grassgen_create", icon='OUTLINER_OB_CURVES') + col = layout.column(align=True) + col.label(text="UV0.V = Windmaske (Wurzel 0 - Spitze 1)", icon='INFO') + col.label(text="VCol: R=Wind G=Halm B=AO") + + +classes = ( + GrassGenSettings, + GRASSGEN_OT_apply_preset, + GRASSGEN_OT_randomize, + GRASSGEN_OT_create, + VIEW3D_PT_grass_generator, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.grass_gen_settings = PointerProperty(type=GrassGenSettings) + + +def unregister(): + del bpy.types.Scene.grass_gen_settings + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() diff --git a/tests/test_grass.py b/tests/test_grass.py new file mode 100644 index 0000000..effcbc9 --- /dev/null +++ b/tests/test_grass.py @@ -0,0 +1,66 @@ +import bpy, sys, importlib.util, os, tempfile +spec=importlib.util.spec_from_file_location("sgg", r"A:\eco\stylized_rock_generator\stylized_grass_generator.py") +g=importlib.util.module_from_spec(spec); sys.modules["sgg"]=g; spec.loader.exec_module(g) +try: g.unregister() +except Exception: pass +g.register() +fails=[] +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) +s=bpy.context.scene.grass_gen_settings +s.preset='gras'; bpy.ops.grassgen.apply_preset() +s.art_name="Gras"; s.variant=1; s.batch_count=1 +bpy.ops.object.grassgen_create() +ob=[o for o in bpy.context.scene.objects if o.type=='MESH'][0] +me=ob.data +tris=sum(len(p.vertices)-2 for p in me.polygons) +print("OBJEKT %s Tris=%d (Budget %d)" % (ob.name, tris, s.tri_budget)) +if tris > s.tri_budget: fails.append("budget") + +# --- Kernpruefung: UV.V muss mit der HOEHE korrelieren --- +uv=me.uv_layers[0].data +zs=[]; vs=[] +for poly in me.polygons: + for li in poly.loop_indices: + zs.append(me.vertices[me.loops[li].vertex_index].co.z) + vs.append(uv[li].uv[1]) +zmin,zmax=min(zs),max(zs) +wurzel=[vs[i] for i in range(len(vs)) if zs[i] < zmin + (zmax-zmin)*0.05] +spitze=[vs[i] for i in range(len(vs)) if zs[i] > zmin + (zmax-zmin)*0.95] +print("UV.V an der WURZEL: %.3f an der SPITZE: %.3f (Bereich %.2f..%.2f)" + % (sum(wurzel)/len(wurzel), sum(spitze)/len(spitze), min(vs), max(vs))) +if sum(wurzel)/len(wurzel) > 0.12: fails.append("V_wurzel_nicht_0") +if sum(spitze)/len(spitze) < 0.80: fails.append("V_spitze_nicht_1") +if min(vs) < -0.001 or max(vs) > 1.001: fails.append("V_ausserhalb_0_1") +# U ueber die Breite +us=[uv[li].uv[0] for poly in me.polygons for li in poly.loop_indices] +print("UV.U Bereich: %.2f..%.2f" % (min(us),max(us))) + +# --- Vertex-Farben als Zusatzebene --- +ca=me.color_attributes[0] if me.color_attributes else None +if ca is None: fails.append("keine_vcol") +else: + R=[d.color[0] for d in ca.data]; G=[d.color[1] for d in ca.data]; B=[d.color[2] for d in ca.data] + print("VCol R %.2f..%.2f | G %.2f..%.2f | B %.2f..%.2f" % (min(R),max(R),min(G),max(G),min(B),max(B))) + if max(G)-min(G) < 0.2: fails.append("G_keine_halmvariation") + # G muss PRO HALM konstant sein -> Anzahl unterschiedlicher Werte ~ Halmzahl + uniq=len({round(x,4) for x in G}) + print("G unterschiedliche Werte: %d (Halme: %d)" % (uniq, s.blades)) + if uniq > s.blades*1.5: fails.append("G_nicht_pro_halm") + +# Ursprung unten +print("min_z = %.4f (soll ~0)" % zmin) +if abs(zmin) > 0.01: fails.append("ursprung") + +# FBX-Roundtrip +tmp=tempfile.mkdtemp(); fp=os.path.join(tmp,"g.fbx") +ob.select_set(True); bpy.context.view_layer.objects.active=ob +bpy.ops.export_scene.fbx(filepath=fp, use_selection=True, colors_type='SRGB', use_triangles=True) +for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) +bpy.ops.import_scene.fbx(filepath=fp) +imp=[o for o in bpy.data.objects if o.type=='MESH'][0] +iuv=[d.uv[1] for d in imp.data.uv_layers[0].data] +ica=[a.name for a in imp.data.color_attributes] +print("NACH FBX: UV.V %.2f..%.2f VCol=%s" % (min(iuv),max(iuv), ica)) +if max(iuv) < 0.9: fails.append("fbx_uv_verloren") +g.unregister() +print("ERGEBNIS:", "ALLE CHECKS OK" if not fails else "FEHLER: "+", ".join(fails))