Files
stylized-rock-generator/stylized_grass_generator.py
D4rkst3randClaude Opus 4.8 71b9982441 Panels aufgeraeumt (alle vier Addons) + Test-Suite instandgesetzt
Panel-Umbau
-----------
Jedes Addon hatte seine Einstellungen als Kette dauerhaft offener Boxen - beim
Rock Generator 15 Stueck, man musste am ganzen Panel vorbeiscrollen, um an den
Erzeugen-Knopf zu kommen. Jetzt steht oben nur, was man bei jedem Asset anfasst
(Vorlage, Name, grosser Erzeugen-Knopf, Zufall), der Rest liegt in nummerierten,
eingeklappten Unter-Panels in der Reihenfolge des Arbeitsablaufs:

  Rock Gen    9 Unter-Panels (Benennung ... Textur-Bake)
  Tree Gen    4 (Wachstum, Blattwerk, Zusatz-Ebenen, Export)
  Grass Gen   4 (Form, Haltung, Batch, Kanal-Belegung)
  Asset Utils 5 (war schon so, dient als Vorlage)

Schalter, die ein ganzes Unter-Panel steuern (Vertex-Farben backen,
Wachstums-Stufen), sitzen jetzt als Haekchen im Panel-Kopf - Zustand sichtbar
ohne aufzuklappen.

Gemessen statt geglaubt: test_panels.py registriert alle vier Addons, laesst
jedes Panel zeichnen, prueft Tab-Zugehoerigkeit und Auf-/Zugeklappt-Absicht,
dass jeder angebotene Operator existiert - und vor allem, dass JEDE definierte
Property irgendwo erreichbar bleibt (Zweige wie base_shape='BOX' werden dabei
durchgeschaltet). Ergebnis: 0 von 122 Properties beim Umbau verloren.

Test-Suite
----------
Beim Gegenpruefen kam heraus, dass fuenf Tests still veraltet waren und eine
echte Regression nicht gemeldet haetten:

  test_rock_gen      suchte Objekte mit Praefix "Rock_" - die heissen seit dem
                     Benennungssystem "Fels_A_M_01". Fand 0 von 3.
  test_base_shapes   loeschte am Schleifenanfang ALLE Objekte, auch die eigenen
                     Kopien aus vorigen Durchlaeufen -> StructRNA removed.
  test_ecogame_tree  erwartete eine feste Namensliste ohne die spaeter
                     dazugekommenen _Leaf-Objekte.
  test_tree_addon    dito, ausserdem Zip-Version 1.22.0 fest verdrahtet.
  test_leaves        Zip-Version 1.23.0 fest verdrahtet.

Drei Muster vermieden statt nur die Symptome geflickt: neue Objekte ueber die
Differenz vorher/nachher finden statt ueber ein Namens-Praefix, Zips per glob
statt mit fester Version, Zusatz-Ebenen beim Zaehlen herausfiltern.

Neu: run_tests.ps1 faehrt die komplette Suite mit einem Befehl (-Filter fuer
einzelne). Genau das fehlte - deshalb war das Veralten nicht aufgefallen.
13 von 13 Tests bestehen.

Nebenbei
--------
build.ps1 ruft server-generate jetzt mit --html auf (browsbare index.html fuer
statisches Hosting). Nachgemessen und im README festgehalten: zwei Zips
derselben Extension-ID sind KEIN Versions-Archiv - server-generate warnt
("archive found with duplicates ... conflicting blender versions"), mehrere
Versionen einer ID gehen nur bei verschiedenen Blender-Bereichen.

Versionen: Rock 2.13.0, Tree 1.24.0, Grass 1.1.0, Asset Utils 1.1.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 12:12:38 +02:00

394 lines
14 KiB
Python

bl_info = {
"name": "Stylized Grass Generator",
"author": "D4rkst3r",
"version": (1, 1, 0),
"blender": (4, 2, 0),
"location": "View3D > 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):
"""Haupt-Panel: Vorlage, Name, Erzeugen. Der Rest sitzt in Unter-Panels."""
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
row = layout.row(align=True)
row.prop(s, "preset", text="")
row.operator("grassgen.apply_preset", text="", icon='IMPORT')
row = layout.row(align=True)
row.prop(s, "art_name", text="")
row.prop(s, "variant")
layout.separator()
col = layout.column(align=True)
col.scale_y = 1.4
col.operator("object.grassgen_create", text="Gras erzeugen",
icon='OUTLINER_OB_CURVES')
layout.label(text="%s_%02d" % (_art(s), s.variant), icon='INFO')
row = layout.row(align=True)
row.prop(s, "seed")
row.operator("grassgen.randomize", text="", icon='FILE_REFRESH')
class _GrassSub(Panel):
"""Basis aller Unter-Panels."""
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Grass Gen"
bl_parent_id = "VIEW3D_PT_grass_generator"
bl_options = {'DEFAULT_CLOSED'}
class VIEW3D_PT_grass_shape(_GrassSub):
bl_label = "1 - Form"
def draw(self, context):
layout = self.layout
s = context.scene.grass_gen_settings
layout.prop(s, "blades")
col = layout.column(align=True)
col.prop(s, "height")
col.prop(s, "height_var")
col.prop(s, "width")
col.prop(s, "taper")
layout.prop(s, "segments")
class VIEW3D_PT_grass_pose(_GrassSub):
bl_label = "2 - Haltung"
def draw(self, context):
layout = self.layout
s = context.scene.grass_gen_settings
col = layout.column(align=True)
col.prop(s, "bend")
col.prop(s, "tilt")
col.prop(s, "spread")
col.prop(s, "curl")
class VIEW3D_PT_grass_batch(_GrassSub):
bl_label = "3 - Batch & Budget"
def draw(self, context):
layout = self.layout
s = context.scene.grass_gen_settings
col = layout.column(align=True)
col.prop(s, "batch_count")
col.prop(s, "spacing")
layout.prop(s, "tri_budget")
class VIEW3D_PT_grass_info(_GrassSub):
bl_label = "4 - Kanal-Belegung"
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
col.label(text="UV0.V = Windmaske", icon='INFO')
col.label(text="Wurzel 0 - Spitze 1")
col.separator()
col.label(text="VCol: R=Wind G=Halm B=AO")
col.label(text="Biom-Tint laeuft ueber")
col.label(text="PerInstanceCustomData 0/1")
classes = (
GrassGenSettings,
GRASSGEN_OT_apply_preset,
GRASSGEN_OT_randomize,
GRASSGEN_OT_create,
# Eltern-Panel zuerst, sonst findet Blender die bl_parent_id nicht.
VIEW3D_PT_grass_generator,
VIEW3D_PT_grass_shape,
VIEW3D_PT_grass_pose,
VIEW3D_PT_grass_batch,
VIEW3D_PT_grass_info,
)
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()