diff --git a/README.md b/README.md index 2c7571f..e49edd4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,35 @@ --- +## Panel-Aufbau (alle vier Addons) + +Jedes Addon hat oben im N-Panel nur das, was man **bei jedem Asset** anfasst - +Vorlage, Name, den grossen Erzeugen-Knopf, Zufall. Alles andere sitzt in +nummerierten, eingeklappten Unter-Panels in der Reihenfolge des Arbeitsablaufs: + +| Addon | Unter-Panels | +|---|---| +| Rock Gen | 1 Benennung & Groesse · 2 Form · 3 Detail & Cleanup · 4 Shading · 5 Vertex-Farben · 6 UV/LOD/Material · 7 Batch & Transform · 8 FBX-Export · 9 Textur-Bake | +| Tree Gen | 1 Wachstum · 2 Blattwerk · 3 Zusatz-Ebenen · 4 Export | +| Grass Gen | 1 Form · 2 Haltung · 3 Batch & Budget · 4 Kanal-Belegung | +| Asset Utils | 1 Vorbereiten · 2 Vertex-Farben · 3 Benennung · 4 Pruefen · 5 Export | + +Schalter, die ein ganzes Unter-Panel an- und ausknipsen (Vertex-Farben backen, +Wachstums-Stufen), sitzen als Haekchen **im Panel-Kopf** - so sieht man den +Zustand, ohne aufzuklappen. + +In den Generatoren sind alle Unter-Panels zugeklappt, weil es Einstellungen sind, +die man selten aendert. In Asset Utils ist jedes Unter-Panel ein Arbeitsschritt; +1, 2 und 4 macht man bei jedem Asset und bleiben deshalb offen. `tests/test_panels.py` +haelt diese Absicht fest, damit sie nicht versehentlich verrutscht. + +**Falle beim Bauen:** Unter-Panels brauchen dieselbe `bl_category` wie das +Eltern-Panel - sonst landen sie in einem anderen Tab und sind praktisch +unsichtbar. Und das Eltern-Panel muss **vor** seinen Kindern registriert werden, +sonst findet Blender die `bl_parent_id` nicht. + +--- + ## EcoGame-Konventionen (beide Addons) Abgestimmt auf `A:\eco\EcoGame` (UE 5.8). Kurzfassung: @@ -203,6 +232,19 @@ zeigen lassen. ## Installation als Extension (via URL, mit Auto-Update) +> **Ein Repo, vier Extensions.** `blender --command extension server-generate` +> scannt `dist/` und listet jedes gefundene Zip - eine URL liefert in Blender +> alle vier Addons. `build.ps1` ruft das mit `--html` auf, dann entsteht +> zusaetzlich eine `index.html` zum Durchblaettern (auf einem statischen +> Webserver; Gitea liefert rohes HTML als Text aus). +> +> **Kein Versions-Archiv:** zwei Zips derselben Extension-ID nebeneinander sind +> nicht der Weg, eine alte Version zum Zurueckrollen bereitzuhalten - +> server-generate warnt dann (`archive found with duplicates ... conflicting +> blender versions`). Mehrere Versionen einer ID sind nur erlaubt, wenn sie +> verschiedene Blender-Bereiche abdecken. Alte Version wieder her: per +> `git checkout` des Zips. + Ab Blender 4.2 / 5.x als **Remote-Extension-Repository** – einmal die URL eintragen, danach gehen Updates per Klick. @@ -291,14 +333,28 @@ Referenz: ## Tests +Alles auf einmal: + ```bash -blender --background --python tests/test_rock_gen.py +pwsh -File run_tests.ps1 ``` -Weitere Suiten (jeweils `blender --background --python `): +Einzeln geht weiterhin `blender --background --python tests/.py`, +gefiltert `.\run_tests.ps1 -Filter panels`. + +> **Warum der Runner existiert.** Die Tests lagen einzeln herum und wurden nur +> beim Bauen des jeweiligen Features gestartet. Beim Aufraeumen der Panels stellte +> sich heraus, dass **fuenf davon still veraltet waren**: sie prueften gegen +> Objekt-Namen aus der Zeit vor dem Benennungssystem (`Rock_` statt `Fels_A_M_01`), +> gegen fest verdrahtete Zip-Versionen, oder zaehlten die spaeter dazugekommenen +> `_Leaf`-Objekte mit. Sie haetten eine echte Regression nicht gemeldet. Die drei +> Muster, die das ausgeloest haben, sind jetzt vermieden: Objekte ueber die +> **Differenz** vorher/nachher finden statt ueber ein Namens-Praefix, Zips per +> **glob** statt mit fester Version, und Zusatz-Ebenen beim Zaehlen herausfiltern. | Datei | prueft | |---|---| +| `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 | | `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 | diff --git a/blender_manifest.toml b/blender_manifest.toml index 73dd23d..135c553 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.12.0" +version = "2.13.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/blender_manifest_grass.toml b/blender_manifest_grass.toml index bd6496f..50fe427 100644 --- a/blender_manifest_grass.toml +++ b/blender_manifest_grass.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_grass_generator" -version = "1.0.0" +version = "1.1.0" name = "Stylized Grass Generator" tagline = "Gras, Blumen und Farne mit Wind-UV fuer SimpleGrassWind" maintainer = "D4rkst3r" diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml index 9cb4d80..424a455 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.23.0" +version = "1.24.0" name = "Stylized Tree Generator" tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" maintainer = "D4rkst3r" diff --git a/blender_manifest_utils.toml b/blender_manifest_utils.toml index 3dd78e3..bde04d8 100644 --- a/blender_manifest_utils.toml +++ b/blender_manifest_utils.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_asset_utils" -version = "1.0.0" +version = "1.1.0" name = "Stylized Asset Utils" tagline = "Handgebaute Assets auf EcoGame-Konvention bringen" maintainer = "D4rkst3r" diff --git a/build.ps1 b/build.ps1 index 261bfa1..a952cbb 100644 --- a/build.ps1 +++ b/build.ps1 @@ -31,7 +31,11 @@ $extensions = @( @{ name = "utils"; manifest = "blender_manifest_utils.toml"; source = "stylized_asset_utils.py" } ) -# Alte Zips weg, damit geloeschte Versionen nicht im Listing haengen bleiben. +# Alte Zips weg. WICHTIG: zwei Zips derselben Extension-ID im selben Ordner sind +# KEIN Versions-Archiv zum Zurueckrollen - server-generate warnt dann +# ("archive found with duplicates ... conflicting blender versions"). Mehrere +# Versionen einer ID sind nur erlaubt, wenn sie verschiedene Blender-Bereiche +# abdecken. Alte Version braucht man wieder: per git checkout des Zips. Get-ChildItem $dist -Filter *.zip -ErrorAction SilentlyContinue | Remove-Item -Force foreach ($ext in $extensions) { @@ -52,7 +56,10 @@ foreach ($ext in $extensions) { } Write-Host "== server-generate (index.json) ==" -ForegroundColor Cyan -& $Blender --command extension server-generate --repo-dir $dist +# --html erzeugt zusaetzlich eine index.html: auf einem statischen Webserver +# (z.B. nginx) laesst sich das Repo damit im Browser durchblaettern. Gitea liefert +# rohes HTML als text/plain aus, dort sieht man nur den Quelltext - schadet aber nicht. +& $Blender --command extension server-generate --repo-dir $dist --html if ($LASTEXITCODE -ne 0) { throw "server-generate fehlgeschlagen" } Write-Host "Fertig." -ForegroundColor Green diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..39878ea --- /dev/null +++ b/dist/index.html @@ -0,0 +1,66 @@ + + + + + Blender Extensions + + +

Blender Extension Listing:

+

Add-on

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNameDescriptionWebsiteBlender VersionsPython VersionsPlatformsSize
stylized_asset_utils-1.1.0Stylized Asset UtilsHandgebaute Assets auf EcoGame-Konvention bringenlink4.2.0 - ~allall6.2KB
stylized_grass_generator-1.1.0Stylized Grass GeneratorGras, Blumen und Farne mit Wind-UV fuer SimpleGrassWindlink4.2.0 - ~allall4.9KB
stylized_rock_generator-2.13.0Stylized Rock GeneratorBatch-Generator fuer stylized Rocks mit UE5-Exportlink4.2.0 - ~allall21.7KB
stylized_tree_generator-1.24.0Stylized Tree GeneratorParametrische Baeume, Palmen, Bueschen mit Wachstums-Stufenlink4.2.0 - ~allall21.8KB
+ +

Built 2026-07-31, 10:09

+ + diff --git a/dist/index.json b/dist/index.json index fe9de26..a5e52c1 100644 --- a/dist/index.json +++ b/dist/index.json @@ -7,7 +7,7 @@ "id": "stylized_asset_utils", "name": "Stylized Asset Utils", "tagline": "Handgebaute Assets auf EcoGame-Konvention bringen", - "version": "1.0.0", + "version": "1.1.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -23,16 +23,16 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_asset_utils-1.0.0.zip", - "archive_size": 6340, - "archive_hash": "sha256:af8f9e95054432e4e7eea09b520b30a659ab354795191dcab0e5504c518306d3" + "archive_url": "./stylized_asset_utils-1.1.0.zip", + "archive_size": 6351, + "archive_hash": "sha256:71f3d1ee0db0b8ebb8a5e042a763e27de7accb83b655cb374bd2e8a3c6a3d94a" }, { "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", + "version": "1.1.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -48,16 +48,16 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_grass_generator-1.0.0.zip", - "archive_size": 4752, - "archive_hash": "sha256:ed8d7dda2f090f93c4ecad3b81af46c007578701e0851035366404399d13e14f" + "archive_url": "./stylized_grass_generator-1.1.0.zip", + "archive_size": 5027, + "archive_hash": "sha256:7a6335f0fabc8620d42a234a1c70856e1cf05b9fbd555306eec15e11d7f7ee7e" }, { "schema_version": "1.0.0", "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.12.0", + "version": "2.13.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -73,16 +73,16 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.12.0.zip", - "archive_size": 21754, - "archive_hash": "sha256:6e18e32b841d627edfa6530587c99080c50d02ac4b0a532ed6079796bfa2aaf4" + "archive_url": "./stylized_rock_generator-2.13.0.zip", + "archive_size": 22263, + "archive_hash": "sha256:5ae39f5035435ae51803f63e1a0d06b67d8c68a5477e9fdb663a4a21c65d2492" }, { "schema_version": "1.0.0", "id": "stylized_tree_generator", "name": "Stylized Tree Generator", "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", - "version": "1.23.0", + "version": "1.24.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -98,9 +98,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_tree_generator-1.23.0.zip", - "archive_size": 22135, - "archive_hash": "sha256:a9b986aaf5e95f7a9600d529eb3eb305af3d1edc6b327862035cecde50fed6e9" + "archive_url": "./stylized_tree_generator-1.24.0.zip", + "archive_size": 22352, + "archive_hash": "sha256:abdce976729a528b87f4eba56917f26b74a5f7e0c50877a0a427ff94bda40032" } ] } \ No newline at end of file diff --git a/dist/stylized_asset_utils-1.0.0.zip b/dist/stylized_asset_utils-1.1.0.zip similarity index 84% rename from dist/stylized_asset_utils-1.0.0.zip rename to dist/stylized_asset_utils-1.1.0.zip index 0e017fe..dda096c 100644 Binary files a/dist/stylized_asset_utils-1.0.0.zip and b/dist/stylized_asset_utils-1.1.0.zip differ diff --git a/dist/stylized_grass_generator-1.0.0.zip b/dist/stylized_grass_generator-1.0.0.zip deleted file mode 100644 index 08e4389..0000000 Binary files a/dist/stylized_grass_generator-1.0.0.zip and /dev/null differ diff --git a/dist/stylized_grass_generator-1.1.0.zip b/dist/stylized_grass_generator-1.1.0.zip new file mode 100644 index 0000000..cd1f9b5 Binary files /dev/null and b/dist/stylized_grass_generator-1.1.0.zip differ diff --git a/dist/stylized_rock_generator-2.12.0.zip b/dist/stylized_rock_generator-2.12.0.zip deleted file mode 100644 index e47815d..0000000 Binary files a/dist/stylized_rock_generator-2.12.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.13.0.zip b/dist/stylized_rock_generator-2.13.0.zip new file mode 100644 index 0000000..193e963 Binary files /dev/null and b/dist/stylized_rock_generator-2.13.0.zip differ diff --git a/dist/stylized_tree_generator-1.23.0.zip b/dist/stylized_tree_generator-1.23.0.zip deleted file mode 100644 index c3f0394..0000000 Binary files a/dist/stylized_tree_generator-1.23.0.zip and /dev/null differ diff --git a/dist/stylized_tree_generator-1.24.0.zip b/dist/stylized_tree_generator-1.24.0.zip new file mode 100644 index 0000000..9ea0c08 Binary files /dev/null and b/dist/stylized_tree_generator-1.24.0.zip differ diff --git a/run_tests.ps1 b/run_tests.ps1 new file mode 100644 index 0000000..656c2ce --- /dev/null +++ b/run_tests.ps1 @@ -0,0 +1,49 @@ +# Faehrt die komplette Test-Suite gegen die Addon-Dateien. +# +# Warum es das gibt: die Tests lagen einzeln herum und wurden nur beim Bauen des +# jeweiligen Features gestartet. Dadurch waren fuenf davon still veraltet - sie +# pruesten gegen Namen und Zip-Versionen, die es laengst nicht mehr gab, und +# haetten eine echte Regression nicht gemeldet. Ein Befehl fuer alles. +# +# Aufruf: +# .\run_tests.ps1 +# .\run_tests.ps1 -Blender "E:\SteamLibrary\steamapps\common\Blender\blender.exe" +# .\run_tests.ps1 -Filter panels # nur tests\test_panels.py +# +# Hinweis: test_tree_addon.py prueft das GEBAUTE Zip aus dist\ - vorher +# .\build.ps1 laufen lassen, sonst meldet der Test das fehlende Zip. + +param( + [string]$Blender = "blender", + [string]$Filter = "*" +) + +$root = $PSScriptRoot +$tests = Get-ChildItem (Join-Path $root "tests") -Filter "test_$Filter.py" | Sort-Object Name +if (-not $tests) { throw "Keine Tests gefunden fuer Filter '$Filter'" } + +$failed = @() +foreach ($t in $tests) { + $out = & $Blender --background --factory-startup --python $t.FullName 2>&1 | Out-String + + # Die Tests melden ihr Ergebnis als letzte Zeile mit "ERGEBNIS:" bzw. + # "Alle Checks bestanden." - alles andere ist Blender-Rauschen. + $line = ($out -split "`n" | + Where-Object { $_ -match "ERGEBNIS|Alle Checks bestanden|FEHLGESCHLAGEN" } | + Select-Object -Last 1) + if (-not $line) { $line = "" } + $line = $line.Trim() + + $ok = ($line -match "ALLE|OK$|bestanden") -and ($line -notmatch "FEHLER|FEHLGESCHLAGEN") + $color = if ($ok) { "Green" } else { "Red" } + if (-not $ok) { $failed += $t.BaseName } + + Write-Host ("{0,-24} {1}" -f $t.BaseName, $line) -ForegroundColor $color +} + +Write-Host "" +if ($failed) { + Write-Host ("FEHLGESCHLAGEN ({0}): {1}" -f $failed.Count, ($failed -join ", ")) -ForegroundColor Red + exit 1 +} +Write-Host ("Alle {0} Tests bestanden." -f $tests.Count) -ForegroundColor Green diff --git a/stylized_asset_utils.py b/stylized_asset_utils.py index c7d781a..3f4c481 100644 --- a/stylized_asset_utils.py +++ b/stylized_asset_utils.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Asset Utils", "author": "D4rkst3r", - "version": (1, 0, 0), + "version": (1, 1, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Asset Utils", "description": ( diff --git a/stylized_grass_generator.py b/stylized_grass_generator.py index 788b586..26861c7 100644 --- a/stylized_grass_generator.py +++ b/stylized_grass_generator.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Grass Generator", "author": "D4rkst3r", - "version": (1, 0, 0), + "version": (1, 1, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Grass Gen", "description": ( @@ -270,6 +270,7 @@ class GRASSGEN_OT_create(Operator): # --------------------------------------------------------------------------- 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' @@ -280,46 +281,86 @@ class VIEW3D_PT_grass_generator(Panel): layout = self.layout s = context.scene.grass_gen_settings - box = layout.box() - box.label(text="Vorlage", icon='PRESET') - row = box.row(align=True) + row = layout.row(align=True) row.prop(s, "preset", text="") row.operator("grassgen.apply_preset", text="", icon='IMPORT') - row = box.row(align=True) + row = layout.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.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 = ( @@ -327,7 +368,12 @@ classes = ( 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, ) diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index b1ef431..be1b22c 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, 12, 0), + "version": (2, 13, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -1725,6 +1725,12 @@ class ROCKGEN_OT_preset_delete(Operator): # --------------------------------------------------------------------------- class VIEW3D_PT_rock_generator(Panel): + """Haupt-Panel: nur was man bei JEDEM Stein anfasst. + + Alles andere sitzt in eingeklappten Unter-Panels (bl_parent_id). Vorher war + das eine Kette aus 15 dauerhaft offenen Boxen - man musste scrollen, um an + den Erzeugen-Knopf zu kommen. + """ bl_label = "Rock Generator" bl_idname = "VIEW3D_PT_rock_generator" bl_space_type = 'VIEW_3D' @@ -1733,173 +1739,237 @@ class VIEW3D_PT_rock_generator(Panel): def draw(self, context): layout = self.layout - settings = context.scene.rock_gen_settings + s = context.scene.rock_gen_settings - box = layout.box() - box.label(text="Presets", icon='PRESET') - box.prop(settings, "active_preset", text="") - row = box.row(align=True) - row.operator("rockgen.preset_load", text="Laden", icon='IMPORT') - row.operator("rockgen.preset_delete", text="Loeschen", icon='TRASH') - row = box.row(align=True) - row.prop(settings, "new_preset_name", text="") - row.operator("rockgen.preset_save", text="Speichern", icon='ADD') - - box = layout.box() - box.label(text="Benennung", icon='SORTALPHA') - row = box.row(align=True) - row.prop(settings, "name_prefix", text="") - 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') - - box = layout.box() - box.label(text="Basis") - box.prop(settings, "base_shape") - if settings.base_shape == 'BOX': - box.prop(settings, "box_proportions") - box.prop(settings, "box_variation") - box.prop(settings, "subsurf_levels") - - box = layout.box() - box.label(text="Noise / Displace") - box.prop(settings, "texture_type") - box.prop(settings, "noise_scale") - box.prop(settings, "displace_strength") - box.prop(settings, "seed") - box.prop(settings, "randomize_shape") - sub = box.column(align=True) - sub.enabled = settings.randomize_shape - sub.prop(settings, "noise_scale_max") - sub.prop(settings, "displace_strength_max") - - box = layout.box() - box.label(text="Decimate / Cleanup") - box.prop(settings, "decimate_ratio_1") - box.prop(settings, "decimate_ratio_2") - box.prop(settings, "dissolve_angle") - - box = layout.box() - box.label(text="Hard-Surface (optional)") - box.prop(settings, "add_bevel") - sub = box.column(align=True) - sub.enabled = settings.add_bevel - sub.prop(settings, "bevel_width") - sub.prop(settings, "bevel_segments") - box.prop(settings, "add_weighted_normal") - - box = layout.box() - box.label(text="Shading / Form") - box.prop(settings, "shading_mode") - row = box.row() - row.enabled = settings.shading_mode == 'AUTO' - row.prop(settings, "autosmooth_angle") - box.prop(settings, "flatten_bottom") - row = box.row() - row.enabled = settings.flatten_bottom - row.prop(settings, "flatten_ratio") - box.prop(settings, "set_origin_bottom") - - box = layout.box() - box.label(text="UV (Unreal Export)") - box.prop(settings, "generate_uv0") - box.prop(settings, "generate_lightmap_uv") - - box = layout.box() - box.label(text="LOD") - box.prop(settings, "generate_lods") - sub = box.column(align=True) - sub.enabled = settings.generate_lods - sub.prop(settings, "lod_count") - sub.prop(settings, "lod_ratio_step") - - box = layout.box() - box.label(text="Vertex-Cavity (ohne Textur)") - box.prop(settings, "bake_vertex_cavity") - sub = box.column(align=True) - sub.enabled = settings.bake_vertex_cavity - sub.prop(settings, "cavity_strength") - sub.prop(settings, "cavity_blur") - sub.separator() - sub.prop(settings, "moss_amount") - sub.prop(settings, "moss_noise") - sub.prop(settings, "moss_seed") - sub.prop(settings, "part_mask_from_shells") - sub.label(text="R=Cavity G=%s B=Hoehe" - % ("Teil-Maske" if settings.part_mask_from_shells else "Moos"), - icon='INFO') - box.separator() - box.operator("rockgen.bake_cavity_selection", icon='BRUSH_DATA') - - box = layout.box() - box.label(text="Material") - box.prop(settings, "assign_material") - - box = layout.box() - box.label(text="Transform") - box.prop(settings, "randomize_transform") - row = box.row(align=True) - 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") - box.prop(settings, "batch_count") - box.prop(settings, "spacing") - box.prop(settings, "use_modal") - sub = box.row() - 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') + row = layout.row(align=True) + row.prop(s, "active_preset", text="") + row.operator("rockgen.preset_load", text="", icon='IMPORT') + row.operator("rockgen.preset_delete", text="", icon='TRASH') + row = layout.row(align=True) + row.prop(s, "new_preset_name", text="") + row.operator("rockgen.preset_save", text="", icon='ADD') layout.separator() - layout.operator("object.generate_rocks_batch", icon='MESH_ICOSPHERE') + col = layout.column(align=True) + col.scale_y = 1.4 + col.operator("object.generate_rocks_batch", + text="%d Stein(e) erzeugen" % s.batch_count, + icon='MESH_ICOSPHERE') + layout.label(text=_asset_name(s, 0, s.batch_count), icon='INFO') - box = layout.box() - box.label(text="FBX-Export (Unreal)") - box.prop(settings, "export_dir") - box.prop(settings, "export_selected_only") - box.operator("object.export_rocks_fbx", icon='EXPORT') + row = layout.row(align=True) + row.prop(s, "random_amount", text="Zufall") + row.prop(s, "random_shape", text="", icon='MOD_NOISE') + row.operator("rockgen.randomize", text="", icon='FILE_REFRESH') - box = layout.box() - box.label(text="Textur-Bake (tileable / Triplanar)") - box.prop(settings, "bake_dir") - box.prop(settings, "bake_prefix") - box.prop(settings, "bake_resolution") - box.prop(settings, "bake_base_color") - box.prop(settings, "bake_cell_scale") - box.prop(settings, "bake_seam_width") - box.prop(settings, "bake_plate_flat") - row = box.row(align=True) - row.prop(settings, "bake_color", toggle=True) - row.prop(settings, "bake_normal", toggle=True) - box.prop(settings, "bake_ao_into_albedo") - sub = box.row() - sub.enabled = settings.bake_ao_into_albedo - sub.prop(settings, "bake_ao_strength") - row = box.row(align=True) - row.prop(settings, "bake_roughness", toggle=True) - row.prop(settings, "bake_ao", toggle=True) - box.operator("object.bake_rock_textures", icon='TEXTURE') + +class _RockSub(Panel): + """Basis aller Unter-Panels - spart neunmal dieselben vier Zeilen.""" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Rock Gen" + bl_parent_id = "VIEW3D_PT_rock_generator" + bl_options = {'DEFAULT_CLOSED'} + + +class VIEW3D_PT_rock_naming(_RockSub): + bl_label = "1 - Benennung & Groesse" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + row = layout.row(align=True) + row.prop(s, "name_prefix", text="") + row.prop(s, "name_form", text="") + row.prop(s, "name_class", text="") + layout.prop(s, "name_numbered") + layout.separator() + layout.prop(s, "use_class_size") + sub = layout.row() + sub.active = not s.use_class_size + sub.prop(s, "base_size") + if s.use_class_size: + layout.label(text="Klasse %s: x%.2f" + % (s.name_class, CLASS_SIZES.get(s.name_class, 1.0)), + icon='FIXED_SIZE') + + +class VIEW3D_PT_rock_shape(_RockSub): + bl_label = "2 - Form" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + layout.prop(s, "base_shape") + if s.base_shape == 'BOX': + layout.prop(s, "box_proportions") + layout.prop(s, "box_variation") + layout.prop(s, "subsurf_levels") + layout.separator() + layout.prop(s, "texture_type") + col = layout.column(align=True) + col.prop(s, "noise_scale") + col.prop(s, "displace_strength") + layout.prop(s, "seed") + layout.separator() + layout.prop(s, "randomize_shape") + sub = layout.column(align=True) + sub.active = s.randomize_shape + sub.prop(s, "noise_scale_max") + sub.prop(s, "displace_strength_max") + + +class VIEW3D_PT_rock_detail(_RockSub): + bl_label = "3 - Detail & Cleanup" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + col = layout.column(align=True) + col.prop(s, "decimate_ratio_1") + col.prop(s, "decimate_ratio_2") + layout.prop(s, "dissolve_angle") + layout.separator() + layout.prop(s, "add_bevel") + sub = layout.column(align=True) + sub.active = s.add_bevel + sub.prop(s, "bevel_width") + sub.prop(s, "bevel_segments") + layout.prop(s, "add_weighted_normal") + + +class VIEW3D_PT_rock_shading(_RockSub): + bl_label = "4 - Shading & Ausrichtung" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + layout.prop(s, "shading_mode") + row = layout.row() + row.active = s.shading_mode == 'AUTO' + row.prop(s, "autosmooth_angle") + layout.separator() + layout.prop(s, "flatten_bottom") + row = layout.row() + row.active = s.flatten_bottom + row.prop(s, "flatten_ratio") + layout.prop(s, "set_origin_bottom") + + +class VIEW3D_PT_rock_vcol(_RockSub): + bl_label = "5 - Vertex-Farben" + + def draw_header(self, context): + # Schalter im Panel-Kopf: man sieht auch eingeklappt, ob gebacken wird. + self.layout.prop(context.scene.rock_gen_settings, + "bake_vertex_cavity", text="") + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + col = layout.column() + col.active = s.bake_vertex_cavity + sub = col.column(align=True) + sub.prop(s, "cavity_strength") + sub.prop(s, "cavity_blur") + col.separator() + sub = col.column(align=True) + sub.prop(s, "moss_amount") + 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"), + icon='INFO') + # Nicht in der ausgegrauten Spalte: der Knopf arbeitet auf der Auswahl + # und ist auch dann sinnvoll, wenn beim Erzeugen nicht gebacken wird. + layout.separator() + layout.operator("rockgen.bake_cavity_selection", icon='BRUSH_DATA') + + +class VIEW3D_PT_rock_uv(_RockSub): + bl_label = "6 - UV, LOD & Material" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + layout.prop(s, "generate_uv0") + layout.prop(s, "generate_lightmap_uv") + layout.separator() + layout.prop(s, "generate_lods") + sub = layout.column(align=True) + sub.active = s.generate_lods + sub.prop(s, "lod_count") + sub.prop(s, "lod_ratio_step") + layout.separator() + layout.prop(s, "assign_material") + + +class VIEW3D_PT_rock_batch(_RockSub): + bl_label = "7 - Batch & Transform" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + col = layout.column(align=True) + col.prop(s, "batch_count") + col.prop(s, "spacing") + layout.separator() + layout.prop(s, "use_modal") + row = layout.row() + row.active = s.use_modal + row.prop(s, "modal_threshold") + layout.separator() + layout.prop(s, "randomize_transform") + sub = layout.column(align=True) + sub.active = s.randomize_transform + row = sub.row(align=True) + row.prop(s, "scale_min") + row.prop(s, "scale_max") + sub.prop(s, "nonuniform_scale") + sub.prop(s, "keep_size") + + +class VIEW3D_PT_rock_export(_RockSub): + bl_label = "8 - FBX-Export (Unreal)" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + layout.prop(s, "export_dir") + layout.prop(s, "export_selected_only") + layout.operator("object.export_rocks_fbx", icon='EXPORT') + + +class VIEW3D_PT_rock_bake(_RockSub): + bl_label = "9 - Textur-Bake (tileable)" + + def draw(self, context): + layout = self.layout + s = context.scene.rock_gen_settings + layout.prop(s, "bake_dir") + row = layout.row(align=True) + row.prop(s, "bake_prefix", text="") + row.prop(s, "bake_resolution", text="") + layout.separator() + col = layout.column(align=True) + col.prop(s, "bake_base_color") + col.prop(s, "bake_cell_scale") + col.prop(s, "bake_seam_width") + col.prop(s, "bake_plate_flat") + layout.separator() + row = layout.row(align=True) + row.prop(s, "bake_color", toggle=True) + row.prop(s, "bake_normal", toggle=True) + row = layout.row(align=True) + row.prop(s, "bake_roughness", toggle=True) + row.prop(s, "bake_ao", toggle=True) + layout.prop(s, "bake_ao_into_albedo") + row = layout.row() + row.active = s.bake_ao_into_albedo + row.prop(s, "bake_ao_strength") + layout.separator() + layout.operator("object.bake_rock_textures", icon='TEXTURE') # --------------------------------------------------------------------------- @@ -1916,7 +1986,18 @@ classes = ( ROCKGEN_OT_preset_save, ROCKGEN_OT_preset_load, ROCKGEN_OT_preset_delete, + # Das Eltern-Panel MUSS vor seinen Unter-Panels stehen, sonst findet Blender + # die bl_parent_id nicht. Die Reihenfolge hier ist die Reihenfolge im N-Panel. VIEW3D_PT_rock_generator, + VIEW3D_PT_rock_naming, + VIEW3D_PT_rock_shape, + VIEW3D_PT_rock_detail, + VIEW3D_PT_rock_shading, + VIEW3D_PT_rock_vcol, + VIEW3D_PT_rock_uv, + VIEW3D_PT_rock_batch, + VIEW3D_PT_rock_export, + VIEW3D_PT_rock_bake, ) diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py index a8e1c9a..364915d 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, 23, 0), + "version": (1, 24, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", @@ -1518,6 +1518,7 @@ class TREEGEN_OT_create(Operator): class VIEW3D_PT_tree_generator(Panel): + """Haupt-Panel: Vorlage, Name, Erzeugen. Der Rest sitzt in Unter-Panels.""" bl_label = "Tree Generator" bl_idname = "VIEW3D_PT_tree_generator" bl_space_type = 'VIEW_3D' @@ -1528,60 +1529,96 @@ class VIEW3D_PT_tree_generator(Panel): layout = self.layout s = context.scene.tree_gen_settings - box = layout.box() - box.label(text="Vorlage", icon='PRESET') - box.prop(s, "preset", text="") - box.prop(s, "seed") - row = box.row(align=True) + layout.prop(s, "preset", text="") + row = layout.row(align=True) row.prop(s, "art_name", text="") row.prop(s, "variant") - box.label(text="Name: %s_%02d%s" % (_art(s), s.variant, - "_S1..S%d" % s.stages if s.use_growth else ""), - icon='INFO') - - box = layout.box() - box.label(text="Wachstum") - box.prop(s, "use_growth") - sub = box.column(align=True) - sub.enabled = s.use_growth - 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="Blattwerk", icon='OUTLINER_OB_POINTCLOUD') - box.prop(s, "leaf_card") - sub = box.column(align=True) - sub.enabled = s.leaf_card is not None - sub.prop(s, "leaf_density") - sub.prop(s, "leaf_size") - sub.prop(s, "leaf_size_var") - sub.prop(s, "leaf_max_radius") - sub.prop(s, "leaf_tilt") - sub.prop(s, "leaf_budget") - box.operator("object.treegen_leaves", icon='OUTLINER_OB_POINTCLOUD') - if s.leaf_card is None: - box.label(text="Ohne Card bleibt _Leaf leer", icon='INFO') - - 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") - row = box.row() - row.enabled = s.apply_modifier - row.prop(s, "generate_lightmap_uv") layout.separator() - layout.operator("object.treegen_create", icon='OUTLINER_OB_MESH') - layout.label(text="Feinjustierung: am Modifier 'GN_Tree'", icon='INFO') + col = layout.column(align=True) + col.scale_y = 1.4 + col.operator("object.treegen_create", text="Baum erzeugen", + icon='OUTLINER_OB_MESH') + layout.label(text="%s_%02d%s" % (_art(s), s.variant, + "_S1..S%d" % s.stages if s.use_growth else ""), + icon='INFO') + + row = layout.row(align=True) + row.prop(s, "seed") + row.prop(s, "random_amount", text="") + row.operator("object.treegen_randomize", text="", icon='FILE_REFRESH') + + layout.label(text="Feinjustierung: Modifier 'GN_Tree'", icon='MODIFIER') + + +class _TreeSub(Panel): + """Basis aller Unter-Panels.""" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Tree Gen" + bl_parent_id = "VIEW3D_PT_tree_generator" + bl_options = {'DEFAULT_CLOSED'} + + +class VIEW3D_PT_tree_growth(_TreeSub): + bl_label = "1 - Wachstum" + + def draw_header(self, context): + self.layout.prop(context.scene.tree_gen_settings, "use_growth", text="") + + def draw(self, context): + layout = self.layout + s = context.scene.tree_gen_settings + col = layout.column(align=True) + col.active = s.use_growth + col.prop(s, "stages") + col.prop(s, "spacing") + if s.use_growth: + layout.label(text="Setzling bis Baum in %d Stufen" % s.stages, + icon='INFO') + + +class VIEW3D_PT_tree_leaves(_TreeSub): + bl_label = "2 - Blattwerk" + + def draw(self, context): + layout = self.layout + s = context.scene.tree_gen_settings + layout.prop(s, "leaf_card") + col = layout.column(align=True) + col.active = s.leaf_card is not None + col.prop(s, "leaf_density") + col.prop(s, "leaf_size") + col.prop(s, "leaf_size_var") + col.prop(s, "leaf_max_radius") + col.prop(s, "leaf_tilt") + col.prop(s, "leaf_budget") + layout.separator() + layout.operator("object.treegen_leaves", icon='OUTLINER_OB_POINTCLOUD') + if s.leaf_card is None: + layout.label(text="Ohne Card bleibt _Leaf leer", icon='INFO') + + +class VIEW3D_PT_tree_layers(_TreeSub): + bl_label = "3 - Zusatz-Ebenen" + + def draw(self, context): + layout = self.layout + s = context.scene.tree_gen_settings + layout.prop(s, "make_leaf") + layout.prop(s, "make_frucht") + + +class VIEW3D_PT_tree_export(_TreeSub): + bl_label = "4 - Export" + + def draw(self, context): + layout = self.layout + s = context.scene.tree_gen_settings + layout.prop(s, "apply_modifier") + row = layout.row() + row.active = s.apply_modifier + row.prop(s, "generate_lightmap_uv") classes = ( @@ -1589,7 +1626,12 @@ classes = ( TREEGEN_OT_randomize, TREEGEN_OT_leaves, TREEGEN_OT_create, + # Eltern-Panel zuerst, sonst findet Blender die bl_parent_id nicht. VIEW3D_PT_tree_generator, + VIEW3D_PT_tree_growth, + VIEW3D_PT_tree_leaves, + VIEW3D_PT_tree_layers, + VIEW3D_PT_tree_export, ) diff --git a/tests/test_base_shapes.py b/tests/test_base_shapes.py index 4733467..18c59d9 100644 --- a/tests/test_base_shapes.py +++ b/tests/test_base_shapes.py @@ -26,21 +26,31 @@ VARS=[("Wuerfel",'CUBE',None), print("%-9s %-6s %s" % ("Variante","Form","Abmessungen X/Y/Z (m)")) keep=[]; x=0.0 for name,shape,prop in VARS: - for o in list(bpy.data.objects): bpy.data.objects.remove(o,do_unlink=True) + # FALLE: das hier loeschte frueher ALLE Objekte - auch die Kopien aus den + # vorigen Durchlaeufen. Die waren beim Rendern tot ("StructRNA ... removed"). + kept = {k[0] for k in keep} + for o in list(bpy.data.objects): + if o not in kept: bpy.data.objects.remove(o,do_unlink=True) s.base_shape=shape; s.box_variation=0.0 if prop: s.box_proportions=prop s.seed=7 + # Die behaltenen Kopien stehen weiter in der Szene - [0] wuerde eine davon + # greifen statt des frisch erzeugten Steins. Deshalb ueber die Differenz. + before=set(bpy.context.scene.objects) bpy.ops.object.generate_rocks_batch() - ob=[o for o in bpy.context.scene.objects if o.type=='MESH'][0] + ob=[o for o in bpy.context.scene.objects + if o.type=='MESH' and o not in before][0] bb=ob.bound_box d=[max(v[i] for v in bb)-min(v[i] for v in bb) for i in range(3)] print("%-9s %-6s %.2f / %.2f / %.2f" % (name,shape,d[0],d[1],d[2])) if name=="Platte" and not (d[2] < d[0]*0.45): fails.append("platte_nicht_flach") if name=="Riegel" and not (d[0] > d[1]*1.5): fails.append("riegel_nicht_laenglich") - cp=ob.copy(); cp.data=ob.data.copy(); keep.append((cp,x)) + cp=ob.copy(); cp.data=ob.data.copy() + bpy.context.collection.objects.link(cp) # sofort verlinken, nicht spaeter + keep.append((cp,x)) x+=2.4 for cp,px in keep: - bpy.context.collection.objects.link(cp); cp.location=(px,0,0) + cp.location=(px,0,0) cp.data.materials.clear(); cp.data.materials.append(mat) for o in list(bpy.context.scene.objects): if o.type=='MESH' and o not in [k[0] for k in keep]: bpy.data.objects.remove(o,do_unlink=True) diff --git a/tests/test_ecogame_tree.py b/tests/test_ecogame_tree.py index 5811890..b248929 100644 --- a/tests/test_ecogame_tree.py +++ b/tests/test_ecogame_tree.py @@ -16,17 +16,21 @@ if m.TreeGenSettings.bl_rna.properties['generate_lightmap_uv'].default: fails.ap 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() +# Seit der Blattwerk-Ebene entsteht zu jeder Stufe ein zweites Objekt "_Leaf". +# Deshalb Stamm und Ebene getrennt pruefen statt eine feste Gesamtliste. 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") +stems=[n for n in names if not n.endswith(("_Leaf","_Frucht"))] +if stems != ["Birke_01_S1","Birke_01_S2","Birke_01_S3","Birke_01_S4"]: fails.append("stufen_namen") +if [n for n in names if n.endswith("_Leaf")] != [s_+"_Leaf" for s_ in stems]: fails.append("stufen_leaf") # 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'] +single=sorted(o.name for o in bpy.context.scene.objects if o.type=='MESH') print("EINZELN:", single) -if single != ["Eiche_02"]: fails.append("einzel_name") +if [n for n in single if not n.endswith(("_Leaf","_Frucht"))] != ["Eiche_02"]: fails.append("einzel_name") # Ursprung unten-mittig + echte Meter ob=bpy.context.scene.objects[single[0]] diff --git a/tests/test_leaves.py b/tests/test_leaves.py index 1fff081..c3c877e 100644 --- a/tests/test_leaves.py +++ b/tests/test_leaves.py @@ -1,5 +1,12 @@ -import bpy, sys, zipfile, os, tempfile, importlib.util, math, bmesh -ZIP=r"A:\eco\stylized_rock_generator\dist\stylized_tree_generator-1.23.0.zip" +import bpy, glob, sys, zipfile, os, tempfile, importlib.util, math, bmesh +# Version NICHT fest verdrahten - sonst schlaegt der Test nach jedem +# Versions-Bump fehl, obwohl am Addon nichts kaputt ist. +_d = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dist") +_z = sorted(glob.glob(os.path.join(_d, "stylized_tree_generator-*.zip"))) +if not _z: + print("ERGEBNIS: FEHLER: kein gebautes Tree-Zip in dist/ (erst build.ps1)") + sys.exit(1) +ZIP = _z[-1] tmp=tempfile.mkdtemp() with zipfile.ZipFile(ZIP) as z: z.extractall(tmp) init=[os.path.join(r,"__init__.py") for r,_,f in os.walk(tmp) if "__init__.py" in f][0] diff --git a/tests/test_panels.py b/tests/test_panels.py new file mode 100644 index 0000000..ec514c0 --- /dev/null +++ b/tests/test_panels.py @@ -0,0 +1,234 @@ +"""Registriert alle vier Addons und prueft die Panel-Struktur. + +Warum das eigene Test-Datei ist: py_compile sagt nur, dass die Datei syntaktisch +gueltig ist. Ob Blender die Panels ANNIMMT, ist eine andere Frage - bl_parent_id +muss auf ein bereits registriertes Panel zeigen, bl_category muss zwischen +Eltern- und Unter-Panel uebereinstimmen (sonst landet das Unter-Panel in einem +anderen Tab und ist praktisch unsichtbar), und jedes Panel muss zeichnen koennen, +ohne zu werfen. + +Aufruf: + blender --background --factory-startup --python tests/test_panels.py +""" +import os +import sys +import importlib.util + +import bpy + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Addon-Datei -> (Haupt-Panel-idname, Anzahl Unter-Panels, die offen sein DUERFEN) +# +# In den Generatoren sind Unter-Panels Einstellungen, die man selten anfasst - +# die gehoeren alle zugeklappt, damit der Erzeugen-Knopf oben sichtbar bleibt. +# In Asset Utils ist jedes Unter-Panel ein Arbeitsschritt: 1/2/4 macht man bei +# JEDEM Asset, die bleiben offen. Deshalb steht die Erwartung hier pro Addon +# statt als pauschale Regel. +ADDONS = { + "stylized_rock_generator.py": ("VIEW3D_PT_rock_generator", 9, + "rock_gen_settings", set()), + "stylized_tree_generator.py": ("VIEW3D_PT_tree_generator", 4, + "tree_gen_settings", set()), + "stylized_grass_generator.py": ("VIEW3D_PT_grass_generator", 4, + "grass_gen_settings", set()), + "stylized_asset_utils.py": ("VIEW3D_PT_asset_utils", 5, + "asset_utils_settings", + {"1 - Vorbereiten", "2 - Vertex-Farben", + "4 - Pruefen"}), +} + +failures = [] + + +def load(path, name): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +class FakeSelf: + """Ersatz fuer die Panel-Instanz: draw() braucht nur self.layout.""" + + def __init__(self, layout): + self.layout = layout + + +class FakeLayout: + """Minimal-Layout, das jeden UI-Aufruf schluckt und mitzaehlt. + + Damit laesst sich draw() ohne echten UI-Kontext ausfuehren - der ist im + --background-Modus nicht verfuegbar. + """ + + def __init__(self, counter): + self._c = counter + + def _child(self, *a, **k): + return FakeLayout(self._c) + + row = column = box = split = _child + + def prop(self, data, name, **k): + # Zugriff erzwingen: schlaegt fehl, wenn die Property nicht existiert. + getattr(data, name) + self._c["prop"] += 1 + + def operator(self, idname, **k): + self._c["op"] += 1 + self._c["ops"].add(idname) + return self + + def label(self, **k): + self._c["label"] += 1 + + def separator(self, **k): + pass + + def menu(self, *a, **k): + pass + + def template_ID(self, *a, **k): + pass + + def __setattr__(self, k, v): + # scale_y / active / enabled einfach annehmen + object.__setattr__(self, k, v) + + +class CollectLayout(FakeLayout): + """Wie FakeLayout, sammelt aber die Namen der gezeichneten Properties.""" + + def __init__(self, names): + FakeLayout.__init__(self, {"prop": 0, "op": 0, "label": 0, "ops": set()}) + self._names = names + + def _child(self, *a, **k): + return CollectLayout(self._names) + + row = column = box = split = _child + + def prop(self, data, name, **k): + self._names.add(name) + + +for filename, (parent_id, want_subs, settings_attr, open_ok) in ADDONS.items(): + path = os.path.join(ROOT, filename) + mod = load(path, filename[:-3]) + try: + mod.register() + except Exception as exc: + failures.append("%s: register() wirft %s" % (filename, exc)) + continue + + panels = [c for c in mod.classes if issubclass(c, bpy.types.Panel)] + parent = [p for p in panels if getattr(p, "bl_idname", "") == parent_id] + if not parent: + failures.append("%s: Haupt-Panel %s fehlt" % (filename, parent_id)) + mod.unregister() + continue + parent = parent[0] + + subs = [p for p in panels if getattr(p, "bl_parent_id", "") == parent_id] + if len(subs) != want_subs: + failures.append("%s: %d Unter-Panels, erwartet %d" + % (filename, len(subs), want_subs)) + + for sub in subs: + # Gleicher Tab wie das Eltern-Panel? + if sub.bl_category != parent.bl_category: + failures.append("%s/%s: bl_category '%s' != Eltern-Tab '%s'" + % (filename, sub.__name__, sub.bl_category, + parent.bl_category)) + # Eingeklappt - ausser bei den ausdruecklich als offen gewollten. + is_closed = 'DEFAULT_CLOSED' in getattr(sub, "bl_options", set()) + if not is_closed and sub.bl_label not in open_ok: + failures.append("%s/%s (%s): offen, sollte eingeklappt sein" + % (filename, sub.__name__, sub.bl_label)) + if is_closed and sub.bl_label in open_ok: + failures.append("%s/%s (%s): eingeklappt, sollte offen sein" + % (filename, sub.__name__, sub.bl_label)) + # Wirklich registriert? Blender haengt bl_rna an registrierte Klassen. + if not hasattr(sub, "bl_rna"): + failures.append("%s/%s: nicht registriert" + % (filename, sub.__name__)) + + # Jedes Panel einmal zeichnen lassen. + total = {"prop": 0, "op": 0, "label": 0, "ops": set()} + for p in [parent] + subs: + counter = {"prop": 0, "op": 0, "label": 0, "ops": set()} + # FALLE: registrierte Panel-Klassen lassen sich nicht instanziieren + # ("bpy_struct.__new__(struct): expected a single argument"). draw() ist + # aber eine normale Python-Funktion - self darf alles sein, was .layout hat. + inst = FakeSelf(FakeLayout(counter)) + try: + p.draw(inst, bpy.context) + if "draw_header" in p.__dict__: + p.draw_header(inst, bpy.context) + except Exception as exc: + failures.append("%s/%s: draw() wirft %s: %s" + % (filename, p.__name__, type(exc).__name__, exc)) + continue + if counter["prop"] + counter["op"] + counter["label"] == 0: + failures.append("%s/%s: zeichnet gar nichts" + % (filename, p.__name__)) + for k in ("prop", "op", "label"): + total[k] += counter[k] + total["ops"] |= counter["ops"] + + # Jede definierte Property muss irgendwo im Panel erreichbar sein. + # + # Das ist der eigentliche Regressionstest beim Umbauen der Panels: beim + # Verschieben von Reglern zwischen Panels faellt leicht einer heraus, und + # dann gibt es eine Einstellung, an die man in Blender nicht mehr rankommt. + # Ein paar Regler haengen an einem Zweig (BOX-Proportionen nur bei + # base_shape='BOX'), deshalb werden diese Zweige vorher durchgeschaltet. + st = getattr(bpy.context.scene, settings_attr, None) + if st is not None: + drawn = set() + branches = [{}] + for attr, values in (("base_shape", ('CUBE', 'BOX', 'ICOSPHERE')), + ("g_mode", ('SHELLS', 'MOSS')), + ("shading_mode", ('AUTO', 'FLAT', 'SMOOTH'))): + if hasattr(st, attr): + branches += [{attr: v} for v in values] + for combo in branches: + for attr, val in combo.items(): + try: + setattr(st, attr, val) + except TypeError: + continue + for p in [parent] + subs: + try: + p.draw(FakeSelf(CollectLayout(drawn)), bpy.context) + if "draw_header" in p.__dict__: + p.draw_header(FakeSelf(CollectLayout(drawn)), bpy.context) + except Exception: + pass + defined = set(type(st).__annotations__) + unreachable = sorted(defined - drawn) + if unreachable: + failures.append("%s: Property(s) in keinem Panel erreichbar: %s" + % (filename, ", ".join(unreachable))) + + # Jeder Operator, den ein Panel anbietet, muss es auch geben. + for idname in sorted(total["ops"]): + grp, _, op = idname.partition(".") + if not hasattr(getattr(bpy.ops, grp, None), op): + failures.append("%s: Panel ruft unbekannten Operator %s" + % (filename, idname)) + + print("%-32s %d Unter-Panels %3d Props %2d Buttons %2d Labels" + % (filename, len(subs), total["prop"], total["op"], total["label"])) + + mod.unregister() + +print("") +if failures: + print("ERGEBNIS: %d FEHLER" % len(failures)) + for f in failures: + print(" - " + f) + sys.exit(1) +print("ERGEBNIS: ALLE PANELS OK") diff --git a/tests/test_rock_gen.py b/tests/test_rock_gen.py index 91bfe3b..512ade9 100644 --- a/tests/test_rock_gen.py +++ b/tests/test_rock_gen.py @@ -81,12 +81,16 @@ def main(): settings.assign_material = False # 3. Batch erzeugen + # Vorher merken, was schon da ist: das Benennungsschema kommt aus den + # Einstellungen (Fels_A_M_01 ...), ein fest verdrahtetes Praefix wie "Rock_" + # geht schief, sobald jemand die Benennung aendert. Differenz statt Ratename. + before = set(bpy.context.scene.objects) result = bpy.ops.object.generate_rocks_batch() if 'FINISHED' not in result: failures.append("Operator lieferte nicht {'FINISHED'}: %s" % (result,)) rocks = [o for o in bpy.context.scene.objects - if o.type == 'MESH' and o.name.startswith("Rock_")] + if o.type == 'MESH' and o not in before] if len(rocks) != BATCH: failures.append("Erwartet %d Rocks, gefunden %d" % (BATCH, len(rocks))) diff --git a/tests/test_tree_addon.py b/tests/test_tree_addon.py index 8cd043e..3f3164e 100644 --- a/tests/test_tree_addon.py +++ b/tests/test_tree_addon.py @@ -1,7 +1,15 @@ """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.22.0.zip" +# Version NICHT fest verdrahten - sonst schlaegt der Test bei jedem Bump fehl, +# obwohl am Addon nichts kaputt ist. +import glob +_d = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dist") +_z = sorted(glob.glob(os.path.join(_d, "stylized_tree_generator-*.zip"))) +if not _z: + print("ERGEBNIS: FEHLER: kein gebautes Tree-Zip in dist/ (erst build.ps1 laufen lassen)") + sys.exit(1) +ZIP = _z[-1] fails = [] tmp = tempfile.mkdtemp() @@ -46,7 +54,11 @@ 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) +# Nur die Staemme: seit der Blattwerk-Ebene gibt es zu jeder Stufe noch ein +# "_Leaf"-Objekt. Mit denen zusammen ist die Vertex-Folge nicht mehr aufsteigend. +obs = sorted([o for o in bpy.data.objects + if o.type == 'MESH' and not o.name.endswith(("_Leaf", "_Frucht"))], + 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]